diff --git a/app/modules_app/invoices/bulk_approve.php b/app/modules_app/invoices/bulk_approve.php new file mode 100644 index 0000000..74ad5e9 --- /dev/null +++ b/app/modules_app/invoices/bulk_approve.php @@ -0,0 +1,62 @@ +prepare($query); + $stmt->execute($params); + $invoice = $stmt->fetch(); + + if (!$invoice) { + $errors[] = "$id: غير موجودة أو معتمدة مسبقاً"; + continue; + } + + $db->prepare("UPDATE invoices SET status = 'approved', updated_at = NOW() WHERE id = ?") + ->execute([$id]); + + $approved++; + + AuditLogger::log('invoice.bulk_approved', 'invoice', $id, null, [ + 'batch_size' => count($ids), + ], $decoded); + + } catch (\Exception $e) { + $errors[] = "$id: " . $e->getMessage(); + } +} + +json_success([ + 'approved_count' => $approved, + 'total_requested' => count($ids), + 'errors' => $errors, +], "تم اعتماد $approved فاتورة بنجاح"); diff --git a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart index 02d39f5..80323b0 100644 --- a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart +++ b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart @@ -234,6 +234,14 @@ class DashboardView extends GetView { isDark, () => Get.toNamed(AppRoutes.SUBSCRIPTION), ), + const SizedBox(width: 12), + _buildAdminActionCard( + 'التقارير', + Icons.bar_chart_rounded, + const Color(0xFFF59E0B), + isDark, + () => Get.toNamed(AppRoutes.TAX_REPORT), + ), ], ), ), diff --git a/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart b/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart index 97a1af8..76d454c 100644 --- a/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart +++ b/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart @@ -67,7 +67,7 @@ class InvoiceDetailController extends GetxController { try { final res = await DioClient() .client - .post('invoices/approve', data: {'invoice_id': invoiceId}); + .post('invoices/approve', data: {'id': invoiceId}); if (res.data['success'] == true) { AppSnackbar.showSuccess('تم الاعتماد', 'تم اعتماد الفاتورة بنجاح'); fetchInvoiceDetails(); diff --git a/musadaq-app/lib/features/invoices/controllers/invoices_controller.dart b/musadaq-app/lib/features/invoices/controllers/invoices_controller.dart index 1fcceac..a14526c 100644 --- a/musadaq-app/lib/features/invoices/controllers/invoices_controller.dart +++ b/musadaq-app/lib/features/invoices/controllers/invoices_controller.dart @@ -1,5 +1,6 @@ import 'package:get/get.dart'; import '../../../core/network/dio_client.dart'; +import '../../../core/utils/app_snackbar.dart'; import '../../../core/utils/logger.dart'; class InvoicesController extends GetxController { @@ -9,6 +10,10 @@ class InvoicesController extends GetxController { var searchQuery = ''.obs; var isSearching = false.obs; + // Multi-select + var isSelecting = false.obs; + var selectedIds = {}.obs; + @override void onInit() { super.onInit(); @@ -49,6 +54,53 @@ class InvoicesController extends GetxController { if (!isSearching.value) searchQuery.value = ''; } + // ── Multi-select ── + void toggleSelectMode() { + isSelecting.value = !isSelecting.value; + if (!isSelecting.value) selectedIds.clear(); + } + + void toggleSelection(String id) { + if (selectedIds.contains(id)) { + selectedIds.remove(id); + } else { + selectedIds.add(id); + } + } + + void selectAllExtracted() { + selectedIds.clear(); + for (final inv in filteredInvoices) { + if (inv['status'] == 'extracted') { + selectedIds.add(inv['id']); + } + } + } + + Future bulkApprove() async { + if (selectedIds.isEmpty) { + AppSnackbar.showWarning('تنبيه', 'يرجى اختيار فاتورة واحدة على الأقل'); + return; + } + + try { + final res = await DioClient().client.post('invoices/bulk-approve', data: { + 'ids': selectedIds.toList(), + }); + + if (res.data['success'] == true) { + final count = res.data['data']?['approved_count'] ?? 0; + AppSnackbar.showSuccess('تم الاعتماد', 'تم اعتماد $count فاتورة بنجاح'); + selectedIds.clear(); + isSelecting.value = false; + await loadInvoices(); + } + } catch (e) { + AppLogger.error('Failed to bulk approve', e); + AppSnackbar.showError('خطأ', 'فشل اعتماد الفواتير'); + } + } + Future loadInvoices() async { try { isLoading.value = true; diff --git a/musadaq-app/lib/features/invoices/views/invoices_list_view.dart b/musadaq-app/lib/features/invoices/views/invoices_list_view.dart index d2e24b9..a93f66b 100644 --- a/musadaq-app/lib/features/invoices/views/invoices_list_view.dart +++ b/musadaq-app/lib/features/invoices/views/invoices_list_view.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:dio/dio.dart'; import '../controllers/invoices_controller.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/utils/app_snackbar.dart'; class InvoicesListView extends GetView { const InvoicesListView({super.key}); @@ -30,6 +33,31 @@ class InvoicesListView extends GetView { icon: const Icon(Icons.search_rounded, color: Colors.white), onPressed: () => controller.toggleSearch(), ), + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Colors.white), + onSelected: (value) { + if (value == 'report') { + Get.toNamed('/tax-report'); + } else if (value == 'export') { + _exportInvoices(context); + } else if (value == 'select') { + controller.toggleSelectMode(); + } else if (value == 'select_all') { + controller.selectAllExtracted(); + } else if (value == 'bulk_approve') { + controller.bulkApprove(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem(value: 'select', child: Row(children: [Icon(controller.isSelecting.value ? Icons.close : Icons.checklist, size: 18), const SizedBox(width: 8), Text(controller.isSelecting.value ? 'إلغاء التحديد' : 'تحديد متعدد')])), + if (controller.isSelecting.value) ...[ + const PopupMenuItem(value: 'select_all', child: Row(children: [Icon(Icons.select_all, size: 18), SizedBox(width: 8), Text('تحديد الكل (المستخرجة)')])), + const PopupMenuItem(value: 'bulk_approve', child: Row(children: [Icon(Icons.check_circle, size: 18, color: Color(0xFF10B981)), SizedBox(width: 8), Text('اعتماد المحدد')])), + ], + const PopupMenuItem(value: 'report', child: Row(children: [Icon(Icons.bar_chart, size: 18), SizedBox(width: 8), Text('التقرير الضريبي')])), + const PopupMenuItem(value: 'export', child: Row(children: [Icon(Icons.file_download, size: 18), SizedBox(width: 8), Text('تصدير Excel')])), + ], + ), IconButton( icon: const Icon(Icons.refresh_rounded, color: Colors.white), onPressed: () => controller.loadInvoices(), @@ -104,6 +132,42 @@ class InvoicesListView extends GetView { ); }), ), + + // Bulk Approve Bar + Obx(() { + if (!controller.isSelecting.value || controller.selectedIds.isEmpty) { + return const SizedBox.shrink(); + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + color: const Color(0xFF10B981), + child: Row( + children: [ + Text( + '${controller.selectedIds.length} محدد', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15), + ), + const Spacer(), + TextButton.icon( + onPressed: () => controller.selectAllExtracted(), + icon: const Icon(Icons.select_all, color: Colors.white, size: 18), + label: const Text('الكل', style: TextStyle(color: Colors.white)), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: () => controller.bulkApprove(), + icon: const Icon(Icons.check_circle, size: 18), + label: const Text('اعتماد', style: TextStyle(fontWeight: FontWeight.bold)), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF10B981), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ], + ), + ); + }), ], ); } @@ -159,23 +223,44 @@ class InvoicesListView extends GetView { statusIcon = Icons.hourglass_empty; } - return Card( + return Obx(() { + final isSelected = controller.selectedIds.contains(inv['id']); + return Card( margin: const EdgeInsets.only(bottom: 12), elevation: 0, - color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + color: isSelected ? statusColor.withValues(alpha: 0.05) : (isDark ? const Color(0xFF1E1E2E) : Colors.white), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), - side: BorderSide(color: isDark ? Colors.white10 : Colors.grey.shade200), + side: BorderSide(color: isSelected ? statusColor : (isDark ? Colors.white10 : Colors.grey.shade200), width: isSelected ? 2 : 1), ), child: InkWell( borderRadius: BorderRadius.circular(14), onTap: () { - Get.toNamed('/invoice-detail', arguments: {'id': inv['id'].toString()}); + if (controller.isSelecting.value) { + controller.toggleSelection(inv['id']); + } else { + Get.toNamed('/invoice-detail', arguments: {'id': inv['id'].toString()}); + } + }, + onLongPress: () { + if (!controller.isSelecting.value) { + controller.isSelecting.value = true; + } + controller.toggleSelection(inv['id']); }, child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ + if (controller.isSelecting.value) ...[ + Checkbox( + value: isSelected, + onChanged: (_) => controller.toggleSelection(inv['id']), + activeColor: statusColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ), + const SizedBox(width: 4), + ], Container( width: 48, height: 48, @@ -243,6 +328,7 @@ class InvoicesListView extends GetView { ), ), ); + }); } Widget _buildEmptyState(bool isDark) { @@ -284,4 +370,17 @@ class InvoicesListView extends GetView { ), ); } + + void _exportInvoices(BuildContext context) async { + try { + AppSnackbar.showInfo('جاري التصدير', 'يتم تحميل ملف الفواتير...'); + final res = await DioClient().client.get( + 'invoices/export', + options: Options(responseType: ResponseType.bytes), + ); + AppSnackbar.showSuccess('تم التصدير', 'تم تحميل ملف CSV بنجاح (${(res.data as List).length} bytes)'); + } catch (e) { + AppSnackbar.showError('خطأ', 'فشل تصدير الفواتير'); + } + } } diff --git a/public/index.php b/public/index.php index 0680ddf..118c1b7 100644 --- a/public/index.php +++ b/public/index.php @@ -35,6 +35,7 @@ $routes = [ 'v1/invoices/download_xml' => ['GET', 'invoices/download_xml.php'], 'v1/invoices/submit-jofotara' => ['POST', 'invoices/submit_jofotara.php'], 'v1/invoices/update' => ['POST', 'invoices/update.php'], + 'v1/invoices/bulk-approve' => ['POST', 'invoices/bulk_approve.php'], 'v1/invoices/export' => ['GET', 'invoices/export.php'], 'v1/reports/tax-summary' => ['GET', 'reports/tax_summary.php'], 'v1/companies/stats' => ['GET', 'companies/stats.php'],