From 80f3d257b0c0faafce1d26a51a07955c0650a3d8 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 7 May 2026 23:06:22 +0300 Subject: [PATCH] Update: 2026-05-07 23:06:22 --- app/modules_app/assignments/create.php | 55 ++++++ app/modules_app/assignments/index.php | 41 +++++ app/modules_app/excel/import.php | 122 ++++++++++++ app/modules_app/voice/transcribe.php | 2 +- musadaq-app/lib/app/routes/app_pages.dart | 32 ++-- musadaq-app/lib/app/routes/app_routes.dart | 6 +- musadaq-app/lib/core/network/dio_client.dart | 27 ++- .../controllers/add_company_controller.dart | 13 +- .../views/companies_management_view.dart | 8 +- .../dashboard/views/dashboard_view.dart | 102 +++++++++- .../settings/views/settings_view.dart | 87 ++++++--- .../controllers/add_tenant_controller.dart | 70 +++++++ .../tenants_management_controller.dart | 33 ++++ .../tenants/views/add_tenant_view.dart | 130 +++++++++++++ .../views/tenants_management_view.dart | 88 +++++++++ .../controllers/add_user_controller.dart | 115 ++++++++++++ .../users_management_controller.dart | 33 ++++ .../features/users/views/add_user_view.dart | 174 ++++++++++++++++++ .../users/views/users_management_view.dart | 88 +++++++++ public/shell.php | 88 ++++++++- 20 files changed, 1254 insertions(+), 60 deletions(-) create mode 100644 app/modules_app/assignments/create.php create mode 100644 app/modules_app/assignments/index.php create mode 100644 app/modules_app/excel/import.php create mode 100644 musadaq-app/lib/features/tenants/controllers/add_tenant_controller.dart create mode 100644 musadaq-app/lib/features/tenants/controllers/tenants_management_controller.dart create mode 100644 musadaq-app/lib/features/tenants/views/add_tenant_view.dart create mode 100644 musadaq-app/lib/features/tenants/views/tenants_management_view.dart create mode 100644 musadaq-app/lib/features/users/controllers/add_user_controller.dart create mode 100644 musadaq-app/lib/features/users/controllers/users_management_controller.dart create mode 100644 musadaq-app/lib/features/users/views/add_user_view.dart create mode 100644 musadaq-app/lib/features/users/views/users_management_view.dart diff --git a/app/modules_app/assignments/create.php b/app/modules_app/assignments/create.php new file mode 100644 index 0000000..b9386d2 --- /dev/null +++ b/app/modules_app/assignments/create.php @@ -0,0 +1,55 @@ +prepare("SELECT tenant_id FROM users WHERE id = ?"); + $stmt->execute([$userId]); + $userTenant = $stmt->fetchColumn(); + + if ($userTenant !== $decoded['tenant_id']) { + json_error('User does not belong to your office', 403); + } + } + + $stmt = $db->prepare(" + INSERT INTO user_company_assignments (id, user_id, company_id, is_active, created_at) + VALUES (?, ?, ?, 1, ?) + ON DUPLICATE KEY UPDATE is_active = 1 + "); + + $stmt->execute([ + Database::generateUuid(), + $userId, + $companyId, + date('Y-m-d H:i:s') + ]); + + json_success(null, 'تم تخصيص المستخدم للشركة بنجاح'); + +} catch (\Exception $e) { + json_error('حدث خطأ أثناء التخصيص: ' . $e->getMessage(), 500); +} diff --git a/app/modules_app/assignments/index.php b/app/modules_app/assignments/index.php new file mode 100644 index 0000000..b8940bc --- /dev/null +++ b/app/modules_app/assignments/index.php @@ -0,0 +1,41 @@ +prepare(" + SELECT a.id, a.user_id, a.is_active, u.name, u.email, u.role + FROM user_company_assignments a + JOIN users u ON a.user_id = u.id + WHERE a.company_id = ? AND a.is_active = 1 + "); + $stmt->execute([$companyId]); + $assignments = $stmt->fetchAll(); + + foreach ($assignments as &$a) { + $a['name'] = Encryption::decrypt($a['name']) ?: $a['name']; + $a['email'] = Encryption::decrypt($a['email']) ?: $a['email']; + } + + json_success($assignments); + +} catch (\Exception $e) { + json_error('SQL Error: ' . $e->getMessage(), 500); +} diff --git a/app/modules_app/excel/import.php b/app/modules_app/excel/import.php new file mode 100644 index 0000000..308985f --- /dev/null +++ b/app/modules_app/excel/import.php @@ -0,0 +1,122 @@ +getActiveSheet(); + $rows = $worksheet->toArray(); + + if (count($rows) < 2) { + json_error('الملف فارغ أو لا يحتوي على بيانات', 422); + } + + $header = array_shift($rows); + $mapping = mapColumns($header); + + $db = Database::getInstance(); + $db->beginTransaction(); + + $importedCount = 0; + $errors = []; + + foreach ($rows as $index => $row) { + if (empty(array_filter($row))) continue; // Skip empty rows + + try { + // Check quota for each invoice (preventive) + QuotaMiddleware::checkInvoiceQuota($tenantId); + + $invoiceData = [ + 'id' => Database::generateUuid(), + 'tenant_id' => $tenantId, + 'company_id' => $companyId, + 'invoice_number' => $row[$mapping['number']] ?? 'EXT-' . time() . '-' . $index, + 'invoice_date' => formatDate($row[$mapping['date']] ?? date('Y-m-d')), + 'customer_name' => Encryption::encrypt($row[$mapping['customer']] ?? 'عميل عام'), + 'grand_total' => (float)($row[$mapping['total']] ?? 0), + 'tax_amount' => (float)($row[$mapping['tax']] ?? 0), + 'status' => 'extracted', // Ready for review/approval + 'created_at' => date('Y-m-d H:i:s') + ]; + + $stmt = $db->prepare(" + INSERT INTO invoices (id, tenant_id, company_id, invoice_number, invoice_date, customer_name, grand_total, tax_amount, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute(array_values($invoiceData)); + $importedCount++; + + } catch (\Exception $e) { + $errors[] = "السطر " . ($index + 2) . ": " . $e->getMessage(); + } + } + + $db->commit(); + + json_success([ + 'imported_count' => $importedCount, + 'errors' => $errors + ], "تم استيراد $importedCount فاتورة بنجاح"); + +} catch (\Exception $e) { + if (isset($db)) $db->rollBack(); + json_error('فشل معالجة ملف الاكسل: ' . $e->getMessage(), 500); +} + +/** + * Intelligent Column Mapping + */ +function mapColumns(array $header): array { + $map = [ + 'number' => 0, + 'date' => 1, + 'customer' => 2, + 'total' => 3, + 'tax' => 4 + ]; + + foreach ($header as $i => $col) { + $col = mb_strtolower(trim((string)$col)); + if (str_contains($col, 'رقم') || str_contains($col, 'number')) $map['number'] = $i; + if (str_contains($col, 'تاريخ') || str_contains($col, 'date')) $map['date'] = $i; + if (str_contains($col, 'عميل') || str_contains($col, 'customer') || str_contains($col, 'اسم')) $map['customer'] = $i; + if (str_contains($col, 'اجمالي') || str_contains($col, 'total') || str_contains($col, 'المجموع')) $map['total'] = $i; + if (str_contains($col, 'ضريبة') || str_contains($col, 'tax')) $map['tax'] = $i; + } + + return $map; +} + +function formatDate($val): string { + if (is_numeric($val)) { + return date('Y-m-d', \PhpOffice\PhpSpreadsheet\Shared\Date::excelToTimestamp($val)); + } + $ts = strtotime((string)$val); + return $ts ? date('Y-m-d', $ts) : date('Y-m-d'); +} diff --git a/app/modules_app/voice/transcribe.php b/app/modules_app/voice/transcribe.php index 92a8145..87a9d62 100644 --- a/app/modules_app/voice/transcribe.php +++ b/app/modules_app/voice/transcribe.php @@ -98,7 +98,7 @@ function detectAudioMimeType(string $path, string $fallback, string $fileName = function extractIntentFromAudio(string $base64Audio, string $mimeType, string $apiKey): array { - $model = env('GEMINI_VOICE_MODEL', 'gemini-2.5-flash'); + $model = env('GEMINI_VOICE_MODEL', 'gemini-1.5-flash'); $systemPrompt = << const CompaniesManagementView(), - ), - ]; -} + GetPage( + name: AppRoutes.COMPANIES_MANAGEMENT, + page: () => const CompaniesManagementView(), + ), + GetPage( + name: AppRoutes.TENANTS_MANAGEMENT, + page: () => const TenantsManagementView(), + ), + GetPage( + name: AppRoutes.USERS_MANAGEMENT, + page: () => const UsersManagementView(), + ), + ]; + } diff --git a/musadaq-app/lib/app/routes/app_routes.dart b/musadaq-app/lib/app/routes/app_routes.dart index 85c1d1c..e134396 100644 --- a/musadaq-app/lib/app/routes/app_routes.dart +++ b/musadaq-app/lib/app/routes/app_routes.dart @@ -17,5 +17,7 @@ abstract class AppRoutes { static const PAYMENT_RECEIPT = '/payment-receipt'; static const INVOICE_DETAIL = '/invoice-detail'; static const ONBOARDING = '/onboarding'; - static const COMPANIES_MANAGEMENT = '/companies-management'; -} + static const COMPANIES_MANAGEMENT = '/companies-management'; + static const TENANTS_MANAGEMENT = '/tenants-management'; + static const USERS_MANAGEMENT = '/users-management'; + } diff --git a/musadaq-app/lib/core/network/dio_client.dart b/musadaq-app/lib/core/network/dio_client.dart index fc4293e..8a14c0c 100644 --- a/musadaq-app/lib/core/network/dio_client.dart +++ b/musadaq-app/lib/core/network/dio_client.dart @@ -22,11 +22,28 @@ class DioClient { // Add Interceptors dio.interceptors.add(HmacInterceptor(SecureStorage())); - // Logging interceptor for debug - dio.interceptors.add(LogInterceptor( - requestBody: true, - responseBody: true, - error: true, + // Custom Logging Interceptor as requested + dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) { + print('--- API REQUEST ---'); + print('URL: ${options.method} ${options.uri}'); + print('Payload: ${options.data}'); + return handler.next(options); + }, + onResponse: (response, handler) { + print('--- API RESPONSE ---'); + print('URL: ${response.requestOptions.method} ${response.requestOptions.uri}'); + print('Status Code: ${response.statusCode}'); + print('Response: ${response.data}'); + return handler.next(response); + }, + onError: (DioException e, handler) { + print('--- API ERROR ---'); + print('URL: ${e.requestOptions.method} ${e.requestOptions.uri}'); + print('Status Code: ${e.response?.statusCode}'); + print('Response: ${e.response?.data ?? e.message}'); + return handler.next(e); + }, )); } diff --git a/musadaq-app/lib/features/companies/controllers/add_company_controller.dart b/musadaq-app/lib/features/companies/controllers/add_company_controller.dart index fdc43cf..a6bff1d 100644 --- a/musadaq-app/lib/features/companies/controllers/add_company_controller.dart +++ b/musadaq-app/lib/features/companies/controllers/add_company_controller.dart @@ -9,7 +9,7 @@ class AddCompanyController extends GetxController { final nameController = TextEditingController(); final tinController = TextEditingController(); final crnController = TextEditingController(); - + var isSubmitting = false.obs; @override @@ -23,7 +23,7 @@ class AddCompanyController extends GetxController { Future submit() async { final name = nameController.text.trim(); final tin = tinController.text.trim(); - + if (name.isEmpty || tin.isEmpty) { AppSnackbar.showError('خطأ', 'الرجاء إدخال اسم الشركة والرقم الضريبي'); return; @@ -32,7 +32,7 @@ class AddCompanyController extends GetxController { try { isSubmitting.value = true; final dio = DioClient().client; - final response = await dio.post('companies', data: { + final response = await dio.post('companies/create', data: { 'name': name, 'tax_identification_number': tin, 'commercial_registration_number': crnController.text.trim(), @@ -40,17 +40,18 @@ class AddCompanyController extends GetxController { if (response.data['success'] == true) { AppSnackbar.showSuccess('نجاح', 'تمت إضافة الشركة بنجاح'); - + // Refresh list if controller exists if (Get.isRegistered()) { Get.find().fetchCompanies(); } - + Get.back(); } } on DioException catch (e) { if (e.response?.statusCode == 403) { - AppSnackbar.showError('خطأ', 'لقد وصلت للحد الأقصى المسموح به للشركات في باقتك'); + AppSnackbar.showError( + 'خطأ', 'لقد وصلت للحد الأقصى المسموح به للشركات في باقتك'); } else { AppSnackbar.showError('خطأ', 'تعذر إضافة الشركة'); } diff --git a/musadaq-app/lib/features/companies/views/companies_management_view.dart b/musadaq-app/lib/features/companies/views/companies_management_view.dart index 45ce219..685a0a9 100644 --- a/musadaq-app/lib/features/companies/views/companies_management_view.dart +++ b/musadaq-app/lib/features/companies/views/companies_management_view.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../controllers/companies_management_controller.dart'; import 'add_company_view.dart'; +import '../../../core/utils/app_snackbar.dart'; +import '../../../app/routes/app_pages.dart'; class CompaniesManagementView extends StatelessWidget { const CompaniesManagementView({super.key}); @@ -96,8 +98,10 @@ class CompaniesManagementView extends StatelessWidget { onSelected: (value) { if (value == 'delete') { _confirmDelete(context, controller, company['id']); + } else if (value == 'employees') { + Get.toNamed(AppRoutes.USERS_MANAGEMENT); } else { - Get.snackbar('قريباً', 'الواجهة قيد البرمجة'); + AppSnackbar.showInfo('قريباً', 'سيتم تفعيل هذه الميزة قريباً'); } }, ), @@ -117,7 +121,7 @@ class CompaniesManagementView extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton.icon( - onPressed: () => Get.snackbar('إحصائيات', 'عرض إحصائيات الشركة'), + onPressed: () => AppSnackbar.showInfo('إحصائيات', 'عرض إحصائيات الشركة'), icon: const Icon(Icons.bar_chart, size: 18), label: const Text('الإحصائيات'), ), diff --git a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart index 57033f2..02d39f5 100644 --- a/musadaq-app/lib/features/dashboard/views/dashboard_view.dart +++ b/musadaq-app/lib/features/dashboard/views/dashboard_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../../../app/routes/app_pages.dart'; import '../controllers/dashboard_controller.dart'; +import '../../../core/utils/app_snackbar.dart'; class DashboardView extends GetView { const DashboardView({super.key}); @@ -38,7 +39,7 @@ class DashboardView extends GetView { icon: const Icon(Icons.notifications_outlined, color: Colors.white), onPressed: () => - Get.snackbar('قريباً', 'الإشعارات ستتوفر قريباً'), + AppSnackbar.showInfo('قريباً', 'الإشعارات ستتوفر قريباً'), ), IconButton( icon: const Icon(Icons.refresh, color: Colors.white), @@ -69,6 +70,10 @@ class DashboardView extends GetView { _buildWelcomeHeader(role, isDark), const SizedBox(height: 24), _buildQuickActions(isDark), + if (role == 'admin' || role == 'super_admin') ...[ + const SizedBox(height: 24), + _buildAdminQuickActions(role, isDark), + ], const SizedBox(height: 32), const Text('إحصائيات الفواتير', style: TextStyle( @@ -185,6 +190,101 @@ class DashboardView extends GetView { ); } + Widget _buildAdminQuickActions(String role, bool isDark) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('إدارة الأعمال', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildAdminActionCard( + 'الشركات', + Icons.business_rounded, + Colors.indigo, + isDark, + () => Get.toNamed(AppRoutes.COMPANIES_MANAGEMENT), + ), + const SizedBox(width: 12), + _buildAdminActionCard( + 'الموظفين', + Icons.people_rounded, + Colors.purple, + isDark, + () => Get.toNamed(AppRoutes.USERS_MANAGEMENT), + ), + if (role == 'super_admin') ...[ + const SizedBox(width: 12), + _buildAdminActionCard( + 'المكاتب', + Icons.account_balance_rounded, + Colors.teal, + isDark, + () => Get.toNamed(AppRoutes.TENANTS_MANAGEMENT), + ), + ], + const SizedBox(width: 12), + _buildAdminActionCard( + 'الاشتراك', + Icons.workspace_premium_rounded, + const Color(0xFFD4AF37), + isDark, + () => Get.toNamed(AppRoutes.SUBSCRIPTION), + ), + ], + ), + ), + ], + ); + } + + Widget _buildAdminActionCard(String title, IconData icon, Color color, + bool isDark, VoidCallback onTap) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + width: 100, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: + Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + boxShadow: [ + if (!isDark) + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 24), + ), + const SizedBox(height: 10), + Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + Widget _buildInvoiceStats(Map stats, bool isDark) { final inv = stats['invoices'] ?? {'total': 0, 'pending': 0, 'approved': 0}; return Row( diff --git a/musadaq-app/lib/features/settings/views/settings_view.dart b/musadaq-app/lib/features/settings/views/settings_view.dart index 64b46c9..c7b1afa 100644 --- a/musadaq-app/lib/features/settings/views/settings_view.dart +++ b/musadaq-app/lib/features/settings/views/settings_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../controllers/settings_controller.dart'; import '../../../app/routes/app_pages.dart'; +import '../../../core/utils/app_snackbar.dart'; class SettingsView extends GetView { const SettingsView({super.key}); @@ -79,20 +80,43 @@ class SettingsView extends GetView { controller.userRole.value == 'super_admin') { return Column( children: [ - _buildSectionTitle('إدارة المكتب', - Icons.admin_panel_settings_rounded, isDark), + _buildSectionTitle('إدارة الأعمال والصلاحيات', + Icons.admin_panel_settings_rounded, isDark, + showBackground: true), const SizedBox(height: 8), - _buildSettingsCard(isDark, [ - _buildInfoTile( - icon: Icons.business_rounded, - title: 'الشركات والموظفين', - trailing: 'إدارة →', - isDark: isDark, - onTap: () { - Get.toNamed(AppRoutes.COMPANIES_MANAGEMENT); - }, - ), - ]), + _buildSettingsCard(isDark, [ + _buildInfoTile( + icon: Icons.business_rounded, + title: 'الشركات والموظفين', + trailing: 'إدارة →', + isDark: isDark, + onTap: () { + Get.toNamed(AppRoutes.COMPANIES_MANAGEMENT); + }, + ), + if (controller.userRole.value == 'super_admin') ...[ + const Divider(height: 1), + _buildInfoTile( + icon: Icons.account_balance_rounded, + title: 'المكاتب المحاسبية', + trailing: 'إدارة →', + isDark: isDark, + onTap: () { + Get.toNamed(AppRoutes.TENANTS_MANAGEMENT); + }, + ), + ], + const Divider(height: 1), + _buildInfoTile( + icon: Icons.group_rounded, + title: 'مستخدمي النظام', + trailing: 'إدارة →', + isDark: isDark, + onTap: () { + Get.toNamed(AppRoutes.USERS_MANAGEMENT); + }, + ), + ]), const SizedBox(height: 20), ], ); @@ -236,17 +260,30 @@ class SettingsView extends GetView { ); } - Widget _buildSectionTitle(String title, IconData icon, bool isDark) { - return Row( - children: [ - Icon(icon, size: 18, color: const Color(0xFF0F4C81)), - const SizedBox(width: 8), - Text(title, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: isDark ? Colors.white70 : const Color(0xFF0F4C81))), - ], + Widget _buildSectionTitle(String title, IconData icon, bool isDark, + {bool showBackground = false}) { + return Container( + padding: showBackground + ? const EdgeInsets.symmetric(horizontal: 12, vertical: 8) + : null, + decoration: showBackground + ? BoxDecoration( + color: const Color(0xFF0F4C81).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ) + : null, + child: Row( + children: [ + Icon(icon, + size: 18, color: showBackground ? const Color(0xFF0F4C81) : const Color(0xFF0F4C81)), + const SizedBox(width: 8), + Text(title, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white70 : const Color(0xFF0F4C81))), + ], + ), ); } @@ -370,7 +407,7 @@ class SettingsView extends GetView { buttonColor: const Color(0xFFDC2626), onConfirm: () { Get.back(); - Get.snackbar('قريباً', 'سيتم تفعيل هذه الميزة قريباً'); + AppSnackbar.showInfo('قريباً', 'سيتم تفعيل هذه الميزة قريباً'); }, titleStyle: const TextStyle(fontWeight: FontWeight.bold), radius: 14, diff --git a/musadaq-app/lib/features/tenants/controllers/add_tenant_controller.dart b/musadaq-app/lib/features/tenants/controllers/add_tenant_controller.dart new file mode 100644 index 0000000..d9d9625 --- /dev/null +++ b/musadaq-app/lib/features/tenants/controllers/add_tenant_controller.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:dio/dio.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/utils/app_snackbar.dart'; +import '../../../core/utils/logger.dart'; +import 'tenants_management_controller.dart'; + +class AddTenantController extends GetxController { + final nameController = TextEditingController(); + final emailController = TextEditingController(); + final managerNameController = TextEditingController(); + final managerEmailController = TextEditingController(); + final managerPasswordController = TextEditingController(); + + var isSubmitting = false.obs; + final Dio _dio = DioClient().client; + + @override + void onClose() { + nameController.dispose(); + emailController.dispose(); + managerNameController.dispose(); + managerEmailController.dispose(); + managerPasswordController.dispose(); + super.onClose(); + } + + Future submit() async { + final name = nameController.text.trim(); + final email = emailController.text.trim(); + final managerName = managerNameController.text.trim(); + final managerEmail = managerEmailController.text.trim(); + final managerPassword = managerPasswordController.text; + + if (name.isEmpty || email.isEmpty || managerName.isEmpty || managerEmail.isEmpty || managerPassword.isEmpty) { + AppSnackbar.showWarning('تنبيه', 'الرجاء إدخال جميع البيانات المطلوبة'); + return; + } + + try { + isSubmitting.value = true; + final response = await _dio.post('tenants/create', data: { + 'name': name, + 'email': email, + 'manager_name': managerName, + 'manager_email': managerEmail, + 'manager_password': managerPassword, + }); + + if (response.statusCode == 200 || response.statusCode == 201) { + AppSnackbar.showSuccess('نجاح', 'تم إضافة المكتب المحاسبي بنجاح'); + + // Refresh the list if it exists + if (Get.isRegistered()) { + Get.find().fetchTenants(); + } + + Get.back(); + } else { + AppSnackbar.showError('خطأ', 'فشل إضافة المكتب المحاسبي'); + } + } catch (e) { + AppLogger.error('Failed to create tenant', e); + AppSnackbar.showError('خطأ', 'تعذر إضافة المكتب المحاسبي، يرجى المحاولة لاحقاً'); + } finally { + isSubmitting.value = false; + } + } +} diff --git a/musadaq-app/lib/features/tenants/controllers/tenants_management_controller.dart b/musadaq-app/lib/features/tenants/controllers/tenants_management_controller.dart new file mode 100644 index 0000000..3bffeb9 --- /dev/null +++ b/musadaq-app/lib/features/tenants/controllers/tenants_management_controller.dart @@ -0,0 +1,33 @@ +import 'package:get/get.dart'; +import 'package:dio/dio.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/utils/app_snackbar.dart'; +import '../../../core/utils/logger.dart'; + +class TenantsManagementController extends GetxController { + final Dio _dio = DioClient().client; + + var isLoading = true.obs; + var tenants = >[].obs; + + @override + void onInit() { + super.onInit(); + fetchTenants(); + } + + Future fetchTenants() async { + try { + isLoading.value = true; + final response = await _dio.get('tenants'); + if (response.data['success'] == true) { + tenants.value = List>.from(response.data['data']); + } + } catch (e) { + AppLogger.error('Failed to fetch tenants', e); + AppSnackbar.showError('خطأ', 'تعذر تحميل المكاتب المحاسبية'); + } finally { + isLoading.value = false; + } + } +} diff --git a/musadaq-app/lib/features/tenants/views/add_tenant_view.dart b/musadaq-app/lib/features/tenants/views/add_tenant_view.dart new file mode 100644 index 0000000..d2b4e5a --- /dev/null +++ b/musadaq-app/lib/features/tenants/views/add_tenant_view.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/add_tenant_controller.dart'; + +class AddTenantView extends StatelessWidget { + const AddTenantView({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(AddTenantController()); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: const Text('إضافة مكتب محاسبي', style: TextStyle(fontFamily: 'El Messiri')), + centerTitle: true, + backgroundColor: const Color(0xFF0F4C81), + foregroundColor: Colors.white, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'بيانات المكتب الأساسية', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 24), + _buildTextField( + controller: controller.nameController, + label: 'اسم المكتب', + icon: Icons.account_balance, + isDark: isDark, + ), + const SizedBox(height: 16), + _buildTextField( + controller: controller.emailController, + label: 'البريد الإلكتروني للمكتب', + icon: Icons.email, + keyboardType: TextInputType.emailAddress, + isDark: isDark, + ), + const SizedBox(height: 24), + const Text( + 'بيانات مدير المكتب', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + _buildTextField( + controller: controller.managerNameController, + label: 'اسم المدير', + icon: Icons.person, + isDark: isDark, + ), + const SizedBox(height: 16), + _buildTextField( + controller: controller.managerEmailController, + label: 'البريد الإلكتروني للمدير', + icon: Icons.alternate_email, + keyboardType: TextInputType.emailAddress, + isDark: isDark, + ), + const SizedBox(height: 16), + _buildTextField( + controller: controller.managerPasswordController, + label: 'كلمة مرور المدير', + icon: Icons.lock, + isPassword: true, + isDark: isDark, + ), + const SizedBox(height: 40), + SizedBox( + width: double.infinity, + height: 54, + child: Obx( + () => ElevatedButton( + onPressed: controller.isSubmitting.value ? null : controller.submit, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0F4C81), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + child: controller.isSubmitting.value + ? const CircularProgressIndicator(color: Colors.white) + : const Text( + 'حفظ وإضافة', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required IconData icon, + TextInputType? keyboardType, + bool isPassword = false, + required bool isDark, + }) { + return TextField( + controller: controller, + keyboardType: keyboardType, + obscureText: isPassword, + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon, color: const Color(0xFF0F4C81)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: isDark ? Colors.white24 : Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: isDark ? Colors.white24 : Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF0F4C81), width: 2), + ), + filled: true, + fillColor: isDark ? Colors.white.withValues(alpha: 0.05) : Colors.white, + ), + ); + } +} diff --git a/musadaq-app/lib/features/tenants/views/tenants_management_view.dart b/musadaq-app/lib/features/tenants/views/tenants_management_view.dart new file mode 100644 index 0000000..891be3a --- /dev/null +++ b/musadaq-app/lib/features/tenants/views/tenants_management_view.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/tenants_management_controller.dart'; +import 'add_tenant_view.dart'; + +class TenantsManagementView extends StatelessWidget { + const TenantsManagementView({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(TenantsManagementController()); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: const Text('إدارة المكاتب المحاسبية', style: TextStyle(fontFamily: 'El Messiri')), + centerTitle: true, + backgroundColor: const Color(0xFF0F4C81), + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () { + Get.to(() => const AddTenantView()); + }, + ), + ], + ), + body: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.tenants.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.account_balance_outlined, size: 80, color: Colors.grey.shade400), + const SizedBox(height: 16), + const Text('لا يوجد مكاتب محاسبية مسجلة', style: TextStyle(fontSize: 18, color: Colors.grey)), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: controller.fetchTenants, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: controller.tenants.length, + itemBuilder: (context, index) { + final tenant = controller.tenants[index]; + return Card( + elevation: 2, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + leading: CircleAvatar( + backgroundColor: const Color(0xFF0F4C81).withValues(alpha: 0.1), + child: const Icon(Icons.account_balance, color: Color(0xFF0F4C81)), + ), + title: Text( + tenant['name'] ?? 'مكتب محاسبي', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(tenant['email'] ?? ''), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF0F4C81).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + tenant['status'] ?? 'active', + style: const TextStyle(color: Color(0xFF0F4C81), fontSize: 12), + ), + ), + ), + ); + }, + ), + ); + }), + ); + } +} diff --git a/musadaq-app/lib/features/users/controllers/add_user_controller.dart b/musadaq-app/lib/features/users/controllers/add_user_controller.dart new file mode 100644 index 0000000..c7acb4e --- /dev/null +++ b/musadaq-app/lib/features/users/controllers/add_user_controller.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:dio/dio.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/utils/app_snackbar.dart'; +import '../../../core/utils/logger.dart'; +import '../../../core/storage/secure_storage.dart'; +import 'users_management_controller.dart'; + +class AddUserController extends GetxController { + final nameController = TextEditingController(); + final emailController = TextEditingController(); + final phoneController = TextEditingController(); + + var selectedRole = 'accountant'.obs; + var isSubmitting = false.obs; + + var isSuperAdmin = false.obs; + var tenants = >[].obs; + var selectedTenantId = RxnString(); + var isLoadingTenants = false.obs; + + final Dio _dio = DioClient().client; + final SecureStorage _storage = SecureStorage(); + + @override + void onInit() { + super.onInit(); + _checkRoleAndFetchTenants(); + } + + Future _checkRoleAndFetchTenants() async { + final role = await _storage.read('user_role'); + if (role == 'super_admin') { + isSuperAdmin.value = true; + _fetchTenants(); + } + } + + Future _fetchTenants() async { + try { + isLoadingTenants.value = true; + final response = await _dio.get('tenants'); + if (response.data['success'] == true) { + tenants.value = List>.from(response.data['data']); + if (tenants.isNotEmpty) { + selectedTenantId.value = tenants.first['id']; + } + } + } catch (e) { + AppLogger.error('Failed to fetch tenants for user creation', e); + } finally { + isLoadingTenants.value = false; + } + } + + @override + void onClose() { + nameController.dispose(); + emailController.dispose(); + phoneController.dispose(); + super.onClose(); + } + + Future submit() async { + final name = nameController.text.trim(); + final email = emailController.text.trim(); + final phone = phoneController.text.trim(); + + if (name.isEmpty || email.isEmpty) { + AppSnackbar.showWarning('تنبيه', 'الرجاء إدخال جميع البيانات الأساسية'); + return; + } + + if (isSuperAdmin.value && selectedTenantId.value == null) { + AppSnackbar.showWarning('تنبيه', 'الرجاء اختيار المكتب المحاسبي'); + return; + } + + try { + isSubmitting.value = true; + final data = { + 'name': name, + 'email': email, + 'phone': phone, + 'role': selectedRole.value, + 'password': 'password123', // Default temporary password + }; + + if (isSuperAdmin.value) { + data['tenant_id'] = selectedTenantId.value!; + } + + final response = await _dio.post('users/create', data: data); + + if (response.statusCode == 200 || response.statusCode == 201) { + AppSnackbar.showSuccess('نجاح', 'تم إضافة الموظف بنجاح'); + + // Refresh the list if it exists + if (Get.isRegistered()) { + Get.find().fetchUsers(); + } + + Get.back(); + } else { + AppSnackbar.showError('خطأ', 'فشل إضافة الموظف'); + } + } catch (e) { + AppLogger.error('Failed to create user', e); + AppSnackbar.showError('خطأ', 'تعذر إضافة الموظف، يرجى المحاولة لاحقاً'); + } finally { + isSubmitting.value = false; + } + } +} diff --git a/musadaq-app/lib/features/users/controllers/users_management_controller.dart b/musadaq-app/lib/features/users/controllers/users_management_controller.dart new file mode 100644 index 0000000..22740f5 --- /dev/null +++ b/musadaq-app/lib/features/users/controllers/users_management_controller.dart @@ -0,0 +1,33 @@ +import 'package:get/get.dart'; +import 'package:dio/dio.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/utils/app_snackbar.dart'; +import '../../../core/utils/logger.dart'; + +class UsersManagementController extends GetxController { + final Dio _dio = DioClient().client; + + var isLoading = true.obs; + var users = >[].obs; + + @override + void onInit() { + super.onInit(); + fetchUsers(); + } + + Future fetchUsers() async { + try { + isLoading.value = true; + final response = await _dio.get('users'); + if (response.data['success'] == true) { + users.value = List>.from(response.data['data']); + } + } catch (e) { + AppLogger.error('Failed to fetch users', e); + AppSnackbar.showError('خطأ', 'تعذر تحميل قائمة الموظفين'); + } finally { + isLoading.value = false; + } + } +} diff --git a/musadaq-app/lib/features/users/views/add_user_view.dart b/musadaq-app/lib/features/users/views/add_user_view.dart new file mode 100644 index 0000000..bf7e2fe --- /dev/null +++ b/musadaq-app/lib/features/users/views/add_user_view.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/add_user_controller.dart'; + +class AddUserView extends StatelessWidget { + const AddUserView({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(AddUserController()); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: const Text('إضافة موظف', style: TextStyle(fontFamily: 'El Messiri')), + centerTitle: true, + backgroundColor: const Color(0xFF0F4C81), + foregroundColor: Colors.white, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'بيانات الموظف الأساسية', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 24), + _buildTextField( + controller: controller.nameController, + label: 'اسم الموظف', + icon: Icons.person, + isDark: isDark, + ), + const SizedBox(height: 16), + _buildTextField( + controller: controller.emailController, + label: 'البريد الإلكتروني', + icon: Icons.email, + keyboardType: TextInputType.emailAddress, + isDark: isDark, + ), + const SizedBox(height: 16), + _buildTextField( + controller: controller.phoneController, + label: 'رقم الهاتف', + icon: Icons.phone, + keyboardType: TextInputType.phone, + isDark: isDark, + ), + const SizedBox(height: 16), + const Text('صلاحية الموظف', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Obx(() => Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: isDark ? Colors.white.withValues(alpha: 0.05) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: isDark ? Colors.white24 : Colors.grey.shade300), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: controller.selectedRole.value, + isExpanded: true, + dropdownColor: isDark ? const Color(0xFF1E1E2E) : Colors.white, + items: const [ + DropdownMenuItem(value: 'admin', child: Text('مدير مكتب')), + DropdownMenuItem(value: 'accountant', child: Text('محاسب')), + DropdownMenuItem(value: 'viewer', child: Text('مشاهد')), + ], + onChanged: (val) { + if (val != null) controller.selectedRole.value = val; + }, + ), + ), + )), + Obx(() { + if (controller.isSuperAdmin.value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + const Text('المكتب المحاسبي', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + if (controller.isLoadingTenants.value) + const CircularProgressIndicator() + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: isDark ? Colors.white.withValues(alpha: 0.05) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: isDark ? Colors.white24 : Colors.grey.shade300), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: controller.selectedTenantId.value, + isExpanded: true, + dropdownColor: isDark ? const Color(0xFF1E1E2E) : Colors.white, + items: controller.tenants.map((tenant) { + return DropdownMenuItem( + value: tenant['id'], + child: Text(tenant['name'] ?? 'مكتب غير معروف'), + ); + }).toList(), + onChanged: (val) { + if (val != null) controller.selectedTenantId.value = val; + }, + ), + ), + ), + ], + ); + } + return const SizedBox.shrink(); + }), + const SizedBox(height: 40), + SizedBox( + width: double.infinity, + height: 54, + child: Obx( + () => ElevatedButton( + onPressed: controller.isSubmitting.value ? null : controller.submit, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0F4C81), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + child: controller.isSubmitting.value + ? const CircularProgressIndicator(color: Colors.white) + : const Text( + 'حفظ وإضافة', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required IconData icon, + TextInputType? keyboardType, + required bool isDark, + }) { + return TextField( + controller: controller, + keyboardType: keyboardType, + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon, color: const Color(0xFF0F4C81)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: isDark ? Colors.white24 : Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: isDark ? Colors.white24 : Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF0F4C81), width: 2), + ), + filled: true, + fillColor: isDark ? Colors.white.withValues(alpha: 0.05) : Colors.white, + ), + ); + } +} diff --git a/musadaq-app/lib/features/users/views/users_management_view.dart b/musadaq-app/lib/features/users/views/users_management_view.dart new file mode 100644 index 0000000..9d9e052 --- /dev/null +++ b/musadaq-app/lib/features/users/views/users_management_view.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/users_management_controller.dart'; +import 'add_user_view.dart'; + +class UsersManagementView extends StatelessWidget { + const UsersManagementView({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(UsersManagementController()); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: const Text('إدارة مستخدمي النظام', style: TextStyle(fontFamily: 'El Messiri')), + centerTitle: true, + backgroundColor: const Color(0xFF0F4C81), + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () { + Get.to(() => const AddUserView()); + }, + ), + ], + ), + body: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.users.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.people_outline, size: 80, color: Colors.grey.shade400), + const SizedBox(height: 16), + const Text('لا يوجد موظفين مسجلين', style: TextStyle(fontSize: 18, color: Colors.grey)), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: controller.fetchUsers, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: controller.users.length, + itemBuilder: (context, index) { + final user = controller.users[index]; + return Card( + elevation: 2, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + leading: CircleAvatar( + backgroundColor: const Color(0xFF0F4C81).withValues(alpha: 0.1), + child: const Icon(Icons.person, color: Color(0xFF0F4C81)), + ), + title: Text( + user['name'] ?? 'مستخدم', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(user['email'] ?? ''), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF10B981).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + user['role'] ?? '', + style: const TextStyle(color: Color(0xFF10B981), fontSize: 12), + ), + ), + ), + ); + }, + ), + ); + }), + ); + } +} diff --git a/public/shell.php b/public/shell.php index 01a4a93..e4705f7 100644 --- a/public/shell.php +++ b/public/shell.php @@ -1377,9 +1377,13 @@
📄 -

إدارة الفواتير

- -
+

إدارة الفواتير

+ +
+ +
@@ -2211,6 +2215,42 @@ + + + + @@ -2222,9 +2262,10 @@ users: [], companies: [], invoices: [], tenants: [], subscription: null, plans: [], stats: { total: 0, pending: 0, approved: 0 }, - showAddUserModal: false, showAddCompanyModal: false, showConnectModal: false, - showUploadModal: false, showViewModal: false, showCompanyStatsModal: false, - showAddTenantModal: false, showEditTenantModal: false, showTenantStatsModal: false, + showAddUserModal: false, showAddCompanyModal: false, showConnectModal: false, + showUploadModal: false, showViewModal: false, showCompanyStatsModal: false, + showExcelModal: false, + showAddTenantModal: false, showEditTenantModal: false, showTenantStatsModal: false, acknowledgedWarnings: false, isBusy: false, globalError: '', @@ -2451,7 +2492,40 @@ this.isBusy = false; this.showError('حدث خطأ أثناء الاعتماد'); } - }, + }, + + async uploadExcel() { + const fileInput = document.getElementById('excelFileInput'); + if (!fileInput.files[0]) return alert('الرجاء اختيار ملف اكسل'); + if (!this.uploadData.company_id) return alert('الرجاء اختيار الشركة'); + + this.isBusy = true; + const formData = new FormData(); + formData.append('company_id', this.uploadData.company_id); + formData.append('file', fileInput.files[0]); + + try { + const res = await fetch('/index.php?route=v1/excel/import', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + this.token() }, + body: formData + }); + const json = await res.json(); + this.isBusy = false; + + if (json.success) { + alert(json.message); + this.showExcelModal = false; + fileInput.value = ''; + this.loadAll(); + } else { + this.showError(json.message); + } + } catch (e) { + this.isBusy = false; + this.showError('فشل الاتصال بالخادم أثناء استيراد الاكسل'); + } + }, logout() { localStorage.clear(); window.location.href = '/login.php'; } }));