diff --git a/lib/controller/expense/expense_detail_controller.dart b/lib/controller/expense/expense_detail_controller.dart index 116cc84..6d246a6 100644 --- a/lib/controller/expense/expense_detail_controller.dart +++ b/lib/controller/expense/expense_detail_controller.dart @@ -2,12 +2,20 @@ import 'package:get/get.dart'; 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'; class ExpenseDetailController extends GetxController { final Rx expense = Rx(null); - final RxBool isLoading = false.obs; final RxString errorMessage = ''.obs; + final Rx selectedReimbursedBy = Rx(null); + final RxList allEmployees = [].obs; + + @override + void onInit() { + super.onInit(); + fetchAllEmployees(); + } /// Fetch expense details by ID Future fetchExpenseDetails(String expenseId) async { @@ -42,16 +50,85 @@ class ExpenseDetailController extends GetxController { } } + /// Fetch all employees + Future fetchAllEmployees() async { + isLoading.value = true; + errorMessage.value = ''; + + try { + final response = await ApiService.getAllEmployees(); + if (response != null && response.isNotEmpty) { + allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); + logSafe("All Employees fetched: ${allEmployees.length}", + level: LogLevel.info); + } else { + allEmployees.clear(); + logSafe("No employees found.", level: LogLevel.warning); + } + } catch (e) { + allEmployees.clear(); + logSafe("Error fetching employees", level: LogLevel.error, error: e); + } finally { + isLoading.value = false; + update(); + } + } + + /// Update expense with reimbursement info and status + Future updateExpenseStatusWithReimbursement({ + required String expenseId, + required String comment, + required String reimburseTransactionId, + required String reimburseDate, + required String reimburseById, + }) async { + isLoading.value = true; + errorMessage.value = ''; + + try { + logSafe("Submitting reimbursement for expense: $expenseId"); + + final success = await ApiService.updateExpenseStatusApi( + expenseId: expenseId, + statusId: 'reimbursed', + comment: comment, + reimburseTransactionId: reimburseTransactionId, + reimburseDate: reimburseDate, + reimbursedById: reimburseById, + ); + + if (success) { + logSafe("Reimbursement submitted successfully."); + await fetchExpenseDetails(expenseId); + return true; + } else { + errorMessage.value = "Failed to submit reimbursement."; + return false; + } + } catch (e, stack) { + errorMessage.value = 'An unexpected error occurred.'; + logSafe("Exception in updateExpenseStatusWithReimbursement: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; + } finally { + isLoading.value = false; + } + } + /// Update status for this specific expense Future updateExpenseStatus(String expenseId, String statusId) async { isLoading.value = true; errorMessage.value = ''; + try { logSafe("Updating status for expense: $expenseId -> $statusId"); + final success = await ApiService.updateExpenseStatusApi( expenseId: expenseId, statusId: statusId, ); + if (success) { logSafe("Expense status updated successfully."); await fetchExpenseDetails(expenseId); diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 01476e7..90c2888 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -288,21 +288,42 @@ class ApiService { } /// Update Expense Status API + /// Update Expense Status API (supports optional reimbursement fields) static Future updateExpenseStatusApi({ required String expenseId, required String statusId, + String? comment, + String? reimburseTransactionId, + String? reimburseDate, + String? reimbursedById, }) async { - final payload = { + final Map payload = { "expenseId": expenseId, "statusId": statusId, }; + if (comment != null) { + payload["comment"] = comment; + } + if (reimburseTransactionId != null) { + payload["reimburseTransactionId"] = reimburseTransactionId; + } + if (reimburseDate != null) { + payload["reimburseDate"] = reimburseDate; + } + if (reimbursedById != null) { + payload["reimburseById"] = reimbursedById; + } + const endpoint = ApiEndpoints.updateExpenseStatus; logSafe("Updating expense status with payload: $payload"); try { - final response = - await _postRequest(endpoint, payload, customTimeout: extendedTimeout); + final response = await _postRequest( + endpoint, + payload, + customTimeout: extendedTimeout, + ); if (response == null) { logSafe("Update expense status failed: null response", @@ -331,8 +352,7 @@ class ApiService { return false; } - - static Future?> getExpenseListApi({ +static Future?> getExpenseListApi({ String? filter, int pageSize = 20, int pageNumber = 1, @@ -382,7 +402,6 @@ class ApiService { return null; } } - /// Fetch Master Payment Modes static Future?> getMasterPaymentModes() async { const endpoint = ApiEndpoints.getMasterPaymentModes; diff --git a/lib/model/expense/reimbursement_bottom_sheet.dart b/lib/model/expense/reimbursement_bottom_sheet.dart new file mode 100644 index 0000000..3c15655 --- /dev/null +++ b/lib/model/expense/reimbursement_bottom_sheet.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:marco/controller/expense/expense_detail_controller.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; + +class ReimbursementBottomSheet extends StatefulWidget { + final String expenseId; + final String statusId; + final void Function() onClose; + final Future Function({ + required String comment, + required String reimburseTransactionId, + required String reimburseDate, + required String reimburseById, + + }) onSubmit; + + const ReimbursementBottomSheet({ + super.key, + required this.expenseId, + required this.onClose, + required this.onSubmit, + required this.statusId, + + }); + + @override + State createState() => _ReimbursementBottomSheetState(); +} + +class _ReimbursementBottomSheetState extends State { + final ExpenseDetailController controller = Get.find(); + + final TextEditingController commentCtrl = TextEditingController(); + final TextEditingController txnCtrl = TextEditingController(); + final RxString dateStr = ''.obs; + + @override + void dispose() { + commentCtrl.dispose(); + txnCtrl.dispose(); + super.dispose(); + } + + void _showEmployeeList() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + 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); + }, + ); + }, + ); + }), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 50, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(20), + ), + ), + + // Title + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.titleLarge('Reimbursement Info', fontWeight: 700), + const SizedBox(), + ], + ), + const SizedBox(height: 20), + + Flexible( + child: SingleChildScrollView( + child: Column( + children: [ + _buildInputField(label: 'Comment', controller: commentCtrl), + const SizedBox(height: 16), + _buildInputField(label: 'Transaction ID', controller: txnCtrl), + const SizedBox(height: 16), + _buildDatePickerField(), + const SizedBox(height: 16), + _buildEmployeePickerField(), + const SizedBox(height: 24), + _buildActionButtons(), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildInputField({required String label, required TextEditingController controller}) { + return TextField( + controller: controller, + decoration: InputDecoration( + labelText: label, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ); + } + + Widget _buildDatePickerField() { + return Obx(() { + return InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: dateStr.value.isEmpty + ? DateTime.now() + : DateFormat('yyyy-MM-dd').parse(dateStr.value), + firstDate: DateTime(2020), + lastDate: DateTime(2100), + ); + if (picked != null) { + dateStr.value = DateFormat('yyyy-MM-dd').format(picked); + } + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Reimbursement Date', + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + child: Row( + children: [ + Icon(Icons.date_range, color: Colors.grey.shade600), + const SizedBox(width: 10), + Text( + dateStr.value.isEmpty ? "Select Date" : dateStr.value, + style: TextStyle( + fontSize: 14, + color: dateStr.value.isEmpty ? Colors.grey : Colors.black, + ), + ), + ], + ), + ), + ); + }); + } + + Widget _buildEmployeePickerField() { + return Obx(() { + return GestureDetector( + onTap: _showEmployeeList, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Reimbursed By', + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + child: Text( + controller.selectedReimbursedBy.value == null + ? "Select Reimbursed By" + : '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}', + style: TextStyle( + fontSize: 14, + color: controller.selectedReimbursedBy.value == null ? Colors.grey : Colors.black, + ), + ), + ), + ); + }); + } + + Widget _buildActionButtons() { + return Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + widget.onClose(); + Get.back(); + }, + icon: const Icon(Icons.close, color: Colors.white), + label: MyText.bodyMedium("Cancel", color: Colors.white, fontWeight: 600), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 14), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Obx(() { + return ElevatedButton.icon( + onPressed: controller.isLoading.value + ? null + : () async { + if (commentCtrl.text.trim().isEmpty || + txnCtrl.text.trim().isEmpty || + dateStr.value.isEmpty || + controller.selectedReimbursedBy.value == null) { + Get.snackbar("Incomplete", "Please fill all fields"); + return; + } + + final success = await widget.onSubmit( + comment: commentCtrl.text.trim(), + reimburseTransactionId: txnCtrl.text.trim(), + reimburseDate: dateStr.value, + reimburseById: controller.selectedReimbursedBy.value!.id, + ); + + if (success) { + Get.back(); + Get.snackbar('Success', 'Reimbursement submitted'); + } else { + Get.snackbar('Error', controller.errorMessage.value); + } + }, + icon: const Icon(Icons.check_circle_outline, color: Colors.white), + label: MyText.bodyMedium( + controller.isLoading.value ? "Submitting..." : "Submit", + color: Colors.white, + fontWeight: 600, + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 14), + ), + ); + }), + ), + ], + ); + } +} diff --git a/lib/view/expense/expense_detail_screen.dart b/lib/view/expense/expense_detail_screen.dart index f7204a9..698f965 100644 --- a/lib/view/expense/expense_detail_screen.dart +++ b/lib/view/expense/expense_detail_screen.dart @@ -9,6 +9,7 @@ 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'; class ExpenseDetailScreen extends StatelessWidget { final String expenseId; @@ -186,23 +187,75 @@ class ExpenseDetailScreen extends StatelessWidget { borderRadius: BorderRadius.circular(6)), ), onPressed: () async { - final success = await controller.updateExpenseStatus( - expense.id, next.id); - if (success) { - Get.snackbar( - 'Success', - 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', - backgroundColor: Colors.green.withOpacity(0.8), - colorText: Colors.white, + if (expense.status.id == + 'f18c5cfd-7815-4341-8da2-2c2d65778e27') { + 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: + () {}, // <-- This is the missing required parameter + onSubmit: ({ + required String comment, + required String reimburseTransactionId, + required String reimburseDate, + required String reimburseById, + }) async { + final success = await controller + .updateExpenseStatusWithReimbursement( + expenseId: expense.id, + comment: comment, + reimburseTransactionId: reimburseTransactionId, + reimburseDate: reimburseDate, + reimburseById: reimburseById, + ); + + if (success) { + Get.snackbar( + 'Success', + 'Expense reimbursed successfully.', + backgroundColor: Colors.green.withOpacity(0.8), + colorText: Colors.white, + ); + await controller.fetchExpenseDetails(expenseId); + return true; + } else { + Get.snackbar( + 'Error', + 'Failed to reimburse expense.', + backgroundColor: Colors.red.withOpacity(0.8), + colorText: Colors.white, + ); + return false; + } + }, + ), ); - await controller.fetchExpenseDetails(expenseId); } else { - Get.snackbar( - 'Error', - 'Failed to update status.', - backgroundColor: Colors.red.withOpacity(0.8), - colorText: Colors.white, - ); + final success = await controller.updateExpenseStatus( + expense.id, next.id); + if (success) { + Get.snackbar( + 'Success', + 'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}', + backgroundColor: Colors.green.withOpacity(0.8), + colorText: Colors.white, + ); + await controller.fetchExpenseDetails(expenseId); + } else { + Get.snackbar( + 'Error', + 'Failed to update status.', + backgroundColor: Colors.red.withOpacity(0.8), + colorText: Colors.white, + ); + } } }, child: MyText.labelMedium(