diff --git a/app/Core/AuditLogger.php b/app/Core/AuditLogger.php new file mode 100644 index 0000000..608d854 --- /dev/null +++ b/app/Core/AuditLogger.php @@ -0,0 +1,63 @@ +prepare(" + INSERT INTO audit_logs (id, tenant_id, user_id, action, entity_type, entity_id, old_data, new_data, ip_address, user_agent) + VALUES (UUID(), ?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + + $stmt->execute([ + $tenantId, + $userId, + $action, + $entityType, + $entityId, + $oldData ? json_encode($oldData, JSON_UNESCAPED_UNICODE) : null, + $newData ? json_encode($newData, JSON_UNESCAPED_UNICODE) : null, + $ipAddress, + $userAgent, + ]); + } catch (\Exception $e) { + // Audit logging should NEVER crash the main request + error_log("[AuditLogger] Failed to log action '{$action}': " . $e->getMessage()); + } + } +} diff --git a/app/Middleware/CompanyAccessMiddleware.php b/app/Middleware/CompanyAccessMiddleware.php new file mode 100644 index 0000000..1828282 --- /dev/null +++ b/app/Middleware/CompanyAccessMiddleware.php @@ -0,0 +1,104 @@ +prepare("SELECT id, tenant_id FROM companies WHERE id = ? LIMIT 1"); + $stmt->execute([$companyId]); + $company = $stmt->fetch(); + + if (!$company) { + json_error('الشركة غير موجودة', 404); + } + + if ($company['tenant_id'] !== $tenantId) { + // Company exists but belongs to a different tenant — treat as 404 (don't leak info) + json_error('الشركة غير موجودة', 404); + } + + // 2. admin can access all companies in their tenant + if ($role === 'admin') { + return; + } + + // 3. accountant / viewer — must be assigned to this specific company + $stmt = $db->prepare("SELECT company_id FROM users WHERE id = ? AND tenant_id = ? LIMIT 1"); + $stmt->execute([$userId, $tenantId]); + $user = $stmt->fetch(); + + if (!$user || $user['company_id'] !== $companyId) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => 'ليس لديك صلاحية للوصول إلى هذه الشركة', + 'code' => 'COMPANY_ACCESS_DENIED', + ], JSON_UNESCAPED_UNICODE); + exit; + } + } + + /** + * Get the list of company IDs that the user can access. + * Useful for listing/filtering queries. + */ + public static function getAccessibleCompanyIds(array $decoded): ?array + { + $role = $decoded['role'] ?? ''; + $tenantId = $decoded['tenant_id'] ?? ''; + $userId = $decoded['user_id'] ?? ''; + + // super_admin & admin: null means "no filter" (access all) + if ($role === 'super_admin' || $role === 'admin') { + return null; + } + + // accountant / viewer: only their assigned company + $db = Database::getInstance(); + $stmt = $db->prepare("SELECT company_id FROM users WHERE id = ? AND tenant_id = ? LIMIT 1"); + $stmt->execute([$userId, $tenantId]); + $user = $stmt->fetch(); + + if ($user && $user['company_id']) { + return [$user['company_id']]; + } + + return []; // No access to any company + } +} diff --git a/app/Middleware/RoleMiddleware.php b/app/Middleware/RoleMiddleware.php new file mode 100644 index 0000000..daa909b --- /dev/null +++ b/app/Middleware/RoleMiddleware.php @@ -0,0 +1,97 @@ + false, + 'message' => 'ليس لديك صلاحية للوصول إلى هذا المورد', + 'code' => 'FORBIDDEN', + 'required_roles' => $allowedRoles, + 'your_role' => $userRole, + ], JSON_UNESCAPED_UNICODE); + exit; + } + + return $decoded; + } + + /** + * Deny access to specific roles (blacklist approach). + */ + public static function deny(array $deniedRoles, ?array $decoded = null): array + { + if (!$decoded) { + $decoded = AuthMiddleware::check(); + } + + $userRole = $decoded['role'] ?? ''; + + if (in_array($userRole, $deniedRoles, true)) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => 'ليس لديك صلاحية للوصول إلى هذا المورد', + 'code' => 'FORBIDDEN', + ], JSON_UNESCAPED_UNICODE); + exit; + } + + return $decoded; + } + + /** + * Check if the current user is a super_admin. + */ + public static function isSuperAdmin(array $decoded): bool + { + return ($decoded['role'] ?? '') === 'super_admin'; + } + + /** + * Check if the current user is an admin or super_admin. + */ + public static function isAdmin(array $decoded): bool + { + return in_array($decoded['role'] ?? '', ['admin', 'super_admin'], true); + } + + /** + * Check if the current user can write (create/update/delete). + * Viewers are read-only. + */ + public static function canWrite(array $decoded): bool + { + return in_array($decoded['role'] ?? '', ['super_admin', 'admin', 'accountant'], true); + } +} diff --git a/app/modules_app/companies/create.php b/app/modules_app/companies/create.php index b3ac126..d3c6af8 100644 --- a/app/modules_app/companies/create.php +++ b/app/modules_app/companies/create.php @@ -6,12 +6,11 @@ use App\Core\Database; use App\Core\Encryption; use App\Core\Validator; +use App\Core\AuditLogger; use App\Middleware\AuthMiddleware; +use App\Middleware\RoleMiddleware; -$decoded = AuthMiddleware::check(); -if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') { - json_error('Unauthorized', 403); -} +$decoded = RoleMiddleware::require(['super_admin', 'admin']); $data = input(); @@ -80,6 +79,12 @@ try { ]); $db->commit(); + + AuditLogger::log('company.created', 'company', null, null, [ + 'name' => $data['name'], + 'tin' => $data['tax_identification_number'], + ], $decoded); + json_success(null, 'تم إنشاء الشركة بنجاح'); } catch (\Exception $e) { diff --git a/app/modules_app/companies/delete.php b/app/modules_app/companies/delete.php index 0a5845a..bf2142a 100644 --- a/app/modules_app/companies/delete.php +++ b/app/modules_app/companies/delete.php @@ -4,9 +4,12 @@ */ use App\Core\Database; +use App\Core\AuditLogger; use App\Middleware\AuthMiddleware; +use App\Middleware\RoleMiddleware; +use App\Middleware\CompanyAccessMiddleware; -$decoded = AuthMiddleware::check(); +$decoded = RoleMiddleware::require(['super_admin', 'admin']); $db = Database::getInstance(); $companyId = input('id'); @@ -28,12 +31,13 @@ if (!$company) { json_error('الشركة غير موجودة', 404); } -if ($decoded['role'] === 'admin' && $company['tenant_id'] !== $decoded['tenant_id']) { - json_error('ليس لديك صلاحية لحذف هذه الشركة', 403); -} +// Verify tenant access (admin can only delete from their tenant) +CompanyAccessMiddleware::check($companyId, $decoded); // Soft Delete $stmt = $db->prepare("UPDATE companies SET deleted_at = NOW() WHERE id = ?"); $stmt->execute([$companyId]); +AuditLogger::log('company.deleted', 'company', $companyId, null, null, $decoded); + json_success(null, 'تم حذف الشركة بنجاح'); diff --git a/app/modules_app/dashboard/recent_activity.php b/app/modules_app/dashboard/recent_activity.php new file mode 100644 index 0000000..29ed2cb --- /dev/null +++ b/app/modules_app/dashboard/recent_activity.php @@ -0,0 +1,42 @@ +prepare(" + SELECT a.id, a.action, a.entity_type, a.created_at, u.name as user_name + FROM audit_logs a + LEFT JOIN users u ON a.user_id = u.id + $where + ORDER BY a.created_at DESC + LIMIT 20 + "); + $stmt->execute($params); + $activities = $stmt->fetchAll(); + + json_success($activities); + +} catch (\Exception $e) { + json_error('Failed to fetch recent activity', 500); +} diff --git a/app/modules_app/dashboard/stats.php b/app/modules_app/dashboard/stats.php index 97c4dc8..8739d89 100644 --- a/app/modules_app/dashboard/stats.php +++ b/app/modules_app/dashboard/stats.php @@ -15,38 +15,67 @@ $companyId = $decoded['company_id'] ?? null; $role = $decoded['role']; try { - // 2. Apply Filters based on Role + $stats = [ + 'role' => $role, + 'invoices' => [ + 'total' => 0, + 'pending' => 0, + 'approved' => 0 + ] + ]; + + // 2. Fetch Invoice Stats if ($role === 'super_admin') { - // No filters - see everything $where = "WHERE 1=1"; $params = []; + } elseif ($role === 'accountant' || $role === 'viewer') { + $where = "WHERE tenant_id = ? AND company_id = ?"; + $params = [$tenantId, $companyId]; } else { - // Tenant Users (Admin, Accountant, Employee): Filter by Tenant + // admin $where = "WHERE tenant_id = ?"; $params = [$tenantId]; } - // 3. Fetch Stats $stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where"); $stmt->execute($params); - $total = $stmt->fetchColumn(); + $stats['invoices']['total'] = (int)$stmt->fetchColumn(); $stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'extracted'"); $stmt->execute($params); - $pending = $stmt->fetchColumn(); + $stats['invoices']['pending'] = (int)$stmt->fetchColumn(); $stmt = $db->prepare("SELECT COUNT(*) FROM invoices $where AND status = 'approved'"); $stmt->execute($params); - $approved = $stmt->fetchColumn(); + $stats['invoices']['approved'] = (int)$stmt->fetchColumn(); + + // 3. Role-Specific Extra Stats + if ($role === 'super_admin') { + $stats['tenants'] = (int)$db->query("SELECT COUNT(*) FROM tenants")->fetchColumn(); + $stats['total_users'] = (int)$db->query("SELECT COUNT(*) FROM users")->fetchColumn(); + } elseif ($role === 'admin') { + $stmt = $db->prepare("SELECT COUNT(*) FROM companies WHERE tenant_id = ?"); + $stmt->execute([$tenantId]); + $stats['companies'] = (int)$stmt->fetchColumn(); + + $stmt = $db->prepare("SELECT COUNT(*) FROM users WHERE tenant_id = ?"); + $stmt->execute([$tenantId]); + $stats['users'] = (int)$stmt->fetchColumn(); + + // Get Subscription Quota + $stmt = $db->prepare("SELECT max_invoices_per_month, invoices_used_this_month FROM subscriptions WHERE tenant_id = ?"); + $stmt->execute([$tenantId]); + $sub = $stmt->fetch(); + if ($sub) { + $stats['subscription'] = [ + 'limit' => (int)$sub['max_invoices_per_month'], + 'used' => (int)$sub['invoices_used_this_month'] + ]; + } + } } catch (\Exception $e) { - $total = 0; - $pending = 0; - $approved = 0; + // Return default zeroed stats on error } -json_success([ - 'total' => $total, - 'pending' => $pending, - 'approved' => $approved -]); +json_success($stats); diff --git a/app/modules_app/invoices/approve.php b/app/modules_app/invoices/approve.php index f08634d..3b0e945 100644 --- a/app/modules_app/invoices/approve.php +++ b/app/modules_app/invoices/approve.php @@ -5,9 +5,13 @@ use App\Core\Database; use App\Core\JoFotara; +use App\Core\AuditLogger; use App\Middleware\AuthMiddleware; +use App\Middleware\RoleMiddleware; +use App\Middleware\CompanyAccessMiddleware; -$decoded = AuthMiddleware::check(); +// Only admin, accountant, and super_admin can approve. Viewers cannot. +$decoded = RoleMiddleware::require(['super_admin', 'admin', 'accountant']); $db = Database::getInstance(); $data = json_decode(file_get_contents('php://input'), true); @@ -111,6 +115,14 @@ try { 'is_api_success' => $apiResponse['success'] ]); + AuditLogger::log('invoice.approved', 'invoice', $id, [ + 'old_status' => $invoice['status'], + ], [ + 'new_status' => 'approved', + 'jofotara_uuid' => $apiResponse['uuid'] ?? null, + 'api_success' => $apiResponse['success'], + ], $decoded); + } catch (\Exception $e) { if ($db->inTransaction()) $db->rollBack(); error_log("JoFotara Approve Error: " . $e->getMessage()); diff --git a/app/modules_app/users/create.php b/app/modules_app/users/create.php index e4ff628..a2d1685 100644 --- a/app/modules_app/users/create.php +++ b/app/modules_app/users/create.php @@ -6,13 +6,12 @@ use App\Core\Database; use App\Core\Encryption; use App\Core\Validator; +use App\Core\AuditLogger; use App\Middleware\AuthMiddleware; +use App\Middleware\RoleMiddleware; -// 1. Auth Check (Only super_admin or admin can create users) -$decoded = AuthMiddleware::check(); -if ($decoded['role'] !== 'super_admin' && $decoded['role'] !== 'admin') { - json_error('Unauthorized', 403); -} +// 1. Auth + Role Check (Only super_admin or admin can create users) +$decoded = RoleMiddleware::require(['super_admin', 'admin']); $data = input(); @@ -76,6 +75,12 @@ try { ]); json_success(null, 'تم إضافة المستخدم بنجاح'); + + AuditLogger::log('user.created', 'user', null, null, [ + 'name' => $data['name'], + 'email' => $data['email'], + 'role' => $data['role'], + ], $decoded); } catch (\Exception $e) { if (str_contains($e->getMessage(), 'Duplicate entry')) { json_error('البريد الإلكتروني مسجل مسبقاً', 409); diff --git a/app/modules_app/users/delete.php b/app/modules_app/users/delete.php index 871dc8d..3ba7b4b 100644 --- a/app/modules_app/users/delete.php +++ b/app/modules_app/users/delete.php @@ -4,10 +4,12 @@ */ use App\Core\Database; +use App\Core\AuditLogger; use App\Middleware\AuthMiddleware; +use App\Middleware\RoleMiddleware; -// 1. Auth Check -$decoded = AuthMiddleware::check(); +// 1. Auth + Role Check +$decoded = RoleMiddleware::require(['super_admin', 'admin']); $db = Database::getInstance(); $currentUserId = $decoded['user_id']; @@ -52,4 +54,8 @@ if ($currentUserRole === 'super_admin') { $stmt = $db->prepare("UPDATE users SET deleted_at = NOW(), is_active = 0 WHERE id = ?"); $stmt->execute([$targetUserId]); +AuditLogger::log('user.deleted', 'user', $targetUserId, [ + 'role' => $targetUser['role'], +], null, $decoded); + json_success(null, 'تم حذف المستخدم بنجاح'); diff --git a/musadaq-app/android/app/src/main/AndroidManifest.xml b/musadaq-app/android/app/src/main/AndroidManifest.xml index 7e42337..e6df10a 100644 --- a/musadaq-app/android/app/src/main/AndroidManifest.xml +++ b/musadaq-app/android/app/src/main/AndroidManifest.xml @@ -18,7 +18,8 @@ + android:icon="@mipmap/ic_launcher" + android:enableOnBackInvokedCallback="true"> DashboardView(), + page: () => const DashboardView(), + binding: BindingsBuilder(() { + Get.put(DashboardController()); + }), ), GetPage( name: AppRoutes.SCANNER, diff --git a/musadaq-app/lib/features/dashboard/controllers/dashboard_controller.dart b/musadaq-app/lib/features/dashboard/controllers/dashboard_controller.dart new file mode 100644 index 0000000..53e4d9b --- /dev/null +++ b/musadaq-app/lib/features/dashboard/controllers/dashboard_controller.dart @@ -0,0 +1,77 @@ +import 'package:get/get.dart'; +import 'package:dio/dio.dart'; +import '../../../core/storage/secure_storage.dart'; +import '../../../core/utils/app_snackbar.dart'; +import '../../../core/utils/logger.dart'; +import '../../../app/routes/app_pages.dart'; + +class DashboardController extends GetxController { + final SecureStorage _storage = SecureStorage(); + final Dio _dio = Dio(BaseOptions( + baseUrl: 'https://musadaq.intaleqapp.com/api/v1', + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + responseType: ResponseType.json, + )); + + var isLoading = true.obs; + var stats = {}.obs; + var recentActivities = [].obs; + var userRole = ''.obs; + + @override + void onInit() { + super.onInit(); + _loadDashboardData(); + } + + + + Future _loadDashboardData() async { + try { + isLoading.value = true; + final token = await _storage.getToken(); + + if (token == null || token.isEmpty) { + Get.offAllNamed(AppRoutes.PHONE_INPUT); + return; + } + + _dio.options.headers['Authorization'] = 'Bearer $token'; + + // Fetch Stats + final statsResponse = await _dio.get('/dashboard/stats'); + if (statsResponse.data['success'] == true) { + stats.value = statsResponse.data['data']; + userRole.value = statsResponse.data['data']['role'] ?? ''; + } + + // Fetch Recent Activity + final activityResponse = await _dio.get('/dashboard/recent-activity'); + if (activityResponse.data['success'] == true) { + recentActivities.value = activityResponse.data['data']; + } + } on DioException catch (e) { + AppLogger.error('Dashboard Data Fetch Error', e); + if (e.response?.statusCode == 401 || e.response?.statusCode == 403) { + await logout(); + } else { + AppSnackbar.showError( + 'خطأ', 'فشل في جلب البيانات. الرجاء التحقق من اتصالك بالإنترنت.'); + } + } catch (e) { + AppLogger.error('Unexpected error fetching dashboard', e); + } finally { + isLoading.value = false; + } + } + + Future logout() async { + await _storage.clearAll(); + Get.offAllNamed(AppRoutes.PHONE_INPUT); + } + + void refreshData() { + _loadDashboardData(); + } +} diff --git a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart index 2d07414..bf7620f 100644 --- a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart +++ b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart @@ -1,59 +1,317 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../../../app/routes/app_pages.dart'; -import '../../../core/storage/secure_storage.dart'; +import '../controllers/dashboard_controller.dart'; -class DashboardView extends StatelessWidget { - DashboardView({super.key}); - - final SecureStorage _storage = SecureStorage(); - - void _logout() async { - await _storage.clearAll(); - Get.offAllNamed(AppRoutes.PHONE_INPUT); - } +class DashboardView extends GetView { + const DashboardView({super.key}); @override Widget build(BuildContext context) { + // We instantiate the controller here if not bound, though we should use binding in routes. + // For safety, let's put it here or rely on the router binding. + Get.put(DashboardController()); + return Scaffold( + backgroundColor: const Color(0xFFF5F7FA), appBar: AppBar( - title: const Text('لوحة التحكم - مُصادَق'), + title: const Text('لوحة التحكم - مُصادَق', style: TextStyle(fontWeight: FontWeight.bold)), + backgroundColor: const Color(0xFF0F4C81), + foregroundColor: Colors.white, + elevation: 0, actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => controller.refreshData(), + ), IconButton( icon: const Icon(Icons.logout), - onPressed: _logout, + onPressed: () => controller.logout(), ) ], ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + body: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator(color: Color(0xFF0F4C81))); + } + + final stats = controller.stats; + final role = controller.userRole.value; + + return RefreshIndicator( + onRefresh: () async => controller.refreshData(), + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildWelcomeHeader(role), + const SizedBox(height: 24), + + // Action Buttons + _buildQuickActions(), + const SizedBox(height: 32), + + // Invoice Stats + const Text('إحصائيات الفواتير', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + _buildInvoiceStats(stats), + + // Role Specific Stats (Companies, Users, Tenants) + if (role == 'admin' || role == 'super_admin') ...[ + const SizedBox(height: 24), + const Text('نظرة عامة', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + _buildRoleSpecificStats(stats, role), + ], + + // Quota + if (role == 'admin' && stats['subscription'] != null) ...[ + const SizedBox(height: 24), + _buildQuotaMeter(stats['subscription']), + ], + + const SizedBox(height: 32), + const Text('أحدث النشاطات', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + _buildRecentActivity(), + const SizedBox(height: 40), + ], + ), + ), + ); + }), + ); + } + + Widget _buildWelcomeHeader(String role) { + String roleName = 'مستخدم'; + switch (role) { + case 'super_admin': roleName = 'مدير النظام'; break; + case 'admin': roleName = 'مدير المكتب'; break; + case 'accountant': roleName = 'محاسب'; break; + case 'viewer': roleName = 'مشاهد'; break; + } + + return Row( + children: [ + const CircleAvatar( + radius: 30, + backgroundColor: Color(0xFFE2E8F0), + child: Icon(Icons.person, size: 30, color: Color(0xFF64748B)), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.check_circle, size: 80, color: Colors.green), - const SizedBox(height: 24), - const Text( - 'أهلاً بك في مُصادَق!', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + const Text('مرحباً بك في مُصادَق 👋', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text('صلاحيات: $roleName', style: const TextStyle(color: Colors.grey, fontSize: 14)), + ], + ), + ], + ); + } + + Widget _buildQuickActions() { + return Row( + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.document_scanner), + label: const Text('المسح الضوئي', style: TextStyle(fontWeight: FontWeight.bold)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0F4C81), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), - const SizedBox(height: 16), - const Text( - 'تم تسجيل الدخول بنجاح وتفعيل الـ HMAC.', - style: TextStyle(color: Colors.grey), - ), - const SizedBox(height: 48), - ElevatedButton.icon( - icon: const Icon(Icons.document_scanner), - label: const Text('مسح فاتورة جديدة'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0F4C81), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + onPressed: () => Get.toNamed(AppRoutes.SCANNER), + ), + ), + const SizedBox(width: 12), + if (controller.userRole.value == 'admin') + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.business), + label: const Text('إدارة الشركات', style: TextStyle(fontWeight: FontWeight.bold)), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF0F4C81), + side: const BorderSide(color: Color(0xFF0F4C81)), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), - onPressed: () => Get.toNamed(AppRoutes.SCANNER), - ) + onPressed: () { + Get.snackbar('قريباً', 'سيتم إطلاق هذه الميزة قريباً'); + }, + ), + ), + ], + ); + } + + Widget _buildInvoiceStats(Map stats) { + final inv = stats['invoices'] ?? {'total': 0, 'pending': 0, 'approved': 0}; + return Row( + children: [ + _buildStatCard('الكل', inv['total'].toString(), Icons.receipt_long, Colors.blue), + const SizedBox(width: 12), + _buildStatCard('قيد المعالجة', inv['pending'].toString(), Icons.hourglass_empty, Colors.orange), + const SizedBox(width: 12), + _buildStatCard('معتمدة', inv['approved'].toString(), Icons.check_circle, Colors.green), + ], + ); + } + + Widget _buildRoleSpecificStats(Map stats, String role) { + if (role == 'super_admin') { + return Row( + children: [ + _buildStatCard('المستأجرين', (stats['tenants'] ?? 0).toString(), Icons.business_center, Colors.indigo), + const SizedBox(width: 12), + _buildStatCard('المستخدمين', (stats['total_users'] ?? 0).toString(), Icons.people, Colors.purple), + ], + ); + } else { + return Row( + children: [ + _buildStatCard('الشركات', (stats['companies'] ?? 0).toString(), Icons.business, Colors.indigo), + const SizedBox(width: 12), + _buildStatCard('المستخدمين', (stats['users'] ?? 0).toString(), Icons.people, Colors.purple), + ], + ); + } + } + + Widget _buildStatCard(String title, String count, IconData icon, Color color) { + return Expanded( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4)), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 28), + const SizedBox(height: 12), + Text(count, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)), ], ), ), ); } + + Widget _buildQuotaMeter(Map subscription) { + int limit = subscription['limit'] ?? 100; + int used = subscription['used'] ?? 0; + double progress = limit > 0 ? (used / limit) : 0; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('استهلاك الباقة الشهرية (AI)', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + LinearProgressIndicator( + value: progress, + backgroundColor: Colors.grey.shade200, + color: progress > 0.9 ? Colors.red : const Color(0xFF0F4C81), + minHeight: 8, + borderRadius: BorderRadius.circular(4), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('$used فاتورة', style: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF0F4C81))), + Text('من $limit', style: const TextStyle(color: Colors.grey)), + ], + ) + ], + ), + ); + } + + Widget _buildRecentActivity() { + if (controller.recentActivities.isEmpty) { + return const Center(child: Text('لا توجد نشاطات حديثة', style: TextStyle(color: Colors.grey))); + } + + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: controller.recentActivities.length, + itemBuilder: (context, index) { + final act = controller.recentActivities[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 0, + color: Colors.white, + shape: RoundedRectangleBorder( + side: const BorderSide(color: Color(0xFFE2E8F0)), + borderRadius: BorderRadius.circular(12), + ), + child: ListTile( + leading: CircleAvatar( + backgroundColor: const Color(0xFFF1F5F9), + child: Icon(_getActivityIcon(act['action']), color: const Color(0xFF64748B), size: 18), + ), + title: Text(_formatAction(act['action'])), + subtitle: Text('بواسطة: ${act['user_name'] ?? 'مستخدم مجهول'}'), + trailing: Text( + _timeAgo(act['created_at']), + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ), + ); + }, + ); + } + + IconData _getActivityIcon(String action) { + if (action.contains('approved')) return Icons.check_circle; + if (action.contains('created')) return Icons.add_circle; + if (action.contains('deleted')) return Icons.delete; + if (action.contains('login')) return Icons.login; + return Icons.info; + } + + String _formatAction(String action) { + switch (action) { + case 'invoice.approved': return 'اعتماد فاتورة'; + case 'invoice.extracted': return 'استخراج بيانات فاتورة'; + case 'company.created': return 'إضافة شركة'; + case 'company.deleted': return 'حذف شركة'; + case 'user.created': return 'إضافة مستخدم'; + case 'user.deleted': return 'حذف مستخدم'; + case 'user.login': return 'تسجيل دخول'; + default: return action; + } + } + + String _timeAgo(String datetime) { + // A simple timeAgo formatter for demo purposes + try { + final dt = DateTime.parse(datetime); + final diff = DateTime.now().difference(dt); + if (diff.inDays > 0) return 'منذ ${diff.inDays} يوم'; + if (diff.inHours > 0) return 'منذ ${diff.inHours} ساعة'; + if (diff.inMinutes > 0) return 'منذ ${diff.inMinutes} دقيقة'; + return 'الآن'; + } catch (e) { + return ''; + } + } } diff --git a/public/index.php b/public/index.php index 01321f7..3ae96af 100644 --- a/public/index.php +++ b/public/index.php @@ -34,6 +34,7 @@ $routes = [ 'v1/companies/stats' => ['GET', 'companies/stats.php'], 'v1/companies/connect' => ['POST', 'companies/connect_jofotara.php'], 'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'], + 'v1/dashboard/recent-activity' => ['GET', 'dashboard/recent_activity.php'], 'v1/tenants' => ['GET', 'tenants/index.php'], 'v1/tenants/create' => ['POST', 'tenants/create.php'], 'v1/tenants/update' => ['POST', 'tenants/update.php'], diff --git a/scratch_check_db.php b/scratch_check_db.php new file mode 100644 index 0000000..cf13070 --- /dev/null +++ b/scratch_check_db.php @@ -0,0 +1,12 @@ +query("DESCRIBE users"); + $columns = $stmt->fetchAll(PDO::FETCH_ASSOC); + echo json_encode($columns, JSON_PRETTY_PRINT); +} catch (Exception $e) { + echo "Error: " . $e->getMessage(); +}