added process flow and make payment functionallity

This commit is contained in:
Vaibhav Surve 2025-11-07 17:19:56 +05:30
parent a2cf65fb86
commit 44674da8ac
12 changed files with 1550 additions and 325 deletions

View File

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/model/document/document_filter_model.dart';
import 'package:marco/model/document/documents_list_model.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class DocumentController extends GetxController {
// ==================== Observables ====================
@ -38,7 +39,6 @@ class DocumentController extends GetxController {
final endDate = Rxn<DateTime>();
// ==================== Lifecycle ====================
@override
void onClose() {
// Don't dispose searchController here - it's managed by the page
@ -87,13 +87,22 @@ class DocumentController extends GetxController {
entityId: entityId,
reset: true,
);
showAppSnackbar(
title: 'Success',
message: 'Document state updated successfully',
type: SnackbarType.success,
);
return true;
} else {
errorMessage.value = 'Failed to update document state';
_showError('Failed to update document state');
return false;
}
} catch (e) {
errorMessage.value = 'Error updating document: $e';
_showError('Error updating document: $e');
debugPrint('❌ Error toggling document state: $e');
return false;
} finally {
@ -110,17 +119,13 @@ class DocumentController extends GetxController {
bool reset = false,
}) async {
try {
// Reset pagination if needed
if (reset) {
pageNumber.value = 1;
documents.clear();
hasMore.value = true;
}
// Don't fetch if no more data
if (!hasMore.value && !reset) return;
// Prevent duplicate requests
if (isLoading.value) return;
isLoading.value = true;
@ -187,15 +192,10 @@ class DocumentController extends GetxController {
/// Show error message
void _showError(String message) {
Get.snackbar(
'Error',
message,
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red.shade100,
colorText: Colors.red.shade900,
margin: const EdgeInsets.all(16),
borderRadius: 8,
duration: const Duration(seconds: 3),
showAppSnackbar(
title: 'Error',
message: message,
type: SnackbarType.error,
);
}

View File

@ -40,10 +40,12 @@ class PaymentRequestController extends GetxController {
statuses.assignAll(response.data.status);
createdBy.assignAll(response.data.createdBy);
} else {
logSafe("Payment request filter API returned null", level: LogLevel.warning);
logSafe("Payment request filter API returned null",
level: LogLevel.warning);
}
} catch (e, stack) {
logSafe("Exception in fetchPaymentRequestFilterOptions: $e", level: LogLevel.error);
logSafe("Exception in fetchPaymentRequestFilterOptions: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
}
@ -84,9 +86,11 @@ class PaymentRequestController extends GetxController {
if (response != null && response.data.data.isNotEmpty) {
if (_pageNumber == 1) {
// First page, replace the list
paymentRequests.assignAll(response.data.data);
} else {
paymentRequests.addAll(response.data.data);
// Insert new data at the top for latest first
paymentRequests.insertAll(0, response.data.data);
}
} else {
if (_pageNumber == 1) {
@ -97,7 +101,8 @@ class PaymentRequestController extends GetxController {
}
} catch (e, stack) {
errorMessage.value = 'Failed to fetch payment requests.';
logSafe("Exception in _fetchPaymentRequestsFromApi: $e", level: LogLevel.error);
logSafe("Exception in _fetchPaymentRequestsFromApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
}

View File

@ -1,31 +1,363 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/employees/employee_model.dart';
import 'package:marco/model/finance/payment_request_details_model.dart';
import 'package:marco/model/expense/payment_types_model.dart';
import 'package:marco/helpers/widgets/time_stamp_image_helper.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'package:mime/mime.dart';
class PaymentRequestDetailController extends GetxController {
final Rx<dynamic> paymentRequest = Rx<dynamic>(null);
final Rx<PaymentRequestData?> paymentRequest = Rx<PaymentRequestData?>(null);
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
// Employee selection
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> employeeSearchResults = <EmployeeModel>[].obs;
final TextEditingController employeeSearchController =
TextEditingController();
final RxBool isSearchingEmployees = false.obs;
// Attachments
final RxList<File> attachments = <File>[].obs;
final RxList<Map<String, dynamic>> existingAttachments =
<Map<String, dynamic>>[].obs;
final isProcessingAttachment = false.obs;
// Payment mode
final selectedPaymentMode = Rxn<PaymentModeModel>();
// Text controllers for form
final TextEditingController locationController = TextEditingController();
final TextEditingController gstNumberController = TextEditingController();
// Form submission state
final RxBool isSubmitting = false.obs;
late String _requestId;
bool _isInitialized = false;
RxBool paymentSheetOpened = false.obs;
final ImagePicker _picker = ImagePicker();
/// Initialize controller
void init(String requestId) {
if (_isInitialized) return;
_isInitialized = true;
_requestId = requestId;
fetchPaymentRequestDetail();
// Fetch payment request details + employees concurrently
Future.wait([
fetchPaymentRequestDetail(),
fetchAllEmployees(),
fetchPaymentModes(),
]);
}
Future<void> fetchPaymentRequestDetail() async {
/// Generic API wrapper for error handling
Future<T?> _apiCallWrapper<T>(
Future<T?> Function() apiCall, String operationName) async {
isLoading.value = true;
errorMessage.value = '';
try {
isLoading.value = true;
final response = await ApiService.getExpensePaymentRequestDetailApi(_requestId);
if (response != null) {
paymentRequest.value = response.data; // adapt to your API model
} else {
errorMessage.value = "Failed to fetch payment request details";
}
final result = await apiCall();
return result;
} catch (e) {
errorMessage.value = "Error fetching payment request details: $e";
errorMessage.value = 'Error during $operationName: $e';
showAppSnackbar(
title: 'Error',
message: errorMessage.value,
type: SnackbarType.error);
return null;
} finally {
isLoading.value = false;
}
}
/// Fetch payment request details
Future<void> fetchPaymentRequestDetail() async {
isLoading.value = true;
try {
final response =
await ApiService.getExpensePaymentRequestDetailApi(_requestId);
if (response != null) {
paymentRequest.value = response.data;
} else {
errorMessage.value = "Failed to fetch payment request details";
showAppSnackbar(
title: 'Error',
message: errorMessage.value,
type: SnackbarType.error,
);
}
} catch (e) {
errorMessage.value = "Error fetching payment request details: $e";
showAppSnackbar(
title: 'Error',
message: errorMessage.value,
type: SnackbarType.error,
);
} finally {
isLoading.value = false;
}
}
/// Pick files from gallery or file picker
Future<void> pickAttachments() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
allowMultiple: true,
);
if (result != null) {
attachments.addAll(
result.paths.whereType<String>().map(File.new),
);
}
} catch (e) {
_errorSnackbar("Attachment error: $e");
}
}
void removeAttachment(File file) => attachments.remove(file);
Future<void> pickFromCamera() async {
try {
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) {
isProcessingAttachment.value = true;
File imageFile = File(pickedFile.path);
File timestampedFile = await TimestampImageHelper.addTimestamp(
imageFile: imageFile,
);
attachments.add(timestampedFile);
attachments.refresh();
}
} catch (e) {
_errorSnackbar("Camera error: $e");
} finally {
isProcessingAttachment.value = false;
}
}
// --- Location ---
final RxBool isFetchingLocation = false.obs;
Future<void> fetchCurrentLocation() async {
isFetchingLocation.value = true;
try {
if (!await _ensureLocationPermission()) return;
final position = await Geolocator.getCurrentPosition();
final placemarks =
await placemarkFromCoordinates(position.latitude, position.longitude);
locationController.text = placemarks.isNotEmpty
? [
placemarks.first.name,
placemarks.first.street,
placemarks.first.locality,
placemarks.first.administrativeArea,
placemarks.first.country,
].where((e) => e?.isNotEmpty == true).join(", ")
: "${position.latitude}, ${position.longitude}";
} catch (e) {
_errorSnackbar("Location error: $e");
} finally {
isFetchingLocation.value = false;
}
}
Future<bool> _ensureLocationPermission() async {
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
_errorSnackbar("Location permission denied.");
return false;
}
}
if (!await Geolocator.isLocationServiceEnabled()) {
_errorSnackbar("Location service disabled.");
return false;
}
return true;
}
/// Fetch all employees
Future<void> fetchAllEmployees() async {
final response = await _apiCallWrapper(
() => ApiService.getAllEmployees(), "fetch all employees");
if (response != null && response.isNotEmpty) {
try {
allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e)));
} catch (e) {
errorMessage.value = 'Failed to parse employee data: $e';
showAppSnackbar(
title: 'Error',
message: errorMessage.value,
type: SnackbarType.error);
}
} else {
allEmployees.clear();
}
}
/// Fetch payment modes
Future<void> fetchPaymentModes() async {
isLoading.value = true;
try {
final paymentModesData = await ApiService.getMasterPaymentModes();
if (paymentModesData is List) {
paymentModes.value =
paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
} else {
paymentModes.clear();
showAppSnackbar(
title: 'Error',
message: 'Failed to fetch payment modes',
type: SnackbarType.error);
}
} catch (e) {
paymentModes.clear();
showAppSnackbar(
title: 'Error',
message: 'Error fetching payment modes: $e',
type: SnackbarType.error);
} finally {
isLoading.value = false;
}
}
/// Search employees
Future<void> searchEmployees(String query) async {
if (query.trim().isEmpty) {
employeeSearchResults.clear();
return;
}
isSearchingEmployees.value = true;
try {
final data =
await ApiService.searchEmployeesBasic(searchString: query.trim());
employeeSearchResults.assignAll(
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
);
} catch (e) {
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
/// Update payment request status
Future<bool> updatePaymentRequestStatus({
required String statusId,
required String comment,
String? paidTransactionId,
String? paidById,
DateTime? paidAt,
double? baseAmount,
double? taxAmount,
String? tdsPercentage,
}) async {
isLoading.value = true;
try {
final success = await ApiService.updateExpensePaymentRequestStatusApi(
paymentRequestId: _requestId,
statusId: statusId,
comment: comment,
paidTransactionId: paidTransactionId,
paidById: paidById,
paidAt: paidAt,
baseAmount: baseAmount,
taxAmount: taxAmount,
tdsPercentage: tdsPercentage,
);
if (success) {
showAppSnackbar(
title: 'Success',
message: 'Payment submitted successfully',
type: SnackbarType.success);
await fetchPaymentRequestDetail();
} else {
showAppSnackbar(
title: 'Error',
message: 'Failed to update status. Please try again.',
type: SnackbarType.error);
}
return success;
} catch (e) {
showAppSnackbar(
title: 'Error',
message: 'Something went wrong: $e',
type: SnackbarType.error);
return false;
} finally {
isLoading.value = false;
}
}
// --- Snackbar Helper ---
void _errorSnackbar(String msg, [String title = "Error"]) {
showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
}
// --- Payment Mode Selection ---
void selectPaymentMode(PaymentModeModel mode) {
selectedPaymentMode.value = mode;
}
// --- Submit Expense ---
Future<bool> submitExpense() async {
if (selectedPaymentMode.value == null) return false;
isSubmitting.value = true;
try {
// Prepare attachments with all required fields
final attachmentsPayload = attachments.map((file) {
final bytes = file.readAsBytesSync();
final mimeType =
lookupMimeType(file.path) ?? 'application/octet-stream';
return {
"fileName": file.path.split('/').last,
"base64Data": base64Encode(bytes),
"contentType": mimeType,
"description": "",
"fileSize": bytes.length,
"isActive": true,
};
}).toList();
// Call API
return await ApiService.createExpenseForPRApi(
paymentModeId: selectedPaymentMode.value!.id,
location: locationController.text,
gstNumber: gstNumberController.text,
paymentRequestId: _requestId,
billAttachments: attachmentsPayload,
);
} finally {
isSubmitting.value = false;
}
}
}

View File

@ -22,7 +22,13 @@ class ApiEndpoints {
static const String getExpensePaymentRequestDetails =
"/Expense/get/payment-request/details";
static const String getExpensePaymentRequestFilter =
"/Expense/get/payment-request/details";
"/Expense/payment-request/filter";
static const String updateExpensePaymentRequestStatus =
"/Expense/payment-request/action";
static const String createExpenseforPR =
"/Expense/payment-request/expense/create";
static const String getDashboardProjectProgress = "/dashboard/progression";
static const String getDashboardTasks = "/dashboard/tasks";
static const String getDashboardTeams = "/dashboard/teams";

View File

@ -297,6 +297,114 @@ class ApiService {
}
}
/// Create Expense for Payment Request
static Future<bool> createExpenseForPRApi({
required String paymentModeId,
required String location,
required String gstNumber,
required String paymentRequestId,
List<Map<String, dynamic>> billAttachments = const [],
}) async {
const endpoint = ApiEndpoints.createExpenseforPR;
final body = {
"paymentModeId": paymentModeId,
"location": location,
"gstNumber": gstNumber,
"paymentRequestId": paymentRequestId,
"billAttachments": billAttachments,
};
try {
final response = await _postRequest(endpoint, body);
if (response == null) {
logSafe("Create Expense for PR failed: null response",
level: LogLevel.error);
return false;
}
logSafe("Create Expense for PR response status: ${response.statusCode}");
logSafe("Create Expense for PR response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe(
"Expense for Payment Request created successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to create Expense for Payment Request: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
}
} catch (e, stack) {
logSafe("Exception during createExpenseForPRApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
}
}
/// Update Expense Payment Request Status
static Future<bool> updateExpensePaymentRequestStatusApi({
required String paymentRequestId,
required String statusId,
required String comment,
String? paidTransactionId,
String? paidById,
DateTime? paidAt,
double? baseAmount,
double? taxAmount,
String? tdsPercentage,
}) async {
const endpoint = ApiEndpoints.updateExpensePaymentRequestStatus;
logSafe("Updating Payment Request Status for ID: $paymentRequestId");
final body = {
"paymentRequestId": paymentRequestId,
"statusId": statusId,
"comment": comment,
"paidTransactionId": paidTransactionId,
"paidById": paidById,
"paidAt": paidAt?.toIso8601String(),
"baseAmount": baseAmount,
"taxAmount": taxAmount,
"tdsPercentage": tdsPercentage ?? "0",
};
try {
final response = await _postRequest(endpoint, body);
if (response == null) {
logSafe("Update Payment Request Status failed: null response",
level: LogLevel.error);
return false;
}
logSafe(
"Update Payment Request Status response: ${response.statusCode} -> ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Payment Request status updated successfully!");
return true;
} else {
logSafe(
"Failed to update Payment Request Status: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
}
} catch (e, stack) {
logSafe("Exception during updateExpensePaymentRequestStatusApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
}
}
/// Get Expense Payment Request Detail by ID
static Future<PaymentRequestDetail?> getExpensePaymentRequestDetailApi(
String paymentRequestId) async {

View File

@ -67,6 +67,7 @@ class CustomTextField extends StatelessWidget {
final int maxLines;
final TextInputType keyboardType;
final String? Function(String?)? validator;
final Widget? suffixIcon;
const CustomTextField({
required this.controller,
@ -74,8 +75,9 @@ class CustomTextField extends StatelessWidget {
this.maxLines = 1,
this.keyboardType = TextInputType.text,
this.validator,
this.suffixIcon,
Key? key,
}) : super(key: key);
}) ;
@override
Widget build(BuildContext context) {
@ -91,6 +93,7 @@ class CustomTextField extends StatelessWidget {
fillColor: Colors.grey.shade100,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
suffixIcon: suffixIcon,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),

View File

@ -0,0 +1,222 @@
// create_expense_bottom_sheet.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart';
import 'package:marco/helpers/utils/validators.dart';
import 'package:marco/controller/finance/payment_request_detail_controller.dart';
Future<T?> showCreateExpenseBottomSheet<T>() {
return Get.bottomSheet<T>(
_CreateExpenseBottomSheet(),
isScrollControlled: true,
);
}
class _CreateExpenseBottomSheet extends StatefulWidget {
@override
State<_CreateExpenseBottomSheet> createState() =>
_CreateExpenseBottomSheetState();
}
class _CreateExpenseBottomSheetState extends State<_CreateExpenseBottomSheet> {
final controller = Get.put(PaymentRequestDetailController());
final _formKey = GlobalKey<FormState>();
final _paymentModeDropdownKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Obx(
() => Form(
key: _formKey,
child: BaseBottomSheet(
title: "Create New Expense",
isSubmitting: controller.isSubmitting.value,
onCancel: Get.back,
onSubmit: () async {
if (_formKey.currentState!.validate() && _validateSelections()) {
final success = await controller.submitExpense();
if (success) {
Get.back();
showAppSnackbar(
title: "Success",
message: "Expense created successfully!",
type: SnackbarType.success,
);
}
}
},
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDropdown(
"Payment Mode*",
Icons.payment_outlined,
controller.selectedPaymentMode.value?.name ?? "Select Mode",
controller.paymentModes,
(p) => p.name,
controller.selectPaymentMode,
key: _paymentModeDropdownKey,
),
_gap(),
_buildTextField(
"GST Number",
Icons.receipt_outlined,
controller.gstNumberController,
hint: "Enter GST Number",
validator: null, // optional field
),
_gap(),
_buildTextField(
"Location*",
Icons.location_on_outlined,
controller.locationController,
hint: "Enter location",
validator: Validators.requiredField,
keyboardType: TextInputType.text,
suffixIcon: IconButton(
icon: const Icon(Icons.my_location_outlined),
onPressed: () async {
await controller.fetchCurrentLocation();
},
),
),
_gap(),
_buildAttachmentField(),
],
),
),
),
),
);
}
Widget _buildDropdown<T>(String title, IconData icon, String value,
List<T> options, String Function(T) getLabel, ValueChanged<T> onSelected,
{required GlobalKey key}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title, requiredField: true),
MySpacing.height(6),
DropdownTile(
key: key,
title: value,
onTap: () => _showOptionList(options, getLabel, onSelected, key),
),
],
);
}
Widget _buildTextField(
String title,
IconData icon,
TextEditingController controller, {
String? hint,
FormFieldValidator<String>? validator,
TextInputType? keyboardType,
Widget? suffixIcon, // add this
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(
icon: icon, title: title, requiredField: validator != null),
MySpacing.height(6),
CustomTextField(
controller: controller,
hint: hint ?? "",
validator: validator,
keyboardType: keyboardType ?? TextInputType.text,
suffixIcon: suffixIcon,
),
],
);
}
Widget _buildAttachmentField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(
icon: Icons.attach_file,
title: "Upload Bill*",
requiredField: true),
MySpacing.height(6),
Obx(() {
if (controller.isProcessingAttachment.value) {
return Center(
child: Column(
children: const [
CircularProgressIndicator(),
SizedBox(height: 8),
Text("Processing file, please wait..."),
],
),
);
}
return AttachmentsSection(
attachments: controller.attachments,
existingAttachments: controller.existingAttachments,
onRemoveNew: controller.removeAttachment,
controller: controller,
onAdd: controller.pickAttachments,
);
}),
],
);
}
Widget _gap([double h = 16]) => MySpacing.height(h);
Future<void> _showOptionList<T>(List<T> options, String Function(T) getLabel,
ValueChanged<T> onSelected, GlobalKey key) async {
if (options.isEmpty) {
_showError("No options available");
return;
}
final RenderBox button =
key.currentContext!.findRenderObject() as RenderBox;
final RenderBox overlay =
Overlay.of(context).context.findRenderObject() as RenderBox;
final position = button.localToGlobal(Offset.zero, ancestor: overlay);
final selected = await showMenu<T>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy + button.size.height,
overlay.size.width - position.dx - button.size.width,
0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
items: options
.map(
(opt) => PopupMenuItem<T>(value: opt, child: Text(getLabel(opt))))
.toList(),
);
if (selected != null) onSelected(selected);
}
bool _validateSelections() {
if (controller.selectedPaymentMode.value == null) {
return _showError("Please select a payment mode");
}
if (controller.locationController.text.trim().isEmpty) {
return _showError("Please enter location");
}
if (controller.attachments.isEmpty) {
return _showError("Please upload bill");
}
return true;
}
bool _showError(String msg) {
showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error);
return false;
}
}

