Commit 2821b53d authored by Mahmoud Aglan's avatar Mahmoud Aglan

again

parent f1fb0d5a
<?php
declare(strict_types=1);
namespace App\Modules\Support\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Support\Models\Ticket;
use App\Modules\Support\Services\AttachmentService;
class TicketController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('support.view');
$filters = [
'status' => trim((string) $request->get('status', '')),
'priority' => trim((string) $request->get('priority', '')),
'category' => trim((string) $request->get('category', '')),
'assigned_to' => trim((string) $request->get('assigned_to', '')),
'search' => trim((string) $request->get('search', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = Ticket::search($filters, 20, $page);
$counts = Ticket::countByStatus();
return $this->view('Support.Views.index', [
'tickets' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'counts' => $counts,
]);
}
public function create(Request $request): Response
{
$this->authorize('support.create');
$db = App::getInstance()->db();
$employees = $db->select("SELECT id, full_name_ar FROM employees WHERE is_active = 1 ORDER BY full_name_ar");
return $this->view('Support.Views.create', [
'categories' => Ticket::getCategories(),
'employees' => $employees,
]);
}
public function store(Request $request): Response
{
$this->authorize('support.create');
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$subject = trim((string) $request->post('subject', ''));
$body = trim((string) $request->post('body', ''));
$priority = trim((string) $request->post('priority', 'normal'));
$category = trim((string) $request->post('category', ''));
$assignTo = (int) $request->post('assigned_to', 0);
if ($subject === '' || $body === '') {
return $this->redirect('/support/create')->withError('الموضوع والمحتوى مطلوبان');
}
$ticketNumber = Ticket::generateNumber();
$branch = App::getInstance()->currentBranch();
$ticketId = $db->insert('support_tickets', [
'ticket_number' => $ticketNumber,
'subject' => $subject,
'body' => $body,
'priority' => in_array($priority, ['urgent', 'high', 'normal', 'low']) ? $priority : 'normal',
'status' => 'open',
'category' => $category ?: null,
'created_by_employee_id' => $employee ? (int) $employee->id : null,
'assigned_to_employee_id'=> $assignTo > 0 ? $assignTo : null,
'branch_id' => $branch ? (int) $branch['id'] : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
if (!empty($_FILES['attachments'])) {
AttachmentService::handleUploads($_FILES['attachments'], (int) $ticketId);
}
return $this->redirect('/support/' . $ticketId)->withSuccess('تم إنشاء التذكرة — رقم: ' . $ticketNumber);
}
public function show(Request $request, string $id): Response
{
$this->authorize('support.view');
$ticket = Ticket::find((int) $id);
if (!$ticket) return $this->redirect('/support')->withError('التذكرة غير موجودة');
$replies = Ticket::getReplies((int) $id);
$attachments = Ticket::getAllAttachments((int) $id);
$replyAttachments = [];
foreach ($attachments as $a) {
if ($a['reply_id']) {
$replyAttachments[(int) $a['reply_id']][] = $a;
}
}
$ticketAttachments = array_filter($attachments, fn($a) => empty($a['reply_id']));
$db = App::getInstance()->db();
$employees = $db->select("SELECT id, full_name_ar FROM employees WHERE is_active = 1 ORDER BY full_name_ar");
return $this->view('Support.Views.show', [
'ticket' => $ticket,
'replies' => $replies,
'ticketAttachments' => $ticketAttachments,
'replyAttachments' => $replyAttachments,
'employees' => $employees,
]);
}
public function reply(Request $request, string $id): Response
{
$this->authorize('support.reply');
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$ticket = Ticket::find((int) $id);
if (!$ticket) return $this->redirect('/support')->withError('التذكرة غير موجودة');
$body = trim((string) $request->post('body', ''));
if ($body === '') return $this->redirect('/support/' . $id)->withError('محتوى الرد مطلوب');
$replyId = $db->insert('support_ticket_replies', [
'ticket_id' => (int) $id,
'body' => $body,
'employee_id' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
]);
if (!empty($_FILES['attachments'])) {
AttachmentService::handleUploads($_FILES['attachments'], (int) $id, (int) $replyId);
}
$newStatus = $ticket['status'] === 'open' ? 'in_progress' : $ticket['status'];
$db->update('support_tickets', [
'status' => $newStatus,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect('/support/' . $id)->withSuccess('تم إضافة الرد');
}
public function close(Request $request, string $id): Response
{
$this->authorize('support.close');
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$ticket = Ticket::find((int) $id);
if (!$ticket) return $this->redirect('/support')->withError('التذكرة غير موجودة');
$db->update('support_tickets', [
'status' => 'closed',
'closed_at' => date('Y-m-d H:i:s'),
'closed_by' => $employee ? (int) $employee->id : null,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect('/support/' . $id)->withSuccess('تم إغلاق التذكرة');
}
public function reopen(Request $request, string $id): Response
{
$this->authorize('support.manage');
$db = App::getInstance()->db();
$ticket = Ticket::find((int) $id);
if (!$ticket) return $this->redirect('/support')->withError('التذكرة غير موجودة');
$db->update('support_tickets', [
'status' => 'open',
'closed_at' => null,
'closed_by' => null,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect('/support/' . $id)->withSuccess('تم إعادة فتح التذكرة');
}
public function assign(Request $request, string $id): Response
{
$this->authorize('support.assign');
$db = App::getInstance()->db();
$ticket = Ticket::find((int) $id);
if (!$ticket) return $this->redirect('/support')->withError('التذكرة غير موجودة');
$assignTo = (int) $request->post('assigned_to', 0);
$db->update('support_tickets', [
'assigned_to_employee_id' => $assignTo > 0 ? $assignTo : null,
'status' => $ticket['status'] === 'open' ? 'in_progress' : $ticket['status'],
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect('/support/' . $id)->withSuccess('تم تعيين التذكرة');
}
public function download(Request $request, string $id): Response
{
$this->authorize('support.view');
$attachment = AttachmentService::getAttachment((int) $id);
if (!$attachment) return $this->redirect('/support')->withError('المرفق غير موجود');
$absPath = AttachmentService::getAbsolutePath($attachment);
if (!file_exists($absPath)) return $this->redirect('/support')->withError('الملف غير موجود على السيرفر');
header('Content-Type: ' . $attachment['mime_type']);
header('Content-Disposition: inline; filename="' . $attachment['original_filename'] . '"');
header('Content-Length: ' . filesize($absPath));
readfile($absPath);
exit;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Support\Models;
use App\Core\App;
use App\Core\Pagination;
class Ticket
{
public static function find(int $id): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT t.*, e.full_name_ar as created_by_name, ae.full_name_ar as assigned_to_name
FROM support_tickets t
LEFT JOIN employees e ON e.id = t.created_by_employee_id
LEFT JOIN employees ae ON ae.id = t.assigned_to_employee_id
WHERE t.id = ?",
[$id]
);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['status'])) {
$where .= ' AND t.status = ?';
$params[] = $filters['status'];
}
if (!empty($filters['priority'])) {
$where .= ' AND t.priority = ?';
$params[] = $filters['priority'];
}
if (!empty($filters['category'])) {
$where .= ' AND t.category = ?';
$params[] = $filters['category'];
}
if (!empty($filters['assigned_to'])) {
$where .= ' AND t.assigned_to_employee_id = ?';
$params[] = (int) $filters['assigned_to'];
}
if (!empty($filters['search'])) {
$where .= ' AND (t.ticket_number LIKE ? OR t.subject LIKE ? OR t.body LIKE ?)';
$s = '%' . $filters['search'] . '%';
$params[] = $s; $params[] = $s; $params[] = $s;
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM support_tickets t WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT t.*, e.full_name_ar as created_by_name, ae.full_name_ar as assigned_to_name
FROM support_tickets t
LEFT JOIN employees e ON e.id = t.created_by_employee_id
LEFT JOIN employees ae ON ae.id = t.assigned_to_employee_id
WHERE {$where}
ORDER BY FIELD(t.status, 'open', 'in_progress', 'waiting', 'closed'),
FIELD(t.priority, 'urgent', 'high', 'normal', 'low'),
t.updated_at DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
public static function getReplies(int $ticketId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT r.*, e.full_name_ar as employee_name
FROM support_ticket_replies r
LEFT JOIN employees e ON e.id = r.employee_id
WHERE r.ticket_id = ?
ORDER BY r.created_at ASC",
[$ticketId]
);
}
public static function getAttachments(int $ticketId, ?int $replyId = null): array
{
$db = App::getInstance()->db();
if ($replyId !== null) {
return $db->select(
"SELECT * FROM support_ticket_attachments WHERE reply_id = ? ORDER BY id ASC",
[$replyId]
);
}
return $db->select(
"SELECT * FROM support_ticket_attachments WHERE ticket_id = ? ORDER BY id ASC",
[$ticketId]
);
}
public static function getAllAttachments(int $ticketId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT a.*, e.full_name_ar as uploaded_by_name
FROM support_ticket_attachments a
LEFT JOIN employees e ON e.id = a.uploaded_by
WHERE a.ticket_id = ?
ORDER BY a.created_at ASC",
[$ticketId]
);
}
public static function generateNumber(): string
{
$db = App::getInstance()->db();
$year = date('Y');
$row = $db->selectOne(
"SELECT MAX(CAST(SUBSTRING(ticket_number, 10) AS UNSIGNED)) as max_num
FROM support_tickets WHERE ticket_number LIKE ?",
["TKT-{$year}-%"]
);
$next = ((int) ($row['max_num'] ?? 0)) + 1;
return sprintf('TKT-%s-%06d', $year, $next);
}
public static function getStatusLabel(string $status): string
{
return match ($status) {
'open' => 'مفتوحة',
'in_progress' => 'قيد المعالجة',
'waiting' => 'في الانتظار',
'closed' => 'مغلقة',
default => $status,
};
}
public static function getPriorityLabel(string $priority): string
{
return match ($priority) {
'urgent' => 'عاجل',
'high' => 'مرتفع',
'normal' => 'عادي',
'low' => 'منخفض',
default => $priority,
};
}
public static function getCategoryLabel(string $category): string
{
return match ($category) {
'bug' => 'خلل فني',
'feature' => 'طلب ميزة',
'question' => 'استفسار',
'data_fix' => 'تصحيح بيانات',
'permissions' => 'صلاحيات',
'printing' => 'طباعة',
'financial' => 'مالي',
'other' => 'أخرى',
default => $category ?? 'أخرى',
};
}
public static function getCategories(): array
{
return [
'bug' => 'خلل فني',
'feature' => 'طلب ميزة',
'question' => 'استفسار',
'data_fix' => 'تصحيح بيانات',
'permissions' => 'صلاحيات',
'printing' => 'طباعة',
'financial' => 'مالي',
'other' => 'أخرى',
];
}
public static function countByStatus(): array
{
$db = App::getInstance()->db();
$rows = $db->select("SELECT status, COUNT(*) as cnt FROM support_tickets GROUP BY status");
$result = ['open' => 0, 'in_progress' => 0, 'waiting' => 0, 'closed' => 0];
foreach ($rows as $row) {
$result[$row['status']] = (int) $row['cnt'];
}
return $result;
}
}
<?php
declare(strict_types=1);
return [
['GET', '/support', 'Support\Controllers\TicketController@index', ['auth'], 'support.view'],
['GET', '/support/create', 'Support\Controllers\TicketController@create', ['auth'], 'support.create'],
['POST', '/support', 'Support\Controllers\TicketController@store', ['auth'], 'support.create'],
['GET', '/support/{id:\d+}', 'Support\Controllers\TicketController@show', ['auth'], 'support.view'],
['POST', '/support/{id:\d+}/reply', 'Support\Controllers\TicketController@reply', ['auth'], 'support.reply'],
['POST', '/support/{id:\d+}/close', 'Support\Controllers\TicketController@close', ['auth'], 'support.close'],
['POST', '/support/{id:\d+}/reopen', 'Support\Controllers\TicketController@reopen', ['auth'], 'support.manage'],
['POST', '/support/{id:\d+}/assign', 'Support\Controllers\TicketController@assign', ['auth'], 'support.assign'],
['GET', '/support/attachments/{id:\d+}', 'Support\Controllers\TicketController@download', ['auth'], 'support.view'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Support\Services;
use App\Core\App;
final class AttachmentService
{
private const ALLOWED_MIMES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp',
'application/pdf',
'text/plain', 'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip', 'application/x-rar-compressed',
'video/mp4', 'video/quicktime',
];
private const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB per file
public static function uploadDir(): string
{
$dir = App::getInstance()->basePath() . '/storage/uploads/support/';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
return $dir;
}
public static function handleUploads(array $files, int $ticketId, ?int $replyId = null): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$uploadDir = self::uploadDir();
$saved = [];
$fileList = self::normalizeFiles($files);
foreach ($fileList as $file) {
if ($file['error'] !== UPLOAD_ERR_OK || $file['size'] <= 0) continue;
if ($file['size'] > self::MAX_FILE_SIZE) continue;
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
if (!in_array($mimeType, self::ALLOWED_MIMES, true)) continue;
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$storedFilename = 'tkt_' . $ticketId . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$filePath = $uploadDir . $storedFilename;
if (!move_uploaded_file($file['tmp_name'], $filePath)) continue;
$id = $db->insert('support_ticket_attachments', [
'ticket_id' => $ticketId,
'reply_id' => $replyId,
'original_filename' => $file['name'],
'stored_filename' => $storedFilename,
'file_path' => 'storage/uploads/support/' . $storedFilename,
'file_size' => (int) $file['size'],
'mime_type' => $mimeType,
'uploaded_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
]);
$saved[] = ['id' => $id, 'filename' => $file['name']];
}
return $saved;
}
public static function getAttachment(int $id): ?array
{
$db = App::getInstance()->db();
return $db->selectOne("SELECT * FROM support_ticket_attachments WHERE id = ?", [$id]);
}
public static function getAbsolutePath(array $attachment): string
{
return App::getInstance()->basePath() . '/' . $attachment['file_path'];
}
private static function normalizeFiles(array $files): array
{
if (isset($files['name']) && is_array($files['name'])) {
$normalized = [];
foreach ($files['name'] as $i => $name) {
$normalized[] = [
'name' => $name,
'type' => $files['type'][$i] ?? '',
'tmp_name' => $files['tmp_name'][$i] ?? '',
'error' => $files['error'][$i] ?? UPLOAD_ERR_NO_FILE,
'size' => $files['size'][$i] ?? 0,
];
}
return $normalized;
}
if (isset($files['name'])) {
return [$files];
}
return $files;
}
public static function formatSize(int $bytes): string
{
if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB';
if ($bytes >= 1024) return round($bytes / 1024, 1) . ' KB';
return $bytes . ' B';
}
public static function isImage(string $mimeType): bool
{
return str_starts_with($mimeType, 'image/');
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تذكرة دعم جديدة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/support" class="btn btn-outline">← العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="max-width:800px;">
<div style="padding:20px;">
<form method="POST" action="/support" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="form-group" style="margin-bottom:20px;">
<label class="form-label" style="font-weight:600;">الموضوع <span style="color:#DC2626;">*</span></label>
<input type="text" name="subject" class="form-input" value="<?= e(old('subject')) ?>" placeholder="عنوان مختصر للمشكلة أو الطلب..." required maxlength="255" style="font-size:16px;">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;margin-bottom:20px;">
<div class="form-group">
<label class="form-label">التصنيف</label>
<select name="category" class="form-select">
<option value="">— اختر —</option>
<?php foreach ($categories as $k => $v): ?>
<option value="<?= $k ?>"><?= e($v) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">الأولوية</label>
<select name="priority" class="form-select">
<option value="normal">عادي</option>
<option value="low">منخفض</option>
<option value="high">مرتفع</option>
<option value="urgent">عاجل</option>
</select>
</div>
<div class="form-group">
<label class="form-label">تعيين إلى</label>
<select name="assigned_to" class="form-select">
<option value="">— بدون تعيين —</option>
<?php foreach ($employees as $emp): ?>
<option value="<?= (int) $emp['id'] ?>"><?= e($emp['full_name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-group" style="margin-bottom:20px;">
<label class="form-label" style="font-weight:600;">المحتوى <span style="color:#DC2626;">*</span></label>
<p style="font-size:12px;color:#6B7280;margin:0 0 8px;">اكتب تفاصيل المشكلة أو الطلب بالكامل — يمكنك لصق نصوص كبيرة، أكواد خطأ، أو أي محتوى تحتاجه</p>
<textarea name="body" class="form-input" rows="15" required placeholder="اكتب هنا بالتفصيل...&#10;&#10;يمكنك لصق:&#10;- رسائل الخطأ كاملة&#10;- خطوات إعادة إنتاج المشكلة&#10;- أرقام العضويات أو الاستمارات المتأثرة&#10;- أي بيانات أخرى" style="font-size:14px;line-height:1.7;font-family:inherit;min-height:300px;resize:vertical;"><?= e(old('body')) ?></textarea>
</div>
<div class="form-group" style="margin-bottom:25px;">
<label class="form-label" style="font-weight:600;">المرفقات</label>
<p style="font-size:12px;color:#6B7280;margin:0 0 8px;">صور، ملفات PDF، مستندات — حتى 20 ميجا للملف الواحد — يمكنك رفع عدة ملفات</p>
<div style="border:2px dashed #D1D5DB;border-radius:10px;padding:30px;text-align:center;background:#F9FAFB;" id="dropZone">
<div style="font-size:36px;margin-bottom:10px;">&#x1f4ce;</div>
<p style="margin:0 0 10px;color:#6B7280;">اسحب الملفات هنا أو</p>
<label class="btn btn-outline" style="cursor:pointer;">
اختر ملفات
<input type="file" name="attachments[]" multiple style="display:none;" id="fileInput" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv,.zip,.rar,.mp4,.mov">
</label>
<div id="fileList" style="margin-top:15px;text-align:right;"></div>
</div>
</div>
<div style="border-top:1px solid #E5E7EB;padding-top:20px;display:flex;justify-content:space-between;align-items:center;">
<a href="/support" class="btn btn-outline">إلغاء</a>
<button type="submit" class="btn btn-primary" style="padding:12px 40px;font-size:16px;">إرسال التذكرة</button>
</div>
</form>
</div>
</div>
<script>
var fileInput = document.getElementById('fileInput');
var fileList = document.getElementById('fileList');
var dropZone = document.getElementById('dropZone');
fileInput.addEventListener('change', function() { updateFileList(); });
dropZone.addEventListener('dragover', function(e) { e.preventDefault(); dropZone.style.borderColor = '#0D7377'; dropZone.style.background = '#F0FDFA'; });
dropZone.addEventListener('dragleave', function(e) { e.preventDefault(); dropZone.style.borderColor = '#D1D5DB'; dropZone.style.background = '#F9FAFB'; });
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
dropZone.style.borderColor = '#D1D5DB';
dropZone.style.background = '#F9FAFB';
if (e.dataTransfer.files.length > 0) {
fileInput.files = e.dataTransfer.files;
updateFileList();
}
});
function updateFileList() {
fileList.innerHTML = '';
for (var i = 0; i < fileInput.files.length; i++) {
var f = fileInput.files[i];
var size = f.size > 1048576 ? (f.size / 1048576).toFixed(1) + ' MB' : (f.size / 1024).toFixed(0) + ' KB';
var isImg = f.type.startsWith('image/');
fileList.innerHTML += '<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid #F3F4F6;font-size:13px;">' +
'<span style="font-size:16px;">' + (isImg ? '&#x1f5bc;' : '&#x1f4c4;') + '</span>' +
'<span style="flex:1;">' + f.name + '</span>' +
'<span style="color:#6B7280;">' + size + '</span></div>';
}
}
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تذاكر الدعم الفني<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/support/create" class="btn btn-primary">+ تذكرة جديدة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
use App\Modules\Support\Models\Ticket;
use App\Modules\Support\Services\AttachmentService;
?>
<!-- Status Summary -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px;">
<div style="background:#fff;border:2px solid <?= ($filters['status'] ?? '') === 'open' ? '#DC2626' : '#E5E7EB' ?>;border-radius:10px;padding:15px;text-align:center;">
<a href="/support?status=open" style="text-decoration:none;color:inherit;">
<div style="font-size:28px;font-weight:700;color:#DC2626;"><?= (int) $counts['open'] ?></div>
<div style="font-size:12px;color:#6B7280;">مفتوحة</div>
</a>
</div>
<div style="background:#fff;border:2px solid <?= ($filters['status'] ?? '') === 'in_progress' ? '#3B82F6' : '#E5E7EB' ?>;border-radius:10px;padding:15px;text-align:center;">
<a href="/support?status=in_progress" style="text-decoration:none;color:inherit;">
<div style="font-size:28px;font-weight:700;color:#3B82F6;"><?= (int) $counts['in_progress'] ?></div>
<div style="font-size:12px;color:#6B7280;">قيد المعالجة</div>
</a>
</div>
<div style="background:#fff;border:2px solid <?= ($filters['status'] ?? '') === 'waiting' ? '#F59E0B' : '#E5E7EB' ?>;border-radius:10px;padding:15px;text-align:center;">
<a href="/support?status=waiting" style="text-decoration:none;color:inherit;">
<div style="font-size:28px;font-weight:700;color:#F59E0B;"><?= (int) $counts['waiting'] ?></div>
<div style="font-size:12px;color:#6B7280;">في الانتظار</div>
</a>
</div>
<div style="background:#fff;border:2px solid <?= ($filters['status'] ?? '') === 'closed' ? '#059669' : '#E5E7EB' ?>;border-radius:10px;padding:15px;text-align:center;">
<a href="/support?status=closed" style="text-decoration:none;color:inherit;">
<div style="font-size:28px;font-weight:700;color:#059669;"><?= (int) $counts['closed'] ?></div>
<div style="font-size:12px;color:#6B7280;">مغلقة</div>
</a>
</div>
</div>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:12px 20px;">
<form method="GET" action="/support" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:11px;">بحث</label>
<input type="text" name="search" class="form-input" value="<?= e($filters['search'] ?? '') ?>" placeholder="رقم التذكرة، الموضوع...">
</div>
<div>
<label class="form-label" style="font-size:11px;">الحالة</label>
<select name="status" class="form-select">
<option value="">الكل</option>
<option value="open" <?= ($filters['status'] ?? '') === 'open' ? 'selected' : '' ?>>مفتوحة</option>
<option value="in_progress" <?= ($filters['status'] ?? '') === 'in_progress' ? 'selected' : '' ?>>قيد المعالجة</option>
<option value="waiting" <?= ($filters['status'] ?? '') === 'waiting' ? 'selected' : '' ?>>في الانتظار</option>
<option value="closed" <?= ($filters['status'] ?? '') === 'closed' ? 'selected' : '' ?>>مغلقة</option>
</select>
</div>
<div>
<label class="form-label" style="font-size:11px;">الأولوية</label>
<select name="priority" class="form-select">
<option value="">الكل</option>
<option value="urgent" <?= ($filters['priority'] ?? '') === 'urgent' ? 'selected' : '' ?>>عاجل</option>
<option value="high" <?= ($filters['priority'] ?? '') === 'high' ? 'selected' : '' ?>>مرتفع</option>
<option value="normal" <?= ($filters['priority'] ?? '') === 'normal' ? 'selected' : '' ?>>عادي</option>
<option value="low" <?= ($filters['priority'] ?? '') === 'low' ? 'selected' : '' ?>>منخفض</option>
</select>
</div>
<div>
<label class="form-label" style="font-size:11px;">التصنيف</label>
<select name="category" class="form-select">
<option value="">الكل</option>
<?php foreach (Ticket::getCategories() as $k => $v): ?>
<option value="<?= $k ?>" <?= ($filters['category'] ?? '') === $k ? 'selected' : '' ?>><?= e($v) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline">بحث</button>
<?php if (array_filter($filters)): ?>
<a href="/support" class="btn btn-outline" style="color:#DC2626;border-color:#DC2626;">مسح</a>
<?php endif; ?>
</form>
</div>
</div>
<!-- Tickets List -->
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>رقم التذكرة</th>
<th>الموضوع</th>
<th>التصنيف</th>
<th>الأولوية</th>
<th>الحالة</th>
<th>مقدم الطلب</th>
<th>المعين إليه</th>
<th>التاريخ</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($tickets as $t):
$priorityColor = match($t['priority']) { 'urgent' => '#DC2626', 'high' => '#F59E0B', 'normal' => '#6B7280', 'low' => '#9CA3AF', default => '#6B7280' };
$statusColor = match($t['status']) { 'open' => '#DC2626', 'in_progress' => '#3B82F6', 'waiting' => '#F59E0B', 'closed' => '#059669', default => '#6B7280' };
?>
<tr<?= $t['status'] === 'closed' ? ' style="opacity:0.6;"' : '' ?>>
<td style="direction:ltr;text-align:right;font-weight:600;font-size:12px;white-space:nowrap;"><?= e($t['ticket_number']) ?></td>
<td style="font-weight:600;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
<a href="/support/<?= (int)$t['id'] ?>" style="color:#1A1A2E;"><?= e($t['subject']) ?></a>
</td>
<td style="font-size:12px;"><?= e(Ticket::getCategoryLabel($t['category'] ?? '')) ?></td>
<td><span style="color:<?= $priorityColor ?>;font-weight:700;font-size:12px;"><?= e(Ticket::getPriorityLabel($t['priority'])) ?></span></td>
<td><span style="background:<?= $statusColor ?>22;color:<?= $statusColor ?>;padding:3px 10px;border-radius:12px;font-size:12px;font-weight:600;"><?= e(Ticket::getStatusLabel($t['status'])) ?></span></td>
<td style="font-size:12px;"><?= e($t['created_by_name'] ?? '—') ?></td>
<td style="font-size:12px;"><?= e($t['assigned_to_name'] ?? '—') ?></td>
<td style="font-size:11px;color:#6B7280;white-space:nowrap;"><?= e(substr($t['created_at'], 0, 16)) ?></td>
<td><a href="/support/<?= (int)$t['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($tickets)): ?>
<tr><td colspan="9" style="text-align:center;padding:40px;color:#9CA3AF;">
<div style="font-size:48px;margin-bottom:10px;">&#x1f3ab;</div>
لا توجد تذاكر
</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if (!empty($pagination) && $pagination['total_pages'] > 1): ?>
<div style="padding:15px 20px;border-top:1px solid #E5E7EB;display:flex;justify-content:center;gap:5px;">
<?php for ($p = 1; $p <= $pagination['total_pages']; $p++): ?>
<?php $params = array_merge($filters, ['page' => $p]); ?>
<a href="/support?<?= http_build_query(array_filter($params)) ?>" class="btn btn-sm <?= $p === $pagination['current_page'] ? 'btn-primary' : 'btn-outline' ?>"><?= $p ?></a>
<?php endfor; ?>
</div>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
This diff is collapsed.
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('support', [
'label_ar' => 'الدعم الفني',
'label_en' => 'Support',
'icon' => 'life-buoy',
'route' => '/support',
'permission' => 'support.view',
'parent' => null,
'order' => 900,
'children' => [
['label_ar' => 'التذاكر', 'label_en' => 'Tickets', 'route' => '/support', 'permission' => 'support.view', 'order' => 1],
['label_ar' => 'تذكرة جديدة', 'label_en' => 'New Ticket', 'route' => '/support/create', 'permission' => 'support.create', 'order' => 2],
],
]);
PermissionRegistry::register('support', [
'support.view' => ['ar' => 'عرض تذاكر الدعم', 'en' => 'View Support Tickets'],
'support.create' => ['ar' => 'إنشاء تذكرة دعم', 'en' => 'Create Support Ticket'],
'support.reply' => ['ar' => 'الرد على التذاكر', 'en' => 'Reply to Tickets'],
'support.assign' => ['ar' => 'تعيين التذاكر', 'en' => 'Assign Tickets'],
'support.close' => ['ar' => 'إغلاق التذاكر', 'en' => 'Close Tickets'],
'support.manage' => ['ar' => 'إدارة تذاكر الدعم', 'en' => 'Manage Support Tickets'],
]);
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE support_tickets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ticket_number VARCHAR(50) NOT NULL UNIQUE,
subject VARCHAR(255) NOT NULL,
body LONGTEXT NOT NULL,
priority VARCHAR(20) NOT NULL DEFAULT 'normal',
status VARCHAR(30) NOT NULL DEFAULT 'open',
category VARCHAR(50) NULL,
created_by_employee_id BIGINT UNSIGNED NULL,
assigned_to_employee_id BIGINT UNSIGNED NULL,
branch_id BIGINT UNSIGNED NULL,
related_entity_type VARCHAR(100) NULL,
related_entity_id BIGINT UNSIGNED NULL,
closed_at TIMESTAMP NULL,
closed_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_ticket_status (status),
INDEX idx_ticket_priority (priority),
INDEX idx_ticket_created_by (created_by_employee_id),
INDEX idx_ticket_assigned (assigned_to_employee_id),
INDEX idx_ticket_branch (branch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE support_ticket_replies (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ticket_id BIGINT UNSIGNED NOT NULL,
body LONGTEXT NOT NULL,
employee_id BIGINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_reply_ticket (ticket_id),
CONSTRAINT fk_reply_ticket FOREIGN KEY (ticket_id) REFERENCES support_tickets(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE support_ticket_attachments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ticket_id BIGINT UNSIGNED NULL,
reply_id BIGINT UNSIGNED NULL,
original_filename VARCHAR(255) NOT NULL,
stored_filename VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
mime_type VARCHAR(100) NOT NULL,
uploaded_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_attach_ticket (ticket_id),
INDEX idx_attach_reply (reply_id),
CONSTRAINT fk_attach_ticket FOREIGN KEY (ticket_id) REFERENCES support_tickets(id) ON DELETE CASCADE,
CONSTRAINT fk_attach_reply FOREIGN KEY (reply_id) REFERENCES support_ticket_replies(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "
DROP TABLE IF EXISTS support_ticket_attachments;
DROP TABLE IF EXISTS support_ticket_replies;
DROP TABLE IF EXISTS support_tickets
",
];
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$permissions = [
['key' => 'support.view', 'name_ar' => 'عرض تذاكر الدعم', 'name_en' => 'View Support Tickets', 'module' => 'support'],
['key' => 'support.create', 'name_ar' => 'إنشاء تذكرة دعم', 'name_en' => 'Create Support Ticket', 'module' => 'support'],
['key' => 'support.reply', 'name_ar' => 'الرد على التذاكر', 'name_en' => 'Reply to Tickets', 'module' => 'support'],
['key' => 'support.assign', 'name_ar' => 'تعيين التذاكر', 'name_en' => 'Assign Tickets', 'module' => 'support'],
['key' => 'support.close', 'name_ar' => 'إغلاق التذاكر', 'name_en' => 'Close Tickets', 'module' => 'support'],
['key' => 'support.manage', 'name_ar' => 'إدارة تذاكر الدعم', 'name_en' => 'Manage Support Tickets', 'module' => 'support'],
];
foreach ($permissions as $p) {
$exists = $db->selectOne("SELECT id FROM permissions WHERE `key` = ?", [$p['key']]);
if (!$exists) {
$db->insert('permissions', [
'key' => $p['key'],
'name_ar' => $p['name_ar'],
'name_en' => $p['name_en'],
'module' => $p['module'],
]);
}
}
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment