Update: 2026-05-11 01:09:54

This commit is contained in:
Hamza-Ayed
2026-05-11 01:09:55 +03:00
parent d6a06cadf9
commit d86a00fe03
8 changed files with 268 additions and 24 deletions

View File

@@ -0,0 +1,49 @@
<?php
/**
* Delete Invoice
*/
use App\Core\Database;
use App\Core\AuditLogger;
use App\Middleware\AuthMiddleware;
use App\Middleware\RoleMiddleware;
$decoded = RoleMiddleware::require(['super_admin', 'admin', 'accountant']);
$db = Database::getInstance();
$data = json_decode(file_get_contents('php://input'), true);
$id = $data['id'] ?? null;
if (!$id) {
json_error('Invoice ID is required', 422);
}
try {
$db->beginTransaction();
$stmt = $db->prepare("SELECT * FROM invoices WHERE id = ? FOR UPDATE");
$stmt->execute([$id]);
$invoice = $stmt->fetch();
if (!$invoice) json_error('Invoice not found', 404);
// Super admin can delete anything. Others might only delete non-approved, but let's allow admin to delete.
if ($decoded['role'] !== 'super_admin' && $invoice['tenant_id'] !== $decoded['tenant_id']) {
json_error('Access denied', 403);
}
$db->prepare("DELETE FROM invoice_lines WHERE invoice_id = ?")->execute([$id]);
$db->prepare("DELETE FROM jofotara_submissions WHERE invoice_id = ?")->execute([$id]);
$db->prepare("DELETE FROM invoices WHERE id = ?")->execute([$id]);
$db->commit();
AuditLogger::log('invoice.deleted', 'invoice', $id, null, null, $decoded);
json_success(null, 'تم حذف الفاتورة بنجاح');
} catch (\Exception $e) {
if ($db->inTransaction()) $db->rollBack();
error_log("Invoice Delete Error: " . $e->getMessage());
json_error('فشل في حذف الفاتورة', 500);
}

View File

@@ -0,0 +1,48 @@
<?php
/**
* Reject Invoice
*/
use App\Core\Database;
use App\Core\AuditLogger;
use App\Middleware\AuthMiddleware;
use App\Middleware\RoleMiddleware;
$decoded = RoleMiddleware::require(['super_admin', 'admin', 'accountant']);
$db = Database::getInstance();
$data = json_decode(file_get_contents('php://input'), true);
$id = $data['id'] ?? null;
if (!$id) {
json_error('Invoice ID is required', 422);
}
try {
$db->beginTransaction();
$stmt = $db->prepare("SELECT * FROM invoices WHERE id = ? FOR UPDATE");
$stmt->execute([$id]);
$invoice = $stmt->fetch();
if (!$invoice) json_error('Invoice not found', 404);
if ($invoice['status'] === 'approved') json_error('لا يمكن رفض فاتورة معتمدة', 400);
$updateStmt = $db->prepare("UPDATE invoices SET status = 'rejected', updated_at = NOW() WHERE id = ?");
$updateStmt->execute([$id]);
$db->commit();
AuditLogger::log('invoice.rejected', 'invoice', $id, [
'old_status' => $invoice['status'],
], [
'new_status' => 'rejected',
], $decoded);
json_success(null, 'تم رفض الفاتورة بنجاح');
} catch (\Exception $e) {
if ($db->inTransaction()) $db->rollBack();
error_log("Invoice Reject Error: " . $e->getMessage());
json_error('فشل في رفض الفاتورة', 500);
}

View File