View File

@ -0,0 +1,65 @@
class PaymentModeResponse {
final bool success;
final String message;
final List<PaymentModeData> data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
PaymentModeResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory PaymentModeResponse.fromJson(Map<String, dynamic> json) {
return PaymentModeResponse(
success: json['success'] as bool,
message: json['message'] as String,
data: (json['data'] as List)
.map((item) => PaymentModeData.fromJson(item))
.toList(),
errors: json['errors'],
statusCode: json['statusCode'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data.map((e) => e.toJson()).toList(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
class PaymentModeData {
final String id;
final String name;
final String description;
PaymentModeData({
required this.id,
required this.name,
required this.description,
});
factory PaymentModeData.fromJson(Map<String, dynamic> json) {
return PaymentModeData(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'description': description,
};
}

View File

@ -19,7 +19,9 @@ class PaymentRequestDetail {
PaymentRequestDetail(
success: json['success'],
message: json['message'],
data: json['data'] != null ? PaymentRequestData.fromJson(json['data']) : null,
data: json['data'] != null
? PaymentRequestData.fromJson(json['data'])
: null,
errors: json['errors'],
statusCode: json['statusCode'],
timestamp: DateTime.parse(json['timestamp']),
@ -52,15 +54,15 @@ class PaymentRequestData {
ExpenseStatus expenseStatus;
String? paidTransactionId;
DateTime? paidAt;
String? paidBy;
User? paidBy;
bool isAdvancePayment;
DateTime createdAt;
CreatedBy createdBy;
User createdBy;
DateTime updatedAt;
dynamic updatedBy;
User? updatedBy;
List<NextStatus> nextStatus;
List<dynamic> updateLogs;
List<dynamic> attachments;
List<UpdateLog> updateLogs;
List<Attachment> attachments;
bool isActive;
bool isExpenseCreated;
@ -103,8 +105,12 @@ class PaymentRequestData {
payee: json['payee'],
currency: Currency.fromJson(json['currency']),
amount: (json['amount'] as num).toDouble(),
baseAmount: json['baseAmount'] != null ? (json['baseAmount'] as num).toDouble() : null,
taxAmount: json['taxAmount'] != null ? (json['taxAmount'] as num).toDouble() : null,
baseAmount: json['baseAmount'] != null
? (json['baseAmount'] as num).toDouble()
: null,
taxAmount: json['taxAmount'] != null
? (json['taxAmount'] as num).toDouble()
: null,
dueDate: DateTime.parse(json['dueDate']),
project: Project.fromJson(json['project']),
recurringPayment: json['recurringPayment'],
@ -112,17 +118,23 @@ class PaymentRequestData {
expenseStatus: ExpenseStatus.fromJson(json['expenseStatus']),
paidTransactionId: json['paidTransactionId'],
paidAt: json['paidAt'] != null ? DateTime.parse(json['paidAt']) : null,
paidBy: json['paidBy'],
paidBy:
json['paidBy'] != null ? User.fromJson(json['paidBy']) : null,
isAdvancePayment: json['isAdvancePayment'],
createdAt: DateTime.parse(json['createdAt']),
createdBy: CreatedBy.fromJson(json['createdBy']),
createdBy: User.fromJson(json['createdBy']),
updatedAt: DateTime.parse(json['updatedAt']),
updatedBy: json['updatedBy'],
updatedBy:
json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null,
nextStatus: (json['nextStatus'] as List<dynamic>)
.map((e) => NextStatus.fromJson(e))
.toList(),
updateLogs: json['updateLogs'] ?? [],
attachments: json['attachments'] ?? [],
updateLogs: (json['updateLogs'] as List<dynamic>)
.map((e) => UpdateLog.fromJson(e))
.toList(),
attachments: (json['attachments'] as List<dynamic>)
.map((e) => Attachment.fromJson(e))
.toList(),
isActive: json['isActive'],
isExpenseCreated: json['isExpenseCreated'],
);
@ -144,15 +156,15 @@ class PaymentRequestData {
'expenseStatus': expenseStatus.toJson(),
'paidTransactionId': paidTransactionId,
'paidAt': paidAt?.toIso8601String(),
'paidBy': paidBy,
'paidBy': paidBy?.toJson(),
'isAdvancePayment': isAdvancePayment,
'createdAt': createdAt.toIso8601String(),
'createdBy': createdBy.toJson(),
'updatedAt': updatedAt.toIso8601String(),
'updatedBy': updatedBy,
'updatedBy': updatedBy?.toJson(),
'nextStatus': nextStatus.map((e) => e.toJson()).toList(),
'updateLogs': updateLogs,
'attachments': attachments,
'updateLogs': updateLogs.map((e) => e.toJson()).toList(),
'attachments': attachments.map((e) => e.toJson()).toList(),
'isActive': isActive,
'isExpenseCreated': isExpenseCreated,
};
@ -196,15 +208,10 @@ class Project {
Project({required this.id, required this.name});
factory Project.fromJson(Map<String, dynamic> json) => Project(
id: json['id'],
name: json['name'],
);
factory Project.fromJson(Map<String, dynamic> json) =>
Project(id: json['id'], name: json['name']);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
};
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
class ExpenseCategory {
@ -222,7 +229,8 @@ class ExpenseCategory {
required this.description,
});
factory ExpenseCategory.fromJson(Map<String, dynamic> json) => ExpenseCategory(
factory ExpenseCategory.fromJson(Map<String, dynamic> json) =>
ExpenseCategory(
id: json['id'],
name: json['name'],
noOfPersonsRequired: json['noOfPersonsRequired'],
@ -281,7 +289,7 @@ class ExpenseStatus {
};
}
class CreatedBy {
class User {
String id;
String firstName;
String lastName;
@ -290,7 +298,7 @@ class CreatedBy {
String jobRoleId;
String jobRoleName;
CreatedBy({
User({
required this.id,
required this.firstName,
required this.lastName,
@ -300,7 +308,7 @@ class CreatedBy {
required this.jobRoleName,
});
factory CreatedBy.fromJson(Map<String, dynamic> json) => CreatedBy(
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'],
firstName: json['firstName'],
lastName: json['lastName'],
@ -362,3 +370,75 @@ class NextStatus {
'isSystem': isSystem,
};
}
class UpdateLog {
String id;
ExpenseStatus status;
ExpenseStatus nextStatus;
String comment;
DateTime updatedAt;
User updatedBy;
UpdateLog({
required this.id,
required this.status,
required this.nextStatus,
required this.comment,
required this.updatedAt,
required this.updatedBy,
});
factory UpdateLog.fromJson(Map<String, dynamic> json) => UpdateLog(
id: json['id'],
status: ExpenseStatus.fromJson(json['status']),
nextStatus: ExpenseStatus.fromJson(json['nextStatus']),
comment: json['comment'],
updatedAt: DateTime.parse(json['updatedAt']),
updatedBy: User.fromJson(json['updatedBy']),
);
Map<String, dynamic> toJson() => {
'id': id,
'status': status.toJson(),
'nextStatus': nextStatus.toJson(),
'comment': comment,
'updatedAt': updatedAt.toIso8601String(),
'updatedBy': updatedBy.toJson(),
};
}
class Attachment {
String id;
String fileName;
String url;
String? thumbUrl;
int fileSize;
String contentType;
Attachment({
required this.id,
required this.fileName,
required this.url,
this.thumbUrl,
required this.fileSize,
required this.contentType,
});
factory Attachment.fromJson(Map<String, dynamic> json) => Attachment(
id: json['id'],
fileName: json['fileName'],
url: json['url'],
thumbUrl: json['thumbUrl'],
fileSize: json['fileSize'],
contentType: json['contentType'],
);
Map<String, dynamic> toJson() => {
'id': id,
'fileName': fileName,
'url': url,
'thumbUrl': thumbUrl,
'fileSize': fileSize,
'contentType': contentType,
};
}

View File

@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/controller/finance/payment_request_detail_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
class UpdatePaymentRequestWithReimbursement extends StatefulWidget {
final String expenseId;
final String statusId;
final void Function() onClose;
const UpdatePaymentRequestWithReimbursement({
super.key,
required this.expenseId,
required this.onClose,
required this.statusId,
});
@override
State<UpdatePaymentRequestWithReimbursement> createState() =>
_UpdatePaymentRequestWithReimbursement();
}
class _UpdatePaymentRequestWithReimbursement
extends State<UpdatePaymentRequestWithReimbursement> {
final PaymentRequestDetailController controller =
Get.find<PaymentRequestDetailController>();
final TextEditingController commentCtrl = TextEditingController();
final TextEditingController txnCtrl = TextEditingController();
final TextEditingController tdsCtrl = TextEditingController(text: '0');
final TextEditingController baseAmountCtrl = TextEditingController();
final TextEditingController taxAmountCtrl = TextEditingController();
final RxString dateStr = ''.obs;
@override
void dispose() {
commentCtrl.dispose();
txnCtrl.dispose();
tdsCtrl.dispose();
baseAmountCtrl.dispose();
taxAmountCtrl.dispose();
super.dispose();
}
/// Employee selection bottom sheet
void _showEmployeeList() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
backgroundColor: Colors.transparent,
builder: (_) => ReusableEmployeeSelectorBottomSheet(
searchController: controller.employeeSearchController,
searchResults: controller.employeeSearchResults,
isSearching: controller.isSearchingEmployees,
onSearch: controller.searchEmployees,
onSelect: (emp) => controller.selectedReimbursedBy.value = emp,
),
);
// Optional cleanup
controller.employeeSearchController.clear();
controller.employeeSearchResults.clear();
}
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: MyTextStyle.bodySmall(xMuted: true),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
),
contentPadding: MySpacing.all(16),
);
}
@override
Widget build(BuildContext context) {
return Obx(() {
return BaseBottomSheet(
title: "Proceed Payment",
isSubmitting: controller.isLoading.value,
onCancel: () {
widget.onClose();
Navigator.pop(context);
},
onSubmit: () async {
// Mandatory fields validation
if (commentCtrl.text.trim().isEmpty ||
txnCtrl.text.trim().isEmpty ||
dateStr.value.isEmpty ||
baseAmountCtrl.text.trim().isEmpty ||
taxAmountCtrl.text.trim().isEmpty) {
showAppSnackbar(
title: "Incomplete",
message: "Please fill all mandatory fields",
type: SnackbarType.warning,
);
return;
}
try {
// Parse inputs
final parsedDate =
DateFormat('dd-MM-yyyy').parse(dateStr.value, true);
final baseAmount = double.tryParse(baseAmountCtrl.text.trim()) ?? 0;
final taxAmount = double.tryParse(taxAmountCtrl.text.trim()) ?? 0;
final tdsPercentage =
tdsCtrl.text.trim().isEmpty ? null : tdsCtrl.text.trim();
// Call API
final success = await controller.updatePaymentRequestStatus(
statusId: widget.statusId,
comment: commentCtrl.text.trim(),
paidTransactionId: txnCtrl.text.trim(),
paidById: controller.selectedReimbursedBy.value?.id,
paidAt: parsedDate,
baseAmount: baseAmount,
taxAmount: taxAmount,
tdsPercentage: tdsPercentage,
);
// Show snackbar
showAppSnackbar(
title: success ? 'Success' : 'Error',
message: success
? 'Payment updated successfully'
: 'Failed to update payment',
type: success ? SnackbarType.success : SnackbarType.error,
);
if (success) {
// Ensure bottom sheet closes and callback is called
widget.onClose(); // optional callback for parent refresh
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else {
Get.close(1); // fallback if Navigator can't pop
}
}
} catch (e, st) {
print("Error updating payment: $e\n$st");
showAppSnackbar(
title: 'Error',
message: 'Something went wrong. Please try again.',
type: SnackbarType.error,
);
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("Transaction ID*"),
MySpacing.height(8),
TextField(
controller: txnCtrl,
decoration: _inputDecoration("Enter transaction ID"),
),
MySpacing.height(16),
MyText.labelMedium("Transaction Date*"),
MySpacing.height(8),
GestureDetector(
onTap: () async {
final today = DateTime.now();
final firstDate = DateTime(2020);
final lastDate = today;
final picked = await showDatePicker(
context: context,
initialDate: today,
firstDate: firstDate,
lastDate: lastDate,
);
if (picked != null) {
dateStr.value = DateFormat('dd-MM-yyyy').format(picked);
}
},
child: AbsorbPointer(
child: TextField(
controller: TextEditingController(text: dateStr.value),
decoration: _inputDecoration("Select Date").copyWith(
suffixIcon: const Icon(Icons.calendar_today),
),
),
),
),
MySpacing.height(16),
MyText.labelMedium("Paid By (Optional)"),
MySpacing.height(8),
GestureDetector(
onTap: _showEmployeeList,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller.selectedReimbursedBy.value == null
? "Select Paid By"
: '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.arrow_drop_down, size: 22),
],
),
),
),
MySpacing.height(16),
MyText.labelMedium("TDS Percentage (Optional)"),
MySpacing.height(8),
TextField(
controller: tdsCtrl,
keyboardType: TextInputType.number,
decoration: _inputDecoration("Enter TDS Percentage"),
),
MySpacing.height(16),
MyText.labelMedium("Base Amount*"),
MySpacing.height(8),
TextField(
controller: baseAmountCtrl,
keyboardType: TextInputType.number,
decoration: _inputDecoration("Enter Base Amount"),
),
MySpacing.height(16),
MyText.labelMedium("Tax Amount*"),
MySpacing.height(8),
TextField(
controller: taxAmountCtrl,
keyboardType: TextInputType.number,
decoration: _inputDecoration("Enter Tax Amount"),
),
MySpacing.height(16),
MyText.labelMedium("Comment*"),
MySpacing.height(8),
TextField(
controller: commentCtrl,
decoration: _inputDecoration("Enter comment"),
),
],
),
);
});
}
}

View File

@ -14,6 +14,12 @@ 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';
class PaymentRequestDetailScreen extends StatefulWidget {
final String paymentRequestId;
@ -29,11 +35,46 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
final controller = Get.put(PaymentRequestDetailController());
final projectController = Get.find<ProjectController>();
final permissionController = Get.find<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);
final result = isCreatedByCurrentUser && hasDraftNextStatus;
// Debug log
print('🔐 Submit Permission Check:\n'
'Logged-in employee: ${employeeInfo?.id}\n'
'Created by: ${request.createdBy.id}\n'
'Has Draft Next Status: $hasDraftNextStatus\n'
'Can Submit: $result');
canSubmit.value = result;
}
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));
}
@override
@ -46,9 +87,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
if (controller.isLoading.value) {
return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
}
final request =
controller.paymentRequest.value as PaymentRequestData?;
final request = controller.paymentRequest.value;
if (controller.errorMessage.isNotEmpty || request == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
@ -57,7 +96,11 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
onRefresh: controller.fetchPaymentRequestDetail,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom),
12,
12,
12,
60 + MediaQuery.of(context).padding.bottom,
),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 520),
@ -71,13 +114,12 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Header(request: request),
_Header(request: request, colorParser: _parseColor),
const Divider(height: 30, thickness: 1.2),
// Move Logs here, right after header
_Logs(logs: request.updateLogs),
_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),
@ -93,6 +135,134 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
);
}),
),
bottomNavigationBar: Obx(() {
final request = controller.paymentRequest.value;
if (request == null ||
controller.isLoading.value ||
employeeInfo == null) {
return const SizedBox.shrink();
}
// Check permissions once
if (!_checkedPermission) {
_checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(request);
});
}
// Filter statuses
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 there are no next statuses, show "Create Expense" button
if (availableStatuses.isEmpty) {
return SafeArea(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: Colors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: () {
showCreateExpenseBottomSheet();
},
child: const Text(
"Create Expense",
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
),
),
),
);
}
// Normal status buttons
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 {
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: Text(status.displayName,
style: const TextStyle(color: Colors.white)),
);
}).toList(),
),
),
);
}),
);
}
@ -126,27 +296,25 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
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],
),
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],
),
],
);
},
),
),
],
);
}),
],
),
),
@ -158,28 +326,17 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
}
}
// Header Row
class _Header extends StatelessWidget {
final PaymentRequestData request;
const _Header({required this.request});
// Helper to parse hex color string to Color
Color parseColorFromHex(String hexColor) {
hexColor = hexColor.toUpperCase().replaceAll("#", "");
if (hexColor.length == 6) {
hexColor = "FF" + hexColor; // Add alpha if missing
}
return Color(int.parse(hexColor, radix: 16));
}
final Color Function(String) colorParser;
const _Header({required this.request, required this.colorParser});
@override
Widget build(BuildContext context) {
final statusColor = parseColorFromHex(request.expenseStatus.color);
final statusColor = colorParser(request.expenseStatus.color);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left side: wrap in Expanded to prevent overflow
Expanded(
child: Row(
children: [
@ -199,8 +356,6 @@ class _Header extends StatelessWidget {
],
),
),
// Right side: Status Chip
Container(
decoration: BoxDecoration(
color: statusColor.withOpacity(0.15),
@ -211,7 +366,6 @@ class _Header extends StatelessWidget {
Icon(Icons.flag, size: 16, color: statusColor),
MySpacing.width(4),
SizedBox(
// Prevent overflow of long status text
width: 100,
child: MyText.labelSmall(
request.expenseStatus.displayName,
@ -228,185 +382,20 @@ class _Header extends StatelessWidget {
}
}
// Horizontal 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,
),
),
],
),
);
// Parties Section
class _Parties extends StatelessWidget {
final PaymentRequestData request;
const _Parties({required this.request});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
labelValueRow('Project', request.project.name),
labelValueRow('Payee', request.payee),
labelValueRow('Created By',
'${request.createdBy.firstName} ${request.createdBy.lastName}'),
labelValueRow('Pre-Approved', request.isAdvancePayment ? 'Yes' : 'No'),
],
);
}
}
// Details Table
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"),
],
);
}
}
// Documents Section
class _Documents extends StatelessWidget {
final List<dynamic> 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),
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] as Map<String, dynamic>;
return GestureDetector(
onTap: () async {
final imageDocs = documents
.where((d) =>
(d['contentType'] as String).startsWith('image/'))
.toList();
final initialIndex =
imageDocs.indexWhere((d) => d['id'] == doc['id']);
if (imageDocs.isNotEmpty && initialIndex != -1) {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources:
imageDocs.map((e) => e['url'] as String).toList(),
initialIndex: initialIndex,
),
);
} else {
final Uri url = Uri.parse(doc['url'] as String);
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not open document.')),
);
}
}
},
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'] as String).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 _Logs extends StatelessWidget {
final List<dynamic> logs;
const _Logs({required this.logs});
final List<UpdateLog> logs;
final Color Function(String) colorParser;
const _Logs({required this.logs, required this.colorParser});
// Helper to parse hex color string to Color
Color parseColorFromHex(String hexColor) {
hexColor = hexColor.toUpperCase().replaceAll("#", "");
if (hexColor.length == 6) {
hexColor = "FF" + hexColor; // Add alpha for opacity if missing
}
return Color(int.parse(hexColor, radix: 16));
}
DateTime parseTimestamp(String ts) => DateTime.parse(ts);
DateTime _parseTimestamp(DateTime ts) => ts;
@override
Widget build(BuildContext context) {
if (logs.isEmpty) return MyText.bodyMedium('No Timeline', color: Colors.grey);
if (logs.isEmpty) {
return MyText.bodyMedium('No Timeline', color: Colors.grey);
}
final reversedLogs = logs.reversed.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -416,32 +405,24 @@ class _Logs extends StatelessWidget {
physics: const NeverScrollableScrollPhysics(),
itemCount: reversedLogs.length,
itemBuilder: (_, index) {
final log = reversedLogs[index] as Map<String, dynamic>;
final statusMap = log['status'] ?? {};
final status = statusMap['name'] ?? '';
final description = statusMap['description'] ?? '';
final comment = log['comment'] ?? '';
final log = reversedLogs[index];
final nextStatusMap = log['nextStatus'] ?? {};
final nextStatusName = nextStatusMap['name'] ?? '';
final status = log.status.name;
final description = log.status.description;
final comment = log.comment;
final nextStatusName = log.nextStatus.name;
final updatedBy = log['updatedBy'] ?? {};
final updatedBy = log.updatedBy;
final initials =
"${(updatedBy['firstName'] ?? '').isNotEmpty ? (updatedBy['firstName']![0]) : ''}"
"${(updatedBy['lastName'] ?? '').isNotEmpty ? (updatedBy['lastName']![0]) : ''}";
final name =
"${updatedBy['firstName'] ?? ''} ${updatedBy['lastName'] ?? ''}";
'${updatedBy.firstName.isNotEmpty == true ? updatedBy.firstName[0] : ''}'
'${updatedBy.lastName.isNotEmpty == true ? updatedBy.lastName[0] : ''}';
final name = '${updatedBy.firstName} ${updatedBy.lastName}';
final timestamp = parseTimestamp(log['updatedAt']);
final timestamp = _parseTimestamp(log.updatedAt);
final timeAgo = timeago.format(timestamp);
final statusColor = statusMap['color'] != null
? parseColorFromHex(statusMap['color'])
: Colors.black;
final nextStatusColor = nextStatusMap['color'] != null
? parseColorFromHex(nextStatusMap['color'])
: Colors.blue.shade700;
final statusColor = colorParser(log.status.color);
final nextStatusColor = colorParser(log.nextStatus.color);
return TimelineTile(
alignment: TimelineAlign.start,
@ -451,10 +432,8 @@ class _Logs extends StatelessWidget {
width: 16,
height: 16,
indicator: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: statusColor,
),
decoration:
BoxDecoration(shape: BoxShape.circle, color: statusColor),
),
),
beforeLineStyle:
@ -464,20 +443,14 @@ class _Logs extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Status and time in one row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(
status,
fontWeight: 600,
color: statusColor,
),
MyText.bodySmall(
timeAgo,
color: Colors.grey[600],
textAlign: TextAlign.right,
),
MyText.bodyMedium(status,
fontWeight: 600, color: statusColor),
MyText.bodySmall(timeAgo,
color: Colors.grey[600],
textAlign: TextAlign.right),
],
),
if (description.isNotEmpty) ...[
@ -502,11 +475,8 @@ class _Logs extends StatelessWidget {
),
const SizedBox(width: 6),
Expanded(
child: MyText.bodySmall(
name,
overflow: TextOverflow.ellipsis,
),
),
child: MyText.bodySmall(name,
overflow: TextOverflow.ellipsis)),
if (nextStatusName.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
@ -515,11 +485,8 @@ class _Logs extends StatelessWidget {
color: nextStatusColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
child: MyText.bodySmall(
nextStatusName,
fontWeight: 600,
color: nextStatusColor,
),
child: MyText.bodySmall(nextStatusName,
fontWeight: 600, color: nextStatusColor),
),
],
),
@ -533,3 +500,146 @@ class _Logs extends StatelessWidget {
);
}
}
class _Parties extends StatelessWidget {
final PaymentRequestData request;
const _Parties({required this.request});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_labelValueRow('Project', request.project.name),
_labelValueRow('Payee', request.payee),
_labelValueRow('Created By',
'${request.createdBy.firstName} ${request.createdBy.lastName}'),
_labelValueRow('Pre-Approved', request.isAdvancePayment ? 'Yes' : 'No'),
],
);
}
}
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),
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];
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.labelSmall(
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),
),
],
),
);

View File

@ -268,6 +268,18 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
final list = filteredList(isHistory: isHistory);
// Single ScrollController for this list
final scrollController = ScrollController();
// Load more when reaching near bottom
scrollController.addListener(() {
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 100 &&
!paymentController.isLoading.value) {
paymentController.loadMorePaymentRequests();
}
});
return RefreshIndicator(
onRefresh: _refreshPaymentRequests,
child: list.isEmpty
@ -288,11 +300,22 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
],
)
: ListView.separated(
controller: scrollController, // attach controller
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: list.length,
itemCount: list.length + 1, // extra item for loading
separatorBuilder: (_, __) =>
Divider(color: Colors.grey.shade300, height: 20),
itemBuilder: (context, index) {
if (index == list.length) {
// Show loading indicator at bottom
return Obx(() => paymentController.isLoading.value
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
)
: const SizedBox.shrink());
}
final item = list[index];
return _buildPaymentRequestTile(item);
},
@ -349,7 +372,7 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
borderRadius: BorderRadius.circular(5),
),
child: MyText.bodySmall(
item.expenseStatus.displayName,
item.expenseStatus.name,
color: Colors.white,
fontWeight: 500,
),