From ed8203a02e03228121ac612b8bbc5e758d9b8ba5 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 8 May 2026 01:41:28 +0300 Subject: [PATCH] Update: 2026-05-08 01:41:28 --- app/modules_app/audit/index.php | 124 ++++++++++ app/modules_app/invoices/check_duplicate.php | 130 +++++++++++ app/modules_app/notifications/index.php | 42 ++++ app/modules_app/notifications/read.php | 28 +++ musadaq-app/lib/app/routes/app_pages.dart | 5 + musadaq-app/lib/app/routes/app_routes.dart | 1 + .../controllers/audit_log_controller.dart | 55 +++++ .../features/audit/views/audit_log_view.dart | 204 ++++++++++++++++ .../dashboard/views/dashboard_view.dart | 8 + .../invoice_detail_controller.dart | 19 +- .../invoices/views/invoices_list_view.dart | 20 +- .../controllers/notifications_controller.dart | 58 +++++ .../views/notifications_view.dart | 218 ++++++++++++------ public/index.php | 4 + scripts/create_notifications_table.sql | 18 ++ 15 files changed, 855 insertions(+), 79 deletions(-) create mode 100644 app/modules_app/audit/index.php create mode 100644 app/modules_app/invoices/check_duplicate.php create mode 100644 app/modules_app/notifications/index.php create mode 100644 app/modules_app/notifications/read.php create mode 100644 musadaq-app/lib/features/audit/controllers/audit_log_controller.dart create mode 100644 musadaq-app/lib/features/audit/views/audit_log_view.dart create mode 100644 musadaq-app/lib/features/notifications/controllers/notifications_controller.dart create mode 100644 scripts/create_notifications_table.sql diff --git a/app/modules_app/audit/index.php b/app/modules_app/audit/index.php new file mode 100644 index 0000000..2335e2b --- /dev/null +++ b/app/modules_app/audit/index.php @@ -0,0 +1,124 @@ +prepare("SELECT COUNT(*) FROM audit_log a $whereClause"); +$countStmt->execute($params); +$total = $countStmt->fetchColumn(); + +// Fetch logs +$params[] = $limit; +$params[] = $offset; + +$stmt = $db->prepare(" + SELECT a.*, u.name as user_name + FROM audit_log a + LEFT JOIN users u ON a.user_id = u.id + $whereClause + ORDER BY a.created_at DESC + LIMIT ? OFFSET ? +"); +$stmt->execute($params); +$logs = $stmt->fetchAll(); + +// Format logs +foreach ($logs as &$log) { + $log['details'] = json_decode($log['details'] ?? '{}', true); + $log['old_values'] = json_decode($log['old_values'] ?? '{}', true); + + // Generate human-readable summary + $log['summary'] = match(true) { + str_starts_with($log['action'], 'invoice.') => _invoiceSummary($log), + str_starts_with($log['action'], 'user.') => _userSummary($log), + str_starts_with($log['action'], 'company.') => _companySummary($log), + str_starts_with($log['action'], 'payment.') => _paymentSummary($log), + default => $log['action'], + }; +} +unset($log); + +json_success([ + 'logs' => $logs, + 'pagination' => [ + 'page' => $page, + 'limit' => $limit, + 'total' => (int)$total, + 'pages' => ceil($total / $limit), + ], +]); + +function _invoiceSummary(array $log): string { + return match($log['action']) { + 'invoice.approved' => 'تم اعتماد فاتورة', + 'invoice.updated' => 'تم تعديل فاتورة', + 'invoice.bulk_approved' => 'اعتماد جماعي', + 'invoice.uploaded' => 'تم رفع فاتورة', + 'invoice.extracted' => 'تم استخراج بيانات فاتورة', + default => $log['action'], + }; +} + +function _userSummary(array $log): string { + return match($log['action']) { + 'user.created' => 'تم إنشاء مستخدم جديد', + 'user.updated' => 'تم تعديل بيانات مستخدم', + 'user.deleted' => 'تم حذف مستخدم', + 'user.login' => 'تسجيل دخول', + default => $log['action'], + }; +} + +function _companySummary(array $log): string { + return match($log['action']) { + 'company.created' => 'تم إنشاء شركة جديدة', + 'company.updated' => 'تم تعديل بيانات شركة', + default => $log['action'], + }; +} + +function _paymentSummary(array $log): string { + return match($log['action']) { + 'payment.created' => 'تم إنشاء طلب دفع', + 'payment.uploaded' => 'تم رفع وصل دفع', + 'payment.approved' => 'تم اعتماد دفعة', + default => $log['action'], + }; +} diff --git a/app/modules_app/invoices/check_duplicate.php b/app/modules_app/invoices/check_duplicate.php new file mode 100644 index 0000000..fc9d7a7 --- /dev/null +++ b/app/modules_app/invoices/check_duplicate.php @@ -0,0 +1,130 @@ +prepare($sql); + $stmt->execute($params); + $matches = $stmt->fetchAll(); + + foreach ($matches as $m) { + $decName = Encryption::decrypt($m['supplier_name']); + $duplicates[] = [ + 'id' => $m['id'], + 'invoice_number' => $m['invoice_number'], + 'invoice_date' => $m['invoice_date'], + 'grand_total' => $m['grand_total'], + 'status' => $m['status'], + 'supplier_name' => ($decName !== false && $decName !== null) ? $decName : $m['supplier_name'], + 'match_type' => 'exact_number', + 'confidence' => 100, + ]; + } +} + +// 2. Fuzzy match: same supplier TIN + same total + same date +if ($supplierTin && $grandTotal && $invoiceDate && empty($duplicates)) { + $sql = "SELECT id, invoice_number, invoice_date, grand_total, status, supplier_name, supplier_tin + FROM invoices + WHERE tenant_id = ? + AND invoice_date = ? + AND ABS(grand_total - ?) < 0.01"; + $params = [$tenantId, $invoiceDate, $grandTotal]; + + if ($excludeId) { + $sql .= " AND id != ?"; + $params[] = $excludeId; + } + + $stmt = $db->prepare($sql); + $stmt->execute($params); + $matches = $stmt->fetchAll(); + + foreach ($matches as $m) { + $decTin = Encryption::decrypt($m['supplier_tin']); + $decName = Encryption::decrypt($m['supplier_name']); + + if ($decTin === $supplierTin || $m['supplier_tin'] === $supplierTin) { + $duplicates[] = [ + 'id' => $m['id'], + 'invoice_number' => $m['invoice_number'], + 'invoice_date' => $m['invoice_date'], + 'grand_total' => $m['grand_total'], + 'status' => $m['status'], + 'supplier_name' => ($decName !== false && $decName !== null) ? $decName : $m['supplier_name'], + 'match_type' => 'fuzzy_tin_total_date', + 'confidence' => 90, + ]; + } + } +} + +// 3. Near match: same total + near date (±3 days) +if ($grandTotal && $invoiceDate && empty($duplicates)) { + $sql = "SELECT id, invoice_number, invoice_date, grand_total, status, supplier_name + FROM invoices + WHERE tenant_id = ? + AND ABS(grand_total - ?) < 0.01 + AND ABS(DATEDIFF(invoice_date, ?)) <= 3"; + $params = [$tenantId, $grandTotal, $invoiceDate]; + + if ($excludeId) { + $sql .= " AND id != ?"; + $params[] = $excludeId; + } + $sql .= " LIMIT 5"; + + $stmt = $db->prepare($sql); + $stmt->execute($params); + $matches = $stmt->fetchAll(); + + foreach ($matches as $m) { + $decName = Encryption::decrypt($m['supplier_name']); + $duplicates[] = [ + 'id' => $m['id'], + 'invoice_number' => $m['invoice_number'], + 'invoice_date' => $m['invoice_date'], + 'grand_total' => $m['grand_total'], + 'status' => $m['status'], + 'supplier_name' => ($decName !== false && $decName !== null) ? $decName : $m['supplier_name'], + 'match_type' => 'near_total_date', + 'confidence' => 60, + ]; + } +} + +json_success([ + 'is_duplicate' => !empty($duplicates), + 'matches' => $duplicates, + 'count' => count($duplicates), +], empty($duplicates) ? 'لا توجد فواتير مكررة' : 'تم العثور على فواتير مشابهة'); diff --git a/app/modules_app/notifications/index.php b/app/modules_app/notifications/index.php new file mode 100644 index 0000000..c6c49e1 --- /dev/null +++ b/app/modules_app/notifications/index.php @@ -0,0 +1,42 @@ +prepare("SELECT COUNT(*) as total, SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END) as unread FROM notifications WHERE user_id = ?"); +$countStmt->execute([$userId]); +$counts = $countStmt->fetch(); + +// Fetch notifications +$stmt = $db->prepare(" + SELECT * FROM notifications + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? +"); +$stmt->execute([$userId, $limit, $offset]); +$notifications = $stmt->fetchAll(); + +json_success([ + 'notifications' => $notifications, + 'unread_count' => (int)($counts['unread'] ?? 0), + 'pagination' => [ + 'page' => $page, + 'total' => (int)($counts['total'] ?? 0), + 'pages' => ceil(($counts['total'] ?? 0) / $limit), + ], +]); diff --git a/app/modules_app/notifications/read.php b/app/modules_app/notifications/read.php new file mode 100644 index 0000000..733089f --- /dev/null +++ b/app/modules_app/notifications/read.php @@ -0,0 +1,28 @@ +prepare("UPDATE notifications SET is_read = 1, read_at = NOW() WHERE user_id = ? AND is_read = 0") + ->execute([$userId]); + json_success(null, 'تم تعليم جميع الإشعارات كمقروءة'); +} elseif ($id) { + $db->prepare("UPDATE notifications SET is_read = 1, read_at = NOW() WHERE id = ? AND user_id = ?") + ->execute([$id, $userId]); + json_success(null, 'تم تعليم الإشعار كمقروء'); +} else { + json_error('يرجى تحديد الإشعار', 422); +} diff --git a/musadaq-app/lib/app/routes/app_pages.dart b/musadaq-app/lib/app/routes/app_pages.dart index 4f101e3..83c4376 100644 --- a/musadaq-app/lib/app/routes/app_pages.dart +++ b/musadaq-app/lib/app/routes/app_pages.dart @@ -22,6 +22,7 @@ import '../../features/onboarding/views/onboarding_view.dart'; import '../../features/users/views/users_management_view.dart'; import '../../features/tenants/views/tenants_management_view.dart'; import '../../features/reports/views/tax_report_view.dart'; + import '../../features/audit/views/audit_log_view.dart'; part 'app_routes.dart'; @@ -156,5 +157,9 @@ class AppPages { name: AppRoutes.TAX_REPORT, page: () => const TaxReportView(), ), + GetPage( + name: AppRoutes.AUDIT_LOG, + page: () => const AuditLogView(), + ), ]; } diff --git a/musadaq-app/lib/app/routes/app_routes.dart b/musadaq-app/lib/app/routes/app_routes.dart index 87d2293..0d04ee4 100644 --- a/musadaq-app/lib/app/routes/app_routes.dart +++ b/musadaq-app/lib/app/routes/app_routes.dart @@ -22,4 +22,5 @@ abstract class AppRoutes { static const TENANTS_MANAGEMENT = '/tenants-management'; static const USERS_MANAGEMENT = '/users-management'; static const TAX_REPORT = '/tax-report'; + static const AUDIT_LOG = '/audit-log'; } diff --git a/musadaq-app/lib/features/audit/controllers/audit_log_controller.dart b/musadaq-app/lib/features/audit/controllers/audit_log_controller.dart new file mode 100644 index 0000000..4690e8e --- /dev/null +++ b/musadaq-app/lib/features/audit/controllers/audit_log_controller.dart @@ -0,0 +1,55 @@ +import 'package:get/get.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/utils/logger.dart'; + +class AuditLogController extends GetxController { + var logs = >[].obs; + var isLoading = true.obs; + var currentPage = 1.obs; + var totalPages = 1.obs; + var selectedFilter = 'all'.obs; + + @override + void onInit() { + super.onInit(); + fetchLogs(); + } + + Future fetchLogs({int page = 1}) async { + try { + isLoading.value = true; + final params = {'page': page, 'limit': 30}; + + if (selectedFilter.value != 'all') { + params['entity_type'] = selectedFilter.value; + } + + final res = await DioClient().client.get('audit-log', queryParameters: params); + if (res.data['success'] == true) { + final data = res.data['data']; + if (page == 1) { + logs.value = List>.from(data['logs'] ?? []); + } else { + logs.addAll(List>.from(data['logs'] ?? [])); + } + currentPage.value = data['pagination']?['page'] ?? 1; + totalPages.value = data['pagination']?['pages'] ?? 1; + } + } catch (e) { + AppLogger.error('Failed to fetch audit logs', e); + } finally { + isLoading.value = false; + } + } + + void loadMore() { + if (currentPage.value < totalPages.value) { + fetchLogs(page: currentPage.value + 1); + } + } + + void applyFilter(String filter) { + selectedFilter.value = filter; + fetchLogs(); + } +} diff --git a/musadaq-app/lib/features/audit/views/audit_log_view.dart b/musadaq-app/lib/features/audit/views/audit_log_view.dart new file mode 100644 index 0000000..13b99a2 --- /dev/null +++ b/musadaq-app/lib/features/audit/views/audit_log_view.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/audit_log_controller.dart'; + +class AuditLogView extends StatelessWidget { + const AuditLogView({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(AuditLogController()); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + backgroundColor: isDark ? const Color(0xFF121212) : const Color(0xFFF5F7FA), + appBar: AppBar( + title: const Text('سجل النشاط', style: TextStyle(fontWeight: FontWeight.bold)), + backgroundColor: const Color(0xFF0F4C81), + foregroundColor: Colors.white, + ), + body: Column( + children: [ + // Filter chips + Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Obx(() => Row( + children: [ + _filterChip('الكل', 'all', controller, isDark), + _filterChip('الفواتير', 'invoice', controller, isDark), + _filterChip('المستخدمون', 'user', controller, isDark), + _filterChip('الشركات', 'company', controller, isDark), + _filterChip('المدفوعات', 'payment', controller, isDark), + ], + )), + ), + ), + + // Log List + Expanded( + child: Obx(() { + if (controller.isLoading.value && controller.logs.isEmpty) { + return const Center(child: CircularProgressIndicator(color: Color(0xFF0F4C81))); + } + + if (controller.logs.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.history, size: 64, color: isDark ? Colors.white12 : Colors.grey.shade300), + const SizedBox(height: 12), + Text('لا يوجد نشاط', style: TextStyle(color: isDark ? Colors.white38 : Colors.grey, fontSize: 16)), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () => controller.fetchLogs(), + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: controller.logs.length + (controller.currentPage.value < controller.totalPages.value ? 1 : 0), + itemBuilder: (context, index) { + if (index == controller.logs.length) { + return Center( + child: TextButton( + onPressed: () => controller.loadMore(), + child: const Text('تحميل المزيد...'), + ), + ); + } + return _buildLogItem(controller.logs[index], isDark); + }, + ), + ); + }), + ), + ], + ), + ); + } + + Widget _filterChip(String label, String value, AuditLogController ctrl, bool isDark) { + return Obx(() { + final isSelected = ctrl.selectedFilter.value == value; + return Padding( + padding: const EdgeInsets.only(left: 8), + child: ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (_) => ctrl.applyFilter(value), + selectedColor: const Color(0xFF0F4C81), + backgroundColor: isDark ? Colors.white10 : const Color(0xFFF1F5F9), + labelStyle: TextStyle( + color: isSelected ? Colors.white : (isDark ? Colors.white70 : Colors.black87), + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w400, + fontSize: 13, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + side: BorderSide(color: isSelected ? Colors.transparent : Colors.grey.shade300), + ), + ); + }); + } + + Widget _buildLogItem(Map log, bool isDark) { + final action = log['action']?.toString() ?? ''; + final IconData icon; + final Color color; + + if (action.contains('approve')) { + icon = Icons.check_circle; + color = const Color(0xFF10B981); + } else if (action.contains('update')) { + icon = Icons.edit; + color = const Color(0xFF3B82F6); + } else if (action.contains('create') || action.contains('upload')) { + icon = Icons.add_circle; + color = const Color(0xFF6366F1); + } else if (action.contains('delete')) { + icon = Icons.delete; + color = const Color(0xFFEF4444); + } else if (action.contains('login')) { + icon = Icons.login; + color = const Color(0xFFF59E0B); + } else if (action.contains('payment')) { + icon = Icons.payment; + color = const Color(0xFFD4AF37); + } else { + icon = Icons.history; + color = Colors.grey; + } + + final time = log['created_at']?.toString() ?? ''; + final userName = log['user_name']?.toString() ?? 'نظام'; + final summary = log['summary']?.toString() ?? action; + final entityType = log['entity_type']?.toString() ?? ''; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, height: 40, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: color, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(summary, style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: isDark ? Colors.white : Colors.black87)), + const SizedBox(height: 4), + Row( + children: [ + Icon(Icons.person, size: 12, color: isDark ? Colors.white38 : Colors.grey), + const SizedBox(width: 4), + Text(userName, style: TextStyle(fontSize: 12, color: isDark ? Colors.white38 : Colors.grey)), + const SizedBox(width: 12), + if (entityType.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text(_entityLabel(entityType), style: TextStyle(fontSize: 10, color: color, fontWeight: FontWeight.w600)), + ), + ], + ], + ), + const SizedBox(height: 2), + Text(time, style: TextStyle(fontSize: 11, color: isDark ? Colors.white24 : Colors.grey.shade400, fontFamily: 'monospace')), + ], + ), + ), + ], + ), + ); + } + + String _entityLabel(String type) { + return switch (type) { + 'invoice' => 'فاتورة', + 'user' => 'مستخدم', + 'company' => 'شركة', + 'payment' => 'دفعة', + _ => type, + }; + } +} diff --git a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart index 80323b0..6f89c59 100644 --- a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart +++ b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart @@ -242,6 +242,14 @@ class DashboardView extends GetView { isDark, () => Get.toNamed(AppRoutes.TAX_REPORT), ), + const SizedBox(width: 12), + _buildAdminActionCard( + 'سجل النشاط', + Icons.history_rounded, + const Color(0xFF6366F1), + isDark, + () => Get.toNamed(AppRoutes.AUDIT_LOG), + ), ], ), ), 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 68087fc..c614935 100644 --- a/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart +++ b/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart @@ -127,16 +127,21 @@ class InvoiceDetailController extends GetxController { final dir = await getTemporaryDirectory(); final fileName = 'musadaq_invoices_${DateTime.now().millisecondsSinceEpoch}.csv'; final file = File('${dir.path}/$fileName'); - await file.writeAsBytes(res.data); + final bytes = List.from(res.data); + await file.writeAsBytes(bytes); - // Share via native sheet - await Share.shareXFiles( - [XFile(file.path, mimeType: 'text/csv', name: fileName)], - subject: 'تصدير فواتير مُصادَق', - ); + // Try share, fallback to success message + try { + await Share.shareXFiles( + [XFile(file.path, mimeType: 'text/csv', name: fileName)], + subject: 'تصدير فواتير مُصادَق', + ); + } catch (_) { + AppSnackbar.showSuccess('تم الحفظ', 'تم حفظ الملف: ${file.path}'); + } } catch (e) { AppLogger.error('Failed to export', e); - AppSnackbar.showError('خطأ', 'فشل تصدير الفواتير'); + AppSnackbar.showError('خطأ', 'فشل تصدير الفواتير: $e'); } } 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 05ae132..72a3ad7 100644 --- a/musadaq-app/lib/features/invoices/views/invoices_list_view.dart +++ b/musadaq-app/lib/features/invoices/views/invoices_list_view.dart @@ -386,15 +386,21 @@ class InvoicesListView extends GetView { final dir = await getTemporaryDirectory(); final fileName = 'musadaq_invoices_${DateTime.now().millisecondsSinceEpoch}.csv'; final file = File('${dir.path}/$fileName'); - await file.writeAsBytes(res.data); + final bytes = List.from(res.data); + await file.writeAsBytes(bytes); - // Share via native sheet - await Share.shareXFiles( - [XFile(file.path, mimeType: 'text/csv', name: fileName)], - subject: 'تصدير فواتير مُصادَق', - ); + // Try share, fallback to success message + try { + await Share.shareXFiles( + [XFile(file.path, mimeType: 'text/csv', name: fileName)], + subject: 'تصدير فواتير مُصادَق', + ); + } catch (_) { + AppSnackbar.showSuccess('تم الحفظ', 'تم حفظ الملف: ${file.path}'); + } } catch (e) { - AppSnackbar.showError('خطأ', 'فشل تصدير الفواتير'); + debugPrint('Export error: $e'); + AppSnackbar.showError('خطأ', 'فشل تصدير الفواتير: $e'); } } } diff --git a/musadaq-app/lib/features/notifications/controllers/notifications_controller.dart b/musadaq-app/lib/features/notifications/controllers/notifications_controller.dart new file mode 100644 index 0000000..fd56c15 --- /dev/null +++ b/musadaq-app/lib/features/notifications/controllers/notifications_controller.dart @@ -0,0 +1,58 @@ +import 'package:get/get.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/utils/logger.dart'; + +class NotificationsController extends GetxController { + var notifications = >[].obs; + var isLoading = true.obs; + var unreadCount = 0.obs; + + @override + void onInit() { + super.onInit(); + fetchNotifications(); + } + + Future fetchNotifications() async { + try { + isLoading.value = true; + final res = await DioClient().client.get('notifications'); + if (res.data['success'] == true) { + final data = res.data['data']; + notifications.value = List>.from(data['notifications'] ?? []); + unreadCount.value = data['unread_count'] ?? 0; + } + } catch (e) { + AppLogger.error('Failed to fetch notifications', e); + } finally { + isLoading.value = false; + } + } + + Future markAsRead(String id) async { + try { + await DioClient().client.post('notifications/read', data: {'id': id}); + final idx = notifications.indexWhere((n) => n['id'] == id); + if (idx != -1) { + notifications[idx] = {...notifications[idx], 'is_read': 1}; + notifications.refresh(); + unreadCount.value = notifications.where((n) => n['is_read'] == 0).length; + } + } catch (e) { + AppLogger.error('Failed to mark as read', e); + } + } + + Future markAllRead() async { + try { + await DioClient().client.post('notifications/read', data: {'mark_all': true}); + for (var i = 0; i < notifications.length; i++) { + notifications[i] = {...notifications[i], 'is_read': 1}; + } + notifications.refresh(); + unreadCount.value = 0; + } catch (e) { + AppLogger.error('Failed to mark all as read', e); + } + } +} diff --git a/musadaq-app/lib/features/notifications/views/notifications_view.dart b/musadaq-app/lib/features/notifications/views/notifications_view.dart index ec26d8a..51703a1 100644 --- a/musadaq-app/lib/features/notifications/views/notifications_view.dart +++ b/musadaq-app/lib/features/notifications/views/notifications_view.dart @@ -1,86 +1,174 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import '../controllers/notifications_controller.dart'; class NotificationsView extends StatelessWidget { const NotificationsView({super.key}); @override Widget build(BuildContext context) { + final controller = Get.put(NotificationsController()); final isDark = Theme.of(context).brightness == Brightness.dark; - return Column( - children: [ - // Top Bar - Container( - padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top, left: 8, right: 8, bottom: 12), - color: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81), - child: Row( - children: [ - const SizedBox(width: 48), - Expanded( - child: Center( - child: Text( - 'الإشعارات', - style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - ), - IconButton( - icon: const Icon(Icons.done_all_rounded, color: Colors.white), - onPressed: () {}, - tooltip: 'قراءة الكل', - ), - ], - ), - ), + return Scaffold( + backgroundColor: isDark ? const Color(0xFF121212) : const Color(0xFFF5F7FA), + appBar: AppBar( + title: const Text('الإشعارات', style: TextStyle(fontWeight: FontWeight.bold)), + backgroundColor: const Color(0xFF0F4C81), + foregroundColor: Colors.white, + actions: [ + Obx(() => controller.unreadCount.value > 0 + ? TextButton.icon( + onPressed: () => controller.markAllRead(), + icon: const Icon(Icons.done_all, color: Colors.white, size: 18), + label: const Text('قراءة الكل', style: TextStyle(color: Colors.white, fontSize: 12)), + ) + : const SizedBox.shrink()), + ], + ), + body: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator(color: Color(0xFF0F4C81))); + } - // Notifications List - Expanded( - child: _buildEmptyState(isDark), - ), - ], + if (controller.notifications.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.notifications_none, size: 72, color: isDark ? Colors.white12 : Colors.grey.shade300), + const SizedBox(height: 12), + Text('لا توجد إشعارات', style: TextStyle(color: isDark ? Colors.white38 : Colors.grey, fontSize: 16)), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: controller.fetchNotifications, + child: ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: controller.notifications.length, + itemBuilder: (context, index) { + final notif = controller.notifications[index]; + return _buildNotifItem(notif, controller, isDark); + }, + ), + ); + }), ); } - Widget _buildEmptyState(bool isDark) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: isDark ? Colors.white.withOpacity(0.05) : const Color(0xFFF1F5F9), - shape: BoxShape.circle, - ), - child: Icon( - Icons.notifications_off_rounded, - size: 48, - color: isDark ? Colors.white12 : Colors.grey.shade300, - ), + Widget _buildNotifItem(Map notif, NotificationsController ctrl, bool isDark) { + final isRead = notif['is_read'] == 1; + final type = notif['type']?.toString() ?? 'info'; + final category = notif['category']?.toString() ?? 'general'; + + final IconData icon; + final Color color; + switch (type) { + case 'success': + icon = Icons.check_circle; + color = const Color(0xFF10B981); + break; + case 'warning': + icon = Icons.warning_amber_rounded; + color = const Color(0xFFF59E0B); + break; + case 'error': + icon = Icons.error; + color = const Color(0xFFEF4444); + break; + default: + icon = _categoryIcon(category); + color = const Color(0xFF3B82F6); + } + + return GestureDetector( + onTap: () { + if (!isRead) ctrl.markAsRead(notif['id']); + if (notif['entity_type'] == 'invoice' && notif['entity_id'] != null) { + Get.toNamed('/invoice-detail', arguments: {'id': notif['entity_id']}); + } + }, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isRead + ? (isDark ? const Color(0xFF1E1E2E) : Colors.white) + : (isDark ? const Color(0xFF1A2332) : const Color(0xFFF0F7FF)), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isRead + ? (isDark ? Colors.white10 : Colors.grey.shade200) + : const Color(0xFF3B82F6).withValues(alpha: 0.2), ), - const SizedBox(height: 20), - Text( - 'لا توجد إشعارات', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: isDark ? Colors.white38 : Colors.grey, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 42, height: 42, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: color, size: 20), ), - ), - const SizedBox(height: 8), - Text( - 'ستظهر هنا إشعارات معالجة الفواتير\nوتحديثات الاشتراك', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: isDark ? Colors.white24 : Colors.grey.shade400, - height: 1.5, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + notif['title'] ?? '', + style: TextStyle( + fontWeight: isRead ? FontWeight.w500 : FontWeight.w700, + fontSize: 14, + color: isDark ? Colors.white : Colors.black87, + ), + ), + ), + if (!isRead) + Container( + width: 8, height: 8, + decoration: const BoxDecoration(shape: BoxShape.circle, color: Color(0xFF3B82F6)), + ), + ], + ), + if (notif['body'] != null && notif['body'].toString().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + notif['body'], + style: TextStyle(fontSize: 13, color: isDark ? Colors.white54 : Colors.grey.shade600), + maxLines: 2, overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 6), + Text( + notif['created_at'] ?? '', + style: TextStyle(fontSize: 11, color: isDark ? Colors.white24 : Colors.grey.shade400, fontFamily: 'monospace'), + ), + ], + ), ), - ), - ], + ], + ), ), ); } + + IconData _categoryIcon(String category) { + return switch (category) { + 'invoice' => Icons.receipt_long, + 'payment' => Icons.payment, + 'subscription' => Icons.workspace_premium, + 'system' => Icons.settings, + _ => Icons.notifications, + }; + } } diff --git a/public/index.php b/public/index.php index 118c1b7..b798617 100644 --- a/public/index.php +++ b/public/index.php @@ -37,7 +37,11 @@ $routes = [ '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/check-duplicate' => ['POST', 'invoices/check_duplicate.php'], 'v1/reports/tax-summary' => ['GET', 'reports/tax_summary.php'], + 'v1/audit-log' => ['GET', 'audit/index.php'], + 'v1/notifications' => ['GET', 'notifications/index.php'], + 'v1/notifications/read' => ['POST', 'notifications/read.php'], 'v1/companies/stats' => ['GET', 'companies/stats.php'], 'v1/companies/connect' => ['POST', 'companies/connect_jofotara.php'], 'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'], diff --git a/scripts/create_notifications_table.sql b/scripts/create_notifications_table.sql new file mode 100644 index 0000000..a1c4eee --- /dev/null +++ b/scripts/create_notifications_table.sql @@ -0,0 +1,18 @@ +-- Notifications Table +CREATE TABLE IF NOT EXISTS notifications ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + tenant_id CHAR(36) NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT, + type ENUM('info', 'success', 'warning', 'error') DEFAULT 'info', + category VARCHAR(50) DEFAULT 'general', + entity_type VARCHAR(50) NULL, + entity_id CHAR(36) NULL, + is_read TINYINT(1) DEFAULT 0, + read_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user_read (user_id, is_read), + INDEX idx_tenant (tenant_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;