Vaibhav_Feature-#768 #59
@ -3,21 +3,23 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
import 'package:geocoding/geocoding.dart';
|
import 'package:geocoding/geocoding.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:mime/mime.dart';
|
import 'package:mime/mime.dart';
|
||||||
|
|
||||||
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||||
import 'package:marco/helpers/services/api_service.dart';
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
import 'package:marco/helpers/services/app_logger.dart';
|
import 'package:marco/helpers/services/app_logger.dart';
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
|
||||||
import 'package:marco/model/employee_model.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/expense_type_model.dart';
|
||||||
import 'package:marco/model/expense/payment_types_model.dart';
|
import 'package:marco/model/expense/payment_types_model.dart';
|
||||||
|
|
||||||
class AddExpenseController extends GetxController {
|
class AddExpenseController extends GetxController {
|
||||||
|
// Text Controllers
|
||||||
final amountController = TextEditingController();
|
final amountController = TextEditingController();
|
||||||
final descriptionController = TextEditingController();
|
final descriptionController = TextEditingController();
|
||||||
final supplierController = TextEditingController();
|
final supplierController = TextEditingController();
|
||||||
@ -25,30 +27,34 @@ class AddExpenseController extends GetxController {
|
|||||||
final gstController = TextEditingController();
|
final gstController = TextEditingController();
|
||||||
final locationController = TextEditingController();
|
final locationController = TextEditingController();
|
||||||
final transactionDateController = TextEditingController();
|
final transactionDateController = TextEditingController();
|
||||||
final TextEditingController noOfPersonsController = TextEditingController();
|
final noOfPersonsController = TextEditingController();
|
||||||
|
|
||||||
final RxBool isLoading = false.obs;
|
// State
|
||||||
final RxBool isSubmitting = false.obs;
|
final isLoading = false.obs;
|
||||||
final RxBool isFetchingLocation = false.obs;
|
final isSubmitting = false.obs;
|
||||||
|
final isFetchingLocation = false.obs;
|
||||||
|
final isEditMode = false.obs;
|
||||||
|
|
||||||
final Rx<PaymentModeModel?> selectedPaymentMode = Rx<PaymentModeModel?>(null);
|
// Dropdown Selections
|
||||||
final Rx<ExpenseTypeModel?> selectedExpenseType = Rx<ExpenseTypeModel?>(null);
|
final selectedPaymentMode = Rx<PaymentModeModel?>(null);
|
||||||
final Rx<ExpenseStatusModel?> selectedExpenseStatus = Rx<ExpenseStatusModel?>(null);
|
final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
|
||||||
final Rx<EmployeeModel?> selectedPaidBy = Rx<EmployeeModel?>(null);
|
final selectedPaidBy = Rx<EmployeeModel?>(null);
|
||||||
final RxString selectedProject = ''.obs;
|
final selectedProject = ''.obs;
|
||||||
final Rx<DateTime?> selectedTransactionDate = Rx<DateTime?>(null);
|
final selectedTransactionDate = Rx<DateTime?>(null);
|
||||||
|
|
||||||
final RxList<File> attachments = <File>[].obs;
|
// Data Lists
|
||||||
final RxList<String> globalProjects = <String>[].obs;
|
final attachments = <File>[].obs;
|
||||||
final RxList<String> projects = <String>[].obs;
|
final globalProjects = <String>[].obs;
|
||||||
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
|
final projectsMap = <String, String>{}.obs;
|
||||||
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
|
final expenseTypes = <ExpenseTypeModel>[].obs;
|
||||||
final RxList<ExpenseStatusModel> expenseStatuses = <ExpenseStatusModel>[].obs;
|
final paymentModes = <PaymentModeModel>[].obs;
|
||||||
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].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
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
@ -71,6 +77,117 @@ class AddExpenseController extends GetxController {
|
|||||||
super.onClose();
|
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 {
|
Future<void> pickAttachments() async {
|
||||||
try {
|
try {
|
||||||
final result = await FilePicker.platform.pickFiles(
|
final result = await FilePicker.platform.pickFiles(
|
||||||
@ -78,48 +195,34 @@ class AddExpenseController extends GetxController {
|
|||||||
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
|
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
|
||||||
allowMultiple: true,
|
allowMultiple: true,
|
||||||
);
|
);
|
||||||
if (result != null && result.paths.isNotEmpty) {
|
if (result != null) {
|
||||||
final files = result.paths.whereType<String>().map((e) => File(e)).toList();
|
attachments.addAll(
|
||||||
attachments.addAll(files);
|
result.paths.whereType<String>().map((path) => File(path)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: "Error",
|
||||||
message: "Failed to pick attachments: $e",
|
message: "Attachment error: $e",
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeAttachment(File file) {
|
void removeAttachment(File file) => attachments.remove(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}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchCurrentLocation() async {
|
Future<void> fetchCurrentLocation() async {
|
||||||
isFetchingLocation.value = true;
|
isFetchingLocation.value = true;
|
||||||
try {
|
try {
|
||||||
LocationPermission permission = await Geolocator.checkPermission();
|
var permission = await Geolocator.checkPermission();
|
||||||
if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) {
|
if (permission == LocationPermission.denied ||
|
||||||
|
permission == LocationPermission.deniedForever) {
|
||||||
permission = await Geolocator.requestPermission();
|
permission = await Geolocator.requestPermission();
|
||||||
if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) {
|
if (permission == LocationPermission.denied ||
|
||||||
|
permission == LocationPermission.deniedForever) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: "Error",
|
||||||
message: "Location permission denied. Enable in settings.",
|
message: "Location permission denied.",
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@ -129,24 +232,24 @@ class AddExpenseController extends GetxController {
|
|||||||
if (!await Geolocator.isLocationServiceEnabled()) {
|
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: "Error",
|
||||||
message: "Location services are disabled. Enable them.",
|
message: "Location service disabled.",
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
|
final position = await Geolocator.getCurrentPosition();
|
||||||
final placemarks = await placemarkFromCoordinates(position.latitude, position.longitude);
|
final placemarks =
|
||||||
|
await placemarkFromCoordinates(position.latitude, position.longitude);
|
||||||
|
|
||||||
if (placemarks.isNotEmpty) {
|
if (placemarks.isNotEmpty) {
|
||||||
final place = placemarks.first;
|
final place = placemarks.first;
|
||||||
final address = [
|
final address = [
|
||||||
place.name,
|
place.name,
|
||||||
place.street,
|
place.street,
|
||||||
place.subLocality,
|
|
||||||
place.locality,
|
place.locality,
|
||||||
place.administrativeArea,
|
place.administrativeArea,
|
||||||
place.country,
|
place.country
|
||||||
].where((e) => e != null && e.isNotEmpty).join(", ");
|
].where((e) => e != null && e.isNotEmpty).join(", ");
|
||||||
locationController.text = address;
|
locationController.text = address;
|
||||||
} else {
|
} else {
|
||||||
@ -155,7 +258,7 @@ class AddExpenseController extends GetxController {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: "Error",
|
||||||
message: "Error fetching location: $e",
|
message: "Location error: $e",
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@ -163,112 +266,62 @@ class AddExpenseController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> submitExpense() async {
|
// ---------- Submission ----------
|
||||||
|
Future<void> submitOrUpdateExpense() async {
|
||||||
if (isSubmitting.value) return;
|
if (isSubmitting.value) return;
|
||||||
isSubmitting.value = true;
|
isSubmitting.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
List<String> missing = [];
|
final validation = validateForm();
|
||||||
|
if (validation.isNotEmpty) {
|
||||||
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) {
|
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Missing Fields",
|
title: "Missing Fields",
|
||||||
message: "Please provide: ${missing.join(', ')}.",
|
message: validation,
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final amount = double.tryParse(amountController.text);
|
final payload = await _buildExpensePayload();
|
||||||
if (amount == null) {
|
|
||||||
showAppSnackbar(
|
|
||||||
title: "Error",
|
|
||||||
message: "Please enter a valid amount.",
|
|
||||||
type: SnackbarType.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final selectedDate = selectedTransactionDate.value ?? DateTime.now();
|
final success = isEditMode.value && editingExpenseId != null
|
||||||
if (selectedDate.isAfter(DateTime.now())) {
|
? await ApiService.editExpenseApi(
|
||||||
showAppSnackbar(
|
expenseId: editingExpenseId!, payload: payload)
|
||||||
title: "Invalid Date",
|
: await ApiService.createExpenseApi(
|
||||||
message: "Transaction date cannot be in the future.",
|
projectId: payload['projectId'],
|
||||||
type: SnackbarType.error,
|
expensesTypeId: payload['expensesTypeId'],
|
||||||
);
|
paymentModeId: payload['paymentModeId'],
|
||||||
return;
|
paidById: payload['paidById'],
|
||||||
}
|
transactionDate: DateTime.parse(payload['transactionDate']),
|
||||||
|
transactionId: payload['transactionId'],
|
||||||
final projectId = projectsMap[selectedProject.value];
|
description: payload['description'],
|
||||||
if (projectId == null) {
|
location: payload['location'],
|
||||||
showAppSnackbar(
|
supplerName: payload['supplerName'],
|
||||||
title: "Error",
|
amount: payload['amount'],
|
||||||
message: "Invalid project selected.",
|
noOfPersons: payload['noOfPersons'],
|
||||||
type: SnackbarType.error,
|
billAttachments: payload['billAttachments'],
|
||||||
);
|
);
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
await expenseController.fetchExpenses();
|
await expenseController.fetchExpenses();
|
||||||
Get.back();
|
Get.back();
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Success",
|
title: "Success",
|
||||||
message: "Expense created successfully!",
|
message:
|
||||||
|
"Expense ${isEditMode.value ? 'updated' : 'created'} successfully!",
|
||||||
type: SnackbarType.success,
|
type: SnackbarType.success,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: "Error",
|
||||||
message: "Failed to create expense. Try again.",
|
message: "Operation failed. Try again.",
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: "Error",
|
||||||
message: "Something went wrong: $e",
|
message: "Unexpected error: $e",
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
} finally {
|
} 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 {
|
Future<void> fetchMasterData() async {
|
||||||
try {
|
try {
|
||||||
final expenseTypesData = await ApiService.getMasterExpenseTypes();
|
final types = await ApiService.getMasterExpenseTypes();
|
||||||
final paymentModesData = await ApiService.getMasterPaymentModes();
|
final modes = await ApiService.getMasterPaymentModes();
|
||||||
final expenseStatusData = await ApiService.getMasterExpenseStatus();
|
|
||||||
|
|
||||||
if (expenseTypesData is List) {
|
if (types is List) {
|
||||||
expenseTypes.value = expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
|
expenseTypes.value =
|
||||||
|
types.map((e) => ExpenseTypeModel.fromJson(e)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paymentModesData is List) {
|
if (modes is List) {
|
||||||
paymentModes.value = paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
|
paymentModes.value =
|
||||||
}
|
modes.map((e) => PaymentModeModel.fromJson(e)).toList();
|
||||||
|
|
||||||
if (expenseStatusData is List) {
|
|
||||||
expenseStatuses.value = expenseStatusData.map((e) => ExpenseStatusModel.fromJson(e)).toList();
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: "Error",
|
||||||
message: "Failed to fetch master data: $e",
|
message: "Failed to fetch master data",
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -310,16 +440,15 @@ class AddExpenseController extends GetxController {
|
|||||||
for (var item in response) {
|
for (var item in response) {
|
||||||
final name = item['name']?.toString().trim();
|
final name = item['name']?.toString().trim();
|
||||||
final id = item['id']?.toString().trim();
|
final id = item['id']?.toString().trim();
|
||||||
if (name != null && id != null && name.isNotEmpty) {
|
if (name != null && id != null) {
|
||||||
projectsMap[name] = id;
|
projectsMap[name] = id;
|
||||||
names.add(name);
|
names.add(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
globalProjects.assignAll(names);
|
globalProjects.assignAll(names);
|
||||||
logSafe("Fetched ${names.length} global projects");
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
final response = await ApiService.getAllEmployees();
|
final response = await ApiService.getAllEmployees();
|
||||||
if (response != null && response.isNotEmpty) {
|
if (response != null) {
|
||||||
allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e)));
|
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) {
|
} catch (e) {
|
||||||
allEmployees.clear();
|
logSafe("Error fetching employees: $e", level: LogLevel.error);
|
||||||
logSafe("Error fetching employees", level: LogLevel.error, error: e);
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
update();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,8 +15,8 @@ class ApiEndpoints {
|
|||||||
static const String uploadAttendanceImage = "/attendance/record-image";
|
static const String uploadAttendanceImage = "/attendance/record-image";
|
||||||
|
|
||||||
// Employee Screen API Endpoints
|
// Employee Screen API Endpoints
|
||||||
static const String getAllEmployeesByProject = "/Employee/basic";
|
static const String getAllEmployeesByProject = "/employee/list";
|
||||||
static const String getAllEmployees = "/Employee/basic";
|
static const String getAllEmployees = "/employee/list";
|
||||||
static const String getRoles = "/roles/jobrole";
|
static const String getRoles = "/roles/jobrole";
|
||||||
static const String createEmployee = "/employee/manage-mobile";
|
static const String createEmployee = "/employee/manage-mobile";
|
||||||
static const String getEmployeeInfo = "/employee/profile/get";
|
static const String getEmployeeInfo = "/employee/profile/get";
|
||||||
@ -55,7 +55,7 @@ class ApiEndpoints {
|
|||||||
static const String getExpenseList = "/expense/list";
|
static const String getExpenseList = "/expense/list";
|
||||||
static const String getExpenseDetails = "/expense/details";
|
static const String getExpenseDetails = "/expense/details";
|
||||||
static const String createExpense = "/expense/create";
|
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 getMasterPaymentModes = "/master/payment-modes";
|
||||||
static const String getMasterExpenseStatus = "/master/expenses-status";
|
static const String getMasterExpenseStatus = "/master/expenses-status";
|
||||||
static const String getMasterExpenseTypes = "/master/expenses-types";
|
static const String getMasterExpenseTypes = "/master/expenses-types";
|
||||||
|
@ -240,6 +240,48 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Expense APIs === //
|
// === 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 {
|
static Future<bool> deleteExpense(String expenseId) async {
|
||||||
final endpoint = "${ApiEndpoints.deleteExpense}/$expenseId";
|
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/model/expense/payment_types_model.dart';
|
||||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:marco/helpers/utils/base_bottom_sheet.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() {
|
Future<T?> showAddExpenseBottomSheet<T>() {
|
||||||
Get.bottomSheet(const _AddExpenseBottomSheet(), isScrollControlled: true);
|
return Get.bottomSheet<T>(
|
||||||
|
const _AddExpenseBottomSheet(),
|
||||||
|
isScrollControlled: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AddExpenseBottomSheet extends StatefulWidget {
|
class _AddExpenseBottomSheet extends StatefulWidget {
|
||||||
@ -90,7 +96,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
|||||||
onCancel: Get.back,
|
onCancel: Get.back,
|
||||||
onSubmit: () {
|
onSubmit: () {
|
||||||
if (!controller.isSubmitting.value) {
|
if (!controller.isSubmitting.value) {
|
||||||
controller.submitExpense();
|
controller.submitOrUpdateExpense();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -267,7 +273,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
|||||||
MySpacing.height(6),
|
MySpacing.height(6),
|
||||||
_AttachmentsSection(
|
_AttachmentsSection(
|
||||||
attachments: controller.attachments,
|
attachments: controller.attachments,
|
||||||
onRemove: controller.removeAttachment,
|
existingAttachments: controller.existingAttachments,
|
||||||
|
onRemoveNew: controller.removeAttachment,
|
||||||
|
onRemoveExisting: (item) =>
|
||||||
|
controller.existingAttachments.remove(item),
|
||||||
onAdd: controller.pickAttachments,
|
onAdd: controller.pickAttachments,
|
||||||
),
|
),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
@ -447,37 +456,140 @@ class _TileContainer extends StatelessWidget {
|
|||||||
|
|
||||||
class _AttachmentsSection extends StatelessWidget {
|
class _AttachmentsSection extends StatelessWidget {
|
||||||
final RxList<File> attachments;
|
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;
|
final VoidCallback onAdd;
|
||||||
|
|
||||||
const _AttachmentsSection({
|
const _AttachmentsSection({
|
||||||
required this.attachments,
|
required this.attachments,
|
||||||
required this.onRemove,
|
required this.existingAttachments,
|
||||||
|
required this.onRemoveNew,
|
||||||
|
this.onRemoveExisting,
|
||||||
required this.onAdd,
|
required this.onAdd,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Obx(() => Wrap(
|
return Obx(() => Column(
|
||||||
spacing: 8,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
children: [
|
||||||
...attachments.map((file) => _AttachmentTile(
|
if (existingAttachments.isNotEmpty) ...[
|
||||||
file: file,
|
Text(
|
||||||
onRemove: () => onRemove(file),
|
"Existing Attachments",
|
||||||
)),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
|
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:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:intl/intl.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/expense/expense_detail_controller.dart';
|
||||||
import 'package:marco/controller/project_controller.dart';
|
import 'package:marco/controller/project_controller.dart';
|
||||||
import 'package:marco/controller/permission_controller.dart';
|
import 'package:marco/controller/permission_controller.dart';
|
||||||
|
|
||||||
import 'package:marco/helpers/utils/date_time_utils.dart';
|
import 'package:marco/helpers/utils/date_time_utils.dart';
|
||||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.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/model/expense/reimbursement_bottom_sheet.dart';
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
import 'package:marco/model/expense/comment_bottom_sheet.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 {
|
class ExpenseDetailScreen extends StatelessWidget {
|
||||||
final String expenseId;
|
final String expenseId;
|
||||||
@ -48,26 +51,275 @@ class ExpenseDetailScreen extends StatelessWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF7F7F7),
|
backgroundColor: const Color(0xFFF7F7F7),
|
||||||
appBar: AppBar(
|
appBar: _AppBar(projectController: projectController),
|
||||||
automaticallyImplyLeading: false,
|
body: SafeArea(
|
||||||
elevation: 1,
|
child: Obx(() {
|
||||||
backgroundColor: Colors.white,
|
if (controller.isLoading.value) return _buildLoadingSkeleton();
|
||||||
title: Row(
|
final expense = controller.expense.value;
|
||||||
children: [
|
if (controller.errorMessage.isNotEmpty || expense == null) {
|
||||||
IconButton(
|
return Center(child: MyText.bodyMedium("No data to display."));
|
||||||
icon: const Icon(Icons.arrow_back_ios_new,
|
}
|
||||||
color: Colors.black, size: 20),
|
final statusColor = getStatusColor(expense.status.name,
|
||||||
onPressed: () => Get.offAllNamed('/dashboard/expense-main-page'),
|
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,
|
floatingActionButton: Obx(() {
|
||||||
children: [
|
final expense = controller.expense.value;
|
||||||
MyText.titleLarge('Expense Details',
|
if (expense == null) return const SizedBox.shrink();
|
||||||
fontWeight: 700, color: Colors.black),
|
|
||||||
MySpacing.height(2),
|
// Allowed status Ids
|
||||||
GetBuilder<ProjectController>(builder: (_) {
|
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 =
|
final projectName =
|
||||||
projectController.selectedProject?.name ??
|
projectController.selectedProject?.name ??
|
||||||
'Select Project';
|
'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() {
|
@override
|
||||||
return ListView(
|
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- INVOICE SUB-COMPONENTS ----------------
|
// -------- Invoice Sub-Components, unchanged except formatting/const ----------------
|
||||||
|
|
||||||
class _InvoiceHeader extends StatelessWidget {
|
class _InvoiceHeader extends StatelessWidget {
|
||||||
final ExpenseDetailModel expense;
|
final ExpenseDetailModel expense;
|
||||||
const _InvoiceHeader({required this.expense});
|
const _InvoiceHeader({required this.expense});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final dateString = DateTimeUtils.convertUtcToLocal(
|
final dateString = DateTimeUtils.convertUtcToLocal(
|
||||||
expense.transactionDate.toString(),
|
expense.transactionDate.toString(),
|
||||||
format: 'dd-MM-yyyy');
|
format: 'dd-MM-yyyy');
|
||||||
|
|
||||||
final statusColor = ExpenseDetailScreen.getStatusColor(expense.status.name,
|
final statusColor = ExpenseDetailScreen.getStatusColor(expense.status.name,
|
||||||
colorCode: expense.status.color);
|
colorCode: expense.status.color);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
Row(children: [
|
||||||
children: [
|
const Icon(Icons.calendar_month, size: 18, color: Colors.grey),
|
||||||
Row(
|
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: [
|
children: [
|
||||||
const Icon(Icons.calendar_month, size: 18, color: Colors.grey),
|
Icon(Icons.flag, size: 16, color: statusColor),
|
||||||
MySpacing.width(6),
|
MySpacing.width(4),
|
||||||
MyText.bodySmall('Date:', fontWeight: 600),
|
MyText.labelSmall(expense.status.name,
|
||||||
MySpacing.width(6),
|
color: statusColor, fontWeight: 600),
|
||||||
MyText.bodySmall(dateString, fontWeight: 600),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Container(
|
),
|
||||||
decoration: BoxDecoration(
|
])
|
||||||
color: statusColor.withOpacity(0.15),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.flag, size: 16, color: statusColor),
|
|
||||||
MySpacing.width(4),
|
|
||||||
MyText.labelSmall(
|
|
||||||
expense.status.name,
|
|
||||||
color: statusColor,
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -365,7 +398,6 @@ class _InvoiceHeader extends StatelessWidget {
|
|||||||
class _InvoiceParties extends StatelessWidget {
|
class _InvoiceParties extends StatelessWidget {
|
||||||
final ExpenseDetailModel expense;
|
final ExpenseDetailModel expense;
|
||||||
const _InvoiceParties({required this.expense});
|
const _InvoiceParties({required this.expense});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
@ -373,45 +405,31 @@ class _InvoiceParties extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_labelValueBlock('Project', expense.project.name),
|
_labelValueBlock('Project', expense.project.name),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
_labelValueBlock(
|
_labelValueBlock('Paid By:',
|
||||||
'Paid By:',
|
'${expense.paidBy.firstName} ${expense.paidBy.lastName}'),
|
||||||
'${expense.paidBy.firstName} ${expense.paidBy.lastName}',
|
|
||||||
),
|
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
_labelValueBlock('Supplier', expense.supplerName),
|
_labelValueBlock('Supplier', expense.supplerName),
|
||||||
MySpacing.height(16),
|
MySpacing.height(16),
|
||||||
_labelValueBlock(
|
_labelValueBlock('Created By:',
|
||||||
'Created By:',
|
'${expense.createdBy.firstName} ${expense.createdBy.lastName}'),
|
||||||
'${expense.createdBy.firstName} ${expense.createdBy.lastName}',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _labelValueBlock(String label, String value) {
|
Widget _labelValueBlock(String label, String value) => Column(
|
||||||
return Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
MyText.bodySmall(label, fontWeight: 600),
|
||||||
MyText.bodySmall(
|
MySpacing.height(4),
|
||||||
label,
|
MyText.bodySmall(value,
|
||||||
fontWeight: 600,
|
fontWeight: 500, softWrap: true, maxLines: null),
|
||||||
),
|
],
|
||||||
MySpacing.height(4),
|
);
|
||||||
MyText.bodySmall(
|
|
||||||
value,
|
|
||||||
fontWeight: 500,
|
|
||||||
softWrap: true,
|
|
||||||
maxLines: null, // Allow full wrapping
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InvoiceDetailsTable extends StatelessWidget {
|
class _InvoiceDetailsTable extends StatelessWidget {
|
||||||
final ExpenseDetailModel expense;
|
final ExpenseDetailModel expense;
|
||||||
const _InvoiceDetailsTable({required this.expense});
|
const _InvoiceDetailsTable({required this.expense});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final transactionDate = DateTimeUtils.convertUtcToLocal(
|
final transactionDate = DateTimeUtils.convertUtcToLocal(
|
||||||
@ -420,7 +438,6 @@ class _InvoiceDetailsTable extends StatelessWidget {
|
|||||||
final createdAt = DateTimeUtils.convertUtcToLocal(
|
final createdAt = DateTimeUtils.convertUtcToLocal(
|
||||||
expense.createdAt.toString(),
|
expense.createdAt.toString(),
|
||||||
format: 'dd-MM-yyyy hh:mm a');
|
format: 'dd-MM-yyyy hh:mm a');
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -436,39 +453,30 @@ class _InvoiceDetailsTable extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _detailItem(String title, String value, {bool isDescription = false}) {
|
Widget _detailItem(String title, String value,
|
||||||
return Padding(
|
{bool isDescription = false}) =>
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
MyText.bodySmall(
|
children: [
|
||||||
title,
|
MyText.bodySmall(title, fontWeight: 600),
|
||||||
fontWeight: 600,
|
MySpacing.height(3),
|
||||||
),
|
isDescription
|
||||||
MySpacing.height(3),
|
? ExpandableDescription(description: value)
|
||||||
isDescription
|
: MyText.bodySmall(value, fontWeight: 500),
|
||||||
? ExpandableDescription(description: value)
|
],
|
||||||
: MyText.bodySmall(
|
),
|
||||||
value,
|
);
|
||||||
fontWeight: 500,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InvoiceDocuments extends StatelessWidget {
|
class _InvoiceDocuments extends StatelessWidget {
|
||||||
final List<ExpenseDocument> documents;
|
final List<ExpenseDocument> documents;
|
||||||
const _InvoiceDocuments({required this.documents});
|
const _InvoiceDocuments({required this.documents});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (documents.isEmpty) {
|
if (documents.isEmpty)
|
||||||
return MyText.bodyMedium('No Supporting Documents', color: Colors.grey);
|
return MyText.bodyMedium('No Supporting Documents', color: Colors.grey);
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -481,16 +489,13 @@ class _InvoiceDocuments extends StatelessWidget {
|
|||||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final doc = documents[index];
|
final doc = documents[index];
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final imageDocs = documents
|
final imageDocs = documents
|
||||||
.where((d) => d.contentType.startsWith('image/'))
|
.where((d) => d.contentType.startsWith('image/'))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final initialIndex =
|
final initialIndex =
|
||||||
imageDocs.indexWhere((d) => d.documentId == doc.documentId);
|
imageDocs.indexWhere((d) => d.documentId == doc.documentId);
|
||||||
|
|
||||||
if (imageDocs.isNotEmpty && initialIndex != -1) {
|
if (imageDocs.isNotEmpty && initialIndex != -1) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -506,10 +511,9 @@ class _InvoiceDocuments extends StatelessWidget {
|
|||||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
} else {
|
} else {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
message: 'Could not open the document.',
|
message: 'Could not open the document.',
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -557,7 +561,6 @@ class _InvoiceTotals extends StatelessWidget {
|
|||||||
required this.formattedAmount,
|
required this.formattedAmount,
|
||||||
required this.statusColor,
|
required this.statusColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return Row(
|
||||||
@ -573,18 +576,15 @@ class _InvoiceTotals extends StatelessWidget {
|
|||||||
class ExpandableDescription extends StatefulWidget {
|
class ExpandableDescription extends StatefulWidget {
|
||||||
final String description;
|
final String description;
|
||||||
const ExpandableDescription({super.key, required this.description});
|
const ExpandableDescription({super.key, required this.description});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ExpandableDescription> createState() => _ExpandableDescriptionState();
|
State<ExpandableDescription> createState() => _ExpandableDescriptionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ExpandableDescriptionState extends State<ExpandableDescription> {
|
class _ExpandableDescriptionState extends State<ExpandableDescription> {
|
||||||
bool isExpanded = false;
|
bool isExpanded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isLong = widget.description.length > 100;
|
final isLong = widget.description.length > 100;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Loading…
x
Reference in New Issue
Block a user