edit payment request
This commit is contained in:
parent
44674da8ac
commit
fd5ea9a1b3
@ -1,4 +1,3 @@
|
|||||||
// payment_request_bottom_sheet.dart
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/controller/finance/add_payment_request_controller.dart';
|
import 'package:marco/controller/finance/add_payment_request_controller.dart';
|
||||||
@ -10,16 +9,31 @@ import 'package:marco/helpers/widgets/my_snackbar.dart';
|
|||||||
import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart';
|
import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart';
|
||||||
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
||||||
|
|
||||||
Future<T?> showPaymentRequestBottomSheet<T>({bool isEdit = false}) {
|
Future<T?> showPaymentRequestBottomSheet<T>({
|
||||||
|
bool isEdit = false,
|
||||||
|
Map<String, dynamic>? existingData,
|
||||||
|
VoidCallback? onUpdated,
|
||||||
|
}) {
|
||||||
return Get.bottomSheet<T>(
|
return Get.bottomSheet<T>(
|
||||||
_PaymentRequestBottomSheet(isEdit: isEdit),
|
_PaymentRequestBottomSheet(
|
||||||
|
isEdit: isEdit,
|
||||||
|
existingData: existingData,
|
||||||
|
onUpdated: onUpdated,
|
||||||
|
),
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PaymentRequestBottomSheet extends StatefulWidget {
|
class _PaymentRequestBottomSheet extends StatefulWidget {
|
||||||
final bool isEdit;
|
final bool isEdit;
|
||||||
const _PaymentRequestBottomSheet({this.isEdit = false});
|
final Map<String, dynamic>? existingData;
|
||||||
|
final VoidCallback? onUpdated;
|
||||||
|
|
||||||
|
const _PaymentRequestBottomSheet({
|
||||||
|
this.isEdit = false,
|
||||||
|
this.existingData,
|
||||||
|
this.onUpdated,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_PaymentRequestBottomSheet> createState() =>
|
State<_PaymentRequestBottomSheet> createState() =>
|
||||||
@ -35,6 +49,64 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
|
|||||||
final _categoryDropdownKey = GlobalKey();
|
final _categoryDropdownKey = GlobalKey();
|
||||||
final _currencyDropdownKey = GlobalKey();
|
final _currencyDropdownKey = GlobalKey();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
if (widget.isEdit && widget.existingData != null) {
|
||||||
|
final data = widget.existingData!;
|
||||||
|
|
||||||
|
// 🧩 Prefill basic text fields
|
||||||
|
controller.titleController.text = data["title"] ?? "";
|
||||||
|
controller.amountController.text = data["amount"]?.toString() ?? "";
|
||||||
|
controller.descriptionController.text = data["description"] ?? "";
|
||||||
|
controller.dueDateController.text =
|
||||||
|
data["dueDate"]?.toString().split(" ")[0] ?? "";
|
||||||
|
|
||||||
|
// 🧩 Prefill dropdowns & toggles
|
||||||
|
controller.selectedProject.value = {
|
||||||
|
'id': data["projectId"],
|
||||||
|
'name': data["projectName"],
|
||||||
|
};
|
||||||
|
controller.selectedPayee.value = data["payee"] ?? "";
|
||||||
|
controller.isAdvancePayment.value = data["isAdvancePayment"] ?? false;
|
||||||
|
|
||||||
|
// 🕒 Wait until categories & currencies are loaded before setting them
|
||||||
|
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"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🖇 Attachments - Safe parsing (avoids null or wrong type)
|
||||||
|
final attachmentsData = data["attachments"];
|
||||||
|
if (attachmentsData != null &&
|
||||||
|
attachmentsData is List &&
|
||||||
|
attachmentsData.isNotEmpty) {
|
||||||
|
final attachments = attachmentsData
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((a) => {
|
||||||
|
"id": a["id"],
|
||||||
|
"fileName": a["fileName"],
|
||||||
|
"url": a["url"],
|
||||||
|
"thumbUrl": a["thumbUrl"],
|
||||||
|
"fileSize": a["fileSize"] ?? 0,
|
||||||
|
"contentType": a["contentType"] ?? "",
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
controller.existingAttachments.assignAll(attachments);
|
||||||
|
} else {
|
||||||
|
controller.existingAttachments.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Obx(() => Form(
|
return Obx(() => Form(
|
||||||
@ -49,12 +121,14 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
|
|||||||
if (_formKey.currentState!.validate() && _validateSelections()) {
|
if (_formKey.currentState!.validate() && _validateSelections()) {
|
||||||
final success = await controller.submitPaymentRequest();
|
final success = await controller.submitPaymentRequest();
|
||||||
if (success) {
|
if (success) {
|
||||||
// First close the BottomSheet
|
|
||||||
Get.back();
|
Get.back();
|
||||||
// Then show Snackbar
|
if (widget.onUpdated != null) widget.onUpdated!();
|
||||||
|
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Success",
|
title: "Success",
|
||||||
message: "Payment request created successfully!",
|
message: widget.isEdit
|
||||||
|
? "Payment request updated successfully!"
|
||||||
|
: "Payment request created successfully!",
|
||||||
type: SnackbarType.success,
|
type: SnackbarType.success,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -129,6 +203,8 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- Helper Widgets ----------------
|
||||||
|
|
||||||
Widget _buildDropdown<T>(String title, IconData icon, String value,
|
Widget _buildDropdown<T>(String title, IconData icon, String value,
|
||||||
List<T> options, String Function(T) getLabel, ValueChanged<T> onSelected,
|
List<T> options, String Function(T) getLabel, ValueChanged<T> onSelected,
|
||||||
{required GlobalKey key}) {
|
{required GlobalKey key}) {
|
||||||
@ -258,7 +334,6 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
|
|||||||
displayStringForOption: (option) => option,
|
displayStringForOption: (option) => option,
|
||||||
fieldViewBuilder:
|
fieldViewBuilder:
|
||||||
(context, fieldController, focusNode, onFieldSubmitted) {
|
(context, fieldController, focusNode, onFieldSubmitted) {
|
||||||
// Avoid updating during build
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (fieldController.text != controller.selectedPayee.value) {
|
if (fieldController.text != controller.selectedPayee.value) {
|
||||||
fieldController.text = controller.selectedPayee.value;
|
fieldController.text = controller.selectedPayee.value;
|
||||||
@ -425,12 +500,15 @@ class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
|
|||||||
controller.selectedProject.value!['id'].toString().isEmpty) {
|
controller.selectedProject.value!['id'].toString().isEmpty) {
|
||||||
return _showError("Please select a project");
|
return _showError("Please select a project");
|
||||||
}
|
}
|
||||||
if (controller.selectedCategory.value == null)
|
if (controller.selectedCategory.value == null) {
|
||||||
return _showError("Please select a category");
|
return _showError("Please select a category");
|
||||||
if (controller.selectedPayee.value.isEmpty)
|
}
|
||||||
|
if (controller.selectedPayee.value.isEmpty) {
|
||||||
return _showError("Please select a payee");
|
return _showError("Please select a payee");
|
||||||
if (controller.selectedCurrency.value == null)
|
}
|
||||||
|
if (controller.selectedCurrency.value == null) {
|
||||||
return _showError("Please select currency");
|
return _showError("Please select currency");
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -118,8 +118,7 @@ class PaymentRequestData {
|
|||||||
expenseStatus: ExpenseStatus.fromJson(json['expenseStatus']),
|
expenseStatus: ExpenseStatus.fromJson(json['expenseStatus']),
|
||||||
paidTransactionId: json['paidTransactionId'],
|
paidTransactionId: json['paidTransactionId'],
|
||||||
paidAt: json['paidAt'] != null ? DateTime.parse(json['paidAt']) : null,
|
paidAt: json['paidAt'] != null ? DateTime.parse(json['paidAt']) : null,
|
||||||
paidBy:
|
paidBy: json['paidBy'] != null ? User.fromJson(json['paidBy']) : null,
|
||||||
json['paidBy'] != null ? User.fromJson(json['paidBy']) : null,
|
|
||||||
isAdvancePayment: json['isAdvancePayment'],
|
isAdvancePayment: json['isAdvancePayment'],
|
||||||
createdAt: DateTime.parse(json['createdAt']),
|
createdAt: DateTime.parse(json['createdAt']),
|
||||||
createdBy: User.fromJson(json['createdBy']),
|
createdBy: User.fromJson(json['createdBy']),
|
||||||
@ -373,7 +372,7 @@ class NextStatus {
|
|||||||
|
|
||||||
class UpdateLog {
|
class UpdateLog {
|
||||||
String id;
|
String id;
|
||||||
ExpenseStatus status;
|
ExpenseStatus? status;
|
||||||
ExpenseStatus nextStatus;
|
ExpenseStatus nextStatus;
|
||||||
String comment;
|
String comment;
|
||||||
DateTime updatedAt;
|
DateTime updatedAt;
|
||||||
@ -381,7 +380,7 @@ class UpdateLog {
|
|||||||
|
|
||||||
UpdateLog({
|
UpdateLog({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.status,
|
this.status,
|
||||||
required this.nextStatus,
|
required this.nextStatus,
|
||||||
required this.comment,
|
required this.comment,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
@ -390,7 +389,9 @@ class UpdateLog {
|
|||||||
|
|
||||||
factory UpdateLog.fromJson(Map<String, dynamic> json) => UpdateLog(
|
factory UpdateLog.fromJson(Map<String, dynamic> json) => UpdateLog(
|
||||||
id: json['id'],
|
id: json['id'],
|
||||||
status: ExpenseStatus.fromJson(json['status']),
|
status: json['status'] != null
|
||||||
|
? ExpenseStatus.fromJson(json['status'])
|
||||||
|
: null,
|
||||||
nextStatus: ExpenseStatus.fromJson(json['nextStatus']),
|
nextStatus: ExpenseStatus.fromJson(json['nextStatus']),
|
||||||
comment: json['comment'],
|
comment: json['comment'],
|
||||||
updatedAt: DateTime.parse(json['updatedAt']),
|
updatedAt: DateTime.parse(json['updatedAt']),
|
||||||
@ -399,7 +400,7 @@ class UpdateLog {
|
|||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'status': status.toJson(),
|
'status': status?.toJson(),
|
||||||
'nextStatus': nextStatus.toJson(),
|
'nextStatus': nextStatus.toJson(),
|
||||||
'comment': comment,
|
'comment': comment,
|
||||||
'updatedAt': updatedAt.toIso8601String(),
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
@ -441,4 +442,4 @@ class Attachment {
|
|||||||
'fileSize': fileSize,
|
'fileSize': fileSize,
|
||||||
'contentType': contentType,
|
'contentType': contentType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -115,7 +115,7 @@ class _FAQScreenState extends State<FAQScreen> with UIMixin {
|
|||||||
color: contentTheme.primary.withOpacity(0.1),
|
color: contentTheme.primary.withOpacity(0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(LucideIcons.badge_help,
|
child: Icon(LucideIcons.badge_alert,
|
||||||
color: contentTheme.primary, size: 24),
|
color: contentTheme.primary, size: 24),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import 'package:marco/model/employees/employee_info.dart';
|
|||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
import 'package:marco/model/finance/payment_request_rembursement_bottom_sheet.dart';
|
import 'package:marco/model/finance/payment_request_rembursement_bottom_sheet.dart';
|
||||||
import 'package:marco/model/finance/make_expense_bottom_sheet.dart';
|
import 'package:marco/model/finance/make_expense_bottom_sheet.dart';
|
||||||
|
import 'package:marco/model/finance/add_payment_request_bottom_sheet.dart';
|
||||||
|
|
||||||
class PaymentRequestDetailScreen extends StatefulWidget {
|
class PaymentRequestDetailScreen extends StatefulWidget {
|
||||||
final String paymentRequestId;
|
final String paymentRequestId;
|
||||||
@ -49,21 +50,10 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
|
|
||||||
void _checkPermissionToSubmit(PaymentRequestData request) {
|
void _checkPermissionToSubmit(PaymentRequestData request) {
|
||||||
const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
||||||
|
|
||||||
final isCreatedByCurrentUser = employeeInfo?.id == request.createdBy.id;
|
final isCreatedByCurrentUser = employeeInfo?.id == request.createdBy.id;
|
||||||
final hasDraftNextStatus =
|
final hasDraftNextStatus =
|
||||||
request.nextStatus.any((s) => s.id == draftStatusId);
|
request.nextStatus.any((s) => s.id == draftStatusId);
|
||||||
|
canSubmit.value = isCreatedByCurrentUser && hasDraftNextStatus;
|
||||||
final result = isCreatedByCurrentUser && hasDraftNextStatus;
|
|
||||||
|
|
||||||
// Debug log
|
|
||||||
print('🔐 Submit Permission Check:\n'
|
|
||||||
'Logged-in employee: ${employeeInfo?.id}\n'
|
|
||||||
'Created by: ${request.createdBy.id}\n'
|
|
||||||
'Has Draft Next Status: $hasDraftNextStatus\n'
|
|
||||||
'Can Submit: $result');
|
|
||||||
|
|
||||||
canSubmit.value = result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadEmployeeInfo() async {
|
Future<void> _loadEmployeeInfo() async {
|
||||||
@ -77,6 +67,38 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
return Color(int.parse(hex, radix: 16));
|
return Color(int.parse(hex, radix: 16));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openEditPaymentRequestBottomSheet(request) {
|
||||||
|
showPaymentRequestBottomSheet(
|
||||||
|
isEdit: true,
|
||||||
|
existingData: {
|
||||||
|
"paymentRequestId": request.paymentRequestUID,
|
||||||
|
"title": request.title,
|
||||||
|
"projectId": request.project.id,
|
||||||
|
"projectName": request.project.name,
|
||||||
|
"expenseCategoryId": request.expenseCategory.id,
|
||||||
|
"expenseCategoryName": request.expenseCategory.name,
|
||||||
|
"amount": request.amount.toString(),
|
||||||
|
"currencyId": request.currency.id,
|
||||||
|
"currencySymbol": request.currency.symbol,
|
||||||
|
"payee": request.payee,
|
||||||
|
"description": request.description,
|
||||||
|
"isAdvancePayment": request.isAdvancePayment,
|
||||||
|
"dueDate": request.dueDate,
|
||||||
|
"attachments": request.attachments
|
||||||
|
.map((a) => {
|
||||||
|
"url": a.url,
|
||||||
|
"fileName": a.fileName,
|
||||||
|
"documentId": a.id,
|
||||||
|
"contentType": a.contentType,
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
},
|
||||||
|
onUpdated: () async {
|
||||||
|
await controller.fetchPaymentRequestDetail();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -87,6 +109,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
if (controller.isLoading.value) {
|
if (controller.isLoading.value) {
|
||||||
return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
|
return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
|
||||||
}
|
}
|
||||||
|
|
||||||
final request = controller.paymentRequest.value;
|
final request = controller.paymentRequest.value;
|
||||||
if (controller.errorMessage.isNotEmpty || request == null) {
|
if (controller.errorMessage.isNotEmpty || request == null) {
|
||||||
return Center(child: MyText.bodyMedium("No data to display."));
|
return Center(child: MyText.bodyMedium("No data to display."));
|
||||||
@ -125,6 +148,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
_DetailsTable(request: request),
|
_DetailsTable(request: request),
|
||||||
const Divider(height: 30, thickness: 1.2),
|
const Divider(height: 30, thickness: 1.2),
|
||||||
_Documents(documents: request.attachments),
|
_Documents(documents: request.attachments),
|
||||||
|
MySpacing.height(24),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -135,15 +159,17 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
bottomNavigationBar: Obx(() {
|
bottomNavigationBar: _buildBottomActionBar(),
|
||||||
|
|
||||||
|
// ✅ Added Floating Action Button for Edit
|
||||||
|
floatingActionButton: Obx(() {
|
||||||
|
if (controller.isLoading.value) return const SizedBox.shrink();
|
||||||
|
|
||||||
final request = controller.paymentRequest.value;
|
final request = controller.paymentRequest.value;
|
||||||
if (request == null ||
|
if (controller.errorMessage.isNotEmpty || request == null) {
|
||||||
controller.isLoading.value ||
|
|
||||||
employeeInfo == null) {
|
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permissions once
|
|
||||||
if (!_checkedPermission) {
|
if (!_checkedPermission) {
|
||||||
_checkedPermission = true;
|
_checkedPermission = true;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@ -151,121 +177,150 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter statuses
|
final canEdit = PaymentRequestPermissionHelper.canEditPaymentRequest(
|
||||||
const reimbursementStatusId = '61578360-3a49-4c34-8604-7b35a3787b95';
|
employeeInfo,
|
||||||
const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
request,
|
||||||
|
);
|
||||||
|
|
||||||
final availableStatuses = request.nextStatus.where((status) {
|
if (!canEdit) return const SizedBox.shrink();
|
||||||
if (status.id == draftStatusId) {
|
|
||||||
return employeeInfo?.id == request.createdBy.id;
|
|
||||||
}
|
|
||||||
return permissionController
|
|
||||||
.hasAnyPermission(status.permissionIds ?? []);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
// If there are no next statuses, show "Create Expense" button
|
return FloatingActionButton.extended(
|
||||||
if (availableStatuses.isEmpty) {
|
onPressed: () => _openEditPaymentRequestBottomSheet(request),
|
||||||
return SafeArea(
|
backgroundColor: contentTheme.primary,
|
||||||
child: Container(
|
icon: const Icon(Icons.edit),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
label: MyText.bodyMedium(
|
||||||
decoration: BoxDecoration(
|
"Edit Payment Request",
|
||||||
color: Colors.white,
|
fontWeight: 600,
|
||||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
color: Colors.white,
|
||||||
),
|
|
||||||
child: ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
||||||
backgroundColor: Colors.blue,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
showCreateExpenseBottomSheet();
|
|
||||||
},
|
|
||||||
child: const Text(
|
|
||||||
"Create Expense",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal status buttons
|
|
||||||
return SafeArea(
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
|
||||||
child: Wrap(
|
|
||||||
alignment: WrapAlignment.center,
|
|
||||||
spacing: 10,
|
|
||||||
runSpacing: 10,
|
|
||||||
children: availableStatuses.map((status) {
|
|
||||||
final color = _parseColor(status.color);
|
|
||||||
|
|
||||||
return ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16, vertical: 10),
|
|
||||||
backgroundColor: color,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onPressed: () async {
|
|
||||||
if (status.id == reimbursementStatusId) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius:
|
|
||||||
BorderRadius.vertical(top: Radius.circular(5)),
|
|
||||||
),
|
|
||||||
builder: (ctx) => UpdatePaymentRequestWithReimbursement(
|
|
||||||
expenseId: request.paymentRequestUID,
|
|
||||||
statusId: status.id,
|
|
||||||
onClose: () {},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
final comment = await showCommentBottomSheet(
|
|
||||||
context, status.displayName);
|
|
||||||
if (comment == null || comment.trim().isEmpty) return;
|
|
||||||
|
|
||||||
final success =
|
|
||||||
await controller.updatePaymentRequestStatus(
|
|
||||||
statusId: status.id,
|
|
||||||
comment: comment.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
showAppSnackbar(
|
|
||||||
title: success ? 'Success' : 'Error',
|
|
||||||
message: success
|
|
||||||
? 'Status updated successfully'
|
|
||||||
: 'Failed to update status',
|
|
||||||
type:
|
|
||||||
success ? SnackbarType.success : SnackbarType.error,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (success) await controller.fetchPaymentRequestDetail();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Text(status.displayName,
|
|
||||||
style: const TextStyle(color: Colors.white)),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomActionBar() {
|
||||||
|
return Obx(() {
|
||||||
|
final request = controller.paymentRequest.value;
|
||||||
|
if (request == null ||
|
||||||
|
controller.isLoading.value ||
|
||||||
|
employeeInfo == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_checkedPermission) {
|
||||||
|
_checkedPermission = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_checkPermissionToSubmit(request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reimbursementStatusId = '61578360-3a49-4c34-8604-7b35a3787b95';
|
||||||
|
const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
||||||
|
|
||||||
|
final availableStatuses = request.nextStatus.where((status) {
|
||||||
|
if (status.id == draftStatusId) {
|
||||||
|
return employeeInfo?.id == request.createdBy.id;
|
||||||
|
}
|
||||||
|
return permissionController
|
||||||
|
.hasAnyPermission(status.permissionIds ?? []);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (availableStatuses.isEmpty) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||||
|
),
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () => showCreateExpenseBottomSheet(),
|
||||||
|
child: const Text(
|
||||||
|
"Create Expense",
|
||||||
|
style:
|
||||||
|
TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||||
|
child: Wrap(
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
children: availableStatuses.map((status) {
|
||||||
|
final color = _parseColor(status.color);
|
||||||
|
|
||||||
|
return ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
backgroundColor: color,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
if (status.id == reimbursementStatusId) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.vertical(top: Radius.circular(5)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => UpdatePaymentRequestWithReimbursement(
|
||||||
|
expenseId: request.paymentRequestUID,
|
||||||
|
statusId: status.id,
|
||||||
|
onClose: () {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final comment = await showCommentBottomSheet(
|
||||||
|
context, status.displayName);
|
||||||
|
if (comment == null || comment.trim().isEmpty) return;
|
||||||
|
|
||||||
|
final success = await controller.updatePaymentRequestStatus(
|
||||||
|
statusId: status.id,
|
||||||
|
comment: comment.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
showAppSnackbar(
|
||||||
|
title: success ? 'Success' : 'Error',
|
||||||
|
message: success
|
||||||
|
? 'Status updated successfully'
|
||||||
|
: 'Failed to update status',
|
||||||
|
type: success ? SnackbarType.success : SnackbarType.error,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) await controller.fetchPaymentRequestDetail();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(status.displayName,
|
||||||
|
style: const TextStyle(color: Colors.white)),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
PreferredSizeWidget _buildAppBar() {
|
PreferredSizeWidget _buildAppBar() {
|
||||||
return PreferredSize(
|
return PreferredSize(
|
||||||
preferredSize: const Size.fromHeight(72),
|
preferredSize: const Size.fromHeight(72),
|
||||||
@ -326,6 +381,29 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PaymentRequestPermissionHelper {
|
||||||
|
static bool canEditPaymentRequest(
|
||||||
|
EmployeeInfo? employee, PaymentRequestData request) {
|
||||||
|
return employee?.id == request.createdBy.id &&
|
||||||
|
_isInAllowedEditStatus(request.expenseStatus.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool canSubmitPaymentRequest(
|
||||||
|
EmployeeInfo? employee, PaymentRequestData request) {
|
||||||
|
return employee?.id == request.createdBy.id &&
|
||||||
|
request.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 _Header extends StatelessWidget {
|
class _Header extends StatelessWidget {
|
||||||
final PaymentRequestData request;
|
final PaymentRequestData request;
|
||||||
final Color Function(String) colorParser;
|
final Color Function(String) colorParser;
|
||||||
@ -383,124 +461,128 @@ class _Header extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _Logs extends StatelessWidget {
|
class _Logs extends StatelessWidget {
|
||||||
final List<UpdateLog> logs;
|
final List<UpdateLog> logs;
|
||||||
final Color Function(String) colorParser;
|
final Color Function(String) colorParser;
|
||||||
const _Logs({required this.logs, required this.colorParser});
|
const _Logs({required this.logs, required this.colorParser});
|
||||||
|
|
||||||
DateTime _parseTimestamp(DateTime ts) => ts;
|
DateTime _parseTimestamp(DateTime ts) => ts;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (logs.isEmpty) {
|
if (logs.isEmpty) {
|
||||||
return MyText.bodyMedium('No Timeline', color: Colors.grey);
|
return MyText.bodyMedium('No Timeline', color: Colors.grey);
|
||||||
}
|
|
||||||
|
|
||||||
final reversedLogs = logs.reversed.toList();
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.bodySmall("Timeline:", fontWeight: 600),
|
|
||||||
ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
itemCount: reversedLogs.length,
|
|
||||||
itemBuilder: (_, index) {
|
|
||||||
final log = reversedLogs[index];
|
|
||||||
|
|
||||||
final status = log.status.name;
|
|
||||||
final description = log.status.description;
|
|
||||||
final comment = log.comment;
|
|
||||||
final nextStatusName = log.nextStatus.name;
|
|
||||||
|
|
||||||
final updatedBy = log.updatedBy;
|
|
||||||
final initials =
|
|
||||||
'${updatedBy.firstName.isNotEmpty == true ? updatedBy.firstName[0] : ''}'
|
|
||||||
'${updatedBy.lastName.isNotEmpty == true ? updatedBy.lastName[0] : ''}';
|
|
||||||
final name = '${updatedBy.firstName} ${updatedBy.lastName}';
|
|
||||||
|
|
||||||
final timestamp = _parseTimestamp(log.updatedAt);
|
|
||||||
final timeAgo = timeago.format(timestamp);
|
|
||||||
|
|
||||||
final statusColor = colorParser(log.status.color);
|
|
||||||
final nextStatusColor = colorParser(log.nextStatus.color);
|
|
||||||
|
|
||||||
return TimelineTile(
|
|
||||||
alignment: TimelineAlign.start,
|
|
||||||
isFirst: index == 0,
|
|
||||||
isLast: index == reversedLogs.length - 1,
|
|
||||||
indicatorStyle: IndicatorStyle(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
indicator: Container(
|
|
||||||
decoration:
|
|
||||||
BoxDecoration(shape: BoxShape.circle, color: statusColor),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
beforeLineStyle:
|
|
||||||
LineStyle(color: Colors.grey.shade300, thickness: 2),
|
|
||||||
endChild: Padding(
|
|
||||||
padding: const EdgeInsets.all(12.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
MyText.bodyMedium(status,
|
|
||||||
fontWeight: 600, color: statusColor),
|
|
||||||
MyText.bodySmall(timeAgo,
|
|
||||||
color: Colors.grey[600],
|
|
||||||
textAlign: TextAlign.right),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (description.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
MyText.bodySmall(description, color: Colors.grey[800]),
|
|
||||||
],
|
|
||||||
if (comment.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
MyText.bodyMedium(comment, fontWeight: 500),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 6, vertical: 2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: MyText.bodySmall(initials, fontWeight: 600),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Expanded(
|
|
||||||
child: MyText.bodySmall(name,
|
|
||||||
overflow: TextOverflow.ellipsis)),
|
|
||||||
if (nextStatusName.isNotEmpty)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: nextStatusColor.withOpacity(0.15),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: MyText.bodySmall(nextStatusName,
|
|
||||||
fontWeight: 600, color: nextStatusColor),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final reversedLogs = logs.reversed.toList();
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall("Timeline:", fontWeight: 600),
|
||||||
|
ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: reversedLogs.length,
|
||||||
|
itemBuilder: (_, index) {
|
||||||
|
final log = reversedLogs[index];
|
||||||
|
|
||||||
|
final status = log.status?.name ?? 'Unknown';
|
||||||
|
final description = log.status?.description ?? '';
|
||||||
|
final statusColor = log.status != null
|
||||||
|
? colorParser(log.status!.color)
|
||||||
|
: Colors.grey;
|
||||||
|
|
||||||
|
final comment = log.comment;
|
||||||
|
final nextStatusName = log.nextStatus.name;
|
||||||
|
|
||||||
|
final updatedBy = log.updatedBy;
|
||||||
|
final initials =
|
||||||
|
'${updatedBy.firstName.isNotEmpty == true ? updatedBy.firstName[0] : ''}'
|
||||||
|
'${updatedBy.lastName.isNotEmpty == true ? updatedBy.lastName[0] : ''}';
|
||||||
|
final name = '${updatedBy.firstName} ${updatedBy.lastName}';
|
||||||
|
|
||||||
|
final timestamp = _parseTimestamp(log.updatedAt);
|
||||||
|
final timeAgo = timeago.format(timestamp);
|
||||||
|
|
||||||
|
final nextStatusColor = colorParser(log.nextStatus.color);
|
||||||
|
|
||||||
|
return TimelineTile(
|
||||||
|
alignment: TimelineAlign.start,
|
||||||
|
isFirst: index == 0,
|
||||||
|
isLast: index == reversedLogs.length - 1,
|
||||||
|
indicatorStyle: IndicatorStyle(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
indicator: Container(
|
||||||
|
decoration:
|
||||||
|
BoxDecoration(shape: BoxShape.circle, color: statusColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
beforeLineStyle:
|
||||||
|
LineStyle(color: Colors.grey.shade300, thickness: 2),
|
||||||
|
endChild: Padding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium(status,
|
||||||
|
fontWeight: 600, color: statusColor),
|
||||||
|
MyText.bodySmall(timeAgo,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
textAlign: TextAlign.right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (description.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
MyText.bodySmall(description, color: Colors.grey[800]),
|
||||||
|
],
|
||||||
|
if (comment.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
MyText.bodyMedium(comment, fontWeight: 500),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: MyText.bodySmall(initials, fontWeight: 600),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: MyText.bodySmall(name,
|
||||||
|
overflow: TextOverflow.ellipsis)),
|
||||||
|
if (nextStatusName.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: nextStatusColor.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: MyText.bodySmall(nextStatusName,
|
||||||
|
fontWeight: 600, color: nextStatusColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class _Parties extends StatelessWidget {
|
class _Parties extends StatelessWidget {
|
||||||
final PaymentRequestData request;
|
final PaymentRequestData request;
|
||||||
const _Parties({required this.request});
|
const _Parties({required this.request});
|
||||||
|
|||||||
@ -215,7 +215,7 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
),
|
),
|
||||||
SizedBox(height: spacingHeight),
|
SizedBox(height: spacingHeight),
|
||||||
_menuItemRow(
|
_menuItemRow(
|
||||||
icon: LucideIcons.badge_help,
|
icon: LucideIcons.badge_alert,
|
||||||
label: 'FAQ',
|
label: 'FAQ',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Get.to(() => FAQScreen());
|
Get.to(() => FAQScreen());
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user