Update: 2026-05-08 01:15:44

This commit is contained in:
Hamza-Ayed
2026-05-08 01:15:44 +03:00
parent 1a6ed52a52
commit 928e8e27e3
10 changed files with 991 additions and 4 deletions

View File

@@ -0,0 +1,133 @@
<?php
/**
* Export Invoices as CSV (Excel-compatible)
* GET /v1/invoices/export
* Downloads a CSV file with invoice data + line items
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
$tenantId = $decoded['tenant_id'];
$role = $decoded['role'];
$companyId = $_GET['company_id'] ?? null;
$dateFrom = $_GET['date_from'] ?? null;
$dateTo = $_GET['date_to'] ?? null;
$status = $_GET['status'] ?? null;
// Build query with filters
$where = [];
$params = [];
if ($role !== 'super_admin') {
$where[] = 'i.tenant_id = ?';
$params[] = $tenantId;
}
if ($companyId) {
$where[] = 'i.company_id = ?';
$params[] = $companyId;
}
if ($dateFrom) {
$where[] = 'i.invoice_date >= ?';
$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;

View File

@@ -0,0 +1,116 @@
<?php
/**
* Update Invoice (Before Approval Only)
* POST /v1/invoices/update
* Allows editing extracted data before final approval.
*/
use App\Core\Database;
use App\Core\Encryption;
use App\Core\AuditLogger;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$data = input();
$id = $data['id'] ?? null;
if (!$id) json_error('معرّف الفاتورة مطلوب', 422);
$db = Database::getInstance();
$tenantId = $decoded['tenant_id'];
$role = $decoded['role'];
// 1. Fetch & verify access
$query = $role === 'super_admin'
? "SELECT * FROM invoices WHERE id = ?"
: "SELECT * FROM invoices WHERE id = ? AND tenant_id = ?";
$params = $role === 'super_admin' ? [$id] : [$id, $tenantId];
$stmt = $db->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);
}

View File

@@ -0,0 +1,154 @@
<?php
/**
* Monthly Tax Report API
* GET /v1/reports/tax-summary
* Returns monthly summary of tax, revenue, and invoice statistics
*/
use App\Core\Database;
use App\Middleware\AuthMiddleware;
$decoded = AuthMiddleware::check();
$db = Database::getInstance();
$tenantId = $decoded['tenant_id'];
$role = $decoded['role'];
$companyId = $_GET['company_id'] ?? null;
$month = $_GET['month'] ?? date('m');
$year = $_GET['year'] ?? date('Y');
$where = ["MONTH(i.invoice_date) = ? AND YEAR(i.invoice_date) = ?"];
$params = [$month, $year];
if ($role !== 'super_admin') {
$where[] = 'i.tenant_id = ?';
$params[] = $tenantId;
}
if ($companyId) {
$where[] = 'i.company_id = ?';
$params[] = $companyId;
}
$whereClause = 'WHERE ' . implode(' AND ', $where);
// 1. Main aggregation
$stmt = $db->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,
], 'تقرير ضريبة المبيعات الشهري');

View File

@@ -21,6 +21,7 @@ import '../../features/onboarding/views/onboarding_view.dart';
import '../../features/companies/views/company_stats_view.dart'; import '../../features/companies/views/company_stats_view.dart';
import '../../features/users/views/users_management_view.dart'; import '../../features/users/views/users_management_view.dart';
import '../../features/tenants/views/tenants_management_view.dart'; import '../../features/tenants/views/tenants_management_view.dart';
import '../../features/reports/views/tax_report_view.dart';
part 'app_routes.dart'; part 'app_routes.dart';
@@ -151,5 +152,9 @@ class AppPages {
name: AppRoutes.USERS_MANAGEMENT, name: AppRoutes.USERS_MANAGEMENT,
page: () => const UsersManagementView(), page: () => const UsersManagementView(),
), ),
GetPage(
name: AppRoutes.TAX_REPORT,
page: () => const TaxReportView(),
),
]; ];
} }

View File

