import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:on_field_work/controller/finance/payment_request_detail_controller.dart'; import 'package:on_field_work/controller/project_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/helpers/utils/date_time_utils.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart'; import 'package:timeline_tile/timeline_tile.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/model/finance/payment_request_details_model.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:on_field_work/helpers/widgets/image_viewer_dialog.dart'; import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart'; import 'package:timeago/timeago.dart' as timeago; import 'package:on_field_work/model/expense/comment_bottom_sheet.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/model/employees/employee_info.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/model/finance/payment_request_rembursement_bottom_sheet.dart'; import 'package:on_field_work/model/finance/make_expense_bottom_sheet.dart'; import 'package:on_field_work/model/finance/add_payment_request_bottom_sheet.dart'; import 'package:on_field_work/helpers/widgets/custom_app_bar.dart'; class PaymentRequestDetailScreen extends StatefulWidget { final String paymentRequestId; const PaymentRequestDetailScreen({super.key, required this.paymentRequestId}); @override State createState() => _PaymentRequestDetailScreenState(); } class _PaymentRequestDetailScreenState extends State with UIMixin { final controller = Get.put(PaymentRequestDetailController()); final projectController = Get.find(); final permissionController = Get.put(PermissionController()); final RxBool canSubmit = false.obs; bool _checkedPermission = false; EmployeeInfo? employeeInfo; @override void initState() { super.initState(); controller.init(widget.paymentRequestId); _loadEmployeeInfo(); } void _checkPermissionToSubmit(PaymentRequestData request) { const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; final isCreatedByCurrentUser = employeeInfo?.id == request.createdBy?.id; final hasDraftNextStatus = (request.nextStatus ?? []).any((s) => (s.id ?? '') == draftStatusId); canSubmit.value = isCreatedByCurrentUser && hasDraftNextStatus; } Future _loadEmployeeInfo() async { employeeInfo = await LocalStorage.getEmployeeInfo(); setState(() {}); } Color _parseColor(String? hexColor) { try { if (hexColor == null || hexColor.trim().isEmpty) return Colors.grey; String hex = hexColor.toUpperCase().replaceAll('#', ''); if (hex.length == 6) hex = 'FF$hex'; return Color(int.parse(hex, radix: 16)); } catch (_) { return Colors.grey; } } void _openEditPaymentRequestBottomSheet(PaymentRequestData request) { showPaymentRequestBottomSheet( isEdit: true, existingData: { "id": request.id ?? '', "paymentRequestId": request.paymentRequestUID ?? '', "title": request.title ?? '', "projectId": request.project?.id ?? '', "projectName": request.project?.name ?? '', "expenseCategoryId": request.expenseCategory?.id ?? '', "expenseCategoryName": request.expenseCategory?.name ?? '', "amount": (request.amount ?? 0).toString(), "currencyId": request.currency?.id ?? '', "currencySymbol": request.currency?.symbol ?? '', "payee": request.payee ?? '', "description": request.description ?? '', "isAdvancePayment": request.isAdvancePayment ?? false, "dueDate": request.dueDate?.toIso8601String() ?? '', "attachments": (request.attachments ?? []) .map((a) => { "url": a.url ?? '', "fileName": a.fileName ?? '', "documentId": a.id ?? '', "contentType": a.contentType ?? '', }) .toList(), }, onUpdated: () async { await controller.fetchPaymentRequestDetail(); }, ); } @override Widget build(BuildContext context) { final Color appBarColor = contentTheme.primary; return Scaffold( backgroundColor: Colors.white, appBar: CustomAppBar( title: "Payment Request Details", backgroundColor: appBarColor, ), body: Stack( children: [ // ===== TOP GRADIENT ===== Container( height: 80, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ appBarColor, appBarColor.withOpacity(0.0), ], ), ), ), // ===== MAIN CONTENT ===== SafeArea( child: Obx(() { if (controller.isLoading.value && controller.paymentRequest.value == null) { return SkeletonLoaders.paymentRequestDetailSkeletonLoader(); } final request = controller.paymentRequest.value; if ((controller.errorMessage.value).isNotEmpty) { return Center( child: MyText.bodyMedium(controller.errorMessage.value)); } if (request == null) { return Center(child: MyText.bodyMedium("No data to display.")); } return MyRefreshIndicator( onRefresh: controller.fetchPaymentRequestDetail, child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( 12, 12, 12, 60 + MediaQuery.of(context).padding.bottom, ), child: Center( child: Container( constraints: const BoxConstraints(maxWidth: 520), child: Card( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5)), child: Padding( padding: const EdgeInsets.symmetric( vertical: 12, horizontal: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _Header( request: request, colorParser: _parseColor, employeeInfo: employeeInfo, onEdit: () => _openEditPaymentRequestBottomSheet(request), ), const Divider(height: 30, thickness: 1.2), _Logs( logs: request.updateLogs ?? [], colorParser: _parseColor, ), const Divider(height: 30, thickness: 1.2), _Parties(request: request), const Divider(height: 30, thickness: 1.2), _DetailsTable(request: request), const Divider(height: 30, thickness: 1.2), _Documents(documents: request.attachments ?? []), MySpacing.height(24), ], ), ), ), ), ), ), ); }), ), ], ), bottomNavigationBar: _buildBottomActionBar(), ); } Widget _buildBottomActionBar() { return Obx(() { final request = controller.paymentRequest.value; if (request == null || controller.isLoading.value || employeeInfo == null) { return const SizedBox.shrink(); } if (!_checkedPermission && request != null && employeeInfo != null) { _checkedPermission = true; _checkPermissionToSubmit(request); } const reimbursementStatusId = '61578360-3a49-4c34-8604-7b35a3787b95'; const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; final availableStatuses = (request.nextStatus ?? []).where((status) { if ((status.id ?? '') == draftStatusId) { return employeeInfo?.id == request.createdBy?.id; } return permissionController .hasAnyPermission(status.permissionIds ?? []); }).toList(); if (availableStatuses.isEmpty) { return const SizedBox.shrink(); } return SafeArea( child: Container( decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.grey.shade300)), ), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), child: Wrap( alignment: WrapAlignment.center, spacing: 10, runSpacing: 10, children: availableStatuses.map((status) { final color = _parseColor(status.color); return ElevatedButton( style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), backgroundColor: color, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), onPressed: () async { final statusId = status.id ?? ''; final dispName = status.displayName ?? ''; if (statusId == reimbursementStatusId) { showModalBottomSheet( context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(5)), ), builder: (ctx) => UpdatePaymentRequestWithReimbursement( expenseId: request.paymentRequestUID ?? '', statusId: statusId, onClose: () {}, ), ); } else if (statusId == 'b8586f67-dc19-49c3-b4af-224149efe1d3') { showCreateExpenseBottomSheet( statusId: statusId, ); } else { final comment = await showCommentBottomSheet(context, dispName); if (comment == null || comment.trim().isEmpty) return; final success = await controller.updatePaymentRequestStatus( statusId: statusId, comment: comment.trim(), ); if (!success) { showAppSnackbar( title: 'Error', message: 'Failed to update status', type: SnackbarType.error, ); return; } showAppSnackbar( title: 'Success', message: 'Status updated successfully', type: SnackbarType.success, ); } }, child: MyText.bodySmall( status.displayName ?? '', color: Colors.white, fontWeight: 600, ), ); }).toList(), ), ), ); }); } } class PaymentRequestPermissionHelper { static bool canEditPaymentRequest( EmployeeInfo? employee, PaymentRequestData request) { return employee?.id == request.createdBy?.id && _isInAllowedEditStatus(request.expenseStatus?.id ?? ''); } static bool canSubmitPaymentRequest( EmployeeInfo? employee, PaymentRequestData request) { return employee?.id == request.createdBy?.id && (request.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); } } // ------------------ Sub-widgets ------------------ // Header widget class _Header extends StatefulWidget { final PaymentRequestData request; final Color Function(String?) colorParser; final VoidCallback? onEdit; final EmployeeInfo? employeeInfo; const _Header({ required this.request, required this.colorParser, this.onEdit, this.employeeInfo, }); @override State<_Header> createState() => _HeaderState(); } class _HeaderState extends State<_Header> with UIMixin { @override Widget build(BuildContext context) { final statusColor = widget.colorParser(widget.request.expenseStatus?.color); final canEdit = widget.employeeInfo != null && PaymentRequestPermissionHelper.canEditPaymentRequest( widget.employeeInfo, widget.request); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ MyText.bodyMedium( 'ID: ${widget.request.paymentRequestUID ?? '-'}', fontWeight: 700, fontSize: 14, ), // 🔥 ADVANCE CHIP — show only if true if (widget.request.isAdvancePayment == true) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange.shade700, borderRadius: BorderRadius.circular(4), ), child: const Text( 'ADVANCE', style: TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), ), ], ], ), if (canEdit) IconButton( onPressed: widget.onEdit, icon: Icon(Icons.edit, color: contentTheme.primary), tooltip: "Edit Payment Request", ), ], ), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Row( children: [ const Icon(Icons.calendar_month, size: 18, color: Colors.grey), MySpacing.width(6), MyText.bodySmall('Created At:', fontWeight: 600), MySpacing.width(6), Expanded( child: MyText.bodySmall( DateTimeUtils.convertUtcToLocal( widget.request.createdAt?.toIso8601String() ?? '', format: 'dd MMM yyyy'), fontWeight: 600, overflow: TextOverflow.ellipsis, ), ), ], ), ), 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), SizedBox( width: 100, child: MyText.labelSmall( widget.request.expenseStatus?.displayName ?? '-', color: statusColor, fontWeight: 600, overflow: TextOverflow.ellipsis, ), ), ], ), ), ], ), ], ); } } // Logs widget class _Logs extends StatelessWidget { final List logs; final Color Function(String?) colorParser; const _Logs({required this.logs, required this.colorParser}); @override Widget build(BuildContext context) { if (logs.isEmpty) { return MyText.bodyMedium('No Timeline', color: Colors.grey); } final reversedLogs = logs.reversed.toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall("Timeline:", fontWeight: 600), ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: reversedLogs.length, itemBuilder: (_, index) { final log = reversedLogs[index]; final status = log.status?.name ?? 'Unknown'; final description = log.status?.description ?? ''; final statusColor = log.status != null ? colorParser(log.status!.color) : Colors.grey; final comment = log.comment ?? ''; final nextStatusName = log.nextStatus?.name ?? ''; final updatedBy = log.updatedBy; final first = updatedBy?.firstName ?? ''; final last = updatedBy?.lastName ?? ''; final initials = '${first.isNotEmpty ? first[0] : ''}${last.isNotEmpty ? last[0] : ''}'; final name = ((first + ' ' + last).trim().isNotEmpty) ? '$first $last' : '-'; final updatedAt = log.updatedAt; final timeAgo = (updatedAt != null) ? timeago.format(updatedAt .toUtc() .add(const Duration(hours: 5, minutes: 30))) : '-'; final nextStatusColor = colorParser(log.nextStatus?.color ?? ''); return TimelineTile( alignment: TimelineAlign.start, isFirst: index == 0, isLast: index == reversedLogs.length - 1, indicatorStyle: IndicatorStyle( width: 16, height: 16, indicator: Container( decoration: BoxDecoration(shape: BoxShape.circle, color: statusColor), ), ), beforeLineStyle: LineStyle(color: Colors.grey.shade300, thickness: 2), endChild: Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ MyText.bodyMedium(status, fontWeight: 600, color: statusColor), MyText.bodySmall(timeAgo, color: Colors.grey[600], textAlign: TextAlign.right), ], ), if (description.isNotEmpty) ...[ const SizedBox(height: 4), MyText.bodySmall(description, color: Colors.grey[800]), ], if (comment.isNotEmpty) ...[ const SizedBox(height: 8), MyText.bodyMedium(comment, fontWeight: 500), ], const SizedBox(height: 8), Row( children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(4), ), child: MyText.bodySmall(initials, fontWeight: 600), ), const SizedBox(width: 6), Expanded( child: MyText.bodySmall(name, overflow: TextOverflow.ellipsis)), if (nextStatusName.isNotEmpty) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4), decoration: BoxDecoration( color: nextStatusColor.withOpacity(0.15), borderRadius: BorderRadius.circular(4), ), child: MyText.bodySmall(nextStatusName, fontWeight: 600, color: nextStatusColor), ), ], ), ], ), ), ); }, ) ], ); } } // Parties widget class _Parties extends StatelessWidget { final PaymentRequestData request; const _Parties({required this.request}); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall("Parties:", fontWeight: 600), MySpacing.height(8), Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.labelMedium("Payee", fontWeight: 600), MySpacing.height(2), MyText.bodyMedium(request.payee ?? '-'), ], ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.labelMedium("Project", fontWeight: 600), MySpacing.height(2), MyText.bodyMedium(request.project?.name ?? '-'), ], ), ), ], ), ], ); } } // Details table widget class _DetailsTable extends StatelessWidget { final PaymentRequestData request; const _DetailsTable({required this.request}); String _formatCurrencyAmount(String? symbol, double? amount) { final sym = symbol ?? ''; final amt = amount ?? 0.0; return '$sym ${amt.toStringAsFixed(2)}'; } @override Widget build(BuildContext context) { final currencySymbol = request.currency?.symbol ?? ''; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Basic Info _labelValueRow("Payment Request ID:", request.paymentRequestUID ?? '-'), if ((request.paidTransactionId ?? '').isNotEmpty) _labelValueRow("Transaction ID:", request.paidTransactionId ?? ''), _labelValueRow("Payee:", request.payee ?? '-'), _labelValueRow("Project:", request.project?.name ?? '-'), _labelValueRow( "Expense Category:", request.expenseCategory?.name ?? '-'), // Amounts _labelValueRow( "Amount:", _formatCurrencyAmount(currencySymbol, request.amount)), if (request.baseAmount != null) _labelValueRow("Base Amount:", _formatCurrencyAmount(currencySymbol, request.baseAmount)), if (request.taxAmount != null) _labelValueRow("Tax Amount:", _formatCurrencyAmount(currencySymbol, request.taxAmount)), if (request.expenseCategory?.noOfPersonsRequired == true) _labelValueRow("Additional Persons Required:", "Yes"), if (request.expenseCategory?.isAttachmentRequried == true) _labelValueRow("Attachment Required:", "Yes"), // Dates _labelValueRow( "Due Date:", DateTimeUtils.convertUtcToLocal( request.dueDate?.toIso8601String() ?? '', format: 'dd MMM yyyy')), _labelValueRow( "Created At:", DateTimeUtils.convertUtcToLocal( request.createdAt?.toIso8601String() ?? '', format: 'dd MMM yyyy')), _labelValueRow( "Updated At:", DateTimeUtils.convertUtcToLocal( request.updatedAt?.toIso8601String() ?? '', format: 'dd MMM yyyy')), // Payment Info if (request.paidAt != null) _labelValueRow( "Transaction Date:", DateTimeUtils.convertUtcToLocal( request.paidAt?.toIso8601String() ?? '', format: 'dd MMM yyyy'), ), if (request.paidBy != null) _labelValueRow( "Paid By:", "${request.paidBy?.firstName ?? ''} ${request.paidBy?.lastName ?? ''}" .trim()), // Flags _labelValueRow("Advance Payment:", (request.isAdvancePayment ?? false) ? "Yes" : "No"), _labelValueRow("Expense Created:", (request.isExpenseCreated ?? false) ? "Yes" : "No"), _labelValueRow("Active:", (request.isActive ?? false) ? "Yes" : "No"), // Recurring Payment Info if (request.recurringPayment != null) ...[ const SizedBox(height: 6), MyText.bodySmall("Recurring Payment Info:", fontWeight: 600), _labelValueRow("Recurring ID:", request.recurringPayment?.recurringPaymentUID ?? '-'), _labelValueRow( "Amount:", _formatCurrencyAmount( currencySymbol, request.recurringPayment?.amount)), _labelValueRow("Variable Amount:", (request.recurringPayment?.isVariable ?? false) ? "Yes" : "No"), ], // Description & Attachments _labelValueRow("Description:", request.description ?? '-'), _labelValueRow("Attachment:", (request.attachments ?? []).isNotEmpty ? "Yes" : "No"), ], ); } } // Documents widget class _Documents extends StatelessWidget { final List documents; const _Documents({required this.documents}); @override Widget build(BuildContext context) { if (documents.isEmpty) { return MyText.bodyMedium('No Documents', color: Colors.grey); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ MyText.bodySmall("Documents:", fontWeight: 600), MySpacing.height(12), ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: documents.length, separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, index) { final doc = documents[index]; final contentType = doc.contentType ?? ''; final isImage = contentType.startsWith('image/'); return GestureDetector( onTap: () async { final imageDocs = documents .where((d) => (d.contentType ?? '').startsWith('image/')) .toList(); final initialIndex = imageDocs.indexWhere((d) => (d.id ?? '') == (doc.id ?? '')); if (isImage && imageDocs.isNotEmpty && initialIndex != -1) { showDialog( context: context, builder: (_) => ImageViewerDialog( imageSources: imageDocs.map((e) => e.url ?? '').toList(), initialIndex: initialIndex, ), ); } else { final urlStr = doc.url ?? ''; if (urlStr.isEmpty) { showAppSnackbar( title: 'Error', message: 'Document URL is missing.', type: SnackbarType.error, ); return; } final Uri url = Uri.parse(urlStr); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } else { showAppSnackbar( title: 'Error', message: 'Could not open 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(isImage ? Icons.image : Icons.insert_drive_file, size: 20, color: Colors.grey[600]), const SizedBox(width: 7), Expanded( child: MyText.bodySmall( doc.fileName ?? (doc.url ?? '-'), overflow: TextOverflow.ellipsis, ), ), ], ), ), ); }, ), ], ); } } // Utility widget for label-value row. Widget _labelValueRow(String label, String value) => Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 120, child: MyText.bodySmall(label, fontWeight: 600), ), Expanded( child: MyText.bodySmall(value, fontWeight: 500, softWrap: true), ), ], ), );