639 lines
23 KiB
Dart
639 lines
23 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:marco/controller/finance/add_payment_request_controller.dart';
|
|
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
|
import 'package:marco/helpers/utils/validators.dart';
|
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
|
import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart';
|
|
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
|
import 'package:marco/model/employees/multiple_select_bottomsheet.dart';
|
|
import 'package:marco/model/employees/employee_model.dart';
|
|
|
|
Future<T?> showPaymentRequestBottomSheet<T>({
|
|
bool isEdit = false,
|
|
Map<String, dynamic>? existingData,
|
|
VoidCallback? onUpdated,
|
|
}) {
|
|
return Get.bottomSheet<T>(
|
|
_PaymentRequestBottomSheet(
|
|
isEdit: isEdit,
|
|
existingData: existingData,
|
|
onUpdated: onUpdated,
|
|
),
|
|
isScrollControlled: true,
|
|
);
|
|
}
|
|
|
|
class _PaymentRequestBottomSheet extends StatefulWidget {
|
|
final bool isEdit;
|
|
final Map<String, dynamic>? existingData;
|
|
final VoidCallback? onUpdated;
|
|
|
|
const _PaymentRequestBottomSheet({
|
|
this.isEdit = false,
|
|
this.existingData,
|
|
this.onUpdated,
|
|
});
|
|
|
|
@override
|
|
State<_PaymentRequestBottomSheet> createState() =>
|
|
_PaymentRequestBottomSheetState();
|
|
}
|
|
|
|
class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
|
|
with UIMixin {
|
|
final controller = Get.put(AddPaymentRequestController());
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
final _projectDropdownKey = GlobalKey();
|
|
final _categoryDropdownKey = GlobalKey();
|
|
final _currencyDropdownKey = GlobalKey();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
if (widget.isEdit && widget.existingData != null) {
|
|
final data = widget.existingData!;
|
|
|
|
controller.titleController.text = data["title"] ?? "";
|
|
controller.amountController.text = data["amount"]?.toString() ?? "";
|
|
controller.descriptionController.text = data["description"] ?? "";
|
|
|
|
if (data["dueDate"] != null && data["dueDate"].toString().isNotEmpty) {
|
|
DateTime? dueDate = DateTime.tryParse(data["dueDate"].toString());
|
|
if (dueDate != null) {
|
|
controller.selectedDueDate.value = dueDate;
|
|
controller.dueDateController.text =
|
|
DateFormat('dd MMM yyyy').format(dueDate);
|
|
}
|
|
}
|
|
|
|
controller.selectedProject.value = {
|
|
'id': data["projectId"],
|
|
'name': data["projectName"],
|
|
};
|
|
|
|
|
|
controller.selectedPayee.value = data["payee"] ?? "";
|
|
controller.isAdvancePayment.value = data["isAdvancePayment"] ?? false;
|
|
|
|
everAll([controller.categories, controller.currencies], (_) {
|
|
controller.selectedCategory.value = controller.categories
|
|
.firstWhereOrNull((c) => c.id == data["expenseCategoryId"]);
|
|
controller.selectedCurrency.value = controller.currencies
|
|
.firstWhereOrNull((c) => c.id == data["currencyId"]);
|
|
});
|
|
|
|
final attachmentsData = data["attachments"];
|
|
if (attachmentsData != null &&
|
|
attachmentsData is List &&
|
|
attachmentsData.isNotEmpty) {
|
|
final attachments = attachmentsData
|
|
.whereType<Map<String, dynamic>>()
|
|
.map((a) => {
|
|
"id": a["documentId"] ?? a["id"],
|
|
"fileName": a["fileName"],
|
|
"url": a["url"],
|
|
"thumbUrl": a["thumbUrl"],
|
|
"fileSize": a["fileSize"] ?? 0,
|
|
"contentType": a["contentType"] ?? "",
|
|
"isActive": true,
|
|
})
|
|
.toList();
|
|
controller.existingAttachments.assignAll(attachments);
|
|
} else {
|
|
controller.existingAttachments.clear();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Obx(() => SafeArea(
|
|
child: Form(
|
|
key: _formKey,
|
|
child: BaseBottomSheet(
|
|
title: widget.isEdit
|
|
? "Edit Payment Request"
|
|
: "Create Payment Request",
|
|
isSubmitting: controller.isSubmitting.value,
|
|
onCancel: Get.back,
|
|
submitText: "Save as Draft",
|
|
onSubmit: () async {
|
|
if (_formKey.currentState!.validate() &&
|
|
_validateSelections()) {
|
|
bool success = false;
|
|
|
|
if (widget.isEdit && widget.existingData != null) {
|
|
final requestId =
|
|
widget.existingData!['id']?.toString() ?? '';
|
|
if (requestId.isNotEmpty) {
|
|
success = await controller.submitEditedPaymentRequest(
|
|
requestId: requestId);
|
|
} else {
|
|
_showError("Invalid Payment Request ID");
|
|
return;
|
|
}
|
|
} else {
|
|
success = await controller.submitPaymentRequest();
|
|
}
|
|
return Obx(() => SafeArea(
|
|
child: Form(
|
|
key: _formKey,
|
|
child: BaseBottomSheet(
|
|
title: widget.isEdit
|
|
? "Edit Payment Request"
|
|
: "Create Payment Request",
|
|
isSubmitting: controller.isSubmitting.value,
|
|
onCancel: Get.back,
|
|
submitText: "Save as Draft",
|
|
onSubmit: () async {
|
|
if (_formKey.currentState!.validate() &&
|
|
_validateSelections()) {
|
|
bool success = false;
|
|
|
|
if (widget.isEdit && widget.existingData != null) {
|
|
final requestId =
|
|
widget.existingData!['id']?.toString() ?? '';
|
|
if (requestId.isNotEmpty) {
|
|
success = await controller.submitEditedPaymentRequest(
|
|
requestId: requestId);
|
|
} else {
|
|
_showError("Invalid Payment Request ID");
|
|
return;
|
|
}
|
|
} else {
|
|
success = await controller.submitPaymentRequest();
|
|
}
|
|
|
|
if (success) {
|
|
Get.back();
|
|
widget.onUpdated?.call();
|
|
if (success) {
|
|
Get.back();
|
|
widget.onUpdated?.call();
|
|
|
|
showAppSnackbar(
|
|
title: "Success",
|
|
message: widget.isEdit
|
|
? "Payment request updated successfully!"
|
|
: "Payment request created successfully!",
|
|
type: SnackbarType.success,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.only(bottom: 20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildDropdown(
|
|
showAppSnackbar(
|
|
title: "Success",
|
|
message: widget.isEdit
|
|
? "Payment request updated successfully!"
|
|
: "Payment request created successfully!",
|
|
type: SnackbarType.success,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.only(bottom: 20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildDropdown(
|
|
"Select Project",
|
|
Icons.work_outline,
|
|
controller.selectedProject.value?['name'] ??
|
|
"Select Project",
|
|
controller.globalProjects,
|
|
(p) => p['name'],
|
|
controller.selectProject,
|
|
key: _projectDropdownKey,
|
|
),
|
|
_gap(),
|
|
_buildDropdown(
|
|
key: _projectDropdownKey,
|
|
),
|
|
_gap(),
|
|
_buildDropdown(
|
|
"Expense Category",
|
|
Icons.category_outlined,
|
|
controller.selectedCategory.value?.name ??
|
|
"Select Category",
|
|
controller.categories,
|
|
(c) => c.name,
|
|
controller.selectCategory,
|
|
key: _categoryDropdownKey,
|
|
),
|
|
_gap(),
|
|
_buildTextField("Title", Icons.title_outlined,
|
|
controller.titleController,
|
|
hint: "Enter title",
|
|
validator: Validators.requiredField),
|
|
_gap(),
|
|
_buildRadio(
|
|
"Is Advance Payment",
|
|
Icons.attach_money_outlined,
|
|
controller.isAdvancePayment,
|
|
["Yes", "No"]),
|
|
_gap(),
|
|
_buildDueDateField(),
|
|
_gap(),
|
|
_buildTextField("Amount", Icons.currency_rupee,
|
|
controller.amountController,
|
|
hint: "Enter Amount",
|
|
keyboardType: TextInputType.number,
|
|
validator: (v) => (v != null &&
|
|
v.isNotEmpty &&
|
|
double.tryParse(v) != null)
|
|
? null
|
|
: "Enter valid amount"),
|
|
_gap(),
|
|
_buildPayeeAutocompleteField(),
|
|
_gap(),
|
|
_buildDropdown(
|
|
"Currency",
|
|
Icons.monetization_on_outlined,
|
|
controller.selectedCurrency.value?.currencyName ??
|
|
"Select Currency",
|
|
controller.currencies,
|
|
(c) => c.currencyName,
|
|
controller.selectCurrency,
|
|
key: _currencyDropdownKey,
|
|
),
|
|
_gap(),
|
|
_buildTextField("Description", Icons.description_outlined,
|
|
controller.descriptionController,
|
|
hint: "Enter description",
|
|
maxLines: 3,
|
|
validator: Validators.requiredField),
|
|
_gap(),
|
|
_buildAttachmentsSection(),
|
|
MySpacing.height(30),
|
|
],
|
|
),
|
|
key: _currencyDropdownKey,
|
|
),
|
|
_gap(),
|
|
_buildTextField("Description", Icons.description_outlined,
|
|
controller.descriptionController,
|
|
hint: "Enter description",
|
|
maxLines: 3,
|
|
validator: Validators.requiredField),
|
|
_gap(),
|
|
_buildAttachmentsSection(),
|
|
MySpacing.height(30),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
));
|
|
}
|
|
|
|
Widget _buildDropdown<T>(String title, IconData icon, String value,
|
|
List<T> options, String Function(T) getLabel, ValueChanged<T> onSelected,
|
|
{required GlobalKey key}) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SectionTitle(icon: icon, title: title, requiredField: true),
|
|
MySpacing.height(6),
|
|
DropdownTile(
|
|
key: key,
|
|
title: value,
|
|
onTap: () => _showOptionList(options, getLabel, onSelected, key)),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildTextField(
|
|
String title, IconData icon, TextEditingController controller,
|
|
{String? hint,
|
|
TextInputType? keyboardType,
|
|
FormFieldValidator<String>? validator,
|
|
int maxLines = 1}) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SectionTitle(
|
|
icon: icon, title: title, requiredField: validator != null),
|
|
MySpacing.height(6),
|
|
CustomTextField(
|
|
controller: controller,
|
|
hint: hint ?? "",
|
|
keyboardType: keyboardType ?? TextInputType.text,
|
|
validator: validator,
|
|
maxLines: maxLines,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildRadio(
|
|
String title, IconData icon, RxBool controller, List<String> labels) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(icon, size: 20),
|
|
const SizedBox(width: 6),
|
|
Text(title,
|
|
style:
|
|
const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
|
],
|
|
),
|
|
MySpacing.height(6),
|
|
Obx(() => Row(
|
|
children: labels.asMap().entries.map((entry) {
|
|
final i = entry.key;
|
|
final label = entry.value;
|
|
final value = i == 0;
|
|
|
|
|
|
return Expanded(
|
|
child: RadioListTile<bool>(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(label),
|
|
value: value,
|
|
groupValue: controller.value,
|
|
activeColor: contentTheme.primary,
|
|
onChanged: (val) =>
|
|
val != null ? controller.value = val : null,
|
|
),
|
|
);
|
|
}).toList(),
|
|
)),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildDueDateField() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SectionTitle(
|
|
icon: Icons.calendar_today,
|
|
title: "Due To Date",
|
|
requiredField: true),
|
|
MySpacing.height(6),
|
|
GestureDetector(
|
|
onTap: () => controller.pickDueDate(context),
|
|
child: AbsorbPointer(
|
|
child: TextFormField(
|
|
controller: controller.dueDateController,
|
|
decoration: InputDecoration(
|
|
hintText: "Select Due Date",
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
),
|
|
validator: (_) => controller.selectedDueDate.value == null
|
|
? "Please select a due date"
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildPayeeField() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SectionTitle(
|
|
icon: Icons.person_outline, title: "Payee", requiredField: true),
|
|
const SizedBox(height: 6),
|
|
Autocomplete<String>(
|
|
optionsBuilder: (textEditingValue) {
|
|
final query = textEditingValue.text.toLowerCase();
|
|
return query.isEmpty
|
|
? const Iterable<String>.empty()
|
|
: controller.payees
|
|
.where((p) => p.toLowerCase().contains(query));
|
|
},
|
|
displayStringForOption: (option) => option,
|
|
fieldViewBuilder:
|
|
(context, fieldController, focusNode, onFieldSubmitted) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (fieldController.text != controller.selectedPayee.value) {
|
|
fieldController.text = controller.selectedPayee.value;
|
|
fieldController.selection = TextSelection.fromPosition(
|
|
TextPosition(offset: fieldController.text.length));
|
|
}
|
|
});
|
|
|
|
return TextFormField(
|
|
controller: fieldController,
|
|
focusNode: focusNode,
|
|
decoration: InputDecoration(
|
|
hintText: "Type or select payee",
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
),
|
|
validator: (v) =>
|
|
v == null || v.trim().isEmpty ? "Please enter payee" : null,
|
|
onChanged: (val) => controller.selectedPayee.value = val,
|
|
);
|
|
},
|
|
onSelected: (selection) => controller.selectedPayee.value = selection,
|
|
optionsViewBuilder: (context, onSelected, options) => Material(
|
|
color: Colors.white,
|
|
elevation: 4,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxHeight: 200, minWidth: 300),
|
|
child: ListView.builder(
|
|
padding: EdgeInsets.zero,
|
|
itemCount: options.length,
|
|
itemBuilder: (_, index) => InkWell(
|
|
onTap: () => onSelected(options.elementAt(index)),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 10, horizontal: 12),
|
|
child: Text(options.elementAt(index),
|
|
style: const TextStyle(fontSize: 14)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildAttachmentsSection() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SectionTitle(
|
|
icon: Icons.attach_file, title: "Attachments", requiredField: true),
|
|
MySpacing.height(10),
|
|
Obx(() {
|
|
if (controller.isProcessingAttachment.value) {
|
|
return Center(
|
|
child: Column(
|
|
children: [
|
|
CircularProgressIndicator(color: contentTheme.primary),
|
|
const SizedBox(height: 8),
|
|
Text("Processing image, please wait...",
|
|
style:
|
|
TextStyle(fontSize: 14, color: contentTheme.primary)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return AttachmentsSection(
|
|
attachments: controller.attachments,
|
|
existingAttachments: controller.existingAttachments,
|
|
onRemoveNew: controller.removeAttachment,
|
|
controller: controller,
|
|
onRemoveExisting: (item) async {
|
|
await showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (_) => ConfirmDialog(
|
|
title: "Remove Attachment",
|
|
message: "Are you sure you want to remove this attachment?",
|
|
confirmText: "Remove",
|
|
icon: Icons.delete,
|
|
confirmColor: Colors.redAccent,
|
|
onConfirm: () async {
|
|
final index = controller.existingAttachments.indexOf(item);
|
|
if (index != -1) {
|
|
controller.existingAttachments[index]['isActive'] = false;
|
|
controller.existingAttachments.refresh();
|
|
}
|
|
showAppSnackbar(
|
|
title: 'Removed',
|
|
message: 'Attachment has been removed.',
|
|
type: SnackbarType.success);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
onAdd: controller.pickAttachments,
|
|
);
|
|
}),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _gap([double h = 16]) => MySpacing.height(h);
|
|
|
|
Future<void> _showOptionList<T>(List<T> options, String Function(T) getLabel,
|
|
ValueChanged<T> onSelected, GlobalKey key) async {
|
|
if (options.isEmpty) {
|
|
_showError("No options available");
|
|
return;
|
|
}
|
|
|
|
if (key.currentContext == null) {
|
|
final selected = await showDialog<T>(
|
|
context: context,
|
|
builder: (_) => SimpleDialog(
|
|
children: options
|
|
.map((opt) => SimpleDialogOption(
|
|
onPressed: () => Navigator.pop(context, opt),
|
|
child: Text(getLabel(opt)),
|
|
))
|
|
.toList(),
|
|
),
|
|
);
|
|
if (selected != null) onSelected(selected);
|
|
return;
|
|
}
|
|
|
|
final RenderBox button =
|
|
key.currentContext!.findRenderObject() as RenderBox;
|
|
final RenderBox overlay =
|
|
Overlay.of(context).context.findRenderObject() as RenderBox;
|
|
final position = button.localToGlobal(Offset.zero, ancestor: overlay);
|
|
|
|
final selected = await showMenu<T>(
|
|
context: context,
|
|
position: RelativeRect.fromLTRB(
|
|
position.dx,
|
|
position.dy + button.size.height,
|
|
overlay.size.width - position.dx - button.size.width,
|
|
0),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
items: options
|
|
.map(
|
|
(opt) => PopupMenuItem<T>(value: opt, child: Text(getLabel(opt))))
|
|
.toList(),
|
|
);
|
|
|
|
if (selected != null) onSelected(selected);
|
|
}
|
|
|
|
bool _validateSelections() {
|
|
if (controller.selectedProject.value == null ||
|
|
controller.selectedProject.value!['id'].toString().isEmpty) {
|
|
return _showError("Please select a project");
|
|
}
|
|
if (controller.selectedCategory.value == null) {
|
|
return _showError("Please select a category");
|
|
}
|
|
if (controller.selectedPayee.value == null) {
|
|
return _showError("Please select a payee");
|
|
}
|
|
if (controller.selectedCurrency.value == null) {
|
|
return _showError("Please select currency");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Future<void> _showPayeeSelector() async {
|
|
final result = await showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => EmployeeSelectionBottomSheet(
|
|
title: "Select Payee",
|
|
multipleSelection: false,
|
|
initiallySelected: controller.selectedPayee.value != null
|
|
? [controller.selectedPayee.value!]
|
|
: [],
|
|
),
|
|
);
|
|
|
|
if (result is EmployeeModel) {
|
|
controller.selectedPayee.value = result;
|
|
}
|
|
}
|
|
|
|
bool _showError(String msg) {
|
|
showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error);
|
|
return false;
|
|
}
|
|
}
|