diff --git a/app/Core/AI.php b/app/Core/AI.php index cf1b9cc..0f69e47 100644 --- a/app/Core/AI.php +++ b/app/Core/AI.php @@ -103,6 +103,71 @@ class AI return null; } + // Track token usage from Gemini response + $usage = $result['usageMetadata'] ?? []; + if (!empty($usage)) { + self::logTokenUsage($usage); + } + return $data; } + + /** + * Log AI token usage to the database for cost tracking + */ + private static function logTokenUsage(array $usage): void + { + try { + $db = Database::getInstance(); + + $inputTokens = (int)($usage['promptTokenCount'] ?? 0); + $outputTokens = (int)($usage['candidatesTokenCount'] ?? 0); + $totalTokens = (int)($usage['totalTokenCount'] ?? ($inputTokens + $outputTokens)); + + // Gemini Flash Lite pricing: $0.075/1M input, $0.30/1M output + $inputCost = ($inputTokens / 1000000) * 0.075; + $outputCost = ($outputTokens / 1000000) * 0.30; + $totalCostUsd = $inputCost + $outputCost; + $totalCostJod = $totalCostUsd * 0.709; // 1 USD ≈ 0.709 JOD + + $db->prepare(" + INSERT INTO ai_usage_log (id, input_tokens, output_tokens, total_tokens, cost_usd, cost_jod, model, created_at) + VALUES (UUID(), ?, ?, ?, ?, ?, 'gemini-flash-lite', NOW()) + ")->execute([ + $inputTokens, + $outputTokens, + $totalTokens, + round($totalCostUsd, 8), + round($totalCostJod, 8), + ]); + } catch (\Exception $e) { + // Never crash the main flow for logging + error_log("[AI] Token usage log failed: " . $e->getMessage()); + } + } + + /** + * Get aggregated AI usage stats + */ + public static function getUsageStats(?string $tenantId = null): array + { + try { + $db = Database::getInstance(); + $stmt = $db->query(" + SELECT + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(total_tokens), 0) as total_tokens, + COALESCE(SUM(cost_usd), 0) as total_cost_usd, + COALESCE(SUM(cost_jod), 0) as total_cost_jod, + COALESCE(AVG(total_tokens), 0) as avg_tokens_per_request, + COALESCE(AVG(cost_jod), 0) as avg_cost_jod_per_request + FROM ai_usage_log + "); + return $stmt->fetch() ?: []; + } catch (\Exception $e) { + return []; + } + } } diff --git a/app/modules_app/dashboard/ai_usage.php b/app/modules_app/dashboard/ai_usage.php new file mode 100644 index 0000000..83962a7 --- /dev/null +++ b/app/modules_app/dashboard/ai_usage.php @@ -0,0 +1,80 @@ +query(" + SELECT + COUNT(*) as requests, + COALESCE(SUM(total_tokens), 0) as tokens, + COALESCE(SUM(cost_jod), 0) as cost_jod + FROM ai_usage_log + WHERE DATE(created_at) = CURDATE() + "); + $today = $todayStmt->fetch(); + + // This month + $monthStmt = $db->query(" + SELECT + COUNT(*) as requests, + COALESCE(SUM(total_tokens), 0) as tokens, + COALESCE(SUM(cost_jod), 0) as cost_jod + FROM ai_usage_log + WHERE MONTH(created_at) = MONTH(NOW()) AND YEAR(created_at) = YEAR(NOW()) + "); + $month = $monthStmt->fetch(); + + // Daily breakdown (last 30 days) + $dailyStmt = $db->query(" + SELECT + DATE(created_at) as date, + COUNT(*) as requests, + SUM(total_tokens) as tokens, + SUM(cost_jod) as cost_jod + FROM ai_usage_log + WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) + GROUP BY DATE(created_at) + ORDER BY date DESC + "); + $daily = $dailyStmt->fetchAll(); + + json_success([ + 'overall' => [ + 'total_requests' => (int)($overall['total_requests'] ?? 0), + 'total_tokens' => (int)($overall['total_tokens'] ?? 0), + 'total_cost_usd' => round((float)($overall['total_cost_usd'] ?? 0), 4), + 'total_cost_jod' => round((float)($overall['total_cost_jod'] ?? 0), 4), + 'avg_tokens_per_invoice' => round((float)($overall['avg_tokens_per_request'] ?? 0)), + 'avg_cost_per_invoice_jod' => round((float)($overall['avg_cost_jod_per_request'] ?? 0), 6), + ], + 'today' => [ + 'requests' => (int)($today['requests'] ?? 0), + 'tokens' => (int)($today['tokens'] ?? 0), + 'cost_jod' => round((float)($today['cost_jod'] ?? 0), 4), + ], + 'this_month' => [ + 'requests' => (int)($month['requests'] ?? 0), + 'tokens' => (int)($month['tokens'] ?? 0), + 'cost_jod' => round((float)($month['cost_jod'] ?? 0), 4), + ], + 'daily_breakdown' => $daily, + ], 'إحصائيات استخدام الذكاء الاصطناعي'); + +} catch (\Exception $e) { + error_log("AI Usage Stats Error: " . $e->getMessage()); + json_error('خطأ في جلب إحصائيات AI', 500); +} diff --git a/musadaq-app/ios/Podfile.lock b/musadaq-app/ios/Podfile.lock index 5f7cbab..03bb441 100644 --- a/musadaq-app/ios/Podfile.lock +++ b/musadaq-app/ios/Podfile.lock @@ -125,6 +125,8 @@ PODS: - SDWebImageWebPCoder (0.15.0): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) + - share_plus (0.0.1): + - Flutter - speech_to_text (7.2.0): - CwlCatchException - Flutter @@ -151,6 +153,7 @@ DEPENDENCIES: - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - printing (from `.symlinks/plugins/printing/ios`) - record_ios (from `.symlinks/plugins/record_ios/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) - speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) @@ -208,6 +211,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/printing/ios" record_ios: :path: ".symlinks/plugins/record_ios/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" speech_to_text: :path: ".symlinks/plugins/speech_to_text/darwin" sqflite_darwin: @@ -247,6 +252,7 @@ SPEC CHECKSUMS: record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 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 c614935..1e02ba1 100644 --- a/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart +++ b/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart @@ -67,6 +67,79 @@ class InvoiceDetailController extends GetxController { } Future approveInvoice() async { + // First check for duplicates + try { + final dupRes = await DioClient().client.post('invoices/check-duplicate', data: { + 'invoice_number': invoice['invoice_number'], + 'supplier_tin': invoice['supplier_tin'], + 'grand_total': invoice['grand_total'], + 'invoice_date': invoice['invoice_date'], + 'exclude_id': invoiceId, + }); + + if (dupRes.data['success'] == true) { + final matches = dupRes.data['data']?['matches'] as List? ?? []; + if (matches.isNotEmpty) { + // Show duplicate warning + final proceed = await Get.dialog( + AlertDialog( + title: const Row( + children: [ + Icon(Icons.warning_amber, color: Colors.orange, size: 28), + SizedBox(width: 8), + Text('تحذير — فاتورة مكررة!', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('تم العثور على ${matches.length} فاتورة مشابهة:', style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + ...matches.take(3).map((m) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('رقم: ${m['invoice_number'] ?? '—'}', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + Text('المورد: ${m['supplier_name'] ?? '—'}', style: const TextStyle(fontSize: 12)), + Text('المبلغ: ${m['grand_total'] ?? '—'} | نوع التطابق: ${m['match_type'] ?? '—'}', style: const TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + )), + const SizedBox(height: 8), + const Text('هل تريد الاستمرار بالاعتماد؟', style: TextStyle(fontWeight: FontWeight.w600)), + ], + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('إلغاء'), + ), + ElevatedButton( + onPressed: () => Get.back(result: true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), + child: const Text('اعتماد رغم التكرار', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + + if (proceed != true) return; + } + } + } catch (e) { + // If duplicate check fails, proceed with approval anyway + AppLogger.error('Duplicate check failed (proceeding)', e); + } + + // Proceed with approval try { final res = await DioClient() .client @@ -130,13 +203,16 @@ class InvoiceDetailController extends GetxController { final bytes = List.from(res.data); await file.writeAsBytes(bytes); - // Try share, fallback to success message + // Share via native sheet (share_plus v12 API) try { - await Share.shareXFiles( - [XFile(file.path, mimeType: 'text/csv', name: fileName)], - subject: 'تصدير فواتير مُصادَق', + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path, mimeType: 'text/csv', name: fileName)], + title: 'تصدير فواتير مُصادَق', + ), ); - } catch (_) { + } catch (shareErr) { + AppLogger.error('Share fallback', shareErr); AppSnackbar.showSuccess('تم الحفظ', 'تم حفظ الملف: ${file.path}'); } } catch (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 72a3ad7..ceb30c5 100644 --- a/musadaq-app/lib/features/invoices/views/invoices_list_view.dart +++ b/musadaq-app/lib/features/invoices/views/invoices_list_view.dart @@ -19,7 +19,11 @@ class InvoicesListView extends GetView { children: [ // App Bar replacement Container( - padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top, left: 8, right: 8, bottom: 12), + 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: [ @@ -28,7 +32,10 @@ class InvoicesListView extends GetView { child: Center( child: Text( 'الفواتير', - style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold), ), ), ), @@ -52,13 +59,50 @@ class InvoicesListView extends GetView { } }, itemBuilder: (context) => [ - PopupMenuItem(value: 'select', child: Row(children: [Icon(controller.isSelecting.value ? Icons.close : Icons.checklist, size: 18), const SizedBox(width: 8), Text(controller.isSelecting.value ? 'إلغاء التحديد' : 'تحديد متعدد')])), + PopupMenuItem( + value: 'select', + child: Row(children: [ + Icon( + controller.isSelecting.value + ? Icons.close + : Icons.checklist, + size: 18), + const SizedBox(width: 8), + Text(controller.isSelecting.value + ? 'إلغاء التحديد' + : 'تحديد متعدد') + ])), if (controller.isSelecting.value) ...[ - const PopupMenuItem(value: 'select_all', child: Row(children: [Icon(Icons.select_all, size: 18), SizedBox(width: 8), Text('تحديد الكل (المستخرجة)')])), - const PopupMenuItem(value: 'bulk_approve', child: Row(children: [Icon(Icons.check_circle, size: 18, color: Color(0xFF10B981)), SizedBox(width: 8), Text('اعتماد المحدد')])), + const PopupMenuItem( + value: 'select_all', + child: Row(children: [ + Icon(Icons.select_all, size: 18), + SizedBox(width: 8), + Text('تحديد الكل (المستخرجة)') + ])), + const PopupMenuItem( + value: 'bulk_approve', + child: Row(children: [ + Icon(Icons.check_circle, + size: 18, color: Color(0xFF10B981)), + SizedBox(width: 8), + Text('اعتماد المحدد') + ])), ], - const PopupMenuItem(value: 'report', child: Row(children: [Icon(Icons.bar_chart, size: 18), SizedBox(width: 8), Text('التقرير الضريبي')])), - const PopupMenuItem(value: 'export', child: Row(children: [Icon(Icons.file_download, size: 18), SizedBox(width: 8), Text('تصدير Excel')])), + const PopupMenuItem( + value: 'report', + child: Row(children: [ + Icon(Icons.bar_chart, size: 18), + SizedBox(width: 8), + Text('التقرير الضريبي') + ])), + const PopupMenuItem( + value: 'export', + child: Row(children: [ + Icon(Icons.file_download, size: 18), + SizedBox(width: 8), + Text('تصدير Excel') + ])), ], ), IconButton( @@ -71,29 +115,31 @@ class InvoicesListView extends GetView { // Search Bar Obx(() => AnimatedContainer( - duration: const Duration(milliseconds: 300), - height: controller.isSearching.value ? 64 : 0, - child: controller.isSearching.value - ? Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( - onChanged: (v) => controller.searchQuery.value = v, - textDirection: TextDirection.rtl, - decoration: InputDecoration( - hintText: 'بحث بالرقم أو اسم المورد...', - prefixIcon: const Icon(Icons.search, size: 20), - filled: true, - fillColor: isDark ? Colors.white10 : Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, + duration: const Duration(milliseconds: 300), + height: controller.isSearching.value ? 64 : 0, + child: controller.isSearching.value + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + child: TextField( + onChanged: (v) => controller.searchQuery.value = v, + textDirection: TextDirection.rtl, + decoration: InputDecoration( + hintText: 'بحث بالرقم أو اسم المورد...', + prefixIcon: const Icon(Icons.search, size: 20), + filled: true, + fillColor: isDark ? Colors.white10 : Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16), + ), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - ), - ), - ) - : const SizedBox(), - )), + ) + : const SizedBox(), + )), // Filter Tabs Container( @@ -128,9 +174,11 @@ class InvoicesListView extends GetView { return RefreshIndicator( onRefresh: () async => controller.loadInvoices(), child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), itemCount: invoices.length, - itemBuilder: (context, index) => _buildInvoiceCard(invoices[index], isDark), + itemBuilder: (context, index) => + _buildInvoiceCard(invoices[index], isDark), ), ); }), @@ -148,23 +196,30 @@ class InvoicesListView extends GetView { children: [ Text( '${controller.selectedIds.length} محدد', - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15), ), const Spacer(), TextButton.icon( onPressed: () => controller.selectAllExtracted(), - icon: const Icon(Icons.select_all, color: Colors.white, size: 18), - label: const Text('الكل', style: TextStyle(color: Colors.white)), + icon: const Icon(Icons.select_all, + color: Colors.white, size: 18), + label: + const Text('الكل', style: TextStyle(color: Colors.white)), ), const SizedBox(width: 8), ElevatedButton.icon( onPressed: () => controller.bulkApprove(), icon: const Icon(Icons.check_circle, size: 18), - label: const Text('اعتماد', style: TextStyle(fontWeight: FontWeight.bold)), + label: const Text('اعتماد', + style: TextStyle(fontWeight: FontWeight.bold)), style: ElevatedButton.styleFrom( backgroundColor: Colors.white, foregroundColor: const Color(0xFF10B981), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), ), ), ], @@ -175,7 +230,8 @@ class InvoicesListView extends GetView { ); } - Widget _buildFilterChip(String label, String value, InvoicesController ctrl, bool isDark) { + Widget _buildFilterChip( + String label, String value, InvoicesController ctrl, bool isDark) { return Obx(() { final isSelected = ctrl.filterStatus.value == value; return Padding( @@ -187,12 +243,16 @@ class InvoicesListView extends GetView { selectedColor: const Color(0xFF0F4C81), backgroundColor: isDark ? Colors.white10 : Colors.white, labelStyle: TextStyle( - color: isSelected ? Colors.white : (isDark ? Colors.white70 : Colors.black87), + 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), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + side: BorderSide( + color: isSelected ? Colors.transparent : Colors.grey.shade300), ), ); }); @@ -229,108 +289,126 @@ class InvoicesListView extends GetView { return Obx(() { final isSelected = controller.selectedIds.contains(inv['id']); return Card( - margin: const EdgeInsets.only(bottom: 12), - elevation: 0, - color: isSelected ? statusColor.withValues(alpha: 0.05) : (isDark ? const Color(0xFF1E1E2E) : Colors.white), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - side: BorderSide(color: isSelected ? statusColor : (isDark ? Colors.white10 : Colors.grey.shade200), width: isSelected ? 2 : 1), - ), - child: InkWell( - borderRadius: BorderRadius.circular(14), - onTap: () { - if (controller.isSelecting.value) { + margin: const EdgeInsets.only(bottom: 12), + elevation: 0, + color: isSelected + ? statusColor.withValues(alpha: 0.05) + : (isDark ? const Color(0xFF1E1E2E) : Colors.white), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide( + color: isSelected + ? statusColor + : (isDark ? Colors.white10 : Colors.grey.shade200), + width: isSelected ? 2 : 1), + ), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () { + if (controller.isSelecting.value) { + controller.toggleSelection(inv['id']); + } else { + Get.toNamed('/invoice-detail', + arguments: {'id': inv['id'].toString()}); + } + }, + onLongPress: () { + if (!controller.isSelecting.value) { + controller.isSelecting.value = true; + } controller.toggleSelection(inv['id']); - } else { - Get.toNamed('/invoice-detail', arguments: {'id': inv['id'].toString()}); - } - }, - onLongPress: () { - if (!controller.isSelecting.value) { - controller.isSelecting.value = true; - } - controller.toggleSelection(inv['id']); - }, - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - if (controller.isSelecting.value) ...[ - Checkbox( - value: isSelected, - onChanged: (_) => controller.toggleSelection(inv['id']), - activeColor: statusColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (controller.isSelecting.value) ...[ + Checkbox( + value: isSelected, + onChanged: (_) => controller.toggleSelection(inv['id']), + activeColor: statusColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4)), + ), + const SizedBox(width: 4), + ], + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(statusIcon, color: statusColor, size: 24), ), - const SizedBox(width: 4), - ], - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + inv['supplier_name'] ?? + inv['company_name'] ?? + 'بدون اسم', + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + color: + isDark ? Colors.white : const Color(0xFF0F172A), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + '# ${inv['invoice_number'] ?? '—'} • ${inv['invoice_date'] ?? '—'}', + style: TextStyle( + fontSize: 12, + color: + isDark ? Colors.white38 : const Color(0xFF94A3B8), + fontFamily: 'monospace', + ), + ), + ], + ), ), - child: Icon(statusIcon, color: statusColor, size: 24), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Column( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - inv['supplier_name'] ?? inv['company_name'] ?? 'بدون اسم', + '${double.tryParse(inv['grand_total']?.toString() ?? '0')?.toStringAsFixed(2) ?? '0.00'} JOD', style: TextStyle( - fontWeight: FontWeight.w700, - fontSize: 15, - color: isDark ? Colors.white : const Color(0xFF0F172A), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - '# ${inv['invoice_number'] ?? '—'} • ${inv['invoice_date'] ?? '—'}', - style: TextStyle( - fontSize: 12, - color: isDark ? Colors.white38 : const Color(0xFF94A3B8), + fontWeight: FontWeight.w800, + fontSize: 14, + color: isDark + ? const Color(0xFF5EEAD4) + : const Color(0xFF008080), fontFamily: 'monospace', ), ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + statusText, + style: TextStyle( + color: statusColor, + fontSize: 11, + fontWeight: FontWeight.w600), + ), + ), ], ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${double.tryParse(inv['grand_total']?.toString() ?? '0')?.toStringAsFixed(2) ?? '0.00'} JOD', - style: TextStyle( - fontWeight: FontWeight.w800, - fontSize: 14, - color: isDark ? const Color(0xFF5EEAD4) : const Color(0xFF008080), - fontFamily: 'monospace', - ), - ), - const SizedBox(height: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - statusText, - style: TextStyle(color: statusColor, fontSize: 11, fontWeight: FontWeight.w600), - ), - ), - ], - ), - ], + ], + ), ), ), - ), - ); + ); }); } @@ -339,7 +417,8 @@ class InvoicesListView extends GetView { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.receipt_long_rounded, size: 80, color: isDark ? Colors.white12 : Colors.grey.shade300), + Icon(Icons.receipt_long_rounded, + size: 80, color: isDark ? Colors.white12 : Colors.grey.shade300), const SizedBox(height: 16), Text( 'لا توجد فواتير بعد', @@ -352,7 +431,9 @@ class InvoicesListView extends GetView { const SizedBox(height: 8), Text( 'ابدأ بتصوير فواتيرك من زر الماسح الضوئي', - style: TextStyle(fontSize: 13, color: isDark ? Colors.white24 : Colors.grey.shade400), + style: TextStyle( + fontSize: 13, + color: isDark ? Colors.white24 : Colors.grey.shade400), ), ], ), @@ -378,24 +459,28 @@ class InvoicesListView extends GetView { try { AppSnackbar.showInfo('جاري التصدير', 'يتم تحميل ملف الفواتير...'); final res = await DioClient().client.get( - 'invoices/export', - options: Options(responseType: ResponseType.bytes), - ); + 'invoices/export', + options: Options(responseType: ResponseType.bytes), + ); // Save to temp file final dir = await getTemporaryDirectory(); - final fileName = 'musadaq_invoices_${DateTime.now().millisecondsSinceEpoch}.csv'; + final fileName = + 'musadaq_invoices_${DateTime.now().millisecondsSinceEpoch}.csv'; final file = File('${dir.path}/$fileName'); final bytes = List.from(res.data); await file.writeAsBytes(bytes); - // Try share, fallback to success message + // Share via native sheet (share_plus v12 API) try { - await Share.shareXFiles( - [XFile(file.path, mimeType: 'text/csv', name: fileName)], - subject: 'تصدير فواتير مُصادَق', + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path, mimeType: 'text/csv', name: fileName)], + title: 'تصدير فواتير مُصادَق', + ), ); - } catch (_) { + } catch (shareErr) { + debugPrint('Share error (fallback to save): $shareErr'); AppSnackbar.showSuccess('تم الحفظ', 'تم حفظ الملف: ${file.path}'); } } catch (e) { diff --git a/musadaq-app/pubspec.lock b/musadaq-app/pubspec.lock index a9fd645..dfc46fa 100644 --- a/musadaq-app/pubspec.lock +++ b/musadaq-app/pubspec.lock @@ -181,10 +181,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -748,26 +748,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -836,18 +836,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" matrix2d: dependency: transitive description: @@ -860,10 +860,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1401,10 +1401,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.9" timing: dependency: transitive description: @@ -1465,10 +1465,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -1550,5 +1550,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" + dart: ">=3.9.0-0 <4.0.0" flutter: ">=3.32.0" diff --git a/public/index.php b/public/index.php index 43ca832..4758131 100644 --- a/public/index.php +++ b/public/index.php @@ -47,6 +47,7 @@ $routes = [ '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/dashboard/ai-usage' => ['GET', 'dashboard/ai_usage.php'], 'v1/tenants' => ['GET', 'tenants/index.php'], 'v1/tenants/create' => ['POST', 'tenants/create.php'], 'v1/tenants/update' => ['POST', 'tenants/update.php'], diff --git a/scripts/create_ai_usage_table.sql b/scripts/create_ai_usage_table.sql new file mode 100644 index 0000000..1caad4a --- /dev/null +++ b/scripts/create_ai_usage_table.sql @@ -0,0 +1,14 @@ +-- AI Usage Log — Token tracking for cost analysis + +CREATE TABLE IF NOT EXISTS ai_usage_log ( + id CHAR(36) PRIMARY KEY, + input_tokens INT UNSIGNED NOT NULL DEFAULT 0, + output_tokens INT UNSIGNED NOT NULL DEFAULT 0, + total_tokens INT UNSIGNED NOT NULL DEFAULT 0, + cost_usd DECIMAL(12, 8) NOT NULL DEFAULT 0, + cost_jod DECIMAL(12, 8) NOT NULL DEFAULT 0, + model VARCHAR(50) NOT NULL DEFAULT 'gemini-flash-lite', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_created (created_at), + INDEX idx_model (model) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;