diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart index 4bc8561..00b418c 100644 --- a/lib/controller/expense/expense_detail_controller.dart +++ b/lib/controller/expense/expense_detail_controller.dart @@ -3,6 +3,7 @@ import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/model/expense/expense_detail_model.dart'; import 'package:marco/model/employee_model.dart'; +import 'package:flutter/material.dart'; class ExpenseDetailController extends GetxController { final Rx expense = Rx(null); @@ -10,9 +11,11 @@ class ExpenseDetailController extends GetxController { final RxString errorMessage = ''.obs; final Rx selectedReimbursedBy = Rx(null); final RxList allEmployees = [].obs; - + final RxList employeeSearchResults = [].obs; late String _expenseId; bool _isInitialized = false; + final employeeSearchController = TextEditingController(); + final isSearchingEmployees = false.obs; /// Call this once from the screen (NOT inside build) to initialize void init(String expenseId) { @@ -93,6 +96,23 @@ class ExpenseDetailController extends GetxController { return []; } + Future searchEmployees(String query) async { + 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)), + ); + } catch (e) { + logSafe("Error searching employees: $e", level: LogLevel.error); + employeeSearchResults.clear(); + } finally { + isSearchingEmployees.value = false; + } + } + /// Fetch all employees Future fetchAllEmployees() async { final response = await _apiCallWrapper( @@ -151,13 +171,13 @@ class ExpenseDetailController extends GetxController { () => ApiService.updateExpenseStatusApi( expenseId: _expenseId, statusId: statusId, - comment: comment, + comment: comment, ), "update expense status", ); if (success == true) { - await fetchExpenseDetails(); + await fetchExpenseDetails(); return true; } else { errorMessage.value = "Failed to update expense status."; diff --git a/lib/helpers/widgets/expense_detail_helpers.dart b/lib/helpers/widgets/expense_detail_helpers.dart new file mode 100644 index 0000000..1a5f450 --- /dev/null +++ b/lib/helpers/widgets/expense_detail_helpers.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +/// Returns a formatted color for the expense status. +Color getExpenseStatusColor(String? status, {String? colorCode}) { + if (colorCode != null && colorCode.isNotEmpty) { + try { + return Color(int.parse(colorCode.replaceFirst('#', '0xff'))); + } catch (_) {} + } + switch (status) { + case 'Approval Pending': + return Colors.orange; + case 'Process Pending': + return Colors.blue; + case 'Rejected': + return Colors.red; + case 'Paid': + return Colors.green; + default: + return Colors.black; + } +} + +/// Formats amount to ₹ currency string. +String formatExpenseAmount(double amount) { + return NumberFormat.currency( + locale: 'en_IN', symbol: '₹ ', decimalDigits: 2) + .format(amount); +} + +/// Label/Value block as reusable widget. +Widget labelValueBlock(String label, String value) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall(label, fontWeight: 600), + MySpacing.height(4), + MyText.bodySmall(value, + fontWeight: 500, softWrap: true, maxLines: null), + ], + ); + +/// Skeleton loader for lists. +Widget buildLoadingSkeleton() => ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 5, + itemBuilder: (_, __) => Container( + margin: const EdgeInsets.only(bottom: 16), + height: 80, + decoration: BoxDecoration( + color: Colors.grey[300], borderRadius: BorderRadius.circular(10)), + ), + ); + +/// Expandable description widget. +class ExpandableDescription extends StatefulWidget { + final String description; + const ExpandableDescription({Key? key, required this.description}) + : super(key: key); + + @override + State createState() => _ExpandableDescriptionState(); +} + +class _ExpandableDescriptionState extends State { + bool isExpanded = false; + @override + Widget build(BuildContext context) { + final isLong = widget.description.length > 100; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall( + widget.description, + maxLines: isExpanded ? null : 2, + overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis, + fontWeight: 500, + ), + if (isLong || !isExpanded) + InkWell( + onTap: () => setState(() => isExpanded = !isExpanded), + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: MyText.labelSmall( + isExpanded ? 'Show less' : 'Show more', + fontWeight: 600, + color: Colors.blue, + ), + ), + ), + ], + ); + } +} diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 4e6dfec..78d822f 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -48,7 +48,13 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), backgroundColor: Colors.transparent, - builder: (_) => EmployeeSelectorBottomSheet(), + builder: (_) => ReusableEmployeeSelectorBottomSheet( + searchController: controller.employeeSearchController, + searchResults: controller.employeeSearchResults, + isSearching: controller.isSearchingEmployees, + onSearch: controller.searchEmployees, + onSelect: (emp) => controller.selectedPaidBy.value = emp, + ), ); // Optional cleanup diff --git a/lib/model/expense/employee_selector_bottom_sheet.dart b/lib/model/expense/employee_selector_bottom_sheet.dart index fb9fe27..055a7c7 100644 --- a/lib/model/expense/employee_selector_bottom_sheet.dart +++ b/lib/model/expense/employee_selector_bottom_sheet.dart @@ -1,14 +1,25 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/model/employee_model.dart'; -class EmployeeSelectorBottomSheet extends StatelessWidget { - final AddExpenseController controller = Get.find(); +class ReusableEmployeeSelectorBottomSheet extends StatelessWidget { + final TextEditingController searchController; + final RxList searchResults; + final RxBool isSearching; + final void Function(String) onSearch; + final void Function(EmployeeModel) onSelect; - EmployeeSelectorBottomSheet({super.key}); + const ReusableEmployeeSelectorBottomSheet({ + super.key, + required this.searchController, + required this.searchResults, + required this.isSearching, + required this.onSearch, + required this.onSelect, + }); @override Widget build(BuildContext context) { @@ -16,13 +27,13 @@ class EmployeeSelectorBottomSheet extends StatelessWidget { title: "Search Employee", onCancel: () => Get.back(), onSubmit: () {}, - showButtons: false, + showButtons: false, child: Obx(() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( - controller: controller.employeeSearchController, + controller: searchController, decoration: InputDecoration( hintText: "Search by name, email...", prefixIcon: const Icon(Icons.search), @@ -32,24 +43,24 @@ class EmployeeSelectorBottomSheet extends StatelessWidget { contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), ), - onChanged: (value) => controller.searchEmployees(value), + onChanged: onSearch, ), MySpacing.height(12), SizedBox( - height: 400, // Adjust this if needed - child: controller.isSearchingEmployees.value + height: 400, + child: isSearching.value ? const Center(child: CircularProgressIndicator()) - : controller.employeeSearchResults.isEmpty - ? Center( + : searchResults.isEmpty + ? Center( child: MyText.bodyMedium( "No employees found.", fontWeight: 500, ), ) : ListView.builder( - itemCount: controller.employeeSearchResults.length, + itemCount: searchResults.length, itemBuilder: (_, index) { - final emp = controller.employeeSearchResults[index]; + final emp = searchResults[index]; final fullName = '${emp.firstName} ${emp.lastName}'.trim(); return ListTile( @@ -58,7 +69,7 @@ class EmployeeSelectorBottomSheet extends StatelessWidget { fontWeight: 600, ), onTap: () { - controller.selectedPaidBy.value = emp; + onSelect(emp); Get.back(); }, ); diff --git a/lib/model/expense/reimbursement_bottom_sheet.dart b/lib/model/expense/reimbursement_bottom_sheet.dart index 5c51956..c996174 100644 --- a/lib/model/expense/reimbursement_bottom_sheet.dart +++ b/lib/model/expense/reimbursement_bottom_sheet.dart @@ -8,7 +8,7 @@ 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:marco/helpers/widgets/my_snackbar.dart'; - +import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; class ReimbursementBottomSheet extends StatefulWidget { final String expenseId; @@ -50,39 +50,26 @@ class _ReimbursementBottomSheetState extends State { super.dispose(); } - void _showEmployeeList() { - showModalBottomSheet( + void _showEmployeeList() async { + await showModalBottomSheet( context: context, - backgroundColor: Colors.white, + isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (_) { - return SizedBox( - height: 300, - child: Obx(() { - final employees = controller.allEmployees; - if (employees.isEmpty) { - return const Center(child: Text("No employees found")); - } - return ListView.builder( - itemCount: employees.length, - itemBuilder: (_, index) { - final emp = employees[index]; - final fullName = '${emp.firstName} ${emp.lastName}'.trim(); - return ListTile( - title: Text(fullName.isNotEmpty ? fullName : "Unnamed"), - onTap: () { - controller.selectedReimbursedBy.value = emp; - Navigator.pop(context); - }, - ); - }, - ); - }), - ); - }, + backgroundColor: Colors.transparent, + builder: (_) => ReusableEmployeeSelectorBottomSheet( + searchController: controller.employeeSearchController, + searchResults: controller.employeeSearchResults, + isSearching: controller.isSearchingEmployees, + onSearch: controller.searchEmployees, + onSelect: (emp) => controller.selectedReimbursedBy.value = emp, + ), ); + + // Optional cleanup + controller.employeeSearchController.clear(); + controller.employeeSearchResults.clear(); } InputDecoration _inputDecoration(String hint) { @@ -200,16 +187,25 @@ class _ReimbursementBottomSheetState extends State { MySpacing.height(8), GestureDetector( onTap: _showEmployeeList, - child: AbsorbPointer( - child: TextField( - controller: TextEditingController( - text: controller.selectedReimbursedBy.value == null - ? "" - : '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}', - ), - decoration: _inputDecoration("Select Employee").copyWith( - suffixIcon: const Icon(Icons.expand_more), - ), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + controller.selectedReimbursedBy.value == null + ? "Select Paid By" + : '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}', + style: const TextStyle(fontSize: 14), + ), + const Icon(Icons.arrow_drop_down, size: 22), + ], ), ), ), diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index 9a8d7d2..2cf1276 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -1,67 +1,97 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.dart'; -import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; - import 'package:marco/controller/expense/expense_detail_controller.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/permission_controller.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/model/expense/expense_detail_model.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:marco/model/expense/reimbursement_bottom_sheet.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; import 'package:marco/model/expense/comment_bottom_sheet.dart'; +import 'package:marco/model/expense/expense_detail_model.dart'; +import 'package:marco/model/expense/reimbursement_bottom_sheet.dart'; import 'package:marco/controller/expense/add_expense_controller.dart'; import 'package:marco/helpers/services/app_logger.dart'; +import 'package:url_launcher/url_launcher.dart'; -class ExpenseDetailScreen extends StatelessWidget { +import 'package:marco/helpers/widgets/expense_detail_helpers.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; +import 'package:marco/model/employee_info.dart'; + +class ExpenseDetailScreen extends StatefulWidget { final String expenseId; const ExpenseDetailScreen({super.key, required this.expenseId}); - static Color getStatusColor(String? status, {String? colorCode}) { - if (colorCode != null && colorCode.isNotEmpty) { - try { - return Color(int.parse(colorCode.replaceFirst('#', '0xff'))); - } catch (_) {} - } - switch (status) { - case 'Approval Pending': - return Colors.orange; - case 'Process Pending': - return Colors.blue; - case 'Rejected': - return Colors.red; - case 'Paid': - return Colors.green; - default: - return Colors.black; - } + @override + State createState() => _ExpenseDetailScreenState(); +} + +class _ExpenseDetailScreenState extends State { + final controller = Get.put(ExpenseDetailController()); + final projectController = Get.find(); + final permissionController = Get.find(); + + EmployeeInfo? employeeInfo; + final RxBool canSubmit = false.obs; + bool _checkedPermission = false; + + @override + void initState() { + super.initState(); + controller.init(widget.expenseId); + _loadEmployeeInfo(); + } + + void _loadEmployeeInfo() async { + final info = await LocalStorage.getEmployeeInfo(); + employeeInfo = info; + } + + void _checkPermissionToSubmit(ExpenseDetailModel expense) { + const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; + + final isCreatedByCurrentUser = employeeInfo?.id == expense.createdBy.id; + final nextStatusIds = expense.nextStatus.map((e) => e.id).toList(); + final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId); + + final result = isCreatedByCurrentUser && hasRequiredNextStatus; + + logSafe( + '🐛 Checking submit permission:\n' + '🐛 - Logged-in employee ID: ${employeeInfo?.id}\n' + '🐛 - Expense created by ID: ${expense.createdBy.id}\n' + '🐛 - Next Status IDs: $nextStatusIds\n' + '🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n' + '🐛 - Final Permission Result: $result', + level: LogLevel.debug, + ); + + canSubmit.value = result; } @override Widget build(BuildContext context) { - final controller = Get.put(ExpenseDetailController()); - final projectController = Get.find(); - controller.init(expenseId); - final permissionController = Get.find(); - return Scaffold( backgroundColor: const Color(0xFFF7F7F7), appBar: _AppBar(projectController: projectController), body: SafeArea( child: Obx(() { - if (controller.isLoading.value) return _buildLoadingSkeleton(); + if (controller.isLoading.value) return buildLoadingSkeleton(); final expense = controller.expense.value; if (controller.errorMessage.isNotEmpty || expense == null) { return Center(child: MyText.bodyMedium("No data to display.")); } - final statusColor = getStatusColor(expense.status.name, + + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkPermissionToSubmit(expense); + }); + + final statusColor = getExpenseStatusColor(expense.status.name, colorCode: expense.status.color); - final formattedAmount = _formatAmount(expense.amount); + final formattedAmount = formatExpenseAmount(expense.amount); + return SingleChildScrollView( padding: EdgeInsets.fromLTRB( 8, 8, 8, 30 + MediaQuery.of(context).padding.bottom), @@ -87,9 +117,10 @@ class ExpenseDetailScreen extends StatelessWidget { _InvoiceDocuments(documents: expense.documents), const Divider(height: 30, thickness: 1.2), _InvoiceTotals( - expense: expense, - formattedAmount: formattedAmount, - statusColor: statusColor), + expense: expense, + formattedAmount: formattedAmount, + statusColor: statusColor, + ), ], ), ), @@ -100,18 +131,21 @@ class ExpenseDetailScreen extends StatelessWidget { }), ), floatingActionButton: Obx(() { + if (controller.isLoading.value) return buildLoadingSkeleton(); + final expense = controller.expense.value; - if (expense == null) return const SizedBox.shrink(); + if (controller.errorMessage.isNotEmpty || expense == null) { + return Center(child: MyText.bodyMedium("No data to display.")); + } - // Allowed status Ids - const allowedStatusIds = [ - "d1ee5eec-24b6-4364-8673-a8f859c60729", - "965eda62-7907-4963-b4a1-657fb0b2724b", - "297e0d8f-f668-41b5-bfea-e03b354251c8" - ]; + if (!_checkedPermission) { + _checkedPermission = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkPermissionToSubmit(expense); + }); + } - // Show edit button only if status id is in allowedStatusIds - if (!allowedStatusIds.contains(expense.status.id)) { + if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) { return const SizedBox.shrink(); } @@ -130,10 +164,8 @@ class ExpenseDetailScreen extends StatelessWidget { 'expensesTypeId': expense.expensesType.id, 'paymentModeId': expense.paymentMode.id, 'paidById': expense.paidBy.id, - // ==== Add these lines below ==== 'paidByFirstName': expense.paidBy.firstName, 'paidByLastName': expense.paidBy.lastName, - // ================================= 'attachments': expense.documents .map((doc) => { 'url': doc.preSignedUrl, @@ -151,20 +183,17 @@ class ExpenseDetailScreen extends StatelessWidget { addCtrl.populateFieldsForEdit(editData); await showAddExpenseBottomSheet(isEdit: true); - - // Refresh expense details after editing await controller.fetchExpenseDetails(); }, backgroundColor: Colors.red, tooltip: 'Edit Expense', - child: Icon(Icons.edit), + child: const Icon(Icons.edit), ); }), bottomNavigationBar: Obx(() { final expense = controller.expense.value; - if (expense == null || expense.nextStatus.isEmpty) { - return const SizedBox(); - } + if (expense == null) return const SizedBox(); + return SafeArea( child: Container( decoration: const BoxDecoration( @@ -176,12 +205,37 @@ class ExpenseDetailScreen extends StatelessWidget { alignment: WrapAlignment.center, spacing: 10, runSpacing: 10, - children: expense.nextStatus - .where((next) => permissionController.hasAnyPermission( - controller.parsePermissionIds(next.permissionIds))) - .map((next) => - _statusButton(context, controller, expense, next)) - .toList(), + children: expense.nextStatus.where((next) { + const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; + + final rawPermissions = next.permissionIds; + final parsedPermissions = + controller.parsePermissionIds(rawPermissions); + + final isSubmitStatus = next.id == submitStatusId; + final isCreatedByCurrentUser = + employeeInfo?.id == expense.createdBy.id; + + logSafe( + '🔐 Permission Logic:\n' + '🔸 Status: ${next.name}\n' + '🔸 Status ID: ${next.id}\n' + '🔸 Parsed Permissions: $parsedPermissions\n' + '🔸 Is Submit: $isSubmitStatus\n' + '🔸 Created By Current User: $isCreatedByCurrentUser', + level: LogLevel.debug, + ); + + if (isSubmitStatus) { + // Submit can be done ONLY by the creator + return isCreatedByCurrentUser; + } + + // All other statuses - check permission normally + return permissionController.hasAnyPermission(parsedPermissions); + }).map((next) { + return _statusButton(context, controller, expense, next); + }).toList(), ), ), ); @@ -197,6 +251,7 @@ class ExpenseDetailScreen extends StatelessWidget { buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff'))); } catch (_) {} } + return ElevatedButton( style: ElevatedButton.styleFrom( minimumSize: const Size(100, 40), @@ -205,7 +260,6 @@ class ExpenseDetailScreen extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), ), onPressed: () async { - // For brevity, couldn't refactor the logic since it's business-specific. const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27'; if (expense.status.id == reimbursementId) { showModalBottomSheet( @@ -277,25 +331,6 @@ class ExpenseDetailScreen extends StatelessWidget { ), ); } - - static String _formatAmount(double amount) { - return NumberFormat.currency( - locale: 'en_IN', symbol: '₹ ', decimalDigits: 2) - .format(amount); - } - - Widget _buildLoadingSkeleton() { - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: 5, - itemBuilder: (_, __) => Container( - margin: const EdgeInsets.only(bottom: 16), - height: 80, - decoration: BoxDecoration( - color: Colors.grey[300], borderRadius: BorderRadius.circular(10)), - ), - ); - } } class _AppBar extends StatelessWidget implements PreferredSizeWidget { @@ -356,8 +391,6 @@ class _AppBar extends StatelessWidget implements PreferredSizeWidget { Size get preferredSize => const Size.fromHeight(kToolbarHeight); } -// -------- Invoice Sub-Components, unchanged except formatting/const ---------------- - class _InvoiceHeader extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceHeader({required this.expense}); @@ -366,7 +399,7 @@ class _InvoiceHeader extends StatelessWidget { final dateString = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toString(), format: 'dd-MM-yyyy'); - final statusColor = ExpenseDetailScreen.getStatusColor(expense.status.name, + final statusColor = getExpenseStatusColor(expense.status.name, colorCode: expense.status.color); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -407,28 +440,18 @@ class _InvoiceParties extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _labelValueBlock('Project', expense.project.name), + labelValueBlock('Project', expense.project.name), MySpacing.height(16), - _labelValueBlock('Paid By:', + labelValueBlock('Paid By:', '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), MySpacing.height(16), - _labelValueBlock('Supplier', expense.supplerName), + labelValueBlock('Supplier', expense.supplerName), MySpacing.height(16), - _labelValueBlock('Created By:', + labelValueBlock('Created By:', '${expense.createdBy.firstName} ${expense.createdBy.lastName}'), ], ); } - - Widget _labelValueBlock(String label, String value) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall(label, fontWeight: 600), - MySpacing.height(4), - MyText.bodySmall(value, - fontWeight: 500, softWrap: true, maxLines: null), - ], - ); } class _InvoiceDetailsTable extends StatelessWidget { @@ -556,6 +579,29 @@ class _InvoiceDocuments extends StatelessWidget { } } +class ExpensePermissionHelper { + static bool canEditExpense( + EmployeeInfo? employee, ExpenseDetailModel expense) { + return employee?.id == expense.createdBy.id && + _isInAllowedEditStatus(expense.status.id); + } + + static bool canSubmitExpense( + EmployeeInfo? employee, ExpenseDetailModel expense) { + return employee?.id == expense.createdBy.id && + expense.nextStatus.isNotEmpty; + } + + static bool _isInAllowedEditStatus(String statusId) { + const editableStatusIds = [ + "d1ee5eec-24b6-4364-8673-a8f859c60729", + "965eda62-7907-4963-b4a1-657fb0b2724b", + "297e0d8f-f668-41b5-bfea-e03b354251c8" + ]; + return editableStatusIds.contains(statusId); + } +} + class _InvoiceTotals extends StatelessWidget { final ExpenseDetailModel expense; final String formattedAmount; @@ -576,41 +622,3 @@ class _InvoiceTotals extends StatelessWidget { ); } } - -class ExpandableDescription extends StatefulWidget { - final String description; - const ExpandableDescription({super.key, required this.description}); - @override - State createState() => _ExpandableDescriptionState(); -} - -class _ExpandableDescriptionState extends State { - bool isExpanded = false; - @override - Widget build(BuildContext context) { - final isLong = widget.description.length > 100; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall( - widget.description, - maxLines: isExpanded ? null : 2, - overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis, - fontWeight: 500, - ), - if (isLong || !isExpanded) - InkWell( - onTap: () => setState(() => isExpanded = !isExpanded), - child: Padding( - padding: const EdgeInsets.only(top: 4), - child: MyText.labelSmall( - isExpanded ? 'Show less' : 'Show more', - fontWeight: 600, - color: Colors.blue, - ), - ), - ), - ], - ); - } -}