diff --git a/app/modules_app/invoices/export.php b/app/modules_app/invoices/export.php new file mode 100644 index 0000000..248e9be --- /dev/null +++ b/app/modules_app/invoices/export.php @@ -0,0 +1,133 @@ += ?'; + $params[] = $dateFrom; +} + +if ($dateTo) { + $where[] = 'i.invoice_date <= ?'; + $params[] = $dateTo; +} + +if ($status) { + $where[] = 'i.status = ?'; + $params[] = $status; +} + +$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + +$stmt = $db->prepare(" + SELECT i.*, c.name as company_name_raw + FROM invoices i + JOIN companies c ON i.company_id = c.id + $whereClause + ORDER BY i.invoice_date DESC + LIMIT 5000 +"); +$stmt->execute($params); +$invoices = $stmt->fetchAll(); + +// Decrypt helper +$dec = function($val) { + if (empty($val)) return ''; + $result = Encryption::decrypt((string)$val); + return ($result !== false && $result !== null) ? $result : (string)$val; +}; + +// UTF-8 BOM for Excel compatibility +$output = "\xEF\xBB\xBF"; + +// CSV headers +$output .= implode(',', [ + 'رقم الفاتورة', + 'تاريخ الفاتورة', + 'الشركة', + 'اسم المورّد', + 'الرقم الضريبي للمورّد', + 'عنوان المورّد', + 'اسم العميل', + 'الرقم الضريبي للعميل', + 'نوع الفاتورة', + 'المبلغ قبل الضريبة', + 'قيمة الخصم', + 'قيمة الضريبة', + 'الإجمالي', + 'العملة', + 'الحالة', + 'JoFotara UUID', + 'تاريخ الإنشاء', +]) . "\n"; + +foreach ($invoices as $inv) { + $statusAr = match($inv['status']) { + 'extracted' => 'مستخرجة', + 'approved' => 'معتمدة', + 'submitted' => 'مقدمة لجوفتورة', + 'rejected' => 'مرفوضة', + default => $inv['status'] + }; + + $row = [ + '"' . str_replace('"', '""', $inv['invoice_number'] ?? '') . '"', + $inv['invoice_date'] ?? '', + '"' . str_replace('"', '""', $dec($inv['company_name_raw'] ?? '')) . '"', + '"' . str_replace('"', '""', $dec($inv['supplier_name'])) . '"', + '"' . $dec($inv['supplier_tin']) . '"', + '"' . str_replace('"', '""', $dec($inv['supplier_address'])) . '"', + '"' . str_replace('"', '""', $dec($inv['buyer_name'])) . '"', + '"' . $dec($inv['buyer_tin']) . '"', + $inv['invoice_type'] ?? 'cash', + $inv['subtotal'] ?? '0', + $inv['discount_total'] ?? '0', + $inv['tax_amount'] ?? '0', + $inv['grand_total'] ?? '0', + $inv['currency_code'] ?? 'JOD', + $statusAr, + $inv['jofotara_uuid'] ?? '', + $inv['created_at'] ?? '', + ]; + + $output .= implode(',', $row) . "\n"; +} + +// Send as download +header('Content-Type: text/csv; charset=utf-8'); +header('Content-Disposition: attachment; filename="musadaq_invoices_' . date('Y-m-d') . '.csv"'); +header('Cache-Control: no-cache'); + +echo $output; +exit; diff --git a/app/modules_app/invoices/update.php b/app/modules_app/invoices/update.php new file mode 100644 index 0000000..70f910d --- /dev/null +++ b/app/modules_app/invoices/update.php @@ -0,0 +1,116 @@ +prepare($query); +$stmt->execute($params); +$invoice = $stmt->fetch(); + +if (!$invoice) json_error('الفاتورة غير موجودة', 404); + +// 2. Only allow editing extracted (not yet approved) invoices +if (!in_array($invoice['status'], ['extracted', 'pending'])) { + json_error('لا يمكن تعديل الفاتورة بعد اعتمادها', 403); +} + +$db->beginTransaction(); +try { + // 3. Update main invoice fields + $fields = []; + $values = []; + + $plainFields = ['invoice_number', 'invoice_date', 'invoice_type', 'invoice_category', + 'subtotal', 'tax_amount', 'discount_total', 'grand_total', 'currency_code']; + + foreach ($plainFields as $f) { + if (isset($data[$f])) { + $fields[] = "$f = ?"; + $values[] = $data[$f]; + } + } + + // Encrypted fields + $encryptedFields = [ + 'supplier_name' => 'supplier_name', + 'supplier_tin' => 'supplier_tin', + 'supplier_address' => 'supplier_address', + 'buyer_name' => 'buyer_name', + 'buyer_tin' => 'buyer_tin', + 'buyer_national_id' => 'buyer_national_id', + ]; + + foreach ($encryptedFields as $key => $column) { + if (isset($data[$key])) { + $fields[] = "$column = ?"; + $values[] = !empty($data[$key]) ? Encryption::encrypt($data[$key]) : ''; + } + } + + if (!empty($fields)) { + $fields[] = 'updated_at = NOW()'; + $values[] = $id; + $sql = "UPDATE invoices SET " . implode(', ', $fields) . " WHERE id = ?"; + $db->prepare($sql)->execute($values); + } + + // 4. Update line items (if provided) + if (isset($data['items']) && is_array($data['items'])) { + // Delete old lines + $db->prepare("DELETE FROM invoice_lines WHERE invoice_id = ?")->execute([$id]); + + // Insert new lines + $lineStmt = $db->prepare( + "INSERT INTO invoice_lines (id, invoice_id, line_number, description, quantity, unit_price, tax_rate, line_total) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + + foreach ($data['items'] as $idx => $item) { + $lineStmt->execute([ + Database::generateUuid(), + $id, + $item['line_number'] ?? ($idx + 1), + $item['description'] ?? '', + $item['quantity'] ?? 1, + $item['unit_price'] ?? 0, + $item['tax_rate'] ?? 0, + $item['line_total'] ?? 0, + ]); + } + } + + $db->commit(); + + AuditLogger::log('invoice.updated', 'invoice', $id, null, [ + 'fields_updated' => array_keys($data), + ], $decoded); + + json_success(null, 'تم تحديث بيانات الفاتورة بنجاح'); + +} catch (\Exception $e) { + $db->rollBack(); + error_log("Invoice Update Error: " . $e->getMessage()); + json_error('فشل تحديث الفاتورة: ' . $e->getMessage(), 500); +} diff --git a/app/modules_app/reports/tax_summary.php b/app/modules_app/reports/tax_summary.php new file mode 100644 index 0000000..e6151fd --- /dev/null +++ b/app/modules_app/reports/tax_summary.php @@ -0,0 +1,154 @@ +prepare(" + SELECT + COUNT(*) as total_invoices, + SUM(CASE WHEN status = 'approved' OR status = 'submitted' THEN 1 ELSE 0 END) as approved_count, + SUM(CASE WHEN status = 'extracted' THEN 1 ELSE 0 END) as pending_count, + SUM(CASE WHEN status = 'submitted' THEN 1 ELSE 0 END) as submitted_count, + COALESCE(SUM(subtotal), 0) as total_subtotal, + COALESCE(SUM(tax_amount), 0) as total_tax, + COALESCE(SUM(discount_total), 0) as total_discount, + COALESCE(SUM(grand_total), 0) as total_grand, + COALESCE(AVG(grand_total), 0) as avg_invoice_amount, + COALESCE(MAX(grand_total), 0) as max_invoice_amount, + COALESCE(MIN(grand_total), 0) as min_invoice_amount + FROM invoices i + $whereClause +"); +$stmt->execute($params); +$summary = $stmt->fetch(); + +// 2. Daily breakdown for chart +$stmtDaily = $db->prepare(" + SELECT + DAY(i.invoice_date) as day_num, + COUNT(*) as count, + COALESCE(SUM(grand_total), 0) as daily_total, + COALESCE(SUM(tax_amount), 0) as daily_tax + FROM invoices i + $whereClause + GROUP BY DAY(i.invoice_date) + ORDER BY day_num +"); +$stmtDaily->execute($params); +$dailyBreakdown = $stmtDaily->fetchAll(); + +// 3. Invoice type breakdown +$stmtType = $db->prepare(" + SELECT + invoice_type, + COUNT(*) as count, + COALESCE(SUM(grand_total), 0) as total + FROM invoices i + $whereClause + GROUP BY invoice_type +"); +$stmtType->execute($params); +$typeBreakdown = $stmtType->fetchAll(); + +// 4. Top 5 suppliers +$stmtSuppliers = $db->prepare(" + SELECT + supplier_name, + COUNT(*) as invoice_count, + COALESCE(SUM(grand_total), 0) as total_amount + FROM invoices i + $whereClause + GROUP BY supplier_name + ORDER BY total_amount DESC + LIMIT 5 +"); +$stmtSuppliers->execute($params); +$topSuppliers = $stmtSuppliers->fetchAll(); + +// Decrypt supplier names +foreach ($topSuppliers as &$s) { + $decrypted = \App\Core\Encryption::decrypt($s['supplier_name']); + $s['supplier_name'] = ($decrypted !== false && $decrypted !== null) ? $decrypted : $s['supplier_name']; +} +unset($s); + +// 5. Comparison with previous month +$prevMonth = $month == 1 ? 12 : $month - 1; +$prevYear = $month == 1 ? $year - 1 : $year; + +$prevWhere = str_replace( + "MONTH(i.invoice_date) = ? AND YEAR(i.invoice_date) = ?", + "MONTH(i.invoice_date) = ? AND YEAR(i.invoice_date) = ?", + implode(' AND ', $where) +); + +$prevParams = [$prevMonth, $prevYear]; +if ($role !== 'super_admin') $prevParams[] = $tenantId; +if ($companyId) $prevParams[] = $companyId; + +$stmtPrev = $db->prepare(" + SELECT + COUNT(*) as total_invoices, + COALESCE(SUM(grand_total), 0) as total_grand, + COALESCE(SUM(tax_amount), 0) as total_tax + FROM invoices i + WHERE MONTH(i.invoice_date) = ? AND YEAR(i.invoice_date) = ? + " . ($role !== 'super_admin' ? " AND i.tenant_id = ?" : "") + . ($companyId ? " AND i.company_id = ?" : "") +); +$stmtPrev->execute($prevParams); +$previous = $stmtPrev->fetch(); + +// Calculate growth +$growth = [ + 'invoices' => $previous['total_invoices'] > 0 + ? round((($summary['total_invoices'] - $previous['total_invoices']) / $previous['total_invoices']) * 100, 1) + : 0, + 'revenue' => $previous['total_grand'] > 0 + ? round((($summary['total_grand'] - $previous['total_grand']) / $previous['total_grand']) * 100, 1) + : 0, + 'tax' => $previous['total_tax'] > 0 + ? round((($summary['total_tax'] - $previous['total_tax']) / $previous['total_tax']) * 100, 1) + : 0, +]; + +json_success([ + 'month' => (int)$month, + 'year' => (int)$year, + 'summary' => $summary, + 'daily_breakdown' => $dailyBreakdown, + 'type_breakdown' => $typeBreakdown, + 'top_suppliers' => $topSuppliers, + 'previous_month' => $previous, + 'growth' => $growth, +], 'تقرير ضريبة المبيعات الشهري'); diff --git a/musadaq-app/lib/app/routes/app_pages.dart b/musadaq-app/lib/app/routes/app_pages.dart index 4c747c5..4f101e3 100644 --- a/musadaq-app/lib/app/routes/app_pages.dart +++ b/musadaq-app/lib/app/routes/app_pages.dart @@ -21,6 +21,7 @@ import '../../features/onboarding/views/onboarding_view.dart'; import '../../features/companies/views/company_stats_view.dart'; import '../../features/users/views/users_management_view.dart'; import '../../features/tenants/views/tenants_management_view.dart'; + import '../../features/reports/views/tax_report_view.dart'; part 'app_routes.dart'; @@ -151,5 +152,9 @@ class AppPages { name: AppRoutes.USERS_MANAGEMENT, page: () => const UsersManagementView(), ), + GetPage( + name: AppRoutes.TAX_REPORT, + page: () => const TaxReportView(), + ), ]; } diff --git a/musadaq-app/lib/app/routes/app_routes.dart b/musadaq-app/lib/app/routes/app_routes.dart index b5baa18..87d2293 100644 --- a/musadaq-app/lib/app/routes/app_routes.dart +++ b/musadaq-app/lib/app/routes/app_routes.dart @@ -21,4 +21,5 @@ abstract class AppRoutes { static const COMPANY_STATS = '/company-stats'; static const TENANTS_MANAGEMENT = '/tenants-management'; static const USERS_MANAGEMENT = '/users-management'; + static const TAX_REPORT = '/tax-report'; } 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 6bf9ca3..97a1af8 100644 --- a/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart +++ b/musadaq-app/lib/features/invoices/controllers/invoice_detail_controller.dart @@ -1,5 +1,6 @@ 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'; @@ -7,6 +8,7 @@ import '../../../core/utils/logger.dart'; class InvoiceDetailController extends GetxController { var invoice = {}.obs; var isLoading = true.obs; + var isSaving = false.obs; String? invoiceId; @override @@ -42,6 +44,25 @@ class InvoiceDetailController extends GetxController { } } + Future updateInvoice(Map data) async { + try { + isSaving.value = true; + data['id'] = invoiceId; + final res = await DioClient().client.post('invoices/update', data: data); + if (res.data['success'] == true) { + AppSnackbar.showSuccess('تم الحفظ', 'تم تحديث بيانات الفاتورة'); + await fetchInvoiceDetails(); + } else { + AppSnackbar.showError('خطأ', res.data['message'] ?? 'فشل التحديث'); + } + } catch (e) { + AppLogger.error('Failed to update invoice', e); + AppSnackbar.showError('خطأ', 'حدث خطأ أثناء التحديث'); + } finally { + isSaving.value = false; + } + } + Future approveInvoice() async { try { final res = await DioClient() @@ -49,7 +70,6 @@ class InvoiceDetailController extends GetxController { .post('invoices/approve', data: {'invoice_id': invoiceId}); if (res.data['success'] == true) { AppSnackbar.showSuccess('تم الاعتماد', 'تم اعتماد الفاتورة بنجاح'); - // Refresh the detail view fetchInvoiceDetails(); } else { AppSnackbar.showError('خطأ', 'فشل اعتماد الفاتورة'); @@ -63,7 +83,6 @@ class InvoiceDetailController extends GetxController { void viewOriginalImage() { final fileUrl = invoice['file_url']; if (fileUrl != null && fileUrl.isNotEmpty) { - // Navigate to a dedicated image viewer or show in a dialog final fullUrl = 'https://musadaq.intaleqapp.com/api$fileUrl'; Get.to(() => Scaffold( appBar: AppBar( @@ -90,8 +109,24 @@ class InvoiceDetailController extends GetxController { } } + Future exportInvoices({String? companyId}) async { + try { + final cId = companyId ?? invoice['company_id']; + AppSnackbar.showInfo('جاري التصدير', 'يتم تحميل ملف الفواتير...'); + final res = await DioClient().client.get( + 'invoices/export', + queryParameters: {'company_id': cId}, + options: Options(responseType: ResponseType.bytes), + ); + // For now, just confirm download was successful + AppSnackbar.showSuccess('تم التصدير', 'تم تحميل ملف CSV بنجاح (${res.data.length} bytes)'); + } catch (e) { + AppLogger.error('Failed to export', e); + AppSnackbar.showError('خطأ', 'فشل تصدير الفواتير'); + } + } + Future submitToJoFotara() async { - // Confirmation dialog final confirmed = await Get.dialog( AlertDialog( title: const Text('تأكيد الإرسال'), @@ -124,7 +159,7 @@ class InvoiceDetailController extends GetxController { if (res.data['success'] == true) { AppSnackbar.showSuccess('تم الإرسال', 'تم تقديم الفاتورة لجوفتورة بنجاح'); - fetchInvoiceDetails(); // Refresh to show JoFotara status + fetchInvoiceDetails(); } else { AppSnackbar.showError('خطأ', res.data['message'] ?? 'فشل الإرسال'); } diff --git a/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart b/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart index fdbd7b7..1ca67a4 100644 --- a/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart +++ b/musadaq-app/lib/features/invoices/views/invoice_detail_view.dart @@ -79,6 +79,20 @@ class InvoiceDetailView extends StatelessWidget { // ─── Action Buttons ─── if (status == 'extracted') ...[ + SizedBox( + height: 52, + child: ElevatedButton.icon( + onPressed: () => _showEditDialog(context, inv, controller), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF3B82F6), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.edit_note_rounded), + label: const Text('تعديل بيانات الفاتورة', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + ), + ), + const SizedBox(height: 12), SizedBox( height: 52, child: ElevatedButton.icon( @@ -568,4 +582,128 @@ class InvoiceDetailView extends StatelessWidget { if (num == num.truncateToDouble()) return num.toStringAsFixed(0); return num.toStringAsFixed(3); } + + void _showEditDialog(BuildContext context, Map inv, InvoiceDetailController controller) { + final invNumC = TextEditingController(text: inv['invoice_number']?.toString() ?? ''); + final invDateC = TextEditingController(text: inv['invoice_date']?.toString() ?? ''); + final supplierNameC = TextEditingController(text: inv['supplier_name']?.toString() ?? ''); + final supplierTinC = TextEditingController(text: inv['supplier_tin']?.toString() ?? ''); + final supplierAddressC = TextEditingController(text: inv['supplier_address']?.toString() ?? ''); + final buyerNameC = TextEditingController(text: inv['buyer_name']?.toString() ?? ''); + final buyerTinC = TextEditingController(text: inv['buyer_tin']?.toString() ?? ''); + final subtotalC = TextEditingController(text: inv['subtotal']?.toString() ?? '0'); + final taxC = TextEditingController(text: inv['tax_amount']?.toString() ?? '0'); + final discountC = TextEditingController(text: inv['discount_total']?.toString() ?? '0'); + final grandC = TextEditingController(text: inv['grand_total']?.toString() ?? '0'); + + Get.to(() => Scaffold( + appBar: AppBar( + title: const Text('تعديل الفاتورة', style: TextStyle(fontWeight: FontWeight.bold)), + backgroundColor: const Color(0xFF3B82F6), + foregroundColor: Colors.white, + actions: [ + Obx(() => controller.isSaving.value + ? const Padding(padding: EdgeInsets.all(16), child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))) + : IconButton( + icon: const Icon(Icons.save_rounded), + onPressed: () { + controller.updateInvoice({ + 'invoice_number': invNumC.text, + 'invoice_date': invDateC.text, + 'supplier_name': supplierNameC.text, + 'supplier_tin': supplierTinC.text, + 'supplier_address': supplierAddressC.text, + 'buyer_name': buyerNameC.text, + 'buyer_tin': buyerTinC.text, + 'subtotal': subtotalC.text, + 'tax_amount': taxC.text, + 'discount_total': discountC.text, + 'grand_total': grandC.text, + }).then((_) => Get.back()); + }, + ), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('معلومات الفاتورة', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + _editRow('رقم الفاتورة', invNumC, Icons.numbers), + _editRow('تاريخ الفاتورة', invDateC, Icons.calendar_today), + const Divider(height: 32), + const Text('بيانات المورّد', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + _editRow('اسم المورّد', supplierNameC, Icons.store), + _editRow('الرقم الضريبي', supplierTinC, Icons.badge), + _editRow('العنوان', supplierAddressC, Icons.location_on), + const Divider(height: 32), + const Text('بيانات العميل', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + _editRow('اسم العميل', buyerNameC, Icons.person), + _editRow('الرقم الضريبي للعميل', buyerTinC, Icons.badge), + const Divider(height: 32), + const Text('المبالغ', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + _editRow('المبلغ قبل الضريبة', subtotalC, Icons.attach_money, isNumeric: true), + _editRow('الخصم', discountC, Icons.discount, isNumeric: true), + _editRow('الضريبة', taxC, Icons.percent, isNumeric: true), + _editRow('الإجمالي', grandC, Icons.payments, isNumeric: true), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton.icon( + onPressed: () { + controller.updateInvoice({ + 'invoice_number': invNumC.text, + 'invoice_date': invDateC.text, + 'supplier_name': supplierNameC.text, + 'supplier_tin': supplierTinC.text, + 'supplier_address': supplierAddressC.text, + 'buyer_name': buyerNameC.text, + 'buyer_tin': buyerTinC.text, + 'subtotal': subtotalC.text, + 'tax_amount': taxC.text, + 'discount_total': discountC.text, + 'grand_total': grandC.text, + }).then((_) => Get.back()); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.save), + label: const Text('حفظ التعديلات', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + ), + ), + const SizedBox(height: 40), + ], + ), + ), + )); + } + + Widget _editRow(String label, TextEditingController ctrl, IconData icon, {bool isNumeric = false}) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: TextField( + controller: ctrl, + textDirection: TextDirection.rtl, + keyboardType: isNumeric ? const TextInputType.numberWithOptions(decimal: true) : TextInputType.text, + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon, size: 20), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + filled: true, + fillColor: Colors.grey.shade50, + ), + ), + ); + } } diff --git a/musadaq-app/lib/features/reports/controllers/tax_report_controller.dart b/musadaq-app/lib/features/reports/controllers/tax_report_controller.dart new file mode 100644 index 0000000..da8dbb7 --- /dev/null +++ b/musadaq-app/lib/features/reports/controllers/tax_report_controller.dart @@ -0,0 +1,58 @@ +import 'package:get/get.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/utils/app_snackbar.dart'; +import '../../../core/utils/logger.dart'; + +class TaxReportController extends GetxController { + var isLoading = true.obs; + var report = {}.obs; + var selectedMonth = DateTime.now().month.obs; + var selectedYear = DateTime.now().year.obs; + String? companyId; + + @override + void onInit() { + super.onInit(); + if (Get.arguments != null) { + companyId = Get.arguments['company_id']; + } + fetchReport(); + } + + Future fetchReport() async { + try { + isLoading.value = true; + final params = { + 'month': selectedMonth.value, + 'year': selectedYear.value, + }; + if (companyId != null) params['company_id'] = companyId; + + final res = await DioClient().client.get('reports/tax-summary', queryParameters: params); + if (res.data['success'] == true) { + report.value = res.data['data']; + } + } catch (e) { + AppLogger.error('Failed to fetch tax report', e); + AppSnackbar.showError('خطأ', 'تعذر تحميل التقرير'); + } finally { + isLoading.value = false; + } + } + + void changeMonth(int delta) { + var m = selectedMonth.value + delta; + var y = selectedYear.value; + if (m > 12) { m = 1; y++; } + if (m < 1) { m = 12; y--; } + selectedMonth.value = m; + selectedYear.value = y; + fetchReport(); + } + + String get monthName { + const months = ['', 'يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', + 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر']; + return '${months[selectedMonth.value]} ${selectedYear.value}'; + } +} diff --git a/musadaq-app/lib/features/reports/views/tax_report_view.dart b/musadaq-app/lib/features/reports/views/tax_report_view.dart new file mode 100644 index 0000000..7f86e40 --- /dev/null +++ b/musadaq-app/lib/features/reports/views/tax_report_view.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/tax_report_controller.dart'; + +class TaxReportView extends StatelessWidget { + const TaxReportView({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.put(TaxReportController()); + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + backgroundColor: isDark ? const Color(0xFF121212) : const Color(0xFFF5F7FA), + appBar: AppBar( + title: const Text('التقرير الضريبي الشهري', style: TextStyle(fontFamily: 'El Messiri', fontWeight: FontWeight.bold)), + centerTitle: true, + backgroundColor: const Color(0xFF0F4C81), + foregroundColor: Colors.white, + ), + body: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator(color: Color(0xFF0F4C81))); + } + + final summary = controller.report['summary'] as Map? ?? {}; + final growth = controller.report['growth'] as Map? ?? {}; + final topSuppliers = (controller.report['top_suppliers'] as List?) ?? []; + final daily = (controller.report['daily_breakdown'] as List?) ?? []; + + return RefreshIndicator( + onRefresh: controller.fetchReport, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Month Selector + _buildMonthSelector(controller, isDark), + const SizedBox(height: 16), + + // Summary Cards + Row( + children: [ + Expanded(child: _buildMetricCard('إجمالي الفواتير', '${summary['total_invoices'] ?? 0}', Icons.receipt_long, const Color(0xFF3B82F6), growth['invoices'], isDark)), + const SizedBox(width: 12), + Expanded(child: _buildMetricCard('إجمالي المبيعات', '${_fmt(summary['total_grand'])} JOD', Icons.payments, const Color(0xFF10B981), growth['revenue'], isDark)), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: _buildMetricCard('ضريبة المبيعات', '${_fmt(summary['total_tax'])} JOD', Icons.percent, const Color(0xFFF59E0B), growth['tax'], isDark)), + const SizedBox(width: 12), + Expanded(child: _buildMetricCard('مقدمة لجوفتورة', '${summary['submitted_count'] ?? 0}', Icons.send_rounded, const Color(0xFF6366F1), null, isDark)), + ], + ), + + const SizedBox(height: 20), + + // Status Breakdown + _buildStatusBreakdown(summary, isDark), + + const SizedBox(height: 20), + + // Daily Chart (simple bar representation) + if (daily.isNotEmpty) ...[ + _buildDailyChart(daily, isDark), + const SizedBox(height: 20), + ], + + // Top Suppliers + if (topSuppliers.isNotEmpty) ...[ + _buildTopSuppliers(topSuppliers, isDark), + const SizedBox(height: 20), + ], + + // Amount Details + _buildAmountDetails(summary, isDark), + + const SizedBox(height: 40), + ], + ), + ), + ); + }), + ); + } + + Widget _buildMonthSelector(TaxReportController controller, bool isDark) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: () => controller.changeMonth(1), + ), + Obx(() => Text( + controller.monthName, + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold, + color: isDark ? Colors.white : const Color(0xFF0F172A), + ), + )), + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () => controller.changeMonth(-1), + ), + ], + ), + ); + } + + Widget _buildMetricCard(String label, String value, IconData icon, Color color, dynamic growthPct, bool isDark) { + final growth = double.tryParse(growthPct?.toString() ?? '') ?? 0; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: color), + const Spacer(), + if (growthPct != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: (growth >= 0 ? const Color(0xFF10B981) : Colors.red).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${growth >= 0 ? "+" : ""}${growth.toStringAsFixed(1)}%', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: growth >= 0 ? const Color(0xFF10B981) : Colors.red), + ), + ), + ], + ), + const SizedBox(height: 12), + Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: isDark ? Colors.white : const Color(0xFF0F172A), fontFamily: 'monospace')), + const SizedBox(height: 4), + Text(label, style: TextStyle(fontSize: 12, color: isDark ? Colors.white38 : Colors.grey)), + ], + ), + ); + } + + Widget _buildStatusBreakdown(Map summary, bool isDark) { + final total = (int.tryParse(summary['total_invoices']?.toString() ?? '0') ?? 0); + final approved = (int.tryParse(summary['approved_count']?.toString() ?? '0') ?? 0); + final pending = (int.tryParse(summary['pending_count']?.toString() ?? '0') ?? 0); + final submitted = (int.tryParse(summary['submitted_count']?.toString() ?? '0') ?? 0); + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('توزيع الحالات', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + _statusRow('معتمدة', approved, total, const Color(0xFF10B981), isDark), + const SizedBox(height: 8), + _statusRow('قيد المراجعة', pending, total, const Color(0xFFF59E0B), isDark), + const SizedBox(height: 8), + _statusRow('مقدمة لجوفتورة', submitted, total, const Color(0xFF6366F1), isDark), + ], + ), + ); + } + + Widget _statusRow(String label, int count, int total, Color color, bool isDark) { + final pct = total > 0 ? count / total : 0.0; + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(fontSize: 13, color: isDark ? Colors.white70 : Colors.black87)), + Text('$count (${(pct * 100).toStringAsFixed(0)}%)', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: color)), + ], + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator(value: pct, backgroundColor: color.withValues(alpha: 0.1), color: color, minHeight: 6), + ), + ], + ); + } + + Widget _buildDailyChart(List daily, bool isDark) { + final maxVal = daily.fold(0, (max, d) => (double.tryParse(d['daily_total']?.toString() ?? '0') ?? 0) > max ? (double.tryParse(d['daily_total']?.toString() ?? '0') ?? 0) : max); + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('المبيعات اليومية', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + SizedBox( + height: 120, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: daily.map((d) { + final val = double.tryParse(d['daily_total']?.toString() ?? '0') ?? 0; + final height = maxVal > 0 ? (val / maxVal) * 100 : 0.0; + return Expanded( + child: Tooltip( + message: 'يوم ${d['day_num']}: ${val.toStringAsFixed(2)} JOD', + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 1), + height: height, + decoration: BoxDecoration( + color: const Color(0xFF3B82F6).withValues(alpha: 0.7), + borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ); + } + + Widget _buildTopSuppliers(List suppliers, bool isDark) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('أكبر الموردين', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + ...suppliers.asMap().entries.map((e) { + final s = e.value; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + CircleAvatar( + radius: 16, backgroundColor: const Color(0xFF0F4C81).withValues(alpha: 0.1), + child: Text('${e.key + 1}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Color(0xFF0F4C81))), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(s['supplier_name'] ?? '—', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), + Text('${s['invoice_count']} فاتورة', style: TextStyle(fontSize: 12, color: isDark ? Colors.white38 : Colors.grey)), + ], + ), + ), + Text('${_fmt(s['total_amount'])} JOD', style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'monospace', fontSize: 13)), + ], + ), + ); + }), + ], + ), + ); + } + + Widget _buildAmountDetails(Map summary, bool isDark) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('تفاصيل المبالغ', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + _amountRow('إجمالي المبيعات (قبل الضريبة)', summary['total_subtotal'], isDark), + const Divider(height: 20), + _amountRow('إجمالي الخصومات', summary['total_discount'], isDark), + const Divider(height: 20), + _amountRow('إجمالي ضريبة المبيعات', summary['total_tax'], isDark, color: const Color(0xFFF59E0B)), + const Divider(height: 20), + _amountRow('صافي المبيعات', summary['total_grand'], isDark, isBold: true), + const Divider(height: 20), + _amountRow('متوسط قيمة الفاتورة', summary['avg_invoice_amount'], isDark), + const Divider(height: 20), + _amountRow('أعلى فاتورة', summary['max_invoice_amount'], isDark), + ], + ), + ); + } + + Widget _amountRow(String label, dynamic value, bool isDark, {bool isBold = false, Color? color}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(fontSize: 13, color: color ?? (isDark ? Colors.white70 : Colors.grey.shade600))), + Text( + '${_fmt(value)} JOD', + style: TextStyle( + fontSize: isBold ? 17 : 14, + fontWeight: isBold ? FontWeight.w900 : FontWeight.w600, + color: color ?? (isDark ? Colors.white : Colors.black87), + fontFamily: 'monospace', + ), + ), + ], + ); + } + + String _fmt(dynamic v) { + final n = double.tryParse(v?.toString() ?? '0') ?? 0; + return n.toStringAsFixed(2); + } +} diff --git a/public/index.php b/public/index.php index 6b4c5b1..0680ddf 100644 --- a/public/index.php +++ b/public/index.php @@ -34,6 +34,9 @@ $routes = [ 'v1/invoices/upload' => ['POST', 'invoices/upload.php'], 'v1/invoices/download_xml' => ['GET', 'invoices/download_xml.php'], 'v1/invoices/submit-jofotara' => ['POST', 'invoices/submit_jofotara.php'], + 'v1/invoices/update' => ['POST', 'invoices/update.php'], + 'v1/invoices/export' => ['GET', 'invoices/export.php'], + 'v1/reports/tax-summary' => ['GET', 'reports/tax_summary.php'], 'v1/companies/stats' => ['GET', 'companies/stats.php'], 'v1/companies/connect' => ['POST', 'companies/connect_jofotara.php'], 'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'],