Vaibhav_Feature-#768 #59
@ -3,6 +3,7 @@ import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/model/expense/expense_detail_model.dart';
|
||||
import 'package:marco/model/employee_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ExpenseDetailController extends GetxController {
|
||||
final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
|
||||
@ -10,9 +11,11 @@ class ExpenseDetailController extends GetxController {
|
||||
final RxString errorMessage = ''.obs;
|
||||
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
|
||||
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
|
||||
|
||||
final RxList<EmployeeModel> employeeSearchResults = <EmployeeModel>[].obs;
|
||||
late String _expenseId;
|
||||
bool _isInitialized = false;
|
||||
final employeeSearchController = TextEditingController();
|
||||
final isSearchingEmployees = false.obs;
|
||||
|
||||
/// Call this once from the screen (NOT inside build) to initialize
|
||||
void init(String expenseId) {
|
||||
@ -93,6 +96,23 @@ class ExpenseDetailController extends GetxController {
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<void> searchEmployees(String query) async {
|
||||
if (query.trim().isEmpty) return employeeSearchResults.clear();
|
||||
isSearchingEmployees.value = true;
|
||||
try {
|
||||
final data =
|
||||
await ApiService.searchEmployeesBasic(searchString: query.trim());
|
||||
employeeSearchResults.assignAll(
|
||||
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
|
||||
);
|
||||
} catch (e) {
|
||||
logSafe("Error searching employees: $e", level: LogLevel.error);
|
||||
employeeSearchResults.clear();
|
||||
} finally {
|
||||
isSearchingEmployees.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch all employees
|
||||
Future<void> fetchAllEmployees() async {
|
||||
final response = await _apiCallWrapper(
|
||||
@ -151,13 +171,13 @@ class ExpenseDetailController extends GetxController {
|
||||
() => ApiService.updateExpenseStatusApi(
|
||||
expenseId: _expenseId,
|
||||
statusId: statusId,
|
||||
comment: comment,
|
||||
comment: comment,
|
||||
),
|
||||
"update expense status",
|
||||
);
|
||||
|
||||
if (success == true) {
|
||||
await fetchExpenseDetails();
|
||||
await fetchExpenseDetails();
|
||||
return true;
|
||||
} else {
|
||||
errorMessage.value = "Failed to update expense status.";
|
||||
|
96
lib/helpers/widgets/expense_detail_helpers.dart
Normal file
96
lib/helpers/widgets/expense_detail_helpers.dart
Normal file
@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
|
||||
/// Returns a formatted color for the expense status.
|
||||
Color getExpenseStatusColor(String? status, {String? colorCode}) {
|
||||
if (colorCode != null && colorCode.isNotEmpty) {
|
||||
try {
|
||||
return Color(int.parse(colorCode.replaceFirst('#', '0xff')));
|
||||
} catch (_) {}
|
||||
}
|
||||
switch (status) {
|
||||
case 'Approval Pending':
|
||||
return Colors.orange;
|
||||
case 'Process Pending':
|
||||
return Colors.blue;
|
||||
case 'Rejected':
|
||||
return Colors.red;
|
||||
case 'Paid':
|
||||
return Colors.green;
|
||||
default:
|
||||
return Colors.black;
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats amount to ₹ currency string.
|
||||
String formatExpenseAmount(double amount) {
|
||||
return NumberFormat.currency(
|
||||
locale: 'en_IN', symbol: '₹ ', decimalDigits: 2)
|
||||
.format(amount);
|
||||
}
|
||||
|
||||
/// Label/Value block as reusable widget.
|
||||
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),
|
||||
],
|
||||
);
|
||||
|
||||
/// Skeleton loader for lists.
|
||||
Widget buildLoadingSkeleton() => 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)),
|
||||
),
|
||||
);
|
||||
|
||||
/// Expandable description widget.
|
||||
class ExpandableDescription extends StatefulWidget {
|
||||
final String description;
|
||||
const ExpandableDescription({Key? key, required this.description})
|
||||
: super(key: key);
|
||||
|
||||
@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: [
|
||||
MyText.bodySmall(
|
||||
widget.description,
|
||||
maxLines: isExpanded ? null : 2,
|
||||
overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
fontWeight: 500,
|
||||
),
|
||||
if (isLong || !isExpanded)
|
||||
InkWell(
|
||||
onTap: () => setState(() => isExpanded = !isExpanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: MyText.labelSmall(
|
||||
isExpanded ? 'Show less' : 'Show more',
|
||||
fontWeight: 600,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -48,7 +48,13 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => EmployeeSelectorBottomSheet(),
|
||||
builder: (_) => ReusableEmployeeSelectorBottomSheet(
|
||||
searchController: controller.employeeSearchController,
|
||||
searchResults: controller.employeeSearchResults,
|
||||
isSearching: controller.isSearchingEmployees,
|
||||
onSearch: controller.searchEmployees,
|
||||
onSelect: (emp) => controller.selectedPaidBy.value = emp,
|
||||
),
|
||||
);
|
||||
|
||||
// Optional cleanup
|
||||
|
@ -1,14 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/expense/add_expense_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
import 'package:marco/model/employee_model.dart';
|
||||
|
||||
class EmployeeSelectorBottomSheet extends StatelessWidget {
|
||||
final AddExpenseController controller = Get.find<AddExpenseController>();
|
||||
class ReusableEmployeeSelectorBottomSheet extends StatelessWidget {
|
||||
final TextEditingController searchController;
|
||||
final RxList<EmployeeModel> searchResults;
|
||||
final RxBool isSearching;
|
||||
final void Function(String) onSearch;
|
||||
final void Function(EmployeeModel) onSelect;
|
||||
|
||||
EmployeeSelectorBottomSheet({super.key});
|
||||
const ReusableEmployeeSelectorBottomSheet({
|
||||
super.key,
|
||||
required this.searchController,
|
||||
required this.searchResults,
|
||||
required this.isSearching,
|
||||
required this.onSearch,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -16,13 +27,13 @@ class EmployeeSelectorBottomSheet extends StatelessWidget {
|
||||
title: "Search Employee",
|
||||
onCancel: () => Get.back(),
|
||||
onSubmit: () {},
|
||||
showButtons: false,
|
||||
showButtons: false,
|
||||
child: Obx(() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: controller.employeeSearchController,
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Search by name, email...",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
@ -32,24 +43,24 @@ class EmployeeSelectorBottomSheet extends StatelessWidget {
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
),
|
||||
onChanged: (value) => controller.searchEmployees(value),
|
||||
onChanged: onSearch,
|
||||
),
|
||||
MySpacing.height(12),
|
||||
SizedBox(
|
||||
height: 400, // Adjust this if needed
|
||||
child: controller.isSearchingEmployees.value
|
||||
height: 400,
|
||||
child: isSearching.value
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: controller.employeeSearchResults.isEmpty
|
||||
? Center(
|
||||
: searchResults.isEmpty
|
||||
? Center(
|
||||
child: MyText.bodyMedium(
|
||||
"No employees found.",
|
||||
fontWeight: 500,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: controller.employeeSearchResults.length,
|
||||
itemCount: searchResults.length,
|
||||
itemBuilder: (_, index) {
|
||||
final emp = controller.employeeSearchResults[index];
|
||||
final emp = searchResults[index];
|
||||
final fullName =
|
||||
'${emp.firstName} ${emp.lastName}'.trim();
|
||||
return ListTile(
|
||||
@ -58,7 +69,7 @@ class EmployeeSelectorBottomSheet extends StatelessWidget {
|
||||
fontWeight: 600,
|
||||
),
|
||||
onTap: () {
|
||||
controller.selectedPaidBy.value = emp;
|
||||
onSelect(emp);
|
||||
Get.back();
|
||||
},
|
||||
);
|
||||
|
@ -8,7 +8,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
|
||||
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
|
||||
|
||||
class ReimbursementBottomSheet extends StatefulWidget {
|
||||
final String expenseId;
|
||||
@ -50,39 +50,26 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showEmployeeList() {
|
||||
showModalBottomSheet(
|
||||
void _showEmployeeList() async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) {
|
||||
return SizedBox(
|
||||
height: 300,
|
||||
child: Obx(() {
|
||||
final employees = controller.allEmployees;
|
||||
if (employees.isEmpty) {
|
||||
return const Center(child: Text("No employees found"));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: employees.length,
|
||||
itemBuilder: (_, index) {
|
||||
final emp = employees[index];
|
||||
final fullName = '${emp.firstName} ${emp.lastName}'.trim();
|
||||
return ListTile(
|
||||
title: Text(fullName.isNotEmpty ? fullName : "Unnamed"),
|
||||
onTap: () {
|
||||
controller.selectedReimbursedBy.value = emp;
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => ReusableEmployeeSelectorBottomSheet(
|
||||
searchController: controller.employeeSearchController,
|
||||
searchResults: controller.employeeSearchResults,
|
||||
isSearching: controller.isSearchingEmployees,
|
||||
onSearch: controller.searchEmployees,
|
||||
onSelect: (emp) => controller.selectedReimbursedBy.value = emp,
|
||||
),
|
||||
);
|
||||
|
||||
// Optional cleanup
|
||||
controller.employeeSearchController.clear();
|
||||
controller.employeeSearchResults.clear();
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration(String hint) {
|
||||
@ -200,16 +187,25 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
|
||||
MySpacing.height(8),
|
||||
GestureDetector(
|
||||
onTap: _showEmployeeList,
|
||||
child: AbsorbPointer(
|
||||
child: TextField(
|
||||
controller: TextEditingController(
|
||||
text: controller.selectedReimbursedBy.value == null
|
||||
? ""
|
||||
: '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
|
||||
),
|
||||
decoration: _inputDecoration("Select Employee").copyWith(
|
||||
suffixIcon: const Icon(Icons.expand_more),
|
||||
),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
controller.selectedReimbursedBy.value == null
|
||||
? "Select Paid By"
|
||||
: '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, size: 22),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -1,67 +1,97 @@
|
||||
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';
|
||||
import 'package:marco/model/expense/expense_detail_model.dart';
|
||||
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:marco/model/expense/reimbursement_bottom_sheet.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/model/expense/add_expense_bottom_sheet.dart';
|
||||
import 'package:marco/model/expense/comment_bottom_sheet.dart';
|
||||
import 'package:marco/model/expense/expense_detail_model.dart';
|
||||
import 'package:marco/model/expense/reimbursement_bottom_sheet.dart';
|
||||
import 'package:marco/controller/expense/add_expense_controller.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ExpenseDetailScreen extends StatelessWidget {
|
||||
import 'package:marco/helpers/widgets/expense_detail_helpers.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||
import 'package:marco/model/employee_info.dart';
|
||||
|
||||
class ExpenseDetailScreen extends StatefulWidget {
|
||||
final String expenseId;
|
||||
const ExpenseDetailScreen({super.key, required this.expenseId});
|
||||
|
||||
static Color getStatusColor(String? status, {String? colorCode}) {
|
||||
if (colorCode != null && colorCode.isNotEmpty) {
|
||||
try {
|
||||
return Color(int.parse(colorCode.replaceFirst('#', '0xff')));
|
||||
} catch (_) {}
|
||||
}
|
||||
switch (status) {
|
||||
case 'Approval Pending':
|
||||
return Colors.orange;
|
||||
case 'Process Pending':
|
||||
return Colors.blue;
|
||||
case 'Rejected':
|
||||
return Colors.red;
|
||||
case 'Paid':
|
||||
return Colors.green;
|
||||
default:
|
||||
return Colors.black;
|
||||
}
|
||||
@override
|
||||
State<ExpenseDetailScreen> createState() => _ExpenseDetailScreenState();
|
||||
}
|
||||
|
||||
class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
|
||||
final controller = Get.put(ExpenseDetailController());
|
||||
final projectController = Get.find<ProjectController>();
|
||||
final permissionController = Get.find<PermissionController>();
|
||||
|
||||
EmployeeInfo? employeeInfo;
|
||||
final RxBool canSubmit = false.obs;
|
||||
bool _checkedPermission = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller.init(widget.expenseId);
|
||||
_loadEmployeeInfo();
|
||||
}
|
||||
|
||||
void _loadEmployeeInfo() async {
|
||||
final info = await LocalStorage.getEmployeeInfo();
|
||||
employeeInfo = info;
|
||||
}
|
||||
|
||||
void _checkPermissionToSubmit(ExpenseDetailModel expense) {
|
||||
const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
||||
|
||||
final isCreatedByCurrentUser = employeeInfo?.id == expense.createdBy.id;
|
||||
final nextStatusIds = expense.nextStatus.map((e) => e.id).toList();
|
||||
final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId);
|
||||
|
||||
final result = isCreatedByCurrentUser && hasRequiredNextStatus;
|
||||
|
||||
logSafe(
|
||||
'🐛 Checking submit permission:\n'
|
||||
'🐛 - Logged-in employee ID: ${employeeInfo?.id}\n'
|
||||
'🐛 - Expense created by ID: ${expense.createdBy.id}\n'
|
||||
'🐛 - Next Status IDs: $nextStatusIds\n'
|
||||
'🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n'
|
||||
'🐛 - Final Permission Result: $result',
|
||||
level: LogLevel.debug,
|
||||
);
|
||||
|
||||
canSubmit.value = result;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = Get.put(ExpenseDetailController());
|
||||
final projectController = Get.find<ProjectController>();
|
||||
controller.init(expenseId);
|
||||
final permissionController = Get.find<PermissionController>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F7F7),
|
||||
appBar: _AppBar(projectController: projectController),
|
||||
body: SafeArea(
|
||||
child: Obx(() {
|
||||
if (controller.isLoading.value) return _buildLoadingSkeleton();
|
||||
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,
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkPermissionToSubmit(expense);
|
||||
});
|
||||
|
||||
final statusColor = getExpenseStatusColor(expense.status.name,
|
||||
colorCode: expense.status.color);
|
||||
final formattedAmount = _formatAmount(expense.amount);
|
||||
final formattedAmount = formatExpenseAmount(expense.amount);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8, 8, 8, 30 + MediaQuery.of(context).padding.bottom),
|
||||
@ -87,9 +117,10 @@ class ExpenseDetailScreen extends StatelessWidget {
|
||||
_InvoiceDocuments(documents: expense.documents),
|
||||
const Divider(height: 30, thickness: 1.2),
|
||||
_InvoiceTotals(
|
||||
expense: expense,
|
||||
formattedAmount: formattedAmount,
|
||||
statusColor: statusColor),
|
||||
expense: expense,
|
||||
formattedAmount: formattedAmount,
|
||||
statusColor: statusColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -100,18 +131,21 @@ class ExpenseDetailScreen extends StatelessWidget {
|
||||
}),
|
||||
),
|
||||
floatingActionButton: Obx(() {
|
||||
if (controller.isLoading.value) return buildLoadingSkeleton();
|
||||
|
||||
final expense = controller.expense.value;
|
||||
if (expense == null) return const SizedBox.shrink();
|
||||
if (controller.errorMessage.isNotEmpty || expense == null) {
|
||||
return Center(child: MyText.bodyMedium("No data to display."));
|
||||
}
|
||||
|
||||
// Allowed status Ids
|
||||
const allowedStatusIds = [
|
||||
"d1ee5eec-24b6-4364-8673-a8f859c60729",
|
||||
"965eda62-7907-4963-b4a1-657fb0b2724b",
|
||||
"297e0d8f-f668-41b5-bfea-e03b354251c8"
|
||||
];
|
||||
if (!_checkedPermission) {
|
||||
_checkedPermission = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkPermissionToSubmit(expense);
|
||||
});
|
||||
}
|
||||
|
||||
// Show edit button only if status id is in allowedStatusIds
|
||||
if (!allowedStatusIds.contains(expense.status.id)) {
|
||||
if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@ -130,10 +164,8 @@ class ExpenseDetailScreen extends StatelessWidget {
|
||||
'expensesTypeId': expense.expensesType.id,
|
||||
'paymentModeId': expense.paymentMode.id,
|
||||
'paidById': expense.paidBy.id,
|
||||
// ==== Add these lines below ====
|
||||
'paidByFirstName': expense.paidBy.firstName,
|
||||
'paidByLastName': expense.paidBy.lastName,
|
||||
// =================================
|
||||
'attachments': expense.documents
|
||||
.map((doc) => {
|
||||
'url': doc.preSignedUrl,
|
||||
@ -151,20 +183,17 @@ class ExpenseDetailScreen extends StatelessWidget {
|
||||
addCtrl.populateFieldsForEdit(editData);
|
||||
|
||||
await showAddExpenseBottomSheet(isEdit: true);
|
||||
|
||||
// Refresh expense details after editing
|
||||
await controller.fetchExpenseDetails();
|
||||
},
|
||||
backgroundColor: Colors.red,
|
||||
tooltip: 'Edit Expense',
|
||||
child: Icon(Icons.edit),
|
||||
child: const Icon(Icons.edit),
|
||||
);
|
||||
}),
|
||||
bottomNavigationBar: Obx(() {
|
||||
final expense = controller.expense.value;
|
||||
if (expense == null || expense.nextStatus.isEmpty) {
|
||||
return const SizedBox();
|
||||
}
|
||||
if (expense == null) return const SizedBox();
|
||||
|
||||
return SafeArea(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
@ -176,12 +205,37 @@ class ExpenseDetailScreen extends StatelessWidget {
|
||||
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(),
|
||||
children: expense.nextStatus.where((next) {
|
||||
const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
||||
|
||||
final rawPermissions = next.permissionIds;
|
||||
final parsedPermissions =
|
||||
controller.parsePermissionIds(rawPermissions);
|
||||
|
||||
final isSubmitStatus = next.id == submitStatusId;
|
||||
final isCreatedByCurrentUser =
|
||||
employeeInfo?.id == expense.createdBy.id;
|
||||
|
||||
logSafe(
|
||||
'🔐 Permission Logic:\n'
|
||||
'🔸 Status: ${next.name}\n'
|
||||
'🔸 Status ID: ${next.id}\n'
|
||||
'🔸 Parsed Permissions: $parsedPermissions\n'
|
||||
'🔸 Is Submit: $isSubmitStatus\n'
|
||||
'🔸 Created By Current User: $isCreatedByCurrentUser',
|
||||
level: LogLevel.debug,
|
||||
);
|
||||
|
||||
if (isSubmitStatus) {
|
||||
// Submit can be done ONLY by the creator
|
||||
return isCreatedByCurrentUser;
|
||||
}
|
||||
|
||||
// All other statuses - check permission normally
|
||||
return permissionController.hasAnyPermission(parsedPermissions);
|
||||
}).map((next) {
|
||||
return _statusButton(context, controller, expense, next);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -197,6 +251,7 @@ class ExpenseDetailScreen extends StatelessWidget {
|
||||
buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff')));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(100, 40),
|
||||
@ -205,7 +260,6 @@ class ExpenseDetailScreen extends StatelessWidget {
|
||||
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(
|
||||
@ -277,25 +331,6 @@ class ExpenseDetailScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -356,8 +391,6 @@ class _AppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
// -------- Invoice Sub-Components, unchanged except formatting/const ----------------
|
||||
|
||||
class _InvoiceHeader extends StatelessWidget {
|
||||
final ExpenseDetailModel expense;
|
||||
const _InvoiceHeader({required this.expense});
|
||||
@ -366,7 +399,7 @@ class _InvoiceHeader extends StatelessWidget {
|
||||
final dateString = DateTimeUtils.convertUtcToLocal(
|
||||
expense.transactionDate.toString(),
|
||||
format: 'dd-MM-yyyy');
|
||||
final statusColor = ExpenseDetailScreen.getStatusColor(expense.status.name,
|
||||
final statusColor = getExpenseStatusColor(expense.status.name,
|
||||
colorCode: expense.status.color);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -407,28 +440,18 @@ class _InvoiceParties extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_labelValueBlock('Project', expense.project.name),
|
||||
labelValueBlock('Project', expense.project.name),
|
||||
MySpacing.height(16),
|
||||
_labelValueBlock('Paid By:',
|
||||
labelValueBlock('Paid By:',
|
||||
'${expense.paidBy.firstName} ${expense.paidBy.lastName}'),
|
||||
MySpacing.height(16),
|
||||
_labelValueBlock('Supplier', expense.supplerName),
|
||||
labelValueBlock('Supplier', expense.supplerName),
|
||||
MySpacing.height(16),
|
||||
_labelValueBlock('Created By:',
|
||||
labelValueBlock('Created By:',
|
||||
'${expense.createdBy.firstName} ${expense.createdBy.lastName}'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -556,6 +579,29 @@ class _InvoiceDocuments extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class ExpensePermissionHelper {
|
||||
static bool canEditExpense(
|
||||
EmployeeInfo? employee, ExpenseDetailModel expense) {
|
||||
return employee?.id == expense.createdBy.id &&
|
||||
_isInAllowedEditStatus(expense.status.id);
|
||||
}
|
||||
|
||||
static bool canSubmitExpense(
|
||||
EmployeeInfo? employee, ExpenseDetailModel expense) {
|
||||
return employee?.id == expense.createdBy.id &&
|
||||
expense.nextStatus.isNotEmpty;
|
||||
}
|
||||
|
||||
static bool _isInAllowedEditStatus(String statusId) {
|
||||
const editableStatusIds = [
|
||||
"d1ee5eec-24b6-4364-8673-a8f859c60729",
|
||||
"965eda62-7907-4963-b4a1-657fb0b2724b",
|
||||
"297e0d8f-f668-41b5-bfea-e03b354251c8"
|
||||
];
|
||||
return editableStatusIds.contains(statusId);
|
||||
}
|
||||
}
|
||||
|
||||
class _InvoiceTotals extends StatelessWidget {
|
||||
final ExpenseDetailModel expense;
|
||||
final String formattedAmount;
|
||||
@ -576,41 +622,3 @@ 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: [
|
||||
MyText.bodySmall(
|
||||
widget.description,
|
||||
maxLines: isExpanded ? null : 2,
|
||||
overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||
fontWeight: 500,
|
||||
),
|
||||
if (isLong || !isExpanded)
|
||||
InkWell(
|
||||
onTap: () => setState(() => isExpanded = !isExpanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: MyText.labelSmall(
|
||||
isExpanded ? 'Show less' : 'Show more',
|
||||
fontWeight: 600,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user