Vaibhav_Feature-#768 #59

Closed
vaibhav.surve wants to merge 74 commits from Vaibhav_Feature-#768 into Feature_Expense
6 changed files with 330 additions and 193 deletions
Showing only changes of commit 3195fdd4a0 - Show all commits

View File

@ -3,6 +3,7 @@ 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/model/expense/expense_detail_model.dart'; import 'package:marco/model/expense/expense_detail_model.dart';
import 'package:marco/model/employee_model.dart'; import 'package:marco/model/employee_model.dart';
import 'package:flutter/material.dart';
class ExpenseDetailController extends GetxController { class ExpenseDetailController extends GetxController {
final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null); final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
@ -10,9 +11,11 @@ class ExpenseDetailController extends GetxController {
final RxString errorMessage = ''.obs; final RxString errorMessage = ''.obs;
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null); final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs; final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> employeeSearchResults = <EmployeeModel>[].obs;
late String _expenseId; late String _expenseId;
bool _isInitialized = false; bool _isInitialized = false;
final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs;
/// Call this once from the screen (NOT inside build) to initialize /// Call this once from the screen (NOT inside build) to initialize
void init(String expenseId) { void init(String expenseId) {
@ -93,6 +96,23 @@ class ExpenseDetailController extends GetxController {
return []; 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 /// Fetch all employees
Future<void> fetchAllEmployees() async { Future<void> fetchAllEmployees() async {
final response = await _apiCallWrapper( final response = await _apiCallWrapper(

View 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,
),
),
),
],
);
}
}

View File

@ -48,7 +48,13 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
backgroundColor: Colors.transparent, 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 // Optional cleanup

View File

@ -1,14 +1,25 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/model/employee_model.dart';
class EmployeeSelectorBottomSheet extends StatelessWidget { class ReusableEmployeeSelectorBottomSheet extends StatelessWidget {
final AddExpenseController controller = Get.find<AddExpenseController>(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -22,7 +33,7 @@ class EmployeeSelectorBottomSheet extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TextField( TextField(
controller: controller.employeeSearchController, controller: searchController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Search by name, email...", hintText: "Search by name, email...",
prefixIcon: const Icon(Icons.search), prefixIcon: const Icon(Icons.search),
@ -32,14 +43,14 @@ class EmployeeSelectorBottomSheet extends StatelessWidget {
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10), const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
), ),
onChanged: (value) => controller.searchEmployees(value), onChanged: onSearch,
), ),
MySpacing.height(12), MySpacing.height(12),
SizedBox( SizedBox(
height: 400, // Adjust this if needed height: 400,
child: controller.isSearchingEmployees.value child: isSearching.value
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: controller.employeeSearchResults.isEmpty : searchResults.isEmpty
? Center( ? Center(
child: MyText.bodyMedium( child: MyText.bodyMedium(
"No employees found.", "No employees found.",
@ -47,9 +58,9 @@ class EmployeeSelectorBottomSheet extends StatelessWidget {
), ),
) )
: ListView.builder( : ListView.builder(
itemCount: controller.employeeSearchResults.length, itemCount: searchResults.length,
itemBuilder: (_, index) { itemBuilder: (_, index) {
final emp = controller.employeeSearchResults[index]; final emp = searchResults[index];
final fullName = final fullName =
'${emp.firstName} ${emp.lastName}'.trim(); '${emp.firstName} ${emp.lastName}'.trim();
return ListTile( return ListTile(
@ -58,7 +69,7 @@ class EmployeeSelectorBottomSheet extends StatelessWidget {
fontWeight: 600, fontWeight: 600,
), ),
onTap: () { onTap: () {
controller.selectedPaidBy.value = emp; onSelect(emp);
Get.back(); Get.back();
}, },
); );

View File

@ -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/widgets/my_text_style.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart'; import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
class ReimbursementBottomSheet extends StatefulWidget { class ReimbursementBottomSheet extends StatefulWidget {
final String expenseId; final String expenseId;
@ -50,39 +50,26 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
super.dispose(); super.dispose();
} }
void _showEmployeeList() { void _showEmployeeList() async {
showModalBottomSheet( await showModalBottomSheet(
context: context, context: context,
backgroundColor: Colors.white, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (_) { backgroundColor: Colors.transparent,
return SizedBox( builder: (_) => ReusableEmployeeSelectorBottomSheet(
height: 300, searchController: controller.employeeSearchController,
child: Obx(() { searchResults: controller.employeeSearchResults,
final employees = controller.allEmployees; isSearching: controller.isSearchingEmployees,
if (employees.isEmpty) { onSearch: controller.searchEmployees,
return const Center(child: Text("No employees found")); onSelect: (emp) => controller.selectedReimbursedBy.value = emp,
} ),
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);
},
);
},
);
}),
);
},
); );
// Optional cleanup
controller.employeeSearchController.clear();
controller.employeeSearchResults.clear();
} }
InputDecoration _inputDecoration(String hint) { InputDecoration _inputDecoration(String hint) {
@ -200,16 +187,25 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
MySpacing.height(8), MySpacing.height(8),
GestureDetector( GestureDetector(
onTap: _showEmployeeList, onTap: _showEmployeeList,
child: AbsorbPointer( child: Container(
child: TextField( padding:
controller: TextEditingController( const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
text: controller.selectedReimbursedBy.value == null 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 ?? ''}', : '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
style: const TextStyle(fontSize: 14),
), ),
decoration: _inputDecoration("Select Employee").copyWith( const Icon(Icons.arrow_drop_down, size: 22),
suffixIcon: const Icon(Icons.expand_more), ],
),
), ),
), ),
), ),

View File

@ -1,67 +1,97 @@
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: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_text.dart';
import 'package:marco/model/expense/expense_detail_model.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.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/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/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/controller/expense/add_expense_controller.dart';
import 'package:marco/helpers/services/app_logger.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; final String expenseId;
const ExpenseDetailScreen({super.key, required this.expenseId}); const ExpenseDetailScreen({super.key, required this.expenseId});
static Color getStatusColor(String? status, {String? colorCode}) { @override
if (colorCode != null && colorCode.isNotEmpty) { State<ExpenseDetailScreen> createState() => _ExpenseDetailScreenState();
try {
return Color(int.parse(colorCode.replaceFirst('#', '0xff')));
} catch (_) {}
} }
switch (status) {
case 'Approval Pending': class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
return Colors.orange; final controller = Get.put(ExpenseDetailController());
case 'Process Pending': final projectController = Get.find<ProjectController>();
return Colors.blue; final permissionController = Get.find<PermissionController>();
case 'Rejected':
return Colors.red; EmployeeInfo? employeeInfo;
case 'Paid': final RxBool canSubmit = false.obs;
return Colors.green; bool _checkedPermission = false;
default:
return Colors.black; @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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final controller = Get.put(ExpenseDetailController());
final projectController = Get.find<ProjectController>();
controller.init(expenseId);
final permissionController = Get.find<PermissionController>();
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF7F7F7), backgroundColor: const Color(0xFFF7F7F7),
appBar: _AppBar(projectController: projectController), appBar: _AppBar(projectController: projectController),
body: SafeArea( body: SafeArea(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) return _buildLoadingSkeleton(); if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value; final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) { if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display.")); 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); colorCode: expense.status.color);
final formattedAmount = _formatAmount(expense.amount); final formattedAmount = formatExpenseAmount(expense.amount);
return SingleChildScrollView( return SingleChildScrollView(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
8, 8, 8, 30 + MediaQuery.of(context).padding.bottom), 8, 8, 8, 30 + MediaQuery.of(context).padding.bottom),
@ -89,7 +119,8 @@ class ExpenseDetailScreen extends StatelessWidget {
_InvoiceTotals( _InvoiceTotals(
expense: expense, expense: expense,
formattedAmount: formattedAmount, formattedAmount: formattedAmount,
statusColor: statusColor), statusColor: statusColor,
),
], ],
), ),
), ),
@ -100,18 +131,21 @@ class ExpenseDetailScreen extends StatelessWidget {
}), }),
), ),
floatingActionButton: Obx(() { floatingActionButton: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value; 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 if (!_checkedPermission) {
const allowedStatusIds = [ _checkedPermission = true;
"d1ee5eec-24b6-4364-8673-a8f859c60729", WidgetsBinding.instance.addPostFrameCallback((_) {
"965eda62-7907-4963-b4a1-657fb0b2724b", _checkPermissionToSubmit(expense);
"297e0d8f-f668-41b5-bfea-e03b354251c8" });
]; }
// Show edit button only if status id is in allowedStatusIds if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) {
if (!allowedStatusIds.contains(expense.status.id)) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@ -130,10 +164,8 @@ class ExpenseDetailScreen extends StatelessWidget {
'expensesTypeId': expense.expensesType.id, 'expensesTypeId': expense.expensesType.id,
'paymentModeId': expense.paymentMode.id, 'paymentModeId': expense.paymentMode.id,
'paidById': expense.paidBy.id, 'paidById': expense.paidBy.id,
// ==== Add these lines below ====
'paidByFirstName': expense.paidBy.firstName, 'paidByFirstName': expense.paidBy.firstName,
'paidByLastName': expense.paidBy.lastName, 'paidByLastName': expense.paidBy.lastName,
// =================================
'attachments': expense.documents 'attachments': expense.documents
.map((doc) => { .map((doc) => {
'url': doc.preSignedUrl, 'url': doc.preSignedUrl,
@ -151,20 +183,17 @@ class ExpenseDetailScreen extends StatelessWidget {
addCtrl.populateFieldsForEdit(editData); addCtrl.populateFieldsForEdit(editData);
await showAddExpenseBottomSheet(isEdit: true); await showAddExpenseBottomSheet(isEdit: true);
// Refresh expense details after editing
await controller.fetchExpenseDetails(); await controller.fetchExpenseDetails();
}, },
backgroundColor: Colors.red, backgroundColor: Colors.red,
tooltip: 'Edit Expense', tooltip: 'Edit Expense',
child: Icon(Icons.edit), child: const Icon(Icons.edit),
); );
}), }),
bottomNavigationBar: Obx(() { bottomNavigationBar: Obx(() {
final expense = controller.expense.value; final expense = controller.expense.value;
if (expense == null || expense.nextStatus.isEmpty) { if (expense == null) return const SizedBox();
return const SizedBox();
}
return SafeArea( return SafeArea(
child: Container( child: Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
@ -176,12 +205,37 @@ class ExpenseDetailScreen extends StatelessWidget {
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
spacing: 10, spacing: 10,
runSpacing: 10, runSpacing: 10,
children: expense.nextStatus children: expense.nextStatus.where((next) {
.where((next) => permissionController.hasAnyPermission( const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
controller.parsePermissionIds(next.permissionIds)))
.map((next) => final rawPermissions = next.permissionIds;
_statusButton(context, controller, expense, next)) final parsedPermissions =
.toList(), 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'))); buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff')));
} catch (_) {} } catch (_) {}
} }
return ElevatedButton( return ElevatedButton(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
minimumSize: const Size(100, 40), minimumSize: const Size(100, 40),
@ -205,7 +260,6 @@ class ExpenseDetailScreen extends StatelessWidget {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
), ),
onPressed: () async { onPressed: () async {
// For brevity, couldn't refactor the logic since it's business-specific.
const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27'; const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27';
if (expense.status.id == reimbursementId) { if (expense.status.id == reimbursementId) {
showModalBottomSheet( 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 { class _AppBar extends StatelessWidget implements PreferredSizeWidget {
@ -356,8 +391,6 @@ class _AppBar extends StatelessWidget implements PreferredSizeWidget {
Size get preferredSize => const Size.fromHeight(kToolbarHeight); Size get preferredSize => const Size.fromHeight(kToolbarHeight);
} }
// -------- 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});
@ -366,7 +399,7 @@ class _InvoiceHeader extends StatelessWidget {
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 = getExpenseStatusColor(expense.status.name,
colorCode: expense.status.color); colorCode: expense.status.color);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -407,28 +440,18 @@ class _InvoiceParties extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_labelValueBlock('Project', expense.project.name), labelValueBlock('Project', expense.project.name),
MySpacing.height(16), MySpacing.height(16),
_labelValueBlock('Paid By:', labelValueBlock('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('Created By:', labelValueBlock('Created By:',
'${expense.createdBy.firstName} ${expense.createdBy.lastName}'), '${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 { 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 { class _InvoiceTotals extends StatelessWidget {
final ExpenseDetailModel expense; final ExpenseDetailModel expense;
final String formattedAmount; 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,
),
),
),
],
);
}
}