Update: 2026-05-08 01:27:14
This commit is contained in:
62
app/modules_app/invoices/bulk_approve.php
Normal file
62
app/modules_app/invoices/bulk_approve.php
Normal 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 فاتورة بنجاح");
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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('خطأ', 'فشل تصدير الفواتير');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
Reference in New Issue
Block a user