marco.pms.mobileapp/lib/view/finance/payment_request_detail_screen.dart

744 lines
26 KiB
Dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/finance/payment_request_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_refresh_indicator.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:timeline_tile/timeline_tile.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/model/finance/payment_request_details_model.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
import 'package:timeago/timeago.dart' as timeago;
import 'package:marco/model/expense/comment_bottom_sheet.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/model/employees/employee_info.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/finance/payment_request_rembursement_bottom_sheet.dart';
import 'package:marco/model/finance/make_expense_bottom_sheet.dart';
import 'package:marco/model/finance/add_payment_request_bottom_sheet.dart';
class PaymentRequestDetailScreen extends StatefulWidget {
final String paymentRequestId;
const PaymentRequestDetailScreen({super.key, required this.paymentRequestId});
@override
State<PaymentRequestDetailScreen> createState() =>
_PaymentRequestDetailScreenState();
}
class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
with UIMixin {
final controller = Get.put(PaymentRequestDetailController());
final projectController = Get.find<ProjectController>();
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<void> _loadEmployeeInfo() async {
employeeInfo = await LocalStorage.getEmployeeInfo();
setState(() {});
}
Color _parseColor(String hexColor) {
String hex = hexColor.toUpperCase().replaceAll('#', '');
if (hex.length == 6) hex = 'FF$hex';
return Color(int.parse(hex, radix: 16));
}
void _openEditPaymentRequestBottomSheet(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.toString(),
"currencyId": request.currency.id,
"currencySymbol": request.currency.symbol,
"payee": request.payee,
"description": request.description,
"isAdvancePayment": request.isAdvancePayment,
"dueDate": request.dueDate,
"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) {
return Scaffold(
backgroundColor: Colors.white,
appBar: _buildAppBar(),
body: SafeArea(child: Obx(() {
if (controller.isLoading.value &&
controller.paymentRequest.value == null) {
return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
}
final request = controller.paymentRequest.value;
if (controller.errorMessage.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) {
_checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_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();
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 {
if (status.id == reimbursementStatusId) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius:
BorderRadius.vertical(top: Radius.circular(5)),
),
builder: (ctx) => UpdatePaymentRequestWithReimbursement(
expenseId: request.paymentRequestUID,
statusId: status.id,
onClose: () {},
),
);
} else if (status.id ==
'b8586f67-dc19-49c3-b4af-224149efe1d3') {
showCreateExpenseBottomSheet(
statusId: status.id,
);
} else {
final comment = await showCommentBottomSheet(
context, status.displayName);
if (comment == null || comment.trim().isEmpty) return;
final success = await controller.updatePaymentRequestStatus(
statusId: status.id,
comment: comment.trim(),
);
showAppSnackbar(
title: success ? 'Success' : 'Error',
message: success
? 'Status updated successfully'
: 'Failed to update status',
type: success ? SnackbarType.success : SnackbarType.error,
);
if (success) await controller.fetchPaymentRequestDetail();
}
},
child: MyText.bodySmall(
status.displayName,
color: Colors.white,
fontWeight: 600,
),
);
}).toList(),
),
),
);
});
}
PreferredSizeWidget _buildAppBar() {
return PreferredSize(
preferredSize: const Size.fromHeight(72),
child: AppBar(
backgroundColor: const Color(0xFFF5F5F5),
elevation: 0.5,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: MySpacing.xy(16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20),
onPressed: () => Get.back(),
),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Payment Request Details',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(builder: (_) {
final name = 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(
name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
}),
],
),
),
],
),
),
),
);
}
}
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 ------------------
class _Header extends StatelessWidget with UIMixin {
final PaymentRequestData request;
final Color Function(String) colorParser;
final VoidCallback? onEdit;
final EmployeeInfo? employeeInfo;
_Header({
required this.request,
required this.colorParser,
this.onEdit,
this.employeeInfo,
});
@override
Widget build(BuildContext context) {
final statusColor = colorParser(request.expenseStatus.color);
final canEdit = employeeInfo != null &&
PaymentRequestPermissionHelper.canEditPaymentRequest(
employeeInfo, request);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(
'ID: ${request.paymentRequestUID}',
fontWeight: 700,
fontSize: 14,
),
if (canEdit)
IconButton(
onPressed: 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(
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(
request.expenseStatus.displayName,
color: statusColor,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
],
);
}
}
// ------------------ Logs, Parties, Details, Documents ------------------
class _Logs extends StatelessWidget {
final List<UpdateLog> 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 initials =
'${updatedBy.firstName.isNotEmpty == true ? updatedBy.firstName[0] : ''}'
'${updatedBy.lastName.isNotEmpty == true ? updatedBy.lastName[0] : ''}';
final name = '${updatedBy.firstName} ${updatedBy.lastName}';
final timestamp = log.updatedAt
.toUtc()
.add(const Duration(hours: 5, minutes: 30));
final timeAgo = timeago.format(timestamp);
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),
),
],
),
],
),
),
);
},
)
],
);
}
}
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),
],
),
),
],
),
],
);
}
}
class _DetailsTable extends StatelessWidget {
final PaymentRequestData request;
const _DetailsTable({required this.request});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_labelValueRow("Payment Request ID:", request.paymentRequestUID),
_labelValueRow("Expense Category:", request.expenseCategory.name),
_labelValueRow("Amount:",
"${request.currency.symbol} ${request.amount.toStringAsFixed(2)}"),
_labelValueRow(
"Due Date:",
DateTimeUtils.convertUtcToLocal(request.dueDate.toIso8601String(),
format: 'dd MMM yyyy')),
_labelValueRow("Description:", request.description),
_labelValueRow(
"Attachment:", request.attachments.isNotEmpty ? "Yes" : "No"),
],
);
}
}
class _Documents extends StatelessWidget {
final List<Attachment> 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 isImage = doc.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 Uri url = Uri.parse(doc.url);
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,
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),
),
],
),
);