Vaibhav_Feature-#768 #59
@ -3,21 +3,23 @@ import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:geocoding/geocoding.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
|
||||
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
|
||||
import 'package:marco/model/employee_model.dart';
|
||||
import 'package:marco/model/expense/expense_status_model.dart';
|
||||
import 'package:marco/model/expense/expense_type_model.dart';
|
||||
import 'package:marco/model/expense/payment_types_model.dart';
|
||||
|
||||
class AddExpenseController extends GetxController {
|
||||
// Text Controllers
|
||||
final amountController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
final supplierController = TextEditingController();
|
||||
@ -25,30 +27,34 @@ class AddExpenseController extends GetxController {
|
||||
final gstController = TextEditingController();
|
||||
final locationController = TextEditingController();
|
||||
final transactionDateController = TextEditingController();
|
||||
final TextEditingController noOfPersonsController = TextEditingController();
|
||||
final noOfPersonsController = TextEditingController();
|
||||
|
||||
final RxBool isLoading = false.obs;
|
||||
final RxBool isSubmitting = false.obs;
|
||||
final RxBool isFetchingLocation = false.obs;
|
||||
// State
|
||||
final isLoading = false.obs;
|
||||
final isSubmitting = false.obs;
|
||||
final isFetchingLocation = false.obs;
|
||||
final isEditMode = false.obs;
|
||||
|
||||
final Rx<PaymentModeModel?> selectedPaymentMode = Rx<PaymentModeModel?>(null);
|
||||
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(null);
|
||||
final Rx<ExpenseStatusModel?> selectedExpenseStatus = Rx<ExpenseStatusModel?>(null);
|
||||
final Rx<EmployeeModel?> selectedPaidBy = Rx<EmployeeModel?>(null);
|
||||
final RxString selectedProject = ''.obs;
|
||||
final Rx<DateTime?> selectedTransactionDate = Rx<DateTime?>(null);
|
||||
// Dropdown Selections
|
||||
final selectedPaymentMode = Rx<PaymentModeModel?>(null);
|
||||
final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
|
||||
final selectedPaidBy = Rx<EmployeeModel?>(null);
|
||||
final selectedProject = ''.obs;
|
||||
final selectedTransactionDate = Rx<DateTime?>(null);
|
||||
|
||||
final RxList<File> attachments = <File>[].obs;
|
||||
final RxList<String> globalProjects = <String>[].obs;
|
||||
final RxList<String> projects = <String>[].obs;
|
||||
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
|
||||
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
|
||||
final RxList<ExpenseStatusModel> expenseStatuses = <ExpenseStatusModel>[].obs;
|
||||
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
|
||||
// Data Lists
|
||||
final attachments = <File>[].obs;
|
||||
final globalProjects = <String>[].obs;
|
||||
final projectsMap = <String, String>{}.obs;
|
||||
final expenseTypes = <ExpenseTypeModel>[].obs;
|
||||
final paymentModes = <PaymentModeModel>[].obs;
|
||||
final allEmployees = <EmployeeModel>[].obs;
|
||||
final existingAttachments = <Map<String, dynamic>>[].obs;
|
||||
|
||||
final RxMap<String, String> projectsMap = <String, String>{}.obs;
|
||||
// Editing
|
||||
String? editingExpenseId;
|
||||
|
||||
final ExpenseController expenseController = Get.find<ExpenseController>();
|
||||
final expenseController = Get.find<ExpenseController>();
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
@ -71,6 +77,117 @@ class AddExpenseController extends GetxController {
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
// ---------- Form Population for Edit ----------
|
||||
void populateFieldsForEdit(Map<String, dynamic> data) {
|
||||
isEditMode.value = true;
|
||||
editingExpenseId = data['id'];
|
||||
|
||||
// Basic fields
|
||||
selectedProject.value = data['projectName'] ?? '';
|
||||
amountController.text = data['amount']?.toString() ?? '';
|
||||
supplierController.text = data['supplerName'] ?? '';
|
||||
descriptionController.text = data['description'] ?? '';
|
||||
transactionIdController.text = data['transactionId'] ?? '';
|
||||
locationController.text = data['location'] ?? '';
|
||||
|
||||
// Transaction Date
|
||||
if (data['transactionDate'] != null) {
|
||||
try {
|
||||
final parsedDate = DateTime.parse(data['transactionDate']);
|
||||
selectedTransactionDate.value = parsedDate;
|
||||
transactionDateController.text =
|
||||
DateFormat('dd-MM-yyyy').format(parsedDate);
|
||||
} catch (e) {
|
||||
logSafe('Error parsing transactionDate: $e', level: LogLevel.warning);
|
||||
selectedTransactionDate.value = null;
|
||||
transactionDateController.clear();
|
||||
}
|
||||
} else {
|
||||
selectedTransactionDate.value = null;
|
||||
transactionDateController.clear();
|
||||
}
|
||||
|
||||
// No of Persons
|
||||
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString();
|
||||
|
||||
// Select Expense Type and Payment Mode by matching IDs
|
||||
selectedExpenseType.value =
|
||||
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
|
||||
selectedPaymentMode.value =
|
||||
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
|
||||
|
||||
// Select Paid By employee matching id (case insensitive, trimmed)
|
||||
final paidById = data['paidById']?.toString().trim().toLowerCase() ?? '';
|
||||
selectedPaidBy.value = allEmployees
|
||||
.firstWhereOrNull((e) => e.id.trim().toLowerCase() == paidById);
|
||||
|
||||
if (selectedPaidBy.value == null && paidById.isNotEmpty) {
|
||||
logSafe('⚠️ Could not match paidById: "$paidById"',
|
||||
level: LogLevel.warning);
|
||||
for (var emp in allEmployees) {
|
||||
logSafe(
|
||||
'Employee ID: "${emp.id.trim().toLowerCase()}", Name: "${emp.name}"',
|
||||
level: LogLevel.warning);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate existing attachments if present
|
||||
existingAttachments.clear();
|
||||
if (data['attachments'] != null && data['attachments'] is List) {
|
||||
existingAttachments
|
||||
.addAll(List<Map<String, dynamic>>.from(data['attachments']));
|
||||
}
|
||||
|
||||
_logPrefilledData();
|
||||
}
|
||||
|
||||
void _logPrefilledData() {
|
||||
logSafe('--- Prefilled Expense Data ---', level: LogLevel.info);
|
||||
logSafe('ID: $editingExpenseId', level: LogLevel.info);
|
||||
logSafe('Project: ${selectedProject.value}', level: LogLevel.info);
|
||||
logSafe('Amount: ${amountController.text}', level: LogLevel.info);
|
||||
logSafe('Supplier: ${supplierController.text}', level: LogLevel.info);
|
||||
logSafe('Description: ${descriptionController.text}', level: LogLevel.info);
|
||||
logSafe('Transaction ID: ${transactionIdController.text}',
|
||||
level: LogLevel.info);
|
||||
logSafe('Location: ${locationController.text}', level: LogLevel.info);
|
||||
logSafe('Transaction Date: ${transactionDateController.text}',
|
||||
level: LogLevel.info);
|
||||
logSafe('No. of Persons: ${noOfPersonsController.text}',
|
||||
level: LogLevel.info);
|
||||
logSafe('Expense Type: ${selectedExpenseType.value?.name}',
|
||||
level: LogLevel.info);
|
||||
logSafe('Payment Mode: ${selectedPaymentMode.value?.name}',
|
||||
level: LogLevel.info);
|
||||
logSafe('Paid By: ${selectedPaidBy.value?.name}', level: LogLevel.info);
|
||||
logSafe('Attachments: ${attachments.length}', level: LogLevel.info);
|
||||
logSafe('Existing Attachments: ${existingAttachments.length}',
|
||||
level: LogLevel.info);
|
||||
}
|
||||
|
||||
// ---------- Form Actions ----------
|
||||
Future<void> pickTransactionDate(BuildContext context) async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: selectedTransactionDate.value ?? DateTime.now(),
|
||||
firstDate: DateTime(DateTime.now().year - 5),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
selectedTransactionDate.value = picked;
|
||||
transactionDateController.text = DateFormat('dd-MM-yyyy').format(picked);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadMasterData() async {
|
||||
await Future.wait([
|
||||
fetchMasterData(),
|
||||
fetchGlobalProjects(),
|
||||
fetchAllEmployees(),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> pickAttachments() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
@ -78,48 +195,34 @@ class AddExpenseController extends GetxController {
|
||||
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
|
||||
allowMultiple: true,
|
||||
);
|
||||
if (result != null && result.paths.isNotEmpty) {
|
||||
final files = result.paths.whereType<String>().map((e) => File(e)).toList();
|
||||
attachments.addAll(files);
|
||||
if (result != null) {
|
||||
attachments.addAll(
|
||||
result.paths.whereType<String>().map((path) => File(path)),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to pick attachments: $e",
|
||||
message: "Attachment error: $e",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void removeAttachment(File file) {
|
||||
attachments.remove(file);
|
||||
}
|
||||
|
||||
void pickTransactionDate(BuildContext context) async {
|
||||
final now = DateTime.now();
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: selectedTransactionDate.value ?? now,
|
||||
firstDate: DateTime(now.year - 5),
|
||||
lastDate: now,
|
||||
);
|
||||
if (picked != null) {
|
||||
selectedTransactionDate.value = picked;
|
||||
transactionDateController.text =
|
||||
"${picked.day.toString().padLeft(2, '0')}-${picked.month.toString().padLeft(2, '0')}-${picked.year}";
|
||||
}
|
||||
}
|
||||
void removeAttachment(File file) => attachments.remove(file);
|
||||
|
||||
Future<void> fetchCurrentLocation() async {
|
||||
isFetchingLocation.value = true;
|
||||
try {
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) {
|
||||
var permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied ||
|
||||
permission == LocationPermission.deniedForever) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) {
|
||||
if (permission == LocationPermission.denied ||
|
||||
permission == LocationPermission.deniedForever) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Location permission denied. Enable in settings.",
|
||||
message: "Location permission denied.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
@ -129,24 +232,24 @@ class AddExpenseController extends GetxController {
|
||||
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Location services are disabled. Enable them.",
|
||||
message: "Location service disabled.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
|
||||
final placemarks = await placemarkFromCoordinates(position.latitude, position.longitude);
|
||||
final position = await Geolocator.getCurrentPosition();
|
||||
final placemarks =
|
||||
await placemarkFromCoordinates(position.latitude, position.longitude);
|
||||
|
||||
if (placemarks.isNotEmpty) {
|
||||
final place = placemarks.first;
|
||||
final address = [
|
||||
place.name,
|
||||
place.street,
|
||||
place.subLocality,
|
||||
place.locality,
|
||||
place.administrativeArea,
|
||||
place.country,
|
||||
place.country
|
||||
].where((e) => e != null && e.isNotEmpty).join(", ");
|
||||
locationController.text = address;
|
||||
} else {
|
||||
@ -155,7 +258,7 @@ class AddExpenseController extends GetxController {
|
||||
} catch (e) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Error fetching location: $e",
|
||||
message: "Location error: $e",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
} finally {
|
||||
@ -163,112 +266,62 @@ class AddExpenseController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> submitExpense() async {
|
||||
// ---------- Submission ----------
|
||||
Future<void> submitOrUpdateExpense() async {
|
||||
if (isSubmitting.value) return;
|
||||
isSubmitting.value = true;
|
||||
|
||||
try {
|
||||
List<String> missing = [];
|
||||
|
||||
if (selectedProject.value.isEmpty) missing.add("Project");
|
||||
if (selectedExpenseType.value == null) missing.add("Expense Type");
|
||||
if (selectedPaymentMode.value == null) missing.add("Payment Mode");
|
||||
if (selectedPaidBy.value == null) missing.add("Paid By");
|
||||
if (amountController.text.isEmpty) missing.add("Amount");
|
||||
if (supplierController.text.isEmpty) missing.add("Supplier Name");
|
||||
if (descriptionController.text.isEmpty) missing.add("Description");
|
||||
if (attachments.isEmpty) missing.add("Attachments");
|
||||
|
||||
if (missing.isNotEmpty) {
|
||||
final validation = validateForm();
|
||||
if (validation.isNotEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Missing Fields",
|
||||
message: "Please provide: ${missing.join(', ')}.",
|
||||
message: validation,
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final amount = double.tryParse(amountController.text);
|
||||
if (amount == null) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please enter a valid amount.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final payload = await _buildExpensePayload();
|
||||
|
||||
final selectedDate = selectedTransactionDate.value ?? DateTime.now();
|
||||
if (selectedDate.isAfter(DateTime.now())) {
|
||||
showAppSnackbar(
|
||||
title: "Invalid Date",
|
||||
message: "Transaction date cannot be in the future.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final projectId = projectsMap[selectedProject.value];
|
||||
if (projectId == null) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Invalid project selected.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final billAttachments = await Future.wait(attachments.map((file) async {
|
||||
final bytes = await file.readAsBytes();
|
||||
final base64 = base64Encode(bytes);
|
||||
final mime = lookupMimeType(file.path) ?? 'application/octet-stream';
|
||||
final size = await file.length();
|
||||
|
||||
return {
|
||||
"fileName": file.path.split('/').last,
|
||||
"base64Data": base64,
|
||||
"contentType": mime,
|
||||
"fileSize": size,
|
||||
"description": "",
|
||||
};
|
||||
}));
|
||||
|
||||
final success = await ApiService.createExpenseApi(
|
||||
projectId: projectId,
|
||||
expensesTypeId: selectedExpenseType.value!.id,
|
||||
paymentModeId: selectedPaymentMode.value!.id,
|
||||
paidById: selectedPaidBy.value!.id,
|
||||
transactionDate: selectedTransactionDate.value?.toUtc() ?? DateTime.now().toUtc(),
|
||||
transactionId: transactionIdController.text,
|
||||
description: descriptionController.text,
|
||||
location: locationController.text,
|
||||
supplerName: supplierController.text,
|
||||
amount: amount,
|
||||
noOfPersons: selectedExpenseType.value?.noOfPersonsRequired == true
|
||||
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
|
||||
: 0,
|
||||
billAttachments: billAttachments,
|
||||
);
|
||||
final success = isEditMode.value && editingExpenseId != null
|
||||
? await ApiService.editExpenseApi(
|
||||
expenseId: editingExpenseId!, payload: payload)
|
||||
: await ApiService.createExpenseApi(
|
||||
projectId: payload['projectId'],
|
||||
expensesTypeId: payload['expensesTypeId'],
|
||||
paymentModeId: payload['paymentModeId'],
|
||||
paidById: payload['paidById'],
|
||||
transactionDate: DateTime.parse(payload['transactionDate']),
|
||||
transactionId: payload['transactionId'],
|
||||
description: payload['description'],
|
||||
location: payload['location'],
|
||||
supplerName: payload['supplerName'],
|
||||
amount: payload['amount'],
|
||||
noOfPersons: payload['noOfPersons'],
|
||||
billAttachments: payload['billAttachments'],
|
||||
);
|
||||
|
||||
if (success) {
|
||||
await expenseController.fetchExpenses();
|
||||
Get.back();
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: "Expense created successfully!",
|
||||
message:
|
||||
"Expense ${isEditMode.value ? 'updated' : 'created'} successfully!",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to create expense. Try again.",
|
||||
message: "Operation failed. Try again.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Something went wrong: $e",
|
||||
message: "Unexpected error: $e",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
} finally {
|
||||
@ -276,27 +329,104 @@ class AddExpenseController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _buildExpensePayload() async {
|
||||
final amount = double.parse(amountController.text.trim());
|
||||
final projectId = projectsMap[selectedProject.value]!;
|
||||
final selectedDate =
|
||||
selectedTransactionDate.value?.toUtc() ?? DateTime.now().toUtc();
|
||||
final existingAttachmentPayloads = existingAttachments
|
||||
.map((e) => {
|
||||
"fileName": e['fileName'],
|
||||
"contentType": e['contentType'],
|
||||
"fileSize": 0, // optional or populate if known
|
||||
"description": "",
|
||||
"url": e['url'], // custom field if your backend accepts
|
||||
})
|
||||
.toList();
|
||||
|
||||
final newAttachmentPayloads =
|
||||
await Future.wait(attachments.map((file) async {
|
||||
final bytes = await file.readAsBytes();
|
||||
return {
|
||||
"fileName": file.path.split('/').last,
|
||||
"base64Data": base64Encode(bytes),
|
||||
"contentType": lookupMimeType(file.path) ?? 'application/octet-stream',
|
||||
"fileSize": await file.length(),
|
||||
"description": "",
|
||||
};
|
||||
}));
|
||||
final billAttachments = [
|
||||
...existingAttachmentPayloads,
|
||||
...newAttachmentPayloads
|
||||
];
|
||||
|
||||
final Map<String, dynamic> payload = {
|
||||
"projectId": projectId,
|
||||
"expensesTypeId": selectedExpenseType.value!.id,
|
||||
"paymentModeId": selectedPaymentMode.value!.id,
|
||||
"paidById": selectedPaidBy.value!.id,
|
||||
"transactionDate": selectedDate.toIso8601String(),
|
||||
"transactionId": transactionIdController.text,
|
||||
"description": descriptionController.text,
|
||||
"location": locationController.text,
|
||||
"supplerName": supplierController.text,
|
||||
"amount": amount,
|
||||
"noOfPersons": selectedExpenseType.value?.noOfPersonsRequired == true
|
||||
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
|
||||
: 0,
|
||||
"billAttachments": billAttachments,
|
||||
};
|
||||
|
||||
// ✅ Add expense ID if in edit mode
|
||||
if (isEditMode.value && editingExpenseId != null) {
|
||||
payload['id'] = editingExpenseId;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
String validateForm() {
|
||||
final missing = <String>[];
|
||||
|
||||
if (selectedProject.value.isEmpty) missing.add("Project");
|
||||
if (selectedExpenseType.value == null) missing.add("Expense Type");
|
||||
if (selectedPaymentMode.value == null) missing.add("Payment Mode");
|
||||
if (selectedPaidBy.value == null) missing.add("Paid By");
|
||||
if (amountController.text.trim().isEmpty) missing.add("Amount");
|
||||
if (supplierController.text.trim().isEmpty) missing.add("Supplier Name");
|
||||
if (descriptionController.text.trim().isEmpty) missing.add("Description");
|
||||
if (!isEditMode.value && attachments.isEmpty) missing.add("Attachments");
|
||||
|
||||
final amount = double.tryParse(amountController.text.trim());
|
||||
if (amount == null) missing.add("Valid Amount");
|
||||
|
||||
final selectedDate = selectedTransactionDate.value;
|
||||
if (selectedDate != null && selectedDate.isAfter(DateTime.now())) {
|
||||
missing.add("Valid Transaction Date");
|
||||
}
|
||||
|
||||
return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}.";
|
||||
}
|
||||
|
||||
// ---------- Data Fetching ----------
|
||||
Future<void> fetchMasterData() async {
|
||||
try {
|
||||
final expenseTypesData = await ApiService.getMasterExpenseTypes();
|
||||
final paymentModesData = await ApiService.getMasterPaymentModes();
|
||||
final expenseStatusData = await ApiService.getMasterExpenseStatus();
|
||||
final types = await ApiService.getMasterExpenseTypes();
|
||||
final modes = await ApiService.getMasterPaymentModes();
|
||||
|
||||
if (expenseTypesData is List) {
|
||||
expenseTypes.value = expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
|
||||
if (types is List) {
|
||||
expenseTypes.value =
|
||||
types.map((e) => ExpenseTypeModel.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
if (paymentModesData is List) {
|
||||
paymentModes.value = paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
if (expenseStatusData is List) {
|
||||
expenseStatuses.value = expenseStatusData.map((e) => ExpenseStatusModel.fromJson(e)).toList();
|
||||
if (modes is List) {
|
||||
paymentModes.value =
|
||||
modes.map((e) => PaymentModeModel.fromJson(e)).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to fetch master data: $e",
|
||||
message: "Failed to fetch master data",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
@ -310,16 +440,15 @@ class AddExpenseController extends GetxController {
|
||||
for (var item in response) {
|
||||
final name = item['name']?.toString().trim();
|
||||
final id = item['id']?.toString().trim();
|
||||
if (name != null && id != null && name.isNotEmpty) {
|
||||
if (name != null && id != null) {
|
||||
projectsMap[name] = id;
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
globalProjects.assignAll(names);
|
||||
logSafe("Fetched ${names.length} global projects");
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Failed to fetch global projects: $e", level: LogLevel.error);
|
||||
logSafe("Error fetching projects: $e", level: LogLevel.error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -327,19 +456,13 @@ class AddExpenseController extends GetxController {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
final response = await ApiService.getAllEmployees();
|
||||
if (response != null && response.isNotEmpty) {
|
||||
if (response != null) {
|
||||
allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e)));
|
||||
logSafe("All Employees fetched: ${allEmployees.length}", level: LogLevel.info);
|
||||
} else {
|
||||
allEmployees.clear();
|
||||
logSafe("No employees found.", level: LogLevel.warning);
|
||||
}
|
||||
} catch (e) {
|
||||
allEmployees.clear();
|
||||
logSafe("Error fetching employees", level: LogLevel.error, error: e);
|
||||
logSafe("Error fetching employees: $e", level: LogLevel.error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -15,8 +15,8 @@ class ApiEndpoints {
|
||||
static const String uploadAttendanceImage = "/attendance/record-image";
|
||||
|
||||
// Employee Screen API Endpoints
|
||||
static const String getAllEmployeesByProject = "/Employee/basic";
|
||||
static const String getAllEmployees = "/Employee/basic";
|
||||
static const String getAllEmployeesByProject = "/employee/list";
|
||||
static const String getAllEmployees = "/employee/list";
|
||||
static const String getRoles = "/roles/jobrole";
|
||||
static const String createEmployee = "/employee/manage-mobile";
|
||||
static const String getEmployeeInfo = "/employee/profile/get";
|
||||
@ -55,7 +55,7 @@ class ApiEndpoints {
|
||||
static const String getExpenseList = "/expense/list";
|
||||
static const String getExpenseDetails = "/expense/details";
|
||||
static const String createExpense = "/expense/create";
|
||||
static const String updateExpense = "/expense/manage";
|
||||
static const String editExpense = "/Expense/edit";
|
||||
static const String getMasterPaymentModes = "/master/payment-modes";
|
||||
static const String getMasterExpenseStatus = "/master/expenses-status";
|
||||
static const String getMasterExpenseTypes = "/master/expenses-types";
|
||||
|
@ -240,6 +240,48 @@ class ApiService {
|
||||
}
|
||||
|
||||
// === Expense APIs === //
|
||||
|
||||
/// Edit Expense API
|
||||
static Future<bool> editExpenseApi({
|
||||
required String expenseId,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
final endpoint = "${ApiEndpoints.editExpense}/$expenseId";
|
||||
logSafe("Editing expense $expenseId with payload: $payload");
|
||||
|
||||
try {
|
||||
final response = await _putRequest(
|
||||
endpoint,
|
||||
payload,
|
||||
customTimeout: extendedTimeout,
|
||||
);
|
||||
|
||||
if (response == null) {
|
||||
logSafe("Edit expense failed: null response", level: LogLevel.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
logSafe("Edit expense response status: ${response.statusCode}");
|
||||
logSafe("Edit expense response body: ${response.body}");
|
||||
|
||||
final json = jsonDecode(response.body);
|
||||
if (json['success'] == true) {
|
||||
logSafe("Expense updated successfully: ${json['data']}");
|
||||
return true;
|
||||
} else {
|
||||
logSafe(
|
||||
"Failed to update expense: ${json['message'] ?? 'Unknown error'}",
|
||||
level: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
logSafe("Exception during editExpenseApi: $e", level: LogLevel.error);
|
||||
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> deleteExpense(String expenseId) async {
|
||||
final endpoint = "${ApiEndpoints.deleteExpense}/$expenseId";
|
||||
|
||||
|
@ -6,9 +6,15 @@ import 'package:marco/model/expense/expense_type_model.dart';
|
||||
import 'package:marco/model/expense/payment_types_model.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||
|
||||
void showAddExpenseBottomSheet() {
|
||||
Get.bottomSheet(const _AddExpenseBottomSheet(), isScrollControlled: true);
|
||||
Future<T?> showAddExpenseBottomSheet<T>() {
|
||||
return Get.bottomSheet<T>(
|
||||
const _AddExpenseBottomSheet(),
|
||||
isScrollControlled: true,
|
||||
);
|
||||
}
|
||||
|
||||
class _AddExpenseBottomSheet extends StatefulWidget {
|
||||
@ -90,7 +96,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
onCancel: Get.back,
|
||||
onSubmit: () {
|
||||
if (!controller.isSubmitting.value) {
|
||||
controller.submitExpense();
|
||||
controller.submitOrUpdateExpense();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
@ -267,7 +273,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
MySpacing.height(6),
|
||||
_AttachmentsSection(
|
||||
attachments: controller.attachments,
|
||||
onRemove: controller.removeAttachment,
|
||||
existingAttachments: controller.existingAttachments,
|
||||
onRemoveNew: controller.removeAttachment,
|
||||
onRemoveExisting: (item) =>
|
||||
controller.existingAttachments.remove(item),
|
||||
onAdd: controller.pickAttachments,
|
||||
),
|
||||
MySpacing.height(16),
|
||||
@ -447,37 +456,140 @@ class _TileContainer extends StatelessWidget {
|
||||
|
||||
class _AttachmentsSection extends StatelessWidget {
|
||||
final RxList<File> attachments;
|
||||
final ValueChanged<File> onRemove;
|
||||
final List<Map<String, dynamic>> existingAttachments;
|
||||
final ValueChanged<File> onRemoveNew;
|
||||
final ValueChanged<Map<String, dynamic>>? onRemoveExisting;
|
||||
final VoidCallback onAdd;
|
||||
|
||||
const _AttachmentsSection({
|
||||
required this.attachments,
|
||||
required this.onRemove,
|
||||
required this.existingAttachments,
|
||||
required this.onRemoveNew,
|
||||
this.onRemoveExisting,
|
||||
required this.onAdd,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() => Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
return Obx(() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...attachments.map((file) => _AttachmentTile(
|
||||
file: file,
|
||||
onRemove: () => onRemove(file),
|
||||
)),
|
||||
GestureDetector(
|
||||
onTap: onAdd,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: const Icon(Icons.add, size: 30, color: Colors.grey),
|
||||
if (existingAttachments.isNotEmpty) ...[
|
||||
Text(
|
||||
"Existing Attachments",
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: existingAttachments.map((doc) {
|
||||
final isImage =
|
||||
doc['contentType']?.toString().startsWith('image/') ?? false;
|
||||
final url = doc['url'];
|
||||
final fileName = doc['fileName'] ?? 'Unnamed';
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
if (isImage) {
|
||||
final imageDocs = existingAttachments
|
||||
.where((d) => (d['contentType']
|
||||
?.toString()
|
||||
.startsWith('image/') ??
|
||||
false))
|
||||
.toList();
|
||||
final initialIndex = imageDocs.indexWhere((d) => d == doc);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ImageViewerDialog(
|
||||
imageSources: imageDocs.map((e) => e['url']).toList(),
|
||||
initialIndex: initialIndex,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (url != null && await canLaunchUrlString(url)) {
|
||||
await launchUrlString(url,
|
||||
mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: 'Error',
|
||||
message: 'Could not open the document.',
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isImage ? Icons.image : Icons.insert_drive_file,
|
||||
size: 20,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 120),
|
||||
child: Text(
|
||||
fileName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onRemoveExisting != null)
|
||||
Positioned(
|
||||
top: -6,
|
||||
right: -6,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||
onPressed: () => onRemoveExisting!(doc),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// New attachments section - shows preview tiles
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
...attachments.map((file) => _AttachmentTile(
|
||||
file: file,
|
||||
onRemove: () => onRemoveNew(file),
|
||||
)),
|
||||
GestureDetector(
|
||||
onTap: onAdd,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: const Icon(Icons.add, size: 30, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
));
|
||||
|
@ -1,10 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:marco/model/expense/add_expense_bottom_sheet.dart';
|
||||
|
||||
import 'package:marco/controller/expense/expense_detail_controller.dart';
|
||||
import 'package:marco/controller/project_controller.dart';
|
||||
import 'package:marco/controller/permission_controller.dart';
|
||||
|
||||
import 'package:marco/helpers/utils/date_time_utils.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
@ -14,6 +15,8 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:marco/model/expense/reimbursement_bottom_sheet.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/model/expense/comment_bottom_sheet.dart';
|
||||
import 'package:marco/controller/expense/add_expense_controller.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
|
||||
class ExpenseDetailScreen extends StatelessWidget {
|
||||
final String expenseId;
|
||||
@ -48,26 +51,275 @@ class ExpenseDetailScreen extends StatelessWidget {
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F7F7),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
elevation: 1,
|
||||
backgroundColor: Colors.white,
|
||||
title: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.black, size: 20),
|
||||
onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'),
|
||||
appBar: _AppBar(projectController: projectController),
|
||||
body: SafeArea(
|
||||
child: Obx(() {
|
||||
if (controller.isLoading.value) return _buildLoadingSkeleton();
|
||||
final expense = controller.expense.value;
|
||||
if (controller.errorMessage.isNotEmpty || expense == null) {
|
||||
return Center(child: MyText.bodyMedium("No data to display."));
|
||||
}
|
||||
final statusColor = getStatusColor(expense.status.name,
|
||||
colorCode: expense.status.color);
|
||||
final formattedAmount = _formatAmount(expense.amount);
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8, 8, 8, 30 + MediaQuery.of(context).padding.bottom),
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
elevation: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 14, horizontal: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_InvoiceHeader(expense: expense),
|
||||
const Divider(height: 30, thickness: 1.2),
|
||||
_InvoiceParties(expense: expense),
|
||||
const Divider(height: 30, thickness: 1.2),
|
||||
_InvoiceDetailsTable(expense: expense),
|
||||
const Divider(height: 30, thickness: 1.2),
|
||||
_InvoiceDocuments(documents: expense.documents),
|
||||
const Divider(height: 30, thickness: 1.2),
|
||||
_InvoiceTotals(
|
||||
expense: expense,
|
||||
formattedAmount: formattedAmount,
|
||||
statusColor: statusColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleLarge('Expense Details',
|
||||
fontWeight: 700, color: Colors.black),
|
||||
MySpacing.height(2),
|
||||
GetBuilder<ProjectController>(builder: (_) {
|
||||
);
|
||||
}),
|
||||
),
|
||||
floatingActionButton: Obx(() {
|
||||
final expense = controller.expense.value;
|
||||
if (expense == null) return const SizedBox.shrink();
|
||||
|
||||
// Allowed status Ids
|
||||
const allowedStatusIds = [
|
||||
"d1ee5eec-24b6-4364-8673-a8f859c60729",
|
||||
"965eda62-7907-4963-b4a1-657fb0b2724b",
|
||||
"297e0d8f-f668-41b5-bfea-e03b354251c8"
|
||||
];
|
||||
|
||||
// Show edit button only if status id is in allowedStatusIds
|
||||
if (!allowedStatusIds.contains(expense.status.id)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return FloatingActionButton(
|
||||
onPressed: () async {
|
||||
final editData = {
|
||||
'id': expense.id,
|
||||
'projectName': expense.project.name,
|
||||
'amount': expense.amount,
|
||||
'supplerName': expense.supplerName,
|
||||
'description': expense.description,
|
||||
'transactionId': expense.transactionId,
|
||||
'location': expense.location,
|
||||
'transactionDate': expense.transactionDate,
|
||||
'noOfPersons': expense.noOfPersons,
|
||||
'expensesTypeId': expense.expensesType.id,
|
||||
'paymentModeId': expense.paymentMode.id,
|
||||
'paidById': expense.paidBy.id,
|
||||
'attachments': expense.documents
|
||||
.map((doc) => {
|
||||
'url': doc.preSignedUrl,
|
||||
'fileName': doc.fileName,
|
||||
'documentId': doc.documentId,
|
||||
'contentType': doc.contentType,
|
||||
})
|
||||
.toList(),
|
||||
};
|
||||
logSafe('editData: $editData', level: LogLevel.info);
|
||||
|
||||
final addCtrl = Get.put(AddExpenseController());
|
||||
|
||||
await addCtrl.loadMasterData();
|
||||
addCtrl.populateFieldsForEdit(editData);
|
||||
|
||||
await showAddExpenseBottomSheet();
|
||||
|
||||
// Refresh expense details after editing
|
||||
await controller.fetchExpenseDetails();
|
||||
},
|
||||
backgroundColor: Colors.red,
|
||||
tooltip: 'Edit Expense',
|
||||
child: Icon(Icons.edit),
|
||||
);
|
||||
}),
|
||||
bottomNavigationBar: Obx(() {
|
||||
final expense = controller.expense.value;
|
||||
if (expense == null || expense.nextStatus.isEmpty) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return SafeArea(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Color(0x11000000))),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: expense.nextStatus
|
||||
.where((next) => permissionController.hasAnyPermission(
|
||||
controller.parsePermissionIds(next.permissionIds)))
|
||||
.map((next) =>
|
||||
_statusButton(context, controller, expense, next))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusButton(BuildContext context, ExpenseDetailController controller,
|
||||
ExpenseDetailModel expense, dynamic next) {
|
||||
Color buttonColor = Colors.red;
|
||||
if (next.color.isNotEmpty) {
|
||||
try {
|
||||
buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff')));
|
||||
} catch (_) {}
|
||||
}
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(100, 40),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
backgroundColor: buttonColor,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
),
|
||||
onPressed: () async {
|
||||
// For brevity, couldn't refactor the logic since it's business-specific.
|
||||
const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27';
|
||||
if (expense.status.id == reimbursementId) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
builder: (context) => ReimbursementBottomSheet(
|
||||
expenseId: expense.id,
|
||||
statusId: next.id,
|
||||
onClose: () {},
|
||||
onSubmit: ({
|
||||
required String comment,
|
||||
required String reimburseTransactionId,
|
||||
required String reimburseDate,
|
||||
required String reimburseById,
|
||||
required String statusId,
|
||||
}) async {
|
||||
final success =
|
||||
await controller.updateExpenseStatusWithReimbursement(
|
||||
comment: comment,
|
||||
reimburseTransactionId: reimburseTransactionId,
|
||||
reimburseDate: reimburseDate,
|
||||
reimburseById: reimburseById,
|
||||
statusId: statusId,
|
||||
);
|
||||
if (success) {
|
||||
showAppSnackbar(
|
||||
title: 'Success',
|
||||
message: 'Expense reimbursed successfully.',
|
||||
type: SnackbarType.success);
|
||||
await controller.fetchExpenseDetails();
|
||||
return true;
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: 'Error',
|
||||
message: 'Failed to reimburse expense.',
|
||||
type: SnackbarType.error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final comment = await showCommentBottomSheet(context, next.name);
|
||||
if (comment == null) return;
|
||||
final success =
|
||||
await controller.updateExpenseStatus(next.id, comment: comment);
|
||||
if (success) {
|
||||
showAppSnackbar(
|
||||
title: 'Success',
|
||||
message:
|
||||
'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}',
|
||||
type: SnackbarType.success);
|
||||
await controller.fetchExpenseDetails();
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: 'Error',
|
||||
message: 'Failed to update status.',
|
||||
type: SnackbarType.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: MyText.labelMedium(
|
||||
next.displayName.isNotEmpty ? next.displayName : next.name,
|
||||
color: Colors.white,
|
||||
fontWeight: 600,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatAmount(double amount) {
|
||||
return NumberFormat.currency(
|
||||
locale: 'en_IN', symbol: '₹ ', decimalDigits: 2)
|
||||
.format(amount);
|
||||
}
|
||||
|
||||
Widget _buildLoadingSkeleton() {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: 5,
|
||||
itemBuilder: (_, __) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300], borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final ProjectController projectController;
|
||||
const _AppBar({required this.projectController});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
elevation: 1,
|
||||
backgroundColor: Colors.white,
|
||||
title: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.black, size: 20),
|
||||
onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleLarge('Expense Details',
|
||||
fontWeight: 700, color: Colors.black),
|
||||
MySpacing.height(2),
|
||||
GetBuilder<ProjectController>(
|
||||
builder: (_) {
|
||||
final projectName =
|
||||
projectController.selectedProject?.name ??
|
||||
'Select Project';
|
||||
@ -86,277 +338,58 @@ class ExpenseDetailScreen extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return _buildLoadingSkeleton();
|
||||
}
|
||||
|
||||
final expense = controller.expense.value;
|
||||
|
||||
if (controller.errorMessage.isNotEmpty || expense == null) {
|
||||
return Center(
|
||||
child: MyText.bodyMedium("No data to display."),
|
||||
);
|
||||
}
|
||||
|
||||
final statusColor = getStatusColor(expense.status.name,
|
||||
colorCode: expense.status.color);
|
||||
final formattedAmount = NumberFormat.currency(
|
||||
locale: 'en_IN',
|
||||
symbol: '₹ ',
|
||||
decimalDigits: 2,
|
||||
).format(expense.amount);
|
||||
|
||||
// === CHANGE: Add proper bottom padding to always keep content away from device nav bar ===
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8,
|
||||
8,
|
||||
8,
|
||||
16 + MediaQuery.of(context).padding.bottom, // KEY LINE
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
elevation: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 14, horizontal: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_InvoiceHeader(expense: expense),
|
||||
Divider(height: 30, thickness: 1.2),
|
||||
_InvoiceParties(expense: expense),
|
||||
Divider(height: 30, thickness: 1.2),
|
||||
_InvoiceDetailsTable(expense: expense),
|
||||
Divider(height: 30, thickness: 1.2),
|
||||
_InvoiceDocuments(documents: expense.documents),
|
||||
Divider(height: 30, thickness: 1.2),
|
||||
_InvoiceTotals(
|
||||
expense: expense,
|
||||
formattedAmount: formattedAmount,
|
||||
statusColor: statusColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
bottomNavigationBar: Obx(() {
|
||||
final expense = controller.expense.value;
|
||||
if (expense == null || expense.nextStatus.isEmpty) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return SafeArea(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Color(0x11000000))),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: expense.nextStatus.where((next) {
|
||||
return permissionController.hasAnyPermission(
|
||||
controller.parsePermissionIds(next.permissionIds),
|
||||
);
|
||||
}).map((next) {
|
||||
Color buttonColor = Colors.red;
|
||||
if (next.color.isNotEmpty) {
|
||||
try {
|
||||
buttonColor =
|
||||
Color(int.parse(next.color.replaceFirst('#', '0xff')));
|
||||
} catch (_) {}
|
||||
}
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(100, 40),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
backgroundColor: buttonColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6)),
|
||||
),
|
||||
onPressed: () async {
|
||||
const reimbursementId =
|
||||
'f18c5cfd-7815-4341-8da2-2c2d65778e27';
|
||||
|
||||
if (expense.status.id == reimbursementId) {
|
||||
// Open reimbursement flow
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) => ReimbursementBottomSheet(
|
||||
expenseId: expense.id,
|
||||
statusId: next.id,
|
||||
onClose: () {},
|
||||
onSubmit: ({
|
||||
required String comment,
|
||||
required String reimburseTransactionId,
|
||||
required String reimburseDate,
|
||||
required String reimburseById,
|
||||
required String statusId,
|
||||
}) async {
|
||||
final success = await controller
|
||||
.updateExpenseStatusWithReimbursement(
|
||||
comment: comment,
|
||||
reimburseTransactionId: reimburseTransactionId,
|
||||
reimburseDate: reimburseDate,
|
||||
reimburseById: reimburseById,
|
||||
statusId: statusId,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
showAppSnackbar(
|
||||
title: 'Success',
|
||||
message: 'Expense reimbursed successfully.',
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
await controller.fetchExpenseDetails();
|
||||
return true;
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: 'Error',
|
||||
message: 'Failed to reimburse expense.',
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// ✨ New: Show comment sheet
|
||||
final comment =
|
||||
await showCommentBottomSheet(context, next.name);
|
||||
if (comment == null) return;
|
||||
|
||||
final success = await controller.updateExpenseStatus(
|
||||
next.id,
|
||||
comment: comment,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
showAppSnackbar(
|
||||
title: 'Success',
|
||||
message:
|
||||
'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}',
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
await controller.fetchExpenseDetails();
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: 'Error',
|
||||
message: 'Failed to update status.',
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: MyText.labelMedium(
|
||||
next.displayName.isNotEmpty ? next.displayName : next.name,
|
||||
color: Colors.white,
|
||||
fontWeight: 600,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingSkeleton() {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: List.generate(5, (index) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
// ---------------- INVOICE SUB-COMPONENTS ----------------
|
||||
// -------- Invoice Sub-Components, unchanged except formatting/const ----------------
|
||||
|
||||
class _InvoiceHeader extends StatelessWidget {
|
||||
final ExpenseDetailModel expense;
|
||||
const _InvoiceHeader({required this.expense});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateString = DateTimeUtils.convertUtcToLocal(
|
||||
expense.transactionDate.toString(),
|
||||
format: 'dd-MM-yyyy');
|
||||
|
||||
final statusColor = ExpenseDetailScreen.getStatusColor(expense.status.name,
|
||||
colorCode: expense.status.color);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.calendar_month, size: 18, color: Colors.grey),
|
||||
MySpacing.width(6),
|
||||
MyText.bodySmall('Date:', fontWeight: 600),
|
||||
MySpacing.width(6),
|
||||
MyText.bodySmall(dateString, fontWeight: 600),
|
||||
]),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.calendar_month, size: 18, color: Colors.grey),
|
||||
MySpacing.width(6),
|
||||
MyText.bodySmall('Date:', fontWeight: 600),
|
||||
MySpacing.width(6),
|
||||
MyText.bodySmall(dateString, fontWeight: 600),
|
||||
Icon(Icons.flag, size: 16, color: statusColor),
|
||||
MySpacing.width(4),
|
||||
MyText.labelSmall(expense.status.name,
|
||||
color: statusColor, fontWeight: 600),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.flag, size: 16, color: statusColor),
|
||||
MySpacing.width(4),
|
||||
MyText.labelSmall(
|
||||
expense.status.name,
|
||||
color: statusColor,
|
||||
fontWeight: 600,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
])
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -365,7 +398,6 @@ class _InvoiceHeader extends StatelessWidget {
|
||||
class _InvoiceParties extends StatelessWidget {
|
||||
final ExpenseDetailModel expense;
|
||||
const _InvoiceParties({required this.expense});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
@ -373,45 +405,31 @@ class _InvoiceParties extends StatelessWidget {
|
||||
children: [
|
||||
_labelValueBlock('Project', expense.project.name),
|
||||
MySpacing.height(16),
|
||||
_labelValueBlock(
|
||||
'Paid By:',
|
||||
'${expense.paidBy.firstName} ${expense.paidBy.lastName}',
|
||||
),
|
||||
_labelValueBlock('Paid By:',
|
||||
'${expense.paidBy.firstName} ${expense.paidBy.lastName}'),
|
||||
MySpacing.height(16),
|
||||
_labelValueBlock('Supplier', expense.supplerName),
|
||||
MySpacing.height(16),
|
||||
_labelValueBlock(
|
||||
'Created By:',
|
||||
'${expense.createdBy.firstName} ${expense.createdBy.lastName}',
|
||||
),
|
||||
_labelValueBlock('Created By:',
|
||||
'${expense.createdBy.firstName} ${expense.createdBy.lastName}'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _labelValueBlock(String label, String value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.bodySmall(
|
||||
label,
|
||||
fontWeight: 600,
|
||||
),
|
||||
MySpacing.height(4),
|
||||
MyText.bodySmall(
|
||||
value,
|
||||
fontWeight: 500,
|
||||
softWrap: true,
|
||||
maxLines: null, // Allow full wrapping
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
Widget _labelValueBlock(String label, String value) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.bodySmall(label, fontWeight: 600),
|
||||
MySpacing.height(4),
|
||||
MyText.bodySmall(value,
|
||||
fontWeight: 500, softWrap: true, maxLines: null),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _InvoiceDetailsTable extends StatelessWidget {
|
||||
final ExpenseDetailModel expense;
|
||||
const _InvoiceDetailsTable({required this.expense});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final transactionDate = DateTimeUtils.convertUtcToLocal(
|
||||
@ -420,7 +438,6 @@ class _InvoiceDetailsTable extends StatelessWidget {
|
||||
final createdAt = DateTimeUtils.convertUtcToLocal(
|
||||
expense.createdAt.toString(),
|
||||
format: 'dd-MM-yyyy hh:mm a');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -436,39 +453,30 @@ class _InvoiceDetailsTable extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detailItem(String title, String value, {bool isDescription = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.bodySmall(
|
||||
title,
|
||||
fontWeight: 600,
|
||||
),
|
||||
MySpacing.height(3),
|
||||
isDescription
|
||||
? ExpandableDescription(description: value)
|
||||
: MyText.bodySmall(
|
||||
value,
|
||||
fontWeight: 500,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget _detailItem(String title, String value,
|
||||
{bool isDescription = false}) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.bodySmall(title, fontWeight: 600),
|
||||
MySpacing.height(3),
|
||||
isDescription
|
||||
? ExpandableDescription(description: value)
|
||||
: MyText.bodySmall(value, fontWeight: 500),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _InvoiceDocuments extends StatelessWidget {
|
||||
final List<ExpenseDocument> documents;
|
||||
const _InvoiceDocuments({required this.documents});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (documents.isEmpty) {
|
||||
if (documents.isEmpty)
|
||||
return MyText.bodyMedium('No Supporting Documents', color: Colors.grey);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -481,16 +489,13 @@ class _InvoiceDocuments extends StatelessWidget {
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final doc = documents[index];
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
final imageDocs = documents
|
||||
.where((d) => d.contentType.startsWith('image/'))
|
||||
.toList();
|
||||
|
||||
final initialIndex =
|
||||
imageDocs.indexWhere((d) => d.documentId == doc.documentId);
|
||||
|
||||
if (imageDocs.isNotEmpty && initialIndex != -1) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@ -506,10 +511,9 @@ class _InvoiceDocuments extends StatelessWidget {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: 'Error',
|
||||
message: 'Could not open the document.',
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
title: 'Error',
|
||||
message: 'Could not open the document.',
|
||||
type: SnackbarType.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -557,7 +561,6 @@ class _InvoiceTotals extends StatelessWidget {
|
||||
required this.formattedAmount,
|
||||
required this.statusColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
@ -573,18 +576,15 @@ class _InvoiceTotals extends StatelessWidget {
|
||||
class ExpandableDescription extends StatefulWidget {
|
||||
final String description;
|
||||
const ExpandableDescription({super.key, required this.description});
|
||||
|
||||
@override
|
||||
State<ExpandableDescription> createState() => _ExpandableDescriptionState();
|
||||
}
|
||||
|
||||
class _ExpandableDescriptionState extends State<ExpandableDescription> {
|
||||
bool isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLong = widget.description.length > 100;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
Loading…
x
Reference in New Issue
Block a user