added process flow and make payment functionallity
This commit is contained in:
parent
a2cf65fb86
commit
44674da8ac
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
|
||||
222
lib/model/finance/make_expense_bottom_sheet.dart
Normal file
222
lib/model/finance/make_expense_bottom_sheet.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
65
lib/model/finance/payment_mode_response_model.dart
Normal file
65
lib/model/finance/payment_mode_response_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
271
lib/model/finance/payment_request_rembursement_bottom_sheet.dart
Normal file
271
lib/model/finance/payment_request_rembursement_bottom_sheet.dart
Normal 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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user