771 lines
28 KiB
Dart
771 lines
28 KiB
Dart
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_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';
|
|
import 'package:timeline_tile/timeline_tile.dart';
|
|
class ExpenseDetailScreen extends StatefulWidget {
|
|
final String expenseId;
|
|
const ExpenseDetailScreen({super.key, required this.expenseId});
|
|
|
|
@override
|
|
State<ExpenseDetailScreen> createState() => _ExpenseDetailScreenState();
|
|
}
|
|
|
|
class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
|
|
final controller = Get.put(ExpenseDetailController());
|
|
final projectController = Get.find<ProjectController>();
|
|
final permissionController = Get.find<PermissionController>();
|
|
|
|
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(10)),
|
|
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),
|
|
_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),
|
|
InvoiceLogs(logs: expense.expenseLogs),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
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(
|
|
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,
|
|
tooltip: 'Edit Expense',
|
|
child: const Icon(Icons.edit),
|
|
);
|
|
}),
|
|
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(6)),
|
|
),
|
|
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(16))),
|
|
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<ProjectController>(
|
|
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-MM-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(8)),
|
|
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-MM-yyyy hh:mm a');
|
|
final createdAt = DateTimeUtils.convertUtcToLocal(
|
|
expense.createdAt.toString(),
|
|
format: 'dd-MM-yyyy hh:mm a');
|
|
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<ExpenseDocument> 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(6),
|
|
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<ExpenseLog> 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(6),
|
|
),
|
|
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),
|
|
],
|
|
);
|
|
}
|
|
}
|