import 'package:flutter/material.dart'; import 'package:get/get.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/image_viewer_dialog.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'; import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; import 'package:marco/helpers/widgets/expense/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/employees/employee_info.dart'; import 'package:timeline_tile/timeline_tile.dart'; class ExpenseDetailScreen extends StatefulWidget { final String expenseId; const ExpenseDetailScreen({super.key, required this.expenseId}); @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) { return Scaffold( backgroundColor: const Color(0xFFF7F7F7), appBar: _AppBar(projectController: projectController), body: SafeArea( child: Obx(() { 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.")); } WidgetsBinding.instance.addPostFrameCallback((_) { _checkPermissionToSubmit(expense); }); final statusColor = getExpenseStatusColor(expense.status.name, colorCode: expense.status.color); final formattedAmount = formatExpenseAmount(expense.amount); return MyRefreshIndicator( onRefresh: () async { await controller.fetchExpenseDetails(); }, child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( 8, 8, 8, 30 + MediaQuery.of(context).padding.bottom), child: Center( child: Container( constraints: const BoxConstraints(maxWidth: 520), child: Card( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5)), elevation: 3, child: Padding( padding: const EdgeInsets.symmetric( vertical: 14, horizontal: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _InvoiceHeader(expense: expense), const Divider(height: 30, thickness: 1.2), InvoiceLogs(logs: expense.expenseLogs), const Divider(height: 30, thickness: 1.2), _InvoiceParties(expense: expense), const Divider(height: 30, thickness: 1.2), _InvoiceDetailsTable(expense: expense), const Divider(height: 30, thickness: 1.2), _InvoiceDocuments(documents: expense.documents), const Divider(height: 30, thickness: 1.2), _InvoiceTotals( expense: expense, formattedAmount: formattedAmount, statusColor: statusColor, ), const Divider(height: 30, thickness: 1.2), ], ), ), ), ), ), ), ); }), ), floatingActionButton: Obx(() { 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.")); } if (!_checkedPermission) { _checkedPermission = true; WidgetsBinding.instance.addPostFrameCallback((_) { _checkPermissionToSubmit(expense); }); } if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) { return const SizedBox.shrink(); } return FloatingActionButton.extended( onPressed: () async { final editData = { 'id': expense.id, 'projectName': expense.project.name, 'amount': expense.amount, 'supplerName': expense.supplerName, 'description': expense.description, 'transactionId': expense.transactionId, 'location': expense.location, 'transactionDate': expense.transactionDate, 'noOfPersons': expense.noOfPersons, 'expensesTypeId': expense.expensesType.id, 'paymentModeId': expense.paymentMode.id, 'paidById': expense.paidBy.id, 'paidByFirstName': expense.paidBy.firstName, 'paidByLastName': expense.paidBy.lastName, 'attachments': expense.documents .map((doc) => { 'url': doc.preSignedUrl, 'fileName': doc.fileName, 'documentId': doc.documentId, 'contentType': doc.contentType, }) .toList(), }; logSafe('editData: $editData', level: LogLevel.info); final addCtrl = Get.put(AddExpenseController()); await addCtrl.loadMasterData(); addCtrl.populateFieldsForEdit(editData); await showAddExpenseBottomSheet(isEdit: true); await controller.fetchExpenseDetails(); }, backgroundColor: Colors.red, icon: const Icon(Icons.edit), label: MyText.bodyMedium( "Edit Expense", fontWeight: 600, color: Colors.white), ); }), bottomNavigationBar: Obx(() { final expense = controller.expense.value; if (expense == null) return const SizedBox(); return SafeArea( child: Container( decoration: const BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Color(0x11000000))), ), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), child: Wrap( alignment: WrapAlignment.center, spacing: 10, runSpacing: 10, 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(), ), ), ); }), ); } Widget _statusButton(BuildContext context, ExpenseDetailController controller, ExpenseDetailModel expense, dynamic next) { Color buttonColor = Colors.red; if (next.color.isNotEmpty) { try { buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff'))); } catch (_) {} } DateTime onlyDate(DateTime date) { return DateTime(date.year, date.month, date.day); } return ElevatedButton( style: ElevatedButton.styleFrom( minimumSize: const Size(100, 40), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), backgroundColor: buttonColor, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), ), onPressed: () async { const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27'; if (expense.status.id == reimbursementId) { showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(5))), builder: (context) => ReimbursementBottomSheet( expenseId: expense.id, statusId: next.id, onClose: () {}, onSubmit: ({ required String comment, required String reimburseTransactionId, required String reimburseDate, required String reimburseById, required String statusId, }) async { final transactionDate = DateTime.tryParse( controller.expense.value?.transactionDate ?? ''); final selectedReimburseDate = DateTime.tryParse(reimburseDate); final today = DateTime.now(); if (transactionDate == null || selectedReimburseDate == null) { showAppSnackbar( title: 'Invalid date', message: 'Could not parse transaction or reimbursement date.', type: SnackbarType.error, ); return false; } if (onlyDate(selectedReimburseDate) .isBefore(onlyDate(transactionDate))) { showAppSnackbar( title: 'Invalid Date', message: 'Reimbursement date cannot be before the transaction date.', type: SnackbarType.error, ); return false; } if (onlyDate(selectedReimburseDate) .isAfter(onlyDate(today))) { showAppSnackbar( title: 'Invalid Date', message: 'Reimbursement date cannot be in the future.', type: SnackbarType.error, ); return false; } final success = await controller.updateExpenseStatusWithReimbursement( comment: comment, reimburseTransactionId: reimburseTransactionId, reimburseDate: reimburseDate, reimburseById: reimburseById, statusId: statusId, ); if (success) { Navigator.of(context).pop(); showAppSnackbar( title: 'Success', message: 'Expense reimbursed successfully.', type: SnackbarType.success, ); await controller.fetchExpenseDetails(); return true; } else { showAppSnackbar( title: 'Error', message: 'Failed to reimburse expense.', type: SnackbarType.error, ); return false; } }), ); } else { final comment = await showCommentBottomSheet(context, next.name); if (comment == null) return; final success = await controller.updateExpenseStatus(next.id, comment: comment); if (success) { showAppSnackbar( title: 'Success', message: 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', type: SnackbarType.success); await controller.fetchExpenseDetails(); } else { showAppSnackbar( title: 'Error', message: 'Failed to update status.', type: SnackbarType.error); } } }, child: MyText.labelMedium( next.displayName.isNotEmpty ? next.displayName : next.name, color: Colors.white, fontWeight: 600, overflow: TextOverflow.ellipsis, ), ); } } class _AppBar extends StatelessWidget implements PreferredSizeWidget { final ProjectController projectController; const _AppBar({required this.projectController}); @override Widget build(BuildContext context) { return AppBar( automaticallyImplyLeading: false, elevation: 1, backgroundColor: Colors.white, title: Row( children: [ IconButton( icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'), ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.titleLarge('Expense Details', fontWeight: 700, color: Colors.black), MySpacing.height(2), GetBuilder( builder: (_) { final projectName = projectController.selectedProject?.name ?? 'Select Project'; return Row( children: [ const Icon(Icons.work_outline, size: 14, color: Colors.grey), MySpacing.width(4), Expanded( child: MyText.bodySmall( projectName, fontWeight: 600, overflow: TextOverflow.ellipsis, color: Colors.grey[700], ), ), ], ); }, ), ], ), ), ], ), ); } @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); } class _InvoiceHeader extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceHeader({required this.expense}); @override Widget build(BuildContext context) { final dateString = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toString(), format: 'dd MMM yyyy'); final statusColor = getExpenseStatusColor(expense.status.name, colorCode: expense.status.color); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row(children: [ const Icon(Icons.calendar_month, size: 18, color: Colors.grey), MySpacing.width(6), MyText.bodySmall('Date:', fontWeight: 600), MySpacing.width(6), MyText.bodySmall(dateString, fontWeight: 600), ]), Container( decoration: BoxDecoration( color: statusColor.withOpacity(0.15), borderRadius: BorderRadius.circular(5)), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), child: Row( children: [ Icon(Icons.flag, size: 16, color: statusColor), MySpacing.width(4), MyText.labelSmall(expense.status.name, color: statusColor, fontWeight: 600), ], ), ), ]) ], ); } } class _InvoiceParties extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceParties({required this.expense}); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ labelValueBlock('Project', expense.project.name), MySpacing.height(16), labelValueBlock('Paid By:', '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), MySpacing.height(16), labelValueBlock('Supplier', expense.supplerName), MySpacing.height(16), labelValueBlock('Created By:', '${expense.createdBy.firstName} ${expense.createdBy.lastName}'), ], ); } } class _InvoiceDetailsTable extends StatelessWidget { final ExpenseDetailModel expense; const _InvoiceDetailsTable({required this.expense}); @override Widget build(BuildContext context) { final transactionDate = DateTimeUtils.convertUtcToLocal( expense.transactionDate.toString(), format: 'dd MMM yyyy'); final createdAt = DateTimeUtils.convertUtcToLocal( expense.createdAt.toString(), format: 'dd MMM yyyy'); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _detailItem("Expense Type:", expense.expensesType.name), _detailItem("Payment Mode:", expense.paymentMode.name), _detailItem("Transaction Date:", transactionDate), _detailItem("Created At:", createdAt), _detailItem("Pre-Approved:", expense.preApproved ? 'Yes' : 'No'), _detailItem("Description:", expense.description.trim().isNotEmpty ? expense.description : '-', isDescription: true), ], ); } Widget _detailItem(String title, String value, {bool isDescription = false}) => Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall(title, fontWeight: 600), MySpacing.height(3), isDescription ? ExpandableDescription(description: value) : MyText.bodySmall(value, fontWeight: 500), ], ), ); } class _InvoiceDocuments extends StatelessWidget { final List documents; const _InvoiceDocuments({required this.documents}); @override Widget build(BuildContext context) { if (documents.isEmpty) return MyText.bodyMedium('No Supporting Documents', color: Colors.grey); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall("Supporting Documents:", fontWeight: 600), const SizedBox(height: 12), ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: documents.length, separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, index) { final doc = documents[index]; return GestureDetector( onTap: () async { final imageDocs = documents .where((d) => d.contentType.startsWith('image/')) .toList(); final initialIndex = imageDocs.indexWhere((d) => d.documentId == doc.documentId); if (imageDocs.isNotEmpty && initialIndex != -1) { showDialog( context: context, builder: (_) => ImageViewerDialog( imageSources: imageDocs.map((e) => e.preSignedUrl).toList(), initialIndex: initialIndex, ), ); } else { final Uri url = Uri.parse(doc.preSignedUrl); if (await canLaunchUrl(url)) { await launchUrl(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: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(5), color: Colors.grey.shade100, ), child: Row( children: [ Icon( doc.contentType.startsWith('image/') ? Icons.image : Icons.insert_drive_file, size: 20, color: Colors.grey[600], ), const SizedBox(width: 7), Expanded( child: MyText.labelSmall( doc.fileName, overflow: TextOverflow.ellipsis, ), ), ], ), ), ); }, ), ], ); } } class InvoiceLogs extends StatelessWidget { final List logs; const InvoiceLogs({required this.logs}); @override Widget build(BuildContext context) { if (logs.isEmpty) { return Padding( padding: const EdgeInsets.all(16.0), child: MyText.bodyMedium('No Activity Logs', color: Colors.grey), ); } final displayedLogs = logs.reversed.toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall("Activity Logs:", fontWeight: 600), const SizedBox(height: 16), ListView.builder( itemCount: displayedLogs.length, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemBuilder: (_, index) { final log = displayedLogs[index]; final formattedDate = DateTimeUtils.convertUtcToLocal( log.updateAt, format: 'dd MMM yyyy hh:mm a', ); return TimelineTile( alignment: TimelineAlign.start, isFirst: index == 0, isLast: index == displayedLogs.length - 1, indicatorStyle: IndicatorStyle( width: 16, height: 16, indicator: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.blue.shade700, ), ), ), beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2), endChild: Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodyMedium( "${log.updatedBy.firstName} ${log.updatedBy.lastName}", fontWeight: 600, ), const SizedBox(height: 4), MyText.bodyMedium( log.comment.isNotEmpty ? log.comment : log.action, fontWeight: 500, color: Colors.black87, ), const SizedBox(height: 8), Row( children: [ Icon(Icons.access_time, size: 14, color: Colors.grey[600]), const SizedBox(width: 4), MyText.bodySmall(formattedDate, color: Colors.grey[700]), ], ), const SizedBox(height: 8), Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 4), decoration: BoxDecoration( color: Colors.blue.shade50, borderRadius: BorderRadius.circular(5), ), child: MyText.bodySmall( log.action, color: Colors.blue.shade700, fontWeight: 600, ), ), ], ), ), ); }, ), ], ); } } 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; final Color statusColor; const _InvoiceTotals({ required this.expense, required this.formattedAmount, required this.statusColor, }); @override Widget build(BuildContext context) { return Row( children: [ MyText.bodyLarge("Total:", fontWeight: 700), const Spacer(), MyText.bodyLarge(formattedAmount, fontWeight: 700), ], ); } }