Update: 2026-05-08 01:27:14

This commit is contained in:
Hamza-Ayed
2026-05-08 01:27:14 +03:00
parent 928e8e27e3
commit 23813fee95
6 changed files with 227 additions and 5 deletions

View File

@@ -0,0 +1,62 @@
<?php
/**
* Bulk Approve Invoices
* POST /v1/invoices/bulk-approve
* Approves multiple invoices at once
*/
use App\Core\Database;
use App\Core\AuditLogger;
use App\Middleware\RoleMiddleware;
$decoded = RoleMiddleware::require(['super_admin', 'admin', 'accountant']);
$data = input();
$ids = $data['ids'] ?? [];
if (empty($ids) || !is_array($ids)) {
json_error('يرجى اختيار فاتورة واحدة على الأقل', 422);
}
$db = Database::getInstance();
$tenantId = $decoded['tenant_id'];
$role = $decoded['role'];
$approved = 0;
$errors = [];
foreach ($ids as $id) {
try {
// Verify access
$query = $role === 'super_admin'
? "SELECT id, status FROM invoices WHERE id = ? AND status = 'extracted'"
: "SELECT id, status FROM invoices WHERE id = ? AND tenant_id = ? AND status = 'extracted'";
$params = $role === 'super_admin' ? [$id] : [$id, $tenantId];
$stmt = $db->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 فاتورة بنجاح");

View File

@@ -234,6 +234,14 @@ class DashboardView extends GetView<DashboardController> {
isDark, isDark,
() => Get.toNamed(AppRoutes.SUBSCRIPTION), () => Get.toNamed(AppRoutes.SUBSCRIPTION),
), ),
const SizedBox(width: 12),
_buildAdminActionCard(
'التقارير',
Icons.bar_chart_rounded,
const Color(0xFFF59E0B),
isDark,
() => Get.toNamed(AppRoutes.TAX_REPORT),
),
], ],
), ),
), ),

View File

@@ -67,7 +67,7 @@ class InvoiceDetailController extends GetxController {
try { try {
final res = await DioClient() final res = await DioClient()
.client .client
.post('invoices/approve', data: {'invoice_id': invoiceId}); .post('invoices/approve', data: {'id': invoiceId});
if (res.data['success'] == true) { if (res.data['success'] == true) {
AppSnackbar.showSuccess('تم الاعتماد', 'تم اعتماد الفاتورة بنجاح'); AppSnackbar.showSuccess('تم الاعتماد', 'تم اعتماد الفاتورة بنجاح');
fetchInvoiceDetails(); fetchInvoiceDetails();

View File

@@ -1,5 +1,6 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import '../../../core/utils/app_snackbar.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
class InvoicesController extends GetxController { class InvoicesController extends GetxController {
@@ -9,6 +10,10 @@ class InvoicesController extends GetxController {
var searchQuery = ''.obs; var searchQuery = ''.obs;
var isSearching = false.obs; var isSearching = false.obs;
// Multi-select
var isSelecting = false.obs;
var selectedIds = <String>{}.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@@ -49,6 +54,53 @@ class InvoicesController extends GetxController {
if (!isSearching.value) searchQuery.value = ''; 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<void> 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<void> loadInvoices() async { Future<void> loadInvoices() async {
try { try {
isLoading.value = true; isLoading.value = true;

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:dio/dio.dart';
import '../controllers/invoices_controller.dart'; import '../controllers/invoices_controller.dart';
import '../../../core/network/dio_client.dart';
import '../../../core/utils/app_snackbar.dart';
class InvoicesListView extends GetView<InvoicesController> { class InvoicesListView extends GetView<InvoicesController> {
const InvoicesListView({super.key}); const InvoicesListView({super.key});
@@ -30,6 +33,31 @@ class InvoicesListView extends GetView<InvoicesController> {
icon: const Icon(Icons.search_rounded, color: Colors.white), icon: const Icon(Icons.search_rounded, color: Colors.white),
onPressed: () => controller.toggleSearch(), onPressed: () => controller.toggleSearch(),
), ),
PopupMenuButton<String>(
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( IconButton(
icon: const Icon(Icons.refresh_rounded, color: Colors.white), icon: const Icon(Icons.refresh_rounded, color: Colors.white),
onPressed: () => controller.loadInvoices(), onPressed: () => controller.loadInvoices(),
@@ -104,6 +132,42 @@ class InvoicesListView extends GetView<InvoicesController> {
); );
}), }),
), ),
// 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<InvoicesController> {
statusIcon = Icons.hourglass_empty; statusIcon = Icons.hourglass_empty;
} }
return Card( return Obx(() {
final isSelected = controller.selectedIds.contains(inv['id']);
return Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
elevation: 0, elevation: 0,
color: isDark ? const Color(0xFF1E1E2E) : Colors.white, color: isSelected ? statusColor.withValues(alpha: 0.05) : (isDark ? const Color(0xFF1E1E2E) : Colors.white),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14), 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( child: InkWell(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
onTap: () { 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( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row( child: Row(
children: [ 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( Container(
width: 48, width: 48,
height: 48, height: 48,
@@ -243,6 +328,7 @@ class InvoicesListView extends GetView<InvoicesController> {
), ),
), ),
); );
});
} }
Widget _buildEmptyState(bool isDark) { Widget _buildEmptyState(bool isDark) {
@@ -284,4 +370,17 @@ class InvoicesListView extends GetView<InvoicesController> {
), ),
); );
} }
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('خطأ', 'فشل تصدير الفواتير');
}
}
} }

View File

@@ -35,6 +35,7 @@ $routes = [
'v1/invoices/download_xml' => ['GET', 'invoices/download_xml.php'], 'v1/invoices/download_xml' => ['GET', 'invoices/download_xml.php'],
'v1/invoices/submit-jofotara' => ['POST', 'invoices/submit_jofotara.php'], 'v1/invoices/submit-jofotara' => ['POST', 'invoices/submit_jofotara.php'],
'v1/invoices/update' => ['POST', 'invoices/update.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/invoices/export' => ['GET', 'invoices/export.php'],
'v1/reports/tax-summary' => ['GET', 'reports/tax_summary.php'], 'v1/reports/tax-summary' => ['GET', 'reports/tax_summary.php'],
'v1/companies/stats' => ['GET', 'companies/stats.php'], 'v1/companies/stats' => ['GET', 'companies/stats.php'],