@@ -21,4 +21,5 @@ abstract class AppRoutes {
static const COMPANY_STATS = '/company-stats'; static const COMPANY_STATS = '/company-stats';
static const TENANTS_MANAGEMENT = '/tenants-management'; static const TENANTS_MANAGEMENT = '/tenants-management';
static const USERS_MANAGEMENT = '/users-management'; static const USERS_MANAGEMENT = '/users-management';
static const TAX_REPORT = '/tax-report';
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:dio/dio.dart';
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import '../../../core/utils/app_snackbar.dart'; import '../../../core/utils/app_snackbar.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
@@ -7,6 +8,7 @@ import '../../../core/utils/logger.dart';
class InvoiceDetailController extends GetxController { class InvoiceDetailController extends GetxController {
var invoice = {}.obs; var invoice = {}.obs;
var isLoading = true.obs; var isLoading = true.obs;
var isSaving = false.obs;
String? invoiceId; String? invoiceId;
@override @override
@@ -42,6 +44,25 @@ class InvoiceDetailController extends GetxController {
} }
} }
Future<void> updateInvoice(Map<String, dynamic> 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<void> approveInvoice() async { Future<void> approveInvoice() async {
try { try {
final res = await DioClient() final res = await DioClient()
@@ -49,7 +70,6 @@ class InvoiceDetailController extends GetxController {
.post('invoices/approve', data: {'invoice_id': invoiceId}); .post('invoices/approve', data: {'invoice_id': invoiceId});
if (res.data['success'] == true) { if (res.data['success'] == true) {
AppSnackbar.showSuccess('تم الاعتماد', 'تم اعتماد الفاتورة بنجاح'); AppSnackbar.showSuccess('تم الاعتماد', 'تم اعتماد الفاتورة بنجاح');
// Refresh the detail view
fetchInvoiceDetails(); fetchInvoiceDetails();
} else { } else {
AppSnackbar.showError('خطأ', 'فشل اعتماد الفاتورة'); AppSnackbar.showError('خطأ', 'فشل اعتماد الفاتورة');
@@ -63,7 +83,6 @@ class InvoiceDetailController extends GetxController {
void viewOriginalImage() { void viewOriginalImage() {
final fileUrl = invoice['file_url']; final fileUrl = invoice['file_url'];
if (fileUrl != null && fileUrl.isNotEmpty) { if (fileUrl != null && fileUrl.isNotEmpty) {
// Navigate to a dedicated image viewer or show in a dialog
final fullUrl = 'https://musadaq.intaleqapp.com/api$fileUrl'; final fullUrl = 'https://musadaq.intaleqapp.com/api$fileUrl';
Get.to(() => Scaffold( Get.to(() => Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -90,8 +109,24 @@ class InvoiceDetailController extends GetxController {
} }
} }
Future<void> 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<void> submitToJoFotara() async { Future<void> submitToJoFotara() async {
// Confirmation dialog
final confirmed = await Get.dialog<bool>( final confirmed = await Get.dialog<bool>(
AlertDialog( AlertDialog(
title: const Text('تأكيد الإرسال'), title: const Text('تأكيد الإرسال'),
@@ -124,7 +159,7 @@ class InvoiceDetailController extends GetxController {
if (res.data['success'] == true) { if (res.data['success'] == true) {
AppSnackbar.showSuccess('تم الإرسال', 'تم تقديم الفاتورة لجوفتورة بنجاح'); AppSnackbar.showSuccess('تم الإرسال', 'تم تقديم الفاتورة لجوفتورة بنجاح');
fetchInvoiceDetails(); // Refresh to show JoFotara status fetchInvoiceDetails();
} else { } else {
AppSnackbar.showError('خطأ', res.data['message'] ?? 'فشل الإرسال'); AppSnackbar.showError('خطأ', res.data['message'] ?? 'فشل الإرسال');
} }

View File

@@ -79,6 +79,20 @@ class InvoiceDetailView extends StatelessWidget {
// ─── Action Buttons ─── // ─── Action Buttons ───
if (status == 'extracted') ...[ 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( SizedBox(
height: 52, height: 52,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@@ -568,4 +582,128 @@ class InvoiceDetailView extends StatelessWidget {
if (num == num.truncateToDouble()) return num.toStringAsFixed(0); if (num == num.truncateToDouble()) return num.toStringAsFixed(0);
return num.toStringAsFixed(3); 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,
),
),
);
}
} }

View File

@@ -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<void> fetchReport() async {
try {
isLoading.value = true;
final params = <String, dynamic>{
'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}';
}
}

View File

@@ -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<double>(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);
}
}

View File

@@ -34,6 +34,9 @@ $routes = [
'v1/invoices/upload' => ['POST', 'invoices/upload.php'], 'v1/invoices/upload' => ['POST', 'invoices/upload.php'],
'v1/invoices/download_xml' => ['GET', 'invoices/download_xml.php'], 'v1/invoices/download_xml' => ['GET', 'invoices/download_xml.php'],
'v1/invoices/submit-jofotara' => ['POST', 'invoices/submit_jofotara.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/stats' => ['GET', 'companies/stats.php'],
'v1/companies/connect' => ['POST', 'companies/connect_jofotara.php'], 'v1/companies/connect' => ['POST', 'companies/connect_jofotara.php'],
'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'], 'v1/dashboard/stats' => ['GET', 'dashboard/stats.php'],