@@ -4,6 +4,7 @@ import 'package:get/get.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:file_picker/file_picker.dart';
import '../../../core/services/upload_progress_service.dart'; import '../../../core/services/upload_progress_service.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../../../core/utils/app_snackbar.dart'; import '../../../core/utils/app_snackbar.dart';
@@ -91,6 +92,11 @@ class ScannerController extends GetxController {
capturedImages.add(originalFile); capturedImages.add(originalFile);
int index = capturedImages.length - 1; int index = capturedImages.length - 1;
if (imagePath.toLowerCase().endsWith('.pdf')) {
AppLogger.print('Added PDF file, skipping image processing: $imagePath');
return;
}
ImageProcessingService.processInvoiceImage(originalFile) ImageProcessingService.processInvoiceImage(originalFile)
.then((processedFile) { .then((processedFile) {
if (processedFile != null && index < capturedImages.length) { if (processedFile != null && index < capturedImages.length) {
@@ -102,6 +108,28 @@ class ScannerController extends GetxController {
}); });
} }
Future<void> pickPdfFile() async {
try {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
allowMultiple: true,
);
if (result != null) {
for (var file in result.files) {
if (file.path != null) {
addImage(file.path!);
}
}
AppSnackbar.showSuccess('تمت الإضافة', 'تم استيراد ملفات الفواتير بنجاح');
}
} catch (e) {
AppLogger.error('Failed to pick PDF', e);
AppSnackbar.showError('خطأ', 'تعذر استيراد الملفات');
}
}
void removeImage(int index) { void removeImage(int index) {
if (index >= 0 && index < capturedImages.length) { if (index >= 0 && index < capturedImages.length) {
capturedImages.removeAt(index); capturedImages.removeAt(index);

View File

@@ -119,6 +119,18 @@ class ScannerView extends GetView<ScannerController> {
state: state, state: state,
children: [ children: [
AwesomeFlashButton(state: state), AwesomeFlashButton(state: state),
const SizedBox(width: 16),
Container(
decoration: BoxDecoration(
color: Colors.black45,
borderRadius: BorderRadius.circular(8),
),
child: IconButton(
onPressed: () => controller.pickPdfFile(),
icon: const Icon(Icons.picture_as_pdf, color: Colors.white),
tooltip: 'استيراد PDF',
),
),
const Spacer(), const Spacer(),
TextButton.icon( TextButton.icon(
onPressed: () => Get.back(), onPressed: () => Get.back(),

View File

@@ -353,6 +353,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev"
source: hosted
version: "8.3.7"
file_selector_linux: file_selector_linux:
dependency: transitive dependency: transitive
description: description:

View File

@@ -31,6 +31,7 @@ dependencies:
camerawesome: ^2.0.0 camerawesome: ^2.0.0
cunning_document_scanner: ^1.2.3 cunning_document_scanner: ^1.2.3
image_picker: ^1.0.7 image_picker: ^1.0.7
file_picker: ^8.1.2
# ─── Image Processing ─────────────────────────────── # ─── Image Processing ───────────────────────────────
image: ^4.1.7 image: ^4.1.7

View File

@@ -35,6 +35,8 @@ $routes = [
'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/update' => ['POST', 'invoices/update.php'],
'v1/invoices/reject' => ['POST', 'invoices/reject.php'],
'v1/invoices/delete' => ['POST', 'invoices/delete.php'],
'v1/invoices/bulk-approve' => ['POST', 'invoices/bulk_approve.php'], 'v1/invoices/bulk-approve' => ['POST', 'invoices/bulk_approve.php'],
'v1/invoices/export' => ['GET', 'invoices/export.php'], 'v1/invoices/export' => ['GET', 'invoices/export.php'],
'v1/invoices/check-duplicate' => ['POST', 'invoices/check_duplicate.php'], 'v1/invoices/check-duplicate' => ['POST', 'invoices/check_duplicate.php'],

View File

@@ -1759,6 +1759,9 @@
<button @click="viewInvoice(inv.id)" class="btn-table-action btn-ta-navy"> <button @click="viewInvoice(inv.id)" class="btn-table-action btn-ta-navy">
👁️ عرض 👁️ عرض
</button> </button>
<button @click="deleteInvoice(inv.id)" class="btn-table-action btn-ta-red" style="margin-right:4px;">
🗑️ حذف
</button>
</td> </td>
</tr> </tr>
</template> </template>
@@ -2395,11 +2398,11 @@
<!-- ── VIEW INVOICE MODAL ──────────────────────────── --> <!-- ── VIEW INVOICE MODAL ──────────────────────────── -->
<div x-show="showViewModal" x-cloak class="modal-backdrop" @click.self="showViewModal = false"> <div x-show="showViewModal" x-cloak class="modal-backdrop" @click.self="showViewModal = false">
<div <div
style="background:var(--bg-card); border-radius:22px; box-shadow:var(--shadow-lg); width:100%; max-width:900px; height:88vh; display:flex; overflow:hidden;"> style="background:var(--bg-card); border-radius:22px; box-shadow:var(--shadow-lg); width:100%; max-width:95vw; height:95vh; display:flex; overflow:hidden;">
<!-- Document Preview --> <!-- Document Preview -->
<div <div
style="flex:1; background:#F2F1FA; border-left:1px solid var(--border); position:relative; overflow:hidden;"> style="flex:3; background:#F2F1FA; border-left:1px solid var(--border); position:relative; overflow:hidden;">
<div style="position:absolute; top:12px; right:12px; z-index:10;"> <div style="position:absolute; top:12px; right:12px; z-index:10;">
<span class="badge badge-navy">معاينة الملف</span> <span class="badge badge-navy">معاينة الملف</span>
</div> </div>
@@ -2415,15 +2418,20 @@
</div> </div>
<!-- Invoice Data Panel --> <!-- Invoice Data Panel -->
<div style="width:345px; flex-shrink:0; display:flex; flex-direction:column; overflow:hidden;"> <div style="width:25%; min-width:320px; max-width:450px; flex-shrink:0; display:flex; flex-direction:column; overflow:hidden;">
<!-- Header --> <!-- Header -->
<div <div
style="padding:18px 20px 14px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between;"> style="padding:18px 20px 14px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between;">
<div> <div>
<div style="font-size:16px; font-weight:700; color:var(--text-1);">تفاصيل الفاتورة</div> <div style="font-size:16px; font-weight:700; color:var(--text-1); display:flex; gap:10px; align-items:center;">
تفاصيل الفاتورة
<button x-show="currentInvoice?.status === 'extracted' && !isEditingInvoice" @click="isEditingInvoice = true" class="btn-sm btn-ghost" style="font-size:11px; padding:2px 8px;">تعديل ✏️</button>
<button x-show="isEditingInvoice" @click="updateInvoice" class="btn-sm btn-teal" style="font-size:11px; padding:2px 8px;">حفظ 💾</button>
<button x-show="isEditingInvoice" @click="isEditingInvoice = false" class="btn-sm btn-ghost" style="font-size:11px; padding:2px 8px;">إلغاء </button>
</div>
<span class="badge" style="margin-top:4px;" <span class="badge" style="margin-top:4px;"
:class="currentInvoice?.status==='extracted' ? 'badge-blue' : (currentInvoice?.status==='approved' ? 'badge-teal' : 'badge-gray')" :class="currentInvoice?.status==='extracted' ? 'badge-blue' : (currentInvoice?.status==='approved' ? 'badge-teal' : (currentInvoice?.status==='rejected' ? 'badge-red' : 'badge-gray'))"
x-text="currentInvoice?.status === 'approved' ? '✓ مدققة' : (currentInvoice?.status === 'extracted' ? 'جاهزة للتدقيق' : currentInvoice?.status)"> x-text="currentInvoice?.status === 'approved' ? '✓ مدققة' : (currentInvoice?.status === 'extracted' ? 'جاهزة للتدقيق' : (currentInvoice?.status === 'rejected' ? 'مرفوضة' : currentInvoice?.status))">
</span> </span>
</div> </div>
<button @click="showViewModal = false" class="modal-close-btn"></button> <button @click="showViewModal = false" class="modal-close-btn"></button>
@@ -2455,26 +2463,35 @@
<div class="invoice-field-card"> <div class="invoice-field-card">
<div class="invoice-field-label">المورد (البائع)</div> <div class="invoice-field-label">المورد (البائع)</div>
<div class="invoice-field-value" x-text="currentInvoice?.supplier_name || 'غير متوفر'"></div> <div x-show="!isEditingInvoice" class="invoice-field-value" x-text="currentInvoice?.supplier_name || 'غير متوفر'"></div>
<div style="font-size:12px; color:var(--text-3); font-family:'Outfit',sans-serif; margin-top:4px;" <input x-show="isEditingInvoice" type="text" x-model="currentInvoice.supplier_name" class="form-input" style="padding:4px; font-size:13px; margin-bottom:4px;" placeholder="اسم المورد">
<div x-show="!isEditingInvoice" style="font-size:12px; color:var(--text-3); font-family:'Outfit',sans-serif; margin-top:4px;"
x-text="'TIN: ' + (currentInvoice?.supplier_tin || '—')"></div> x-text="'TIN: ' + (currentInvoice?.supplier_tin || '—')"></div>
<input x-show="isEditingInvoice" type="text" x-model="currentInvoice.supplier_tin" class="form-input" placeholder="الرقم الضريبي" style="padding:4px; font-size:12px;">
</div> </div>
<div class="invoice-field-card"> <div class="invoice-field-card">
<div class="invoice-field-label">رقم الفاتورة والتاريخ</div> <div class="invoice-field-label">رقم الفاتورة والتاريخ</div>
<div class="invoice-field-value" x-text="currentInvoice?.invoice_number || '—'"></div> <div x-show="!isEditingInvoice" class="invoice-field-value" x-text="currentInvoice?.invoice_number || '—'"></div>
<div style="font-size:13px; color:var(--text-2); font-family:'Outfit',sans-serif; margin-top:3px;" <input x-show="isEditingInvoice" type="text" x-model="currentInvoice.invoice_number" class="form-input" style="padding:4px; font-size:13px; margin-bottom:4px;" placeholder="رقم الفاتورة">
<div x-show="!isEditingInvoice" style="font-size:13px; color:var(--text-2); font-family:'Outfit',sans-serif; margin-top:3px;"
x-text="currentInvoice?.invoice_date || '—'"></div> x-text="currentInvoice?.invoice_date || '—'"></div>
<input x-show="isEditingInvoice" type="date" x-model="currentInvoice.invoice_date" class="form-input" style="padding:4px; font-size:13px;">
</div> </div>
<div class="invoice-field-card" <div class="invoice-field-card"
style="background:var(--green-subtle); border-color:rgba(5,150,105,0.2);"> style="background:var(--green-subtle); border-color:rgba(5,150,105,0.2);">
<div class="invoice-field-label" style="color:var(--green-mid);">المجموع الكلي</div> <div class="invoice-field-label" style="color:var(--green-mid);">المجموع الكلي</div>
<div style="font-size:26px; font-weight:700; color:var(--green-mid); font-family:'El Messiri',sans-serif;" <div x-show="!isEditingInvoice" style="font-size:26px; font-weight:700; color:var(--green-mid); font-family:'El Messiri',sans-serif;"
x-text="parseFloat(currentInvoice?.grand_total || 0).toLocaleString() + ' JOD'"></div> x-text="parseFloat(currentInvoice?.grand_total || 0).toLocaleString() + ' JOD'"></div>
<div style="font-size:12px; color:var(--amber-mid); margin-top:4px; font-family:'Outfit',sans-serif;" <input x-show="isEditingInvoice" type="number" step="0.01" x-model="currentInvoice.grand_total" class="form-input" style="padding:4px; font-size:16px; font-weight:bold; color:var(--green-mid); margin-bottom:4px;" placeholder="المجموع">
<div x-show="!isEditingInvoice" style="font-size:12px; color:var(--amber-mid); margin-top:4px; font-family:'Outfit',sans-serif;"
x-text="'الضريبة: ' + parseFloat(currentInvoice?.tax_amount || 0).toLocaleString() + ' JOD'"> x-text="'الضريبة: ' + parseFloat(currentInvoice?.tax_amount || 0).toLocaleString() + ' JOD'">
</div> </div>
<input x-show="isEditingInvoice" type="number" step="0.01" x-model="currentInvoice.tax_amount" class="form-input" style="padding:4px; font-size:13px; color:var(--amber-mid);" placeholder="الضريبة">
</div> </div>
<!-- Items Table --> <!-- Items Table -->
@@ -2562,10 +2579,18 @@
<span x-show="isBusy"> جاري التدقيق...</span> <span x-show="isBusy"> جاري التدقيق...</span>
</button> </button>
<button x-show="currentInvoice?.status === 'extracted'" <div style="display:flex; gap:8px;">
style="width:100%; background:var(--red-subtle); color:var(--red-mid); border:none; padding:10px; border-radius:11px; font-family:inherit; font-size:14px; font-weight:600; cursor:pointer; transition:all 0.18s;" <button x-show="currentInvoice?.status === 'extracted' || currentInvoice?.status === 'rejected'"
onmouseover="this.style.background='#FECACA'" @click="rejectInvoice(currentInvoice.id)"
onmouseout="this.style.background='var(--red-subtle)'"> رفض الفاتورة</button> style="flex:1; background:var(--amber-subtle); color:var(--amber-mid); border:none; padding:10px; border-radius:11px; font-family:inherit; font-size:14px; font-weight:600; cursor:pointer; transition:all 0.18s;"
onmouseover="this.style.background='#FDE68A'"
onmouseout="this.style.background='var(--amber-subtle)'"> رفض</button>
<button @click="deleteInvoice(currentInvoice.id)"
style="flex:1; background:var(--red-subtle); color:var(--red-mid); border:none; padding:10px; border-radius:11px; font-family:inherit; font-size:14px; font-weight:600; cursor:pointer; transition:all 0.18s;"
onmouseover="this.style.background='#FECACA'"
onmouseout="this.style.background='var(--red-subtle)'">🗑️ حذف</button>
</div>
<div x-show="currentInvoice?.status === 'approved'" <div x-show="currentInvoice?.status === 'approved'"
style="width:100%; background:var(--green-subtle); color:var(--green-mid); border:1px solid rgba(5,150,105,0.2); padding:10px; border-radius:11px; font-size:14px; font-weight:700; text-align:center;"> style="width:100%; background:var(--green-subtle); color:var(--green-mid); border:1px solid rgba(5,150,105,0.2); padding:10px; border-radius:11px; font-size:14px; font-weight:700; text-align:center;">
@@ -2829,7 +2854,7 @@
showExcelModal: false, showBatchUploadModal: false, showExcelModal: false, showBatchUploadModal: false,
isUploadingBatch: false, batchProgress: { total: 0, current: 0 }, isUploadingBatch: false, batchProgress: { total: 0, current: 0 },
showAddTenantModal: false, showEditTenantModal: false, showTenantStatsModal: false, showAddTenantModal: false, showEditTenantModal: false, showTenantStatsModal: false,
acknowledgedWarnings: false, acknowledgedWarnings: false, isEditingInvoice: false,
isBusy: false, globalError: '', isBusy: false, globalError: '',
newUser: { name: '', email: '', password: '', role: 'accountant', tenant_id: '' }, newUser: { name: '', email: '', password: '', role: 'accountant', tenant_id: '' },
@@ -2849,14 +2874,8 @@
subtitle() { return { dashboard: 'نظرة شاملة على نشاط النظام', users: 'إدارة المستخدمين والصلاحيات', companies: 'إدارة الشركات والربط بالفوترة الحكومية', invoices: 'رفع ومعالجة الفواتير الضريبية', tenants: 'إدارة المكاتب المحاسبية المشتركة', subscription: 'تفاصيل باقتك الحالية واستهلاك الموارد' }[this.page] || ''; }, subtitle() { return { dashboard: 'نظرة شاملة على نشاط النظام', users: 'إدارة المستخدمين والصلاحيات', companies: 'إدارة الشركات والربط بالفوترة الحكومية', invoices: 'رفع ومعالجة الفواتير الضريبية', tenants: 'إدارة المكاتب المحاسبية المشتركة', subscription: 'تفاصيل باقتك الحالية واستهلاك الموارد' }[this.page] || ''; },
get filteredInvoices() { get filteredInvoices() {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return this.invoices.filter(inv => { return this.invoices.filter(inv => {
// 1. Time Filter (Current Month) // Removed time filter to prevent invoices from disappearing after a month
const invDate = new Date(inv.invoice_date || inv.created_at);
if (invDate < startOfMonth) return false;
// 2. Company Filter // 2. Company Filter
if (this.invoiceCompanyFilter && inv.company_id != this.invoiceCompanyFilter) return false; if (this.invoiceCompanyFilter && inv.company_id != this.invoiceCompanyFilter) return false;
@@ -3155,6 +3174,83 @@
} }
}, },
async rejectInvoice(id) {
if (this.isBusy) return;
if (!confirm('هل أنت متأكد من رفض هذه الفاتورة؟')) return;
this.isBusy = true;
try {
const res = await fetch('/index.php?route=v1/invoices/reject', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + this.token(), 'Content-Type': 'application/json' },
body: JSON.stringify({ id: id })
});
const json = await res.json();
this.isBusy = false;
if (json.success) {
alert('تم رفض الفاتورة بنجاح!');
this.showViewModal = false;
this.loadAll();
} else {
this.showError(json.message);
}
} catch (e) {
this.isBusy = false;
this.showError('حدث خطأ أثناء رفض الفاتورة');
}
},
async deleteInvoice(id) {
if (this.isBusy) return;
if (!confirm('هل أنت متأكد من حذف هذه الفاتورة بشكل نهائي؟ لا يمكن التراجع عن هذا الإجراء.')) return;
this.isBusy = true;
try {
const res = await fetch('/index.php?route=v1/invoices/delete', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + this.token(), 'Content-Type': 'application/json' },
body: JSON.stringify({ id: id })
});
const json = await res.json();
this.isBusy = false;
if (json.success) {
alert('تم حذف الفاتورة بنجاح!');
this.showViewModal = false;
this.loadAll();
} else {
this.showError(json.message);
}
} catch (e) {
this.isBusy = false;
this.showError('حدث خطأ أثناء حذف الفاتورة');
}
},
async updateInvoice() {
if (!this.currentInvoice || this.isBusy) return;
this.isBusy = true;
try {
const res = await fetch('/index.php?route=v1/invoices/update', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + this.token(), 'Content-Type': 'application/json' },
body: JSON.stringify(this.currentInvoice)
});
const json = await res.json();
this.isBusy = false;
if (json.success) {
alert('تم تحديث بيانات الفاتورة بنجاح!');
this.isEditingInvoice = false;
this.loadAll();
} else {
this.showError(json.message);
}
} catch (e) {
this.isBusy = false;
this.showError('حدث خطأ أثناء تحديث الفاتورة');
}
},
async uploadExcel() { async uploadExcel() {
const fileInput = document.getElementById('excelFileInput'); const fileInput = document.getElementById('excelFileInput');
if (!fileInput.files[0]) return alert('الرجاء اختيار ملف اكسل'); if (!fileInput.files[0]) return alert('الرجاء اختيار ملف اكسل');