Compare commits
8 Commits
ce95e555d5
...
1989bcb8c8
| Author | SHA1 | Date | |
|---|---|---|---|
| 1989bcb8c8 | |||
| 0a2214bfaf | |||
| e7d01ef576 | |||
| a403e49537 | |||
| 06b7a8f59b | |||
| 9f1a375e5a | |||
| 84cc023bc4 | |||
| 164c2d231a |
@@ -204,6 +204,7 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
|||||||
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
|
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
|
||||||
| `/api/assign_ticket.php` | POST | Assign ticket to user |
|
| `/api/assign_ticket.php` | POST | Assign ticket to user |
|
||||||
| `/api/add_comment.php` | POST | Add comment to ticket |
|
| `/api/add_comment.php` | POST | Add comment to ticket |
|
||||||
|
| `/api/clone_ticket.php` | POST | Clone an existing ticket |
|
||||||
| `/api/get_template.php` | GET | Fetch ticket template |
|
| `/api/get_template.php` | GET | Fetch ticket template |
|
||||||
| `/api/get_users.php` | GET | Get user list for assignments |
|
| `/api/get_users.php` | GET | Get user list for assignments |
|
||||||
| `/api/bulk_operation.php` | POST | Perform bulk operations |
|
| `/api/bulk_operation.php` | POST | Perform bulk operations |
|
||||||
@@ -220,6 +221,11 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
|||||||
| `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
|
| `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
|
||||||
| `/api/manage_templates.php` | CRUD | Templates (admin) |
|
| `/api/manage_templates.php` | CRUD | Templates (admin) |
|
||||||
| `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
|
| `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
|
||||||
|
| `/api/custom_fields.php` | CRUD | Custom field definitions/values (admin) |
|
||||||
|
| `/api/saved_filters.php` | CRUD | Saved filter combinations |
|
||||||
|
| `/api/user_preferences.php` | GET/POST | User preferences |
|
||||||
|
| `/api/audit_log.php` | GET | Audit log entries (admin) |
|
||||||
|
| `/api/health.php` | GET | Health check |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -228,8 +234,12 @@ tinker_tickets/
|
|||||||
├── api/
|
├── api/
|
||||||
│ ├── add_comment.php # POST: Add comment
|
│ ├── add_comment.php # POST: Add comment
|
||||||
│ ├── assign_ticket.php # POST: Assign ticket to user
|
│ ├── assign_ticket.php # POST: Assign ticket to user
|
||||||
|
│ ├── audit_log.php # GET: Audit log entries (admin)
|
||||||
|
│ ├── bootstrap.php # Shared auth/setup include (not a public endpoint)
|
||||||
│ ├── bulk_operation.php # POST: Bulk operations (admin only)
|
│ ├── bulk_operation.php # POST: Bulk operations (admin only)
|
||||||
│ ├── check_duplicates.php # GET: Check for duplicate tickets
|
│ ├── check_duplicates.php # GET: Check for duplicate tickets
|
||||||
|
│ ├── clone_ticket.php # POST: Clone an existing ticket
|
||||||
|
│ ├── custom_fields.php # CRUD: Custom field definitions/values (admin)
|
||||||
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
|
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
|
||||||
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
|
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
|
||||||
│ ├── download_attachment.php # GET: Download with visibility check
|
│ ├── download_attachment.php # GET: Download with visibility check
|
||||||
@@ -237,23 +247,26 @@ tinker_tickets/
|
|||||||
│ ├── generate_api_key.php # POST: Generate API key (admin)
|
│ ├── generate_api_key.php # POST: Generate API key (admin)
|
||||||
│ ├── get_template.php # GET: Fetch ticket template
|
│ ├── get_template.php # GET: Fetch ticket template
|
||||||
│ ├── get_users.php # GET: Get user list
|
│ ├── get_users.php # GET: Get user list
|
||||||
|
│ ├── health.php # GET: Health check endpoint
|
||||||
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
|
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
|
||||||
│ ├── manage_templates.php # CRUD: Templates (admin)
|
│ ├── manage_templates.php # CRUD: Templates (admin)
|
||||||
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
|
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
|
||||||
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
|
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
|
||||||
|
│ ├── saved_filters.php # CRUD: Saved filter combinations
|
||||||
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
|
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
|
||||||
│ ├── update_comment.php # POST: Update comment (owner/admin)
|
│ ├── update_comment.php # POST: Update comment (owner/admin)
|
||||||
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
|
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
|
||||||
│ └── upload_attachment.php # GET/POST: List or upload attachments
|
│ ├── upload_attachment.php # GET/POST: List or upload attachments
|
||||||
|
│ └── user_preferences.php # GET/POST: User preferences
|
||||||
├── assets/
|
├── assets/
|
||||||
│ ├── css/
|
│ ├── css/
|
||||||
│ │ ├── base.css # LotusGuild Terminal Design System (symlinked from web_template)
|
│ │ ├── base.css # LotusGuild Terminal Design System (copied from web_template)
|
||||||
│ │ ├── dashboard.css # Dashboard + terminal styling
|
│ │ ├── dashboard.css # Dashboard + terminal styling
|
||||||
│ │ └── ticket.css # Ticket view styling
|
│ │ └── ticket.css # Ticket view styling
|
||||||
│ ├── js/
|
│ ├── js/
|
||||||
│ │ ├── advanced-search.js # Advanced search modal
|
│ │ ├── advanced-search.js # Advanced search modal
|
||||||
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
|
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
|
||||||
│ │ ├── base.js # LotusGuild JS utilities — window.lt (symlinked from web_template)
|
│ │ ├── base.js # LotusGuild JS utilities — window.lt (copied from web_template)
|
||||||
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
|
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
|
||||||
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
|
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
|
||||||
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
|
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
|
||||||
@@ -292,6 +305,8 @@ tinker_tickets/
|
|||||||
│ ├── UserPreferencesModel.php # User preferences
|
│ ├── UserPreferencesModel.php # User preferences
|
||||||
│ └── WorkflowModel.php # Status transition workflows
|
│ └── WorkflowModel.php # Status transition workflows
|
||||||
├── scripts/
|
├── scripts/
|
||||||
|
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets
|
||||||
|
│ ├── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments
|
||||||
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
|
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads
|
||||||
│ └── create_dependencies_table.php # Create ticket_dependencies table
|
│ └── create_dependencies_table.php # Create ticket_dependencies table
|
||||||
├── uploads/ # File attachment storage
|
├── uploads/ # File attachment storage
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ try {
|
|||||||
require_once $commentModelPath;
|
require_once $commentModelPath;
|
||||||
require_once $auditLogModelPath;
|
require_once $auditLogModelPath;
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
@@ -71,6 +72,24 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify user can access the ticket before allowing a comment
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
http_response_code(404);
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
http_response_code(403);
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize models
|
// Initialize models
|
||||||
$commentModel = new CommentModel($conn);
|
$commentModel = new CommentModel($conn);
|
||||||
$auditLog = new AuditLogModel($conn);
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ $ticketModel = new TicketModel($conn);
|
|||||||
$auditLogModel = new AuditLogModel($conn);
|
$auditLogModel = new AuditLogModel($conn);
|
||||||
$userModel = new UserModel($conn);
|
$userModel = new UserModel($conn);
|
||||||
|
|
||||||
// Verify ticket exists
|
// Verify ticket exists and user can access it
|
||||||
$ticket = $ticketModel->getTicketById($ticketId);
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
if (!$ticket) {
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
// Only accept GET requests
|
// Only accept GET requests
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
@@ -30,6 +31,10 @@ $searchTerm = '%' . $title . '%';
|
|||||||
// Get SOUNDEX of title
|
// Get SOUNDEX of title
|
||||||
$soundexTitle = soundex($title);
|
$soundexTitle = soundex($title);
|
||||||
|
|
||||||
|
// Build visibility filter so users only see titles they have access to
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
|
||||||
|
|
||||||
// First, search for exact substring matches (case-insensitive)
|
// First, search for exact substring matches (case-insensitive)
|
||||||
$sql = "SELECT ticket_id, title, status, priority, created_at
|
$sql = "SELECT ticket_id, title, status, priority, created_at
|
||||||
FROM tickets
|
FROM tickets
|
||||||
@@ -38,11 +43,16 @@ $sql = "SELECT ticket_id, title, status, priority, created_at
|
|||||||
OR SOUNDEX(title) = ?
|
OR SOUNDEX(title) = ?
|
||||||
)
|
)
|
||||||
AND status != 'Closed'
|
AND status != 'Closed'
|
||||||
|
AND ({$visFilter['sql']})
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 10";
|
LIMIT 10";
|
||||||
|
|
||||||
|
$types = "ss" . $visFilter['types'];
|
||||||
|
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
|
||||||
$stmt = $conn->prepare($sql);
|
$stmt = $conn->prepare($sql);
|
||||||
$stmt->bind_param("ss", $searchTerm, $soundexTitle);
|
if (!empty($params)) {
|
||||||
|
$stmt->bind_param($types, ...$params);
|
||||||
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
|||||||
@@ -74,13 +74,11 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorization: non-admins cannot clone internal tickets unless they created/are assigned
|
// Verify the user can access this ticket using centralized visibility logic
|
||||||
if (!$isAdmin && ($sourceTicket['visibility'] ?? 'public') === 'internal') {
|
if (!$ticketModel->canUserAccessTicket($sourceTicket, $_SESSION['user'])) {
|
||||||
if ($sourceTicket['created_by'] != $userId && $sourceTicket['assigned_to'] != $userId) {
|
http_response_code(404);
|
||||||
http_response_code(403);
|
echo json_encode(['success' => false, 'error' => 'Source ticket not found']);
|
||||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
exit;
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare cloned ticket data
|
// Prepare cloned ticket data
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ require_once dirname(__DIR__) . '/helpers/Database.php';
|
|||||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -66,7 +67,14 @@ try {
|
|||||||
ResponseHelper::notFound('Attachment not found');
|
ResponseHelper::notFound('Attachment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permission
|
// Verify user can access the parent ticket
|
||||||
|
$ticketModel = new TicketModel(Database::getConnection());
|
||||||
|
$ticket = $ticketModel->getTicketById((int)$attachment['ticket_id']);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
|
ResponseHelper::notFound('Attachment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permission (must be uploader or admin)
|
||||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||||
if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) {
|
if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) {
|
||||||
ResponseHelper::forbidden('You do not have permission to delete this attachment');
|
ResponseHelper::forbidden('You do not have permission to delete this attachment');
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ require_once dirname(__DIR__) . '/config/config.php';
|
|||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -77,6 +78,7 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$userId = $_SESSION['user']['user_id'];
|
$userId = $_SESSION['user']['user_id'];
|
||||||
|
$currentUser = $_SESSION['user'];
|
||||||
|
|
||||||
// CSRF Protection for POST/DELETE
|
// CSRF Protection for POST/DELETE
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||||
@@ -99,6 +101,7 @@ if ($tableCheck->num_rows === 0) {
|
|||||||
try {
|
try {
|
||||||
$dependencyModel = new DependencyModel($conn);
|
$dependencyModel = new DependencyModel($conn);
|
||||||
$auditLog = new AuditLogModel($conn);
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
|
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
|
||||||
ResponseHelper::serverError('Failed to initialize required components');
|
ResponseHelper::serverError('Failed to initialize required components');
|
||||||
@@ -116,6 +119,12 @@ switch ($method) {
|
|||||||
ResponseHelper::error('Ticket ID required');
|
ResponseHelper::error('Ticket ID required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify user can access this ticket
|
||||||
|
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$dependencies = $dependencyModel->getDependencies($ticketId);
|
$dependencies = $dependencyModel->getDependencies($ticketId);
|
||||||
$dependents = $dependencyModel->getDependentTickets($ticketId);
|
$dependents = $dependencyModel->getDependentTickets($ticketId);
|
||||||
|
|||||||
+17
-2
@@ -59,14 +59,16 @@ try {
|
|||||||
private $workflowModel;
|
private $workflowModel;
|
||||||
private $userId;
|
private $userId;
|
||||||
private $isAdmin;
|
private $isAdmin;
|
||||||
|
private $currentUser;
|
||||||
|
|
||||||
public function __construct($conn, $userId = null, $isAdmin = false) {
|
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = []) {
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
$this->auditLog = new AuditLogModel($conn);
|
$this->auditLog = new AuditLogModel($conn);
|
||||||
$this->workflowModel = new WorkflowModel($conn);
|
$this->workflowModel = new WorkflowModel($conn);
|
||||||
$this->userId = $userId;
|
$this->userId = $userId;
|
||||||
$this->isAdmin = $isAdmin;
|
$this->isAdmin = $isAdmin;
|
||||||
|
$this->currentUser = $currentUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update($id, $data) {
|
public function update($id, $data) {
|
||||||
@@ -79,6 +81,15 @@ try {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visibility check: return 404 for tickets the user cannot access
|
||||||
|
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Ticket not found',
|
||||||
|
'http_status' => 404
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Authorization: admins can edit any ticket; others only their own or assigned
|
// Authorization: admins can edit any ticket; others only their own or assigned
|
||||||
if (!$this->isAdmin
|
if (!$this->isAdmin
|
||||||
&& $currentTicket['created_by'] != $this->userId
|
&& $currentTicket['created_by'] != $this->userId
|
||||||
@@ -206,7 +217,7 @@ try {
|
|||||||
$ticketId = (int)$data['ticket_id'];
|
$ticketId = (int)$data['ticket_id'];
|
||||||
|
|
||||||
// Initialize controller
|
// Initialize controller
|
||||||
$controller = new ApiTicketController($conn, $userId, $isAdmin);
|
$controller = new ApiTicketController($conn, $userId, $isAdmin, $currentUser);
|
||||||
|
|
||||||
// Update ticket
|
// Update ticket
|
||||||
$result = $controller->update($ticketId, $data);
|
$result = $controller->update($ticketId, $data);
|
||||||
@@ -215,6 +226,10 @@ try {
|
|||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
|
|
||||||
// Return response
|
// Return response
|
||||||
|
if (!empty($result['http_status'])) {
|
||||||
|
http_response_code($result['http_status']);
|
||||||
|
unset($result['http_status']);
|
||||||
|
}
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ require_once dirname(__DIR__) . '/helpers/Database.php';
|
|||||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -46,7 +47,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$attachmentModel = new AttachmentModel(Database::getConnection());
|
$conn = Database::getConnection();
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachmentModel = new AttachmentModel($conn);
|
||||||
$attachments = $attachmentModel->getAttachments($ticketId);
|
$attachments = $attachmentModel->getAttachments($ticketId);
|
||||||
|
|
||||||
// Add formatted file size and icon to each attachment
|
// Add formatted file size and icon to each attachment
|
||||||
@@ -83,6 +91,14 @@ if (!preg_match('/^\d{9}$/', $ticketId)) {
|
|||||||
ResponseHelper::error('Invalid ticket ID format');
|
ResponseHelper::error('Invalid ticket ID format');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify user can access the ticket before accepting upload
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
// Check if file was uploaded
|
// Check if file was uploaded
|
||||||
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
|
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
|
||||||
ResponseHelper::error('No file uploaded');
|
ResponseHelper::error('No file uploaded');
|
||||||
|
|||||||
+1
-66
@@ -1079,71 +1079,6 @@ function performBulkDelete() {
|
|||||||
// TERMINAL-STYLE MODAL UTILITIES
|
// TERMINAL-STYLE MODAL UTILITIES
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a terminal-style confirmation modal
|
|
||||||
* @param {string} title - Modal title
|
|
||||||
* @param {string} message - Message body
|
|
||||||
* @param {string} type - 'warning', 'error', 'info' (affects color)
|
|
||||||
* @param {function} onConfirm - Callback when user confirms
|
|
||||||
* @param {function} onCancel - Optional callback when user cancels
|
|
||||||
*/
|
|
||||||
function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
|
|
||||||
const modalId = 'confirmModal' + Date.now();
|
|
||||||
|
|
||||||
// Color scheme based on type
|
|
||||||
const colors = {
|
|
||||||
warning: 'var(--terminal-amber)',
|
|
||||||
error: 'var(--status-closed)',
|
|
||||||
info: 'var(--terminal-cyan)'
|
|
||||||
};
|
|
||||||
const color = colors[type] || colors.warning;
|
|
||||||
|
|
||||||
// Icon based on type
|
|
||||||
const icons = {
|
|
||||||
warning: '[ ! ]',
|
|
||||||
error: '[ X ]',
|
|
||||||
info: '[ i ]',
|
|
||||||
};
|
|
||||||
const icon = icons[type] || icons.warning;
|
|
||||||
|
|
||||||
// Escape user-provided content to prevent XSS
|
|
||||||
const safeTitle = lt.escHtml(title);
|
|
||||||
const safeMessage = lt.escHtml(message);
|
|
||||||
|
|
||||||
const modalHtml = `
|
|
||||||
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
|
|
||||||
<div class="lt-modal lt-modal-sm">
|
|
||||||
<div class="lt-modal-header" style="color: ${color};">
|
|
||||||
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
|
|
||||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="lt-modal-body text-center">
|
|
||||||
<p class="modal-message">${safeMessage}</p>
|
|
||||||
</div>
|
|
||||||
<div class="lt-modal-footer">
|
|
||||||
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
|
|
||||||
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
||||||
|
|
||||||
const modal = document.getElementById(modalId);
|
|
||||||
lt.modal.open(modalId);
|
|
||||||
|
|
||||||
const cleanup = (cb) => {
|
|
||||||
lt.modal.close(modalId);
|
|
||||||
setTimeout(() => modal.remove(), 300);
|
|
||||||
if (cb) cb();
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
|
|
||||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
|
||||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a terminal-style input modal
|
* Show a terminal-style input modal
|
||||||
* @param {string} title - Modal title
|
* @param {string} title - Modal title
|
||||||
@@ -1413,7 +1348,7 @@ function populateKanbanCards() {
|
|||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-id">#${lt.escHtml(ticketId)}</span>
|
<span class="card-id">#${lt.escHtml(ticketId)}</span>
|
||||||
<span class="card-priority p${priority}">P${priority}</span>
|
<span class="lt-priority lt-p${priority}"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-title">${lt.escHtml(title)}</div>
|
<div class="card-title">${lt.escHtml(title)}</div>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
|
|||||||
+3
-12
@@ -188,9 +188,11 @@ function addComment() {
|
|||||||
|
|
||||||
commentsList.insertBefore(commentDiv, commentsList.firstChild);
|
commentsList.insertBefore(commentDiv, commentsList.firstChild);
|
||||||
} else {
|
} else {
|
||||||
|
lt.toast.error(data.error || 'Failed to add comment');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
lt.toast.error('Error adding comment: ' + error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,7 +827,7 @@ function renderAttachments(attachments) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="attachment-meta">
|
<div class="attachment-meta">
|
||||||
${lt.escHtml(att.file_size_formatted || formatFileSize(att.file_size))} • ${lt.escHtml(uploaderName)} • ${uploadDate}
|
${lt.escHtml(att.file_size_formatted || lt.bytes.format(att.file_size))} • ${lt.escHtml(uploaderName)} • ${uploadDate}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="attachment-actions">
|
<div class="attachment-actions">
|
||||||
@@ -839,17 +841,6 @@ function renderAttachments(attachments) {
|
|||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes) {
|
|
||||||
if (bytes >= 1073741824) {
|
|
||||||
return (bytes / 1073741824).toFixed(2) + ' GB';
|
|
||||||
} else if (bytes >= 1048576) {
|
|
||||||
return (bytes / 1048576).toFixed(2) + ' MB';
|
|
||||||
} else if (bytes >= 1024) {
|
|
||||||
return (bytes / 1024).toFixed(2) + ' KB';
|
|
||||||
} else {
|
|
||||||
return bytes + ' bytes';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteAttachment(attachmentId) {
|
function deleteAttachment(attachmentId) {
|
||||||
showConfirmModal(
|
showConfirmModal(
|
||||||
|
|||||||
+29
-32
@@ -13,46 +13,43 @@ function getTicketIdFromUrl() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a terminal-style confirmation modal using the lt.modal system.
|
* Show a terminal-style confirmation modal using the lt.modal system.
|
||||||
* Falls back gracefully if dashboard.js has already defined this function.
|
|
||||||
* @param {string} title - Modal title
|
* @param {string} title - Modal title
|
||||||
* @param {string} message - Confirmation message
|
* @param {string} message - Confirmation message
|
||||||
* @param {string} type - 'warning' | 'error' | 'info'
|
* @param {string} type - 'warning' | 'error' | 'info'
|
||||||
* @param {Function} onConfirm - Called when user confirms
|
* @param {Function} onConfirm - Called when user confirms
|
||||||
* @param {Function|null} onCancel - Called when user cancels
|
* @param {Function|null} onCancel - Called when user cancels
|
||||||
*/
|
*/
|
||||||
if (typeof showConfirmModal === 'undefined') {
|
function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
|
||||||
window.showConfirmModal = function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
|
const modalId = 'confirmModal' + Date.now();
|
||||||
const modalId = 'confirmModal' + Date.now();
|
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
|
||||||
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
|
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
|
||||||
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
|
const color = colors[type] || colors.warning;
|
||||||
const color = colors[type] || colors.warning;
|
const icon = icons[type] || icons.warning;
|
||||||
const icon = icons[type] || icons.warning;
|
const safeTitle = lt.escHtml(title);
|
||||||
const safeTitle = lt.escHtml(title);
|
const safeMessage = lt.escHtml(message);
|
||||||
const safeMessage = lt.escHtml(message);
|
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', `
|
document.body.insertAdjacentHTML('beforeend', `
|
||||||
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
|
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
|
||||||
<div class="lt-modal lt-modal-sm">
|
<div class="lt-modal lt-modal-sm">
|
||||||
<div class="lt-modal-header" style="color:${color};">
|
<div class="lt-modal-header" style="color:${color};">
|
||||||
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
|
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
|
||||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-body text-center">
|
<div class="lt-modal-body text-center">
|
||||||
<p class="modal-message">${safeMessage}</p>
|
<p class="modal-message">${safeMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
|
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
|
||||||
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
|
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`);
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
const modal = document.getElementById(modalId);
|
const modal = document.getElementById(modalId);
|
||||||
lt.modal.open(modalId);
|
lt.modal.open(modalId);
|
||||||
const cleanup = (cb) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (cb) cb(); };
|
const cleanup = (cb) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (cb) cb(); };
|
||||||
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
|
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
|
||||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
||||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class DashboardController {
|
|||||||
$totalPages = $result['pages'];
|
$totalPages = $result['pages'];
|
||||||
|
|
||||||
// Load dashboard statistics
|
// Load dashboard statistics
|
||||||
$stats = $this->statsModel->getAllStats();
|
$stats = $this->statsModel->getAllStats($GLOBALS['currentUser'] ?? []);
|
||||||
|
|
||||||
// Load the dashboard view
|
// Load the dashboard view
|
||||||
include 'views/DashboardView.php';
|
include 'views/DashboardView.php';
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ class TicketController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check visibility access
|
// Check visibility access — return 404 rather than 403 to avoid leaking ticket existence
|
||||||
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
if (!$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
header("HTTP/1.0 403 Forbidden");
|
header("HTTP/1.0 404 Not Found");
|
||||||
echo "Access denied: You do not have permission to view this ticket";
|
echo "Ticket not found";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,34 @@ switch (true) {
|
|||||||
require_once 'api/check_duplicates.php';
|
require_once 'api/check_duplicates.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/custom_fields.php':
|
||||||
|
require_once 'api/custom_fields.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/saved_filters.php':
|
||||||
|
require_once 'api/saved_filters.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/audit_log.php':
|
||||||
|
require_once 'api/audit_log.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/user_preferences.php':
|
||||||
|
require_once 'api/user_preferences.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/download_attachment.php':
|
||||||
|
require_once 'api/download_attachment.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/clone_ticket.php':
|
||||||
|
require_once 'api/clone_ticket.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/health.php':
|
||||||
|
require_once 'api/health.php';
|
||||||
|
break;
|
||||||
|
|
||||||
// Admin Routes - require admin privileges
|
// Admin Routes - require admin privileges
|
||||||
case $requestPath == '/admin/recurring-tickets':
|
case $requestPath == '/admin/recurring-tickets':
|
||||||
if (!$currentUser || !$currentUser['is_admin']) {
|
if (!$currentUser || !$currentUser['is_admin']) {
|
||||||
|
|||||||
+47
-15
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
require_once dirname(__DIR__) . '/helpers/CacheHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
class StatsModel {
|
class StatsModel {
|
||||||
private mysqli $conn;
|
private mysqli $conn;
|
||||||
@@ -173,15 +174,19 @@ class StatsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all stats as a single array
|
* Get all stats as a single array, respecting ticket visibility for the given user.
|
||||||
*
|
*
|
||||||
* Uses caching to reduce database load. Stats are cached for STATS_CACHE_TTL seconds.
|
* Admins use a shared cache; non-admins use a per-user cache key so confidential
|
||||||
|
* tickets are not counted in stats for users who cannot access them.
|
||||||
*
|
*
|
||||||
|
* @param array $user Current user array (must include user_id, is_admin, groups)
|
||||||
* @param bool $forceRefresh Force a cache refresh
|
* @param bool $forceRefresh Force a cache refresh
|
||||||
* @return array All dashboard statistics
|
* @return array All dashboard statistics
|
||||||
*/
|
*/
|
||||||
public function getAllStats(bool $forceRefresh = false): array {
|
public function getAllStats(array $user = [], bool $forceRefresh = false): array {
|
||||||
$cacheKey = 'dashboard_all';
|
$isAdmin = !empty($user['is_admin']);
|
||||||
|
// Admins share one cache entry; non-admins get a per-user cache entry
|
||||||
|
$cacheKey = $isAdmin ? 'dashboard_all' : 'dashboard_user_' . ($user['user_id'] ?? 'anon');
|
||||||
|
|
||||||
if ($forceRefresh) {
|
if ($forceRefresh) {
|
||||||
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
|
CacheHelper::delete(self::CACHE_PREFIX, $cacheKey);
|
||||||
@@ -190,21 +195,28 @@ class StatsModel {
|
|||||||
return CacheHelper::remember(
|
return CacheHelper::remember(
|
||||||
self::CACHE_PREFIX,
|
self::CACHE_PREFIX,
|
||||||
$cacheKey,
|
$cacheKey,
|
||||||
function() {
|
function() use ($user) {
|
||||||
return $this->fetchAllStats();
|
return $this->fetchAllStats($user);
|
||||||
},
|
},
|
||||||
self::STATS_CACHE_TTL
|
self::STATS_CACHE_TTL
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all stats from database (uncached)
|
* Fetch all stats from database (uncached), filtered by the given user's visibility.
|
||||||
*
|
*
|
||||||
* Uses consolidated queries to reduce database round-trips from 12 to 4.
|
* Uses consolidated queries to reduce database round-trips.
|
||||||
*
|
*
|
||||||
|
* @param array $user Current user array
|
||||||
* @return array All dashboard statistics
|
* @return array All dashboard statistics
|
||||||
*/
|
*/
|
||||||
private function fetchAllStats(): array {
|
private function fetchAllStats(array $user = []): array {
|
||||||
|
$ticketModel = new TicketModel($this->conn);
|
||||||
|
$visFilter = $ticketModel->getVisibilityFilter($user);
|
||||||
|
$visSQL = $visFilter['sql'];
|
||||||
|
$visParams = $visFilter['params'];
|
||||||
|
$visTypes = $visFilter['types'];
|
||||||
|
|
||||||
// Query 1: Get all simple counts in one query using conditional aggregation
|
// Query 1: Get all simple counts in one query using conditional aggregation
|
||||||
$countsSql = "SELECT
|
$countsSql = "SELECT
|
||||||
SUM(CASE WHEN status IN ('Open', 'Pending', 'In Progress') THEN 1 ELSE 0 END) as open_tickets,
|
SUM(CASE WHEN status IN ('Open', 'Pending', 'In Progress') THEN 1 ELSE 0 END) as open_tickets,
|
||||||
@@ -216,23 +228,43 @@ class StatsModel {
|
|||||||
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
|
SUM(CASE WHEN priority = 1 AND status != 'Closed' THEN 1 ELSE 0 END) as critical,
|
||||||
AVG(CASE WHEN status = 'Closed' AND closed_at > created_at
|
AVG(CASE WHEN status = 'Closed' AND closed_at > created_at
|
||||||
THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution
|
THEN TIMESTAMPDIFF(HOUR, created_at, closed_at) ELSE NULL END) as avg_resolution
|
||||||
FROM tickets";
|
FROM tickets WHERE ($visSQL)";
|
||||||
|
|
||||||
$countsResult = $this->conn->query($countsSql);
|
if (!empty($visParams)) {
|
||||||
|
$stmt = $this->conn->prepare($countsSql);
|
||||||
|
$stmt->bind_param($visTypes, ...$visParams);
|
||||||
|
$stmt->execute();
|
||||||
|
$countsResult = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
|
} else {
|
||||||
|
$countsResult = $this->conn->query($countsSql);
|
||||||
|
}
|
||||||
$counts = $countsResult->fetch_assoc();
|
$counts = $countsResult->fetch_assoc();
|
||||||
|
|
||||||
// Query 2: Get priority, status, and category breakdowns in one query
|
// Query 2: Get priority, status, and category breakdowns in one query
|
||||||
$breakdownSql = "SELECT
|
$breakdownSql = "SELECT
|
||||||
'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count
|
'priority' as type, CONCAT('P', priority) as label, COUNT(*) as count
|
||||||
FROM tickets WHERE status != 'Closed' GROUP BY priority
|
FROM tickets WHERE status != 'Closed' AND ($visSQL) GROUP BY priority
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'status' as type, status as label, COUNT(*) as count
|
SELECT 'status' as type, status as label, COUNT(*) as count
|
||||||
FROM tickets GROUP BY status
|
FROM tickets WHERE ($visSQL) GROUP BY status
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'category' as type, category as label, COUNT(*) as count
|
SELECT 'category' as type, category as label, COUNT(*) as count
|
||||||
FROM tickets WHERE status != 'Closed' GROUP BY category";
|
FROM tickets WHERE status != 'Closed' AND ($visSQL) GROUP BY category";
|
||||||
|
|
||||||
|
if (!empty($visParams)) {
|
||||||
|
// Need to bind params 3 times (once per UNION branch)
|
||||||
|
$tripleParams = array_merge($visParams, $visParams, $visParams);
|
||||||
|
$tripleTypes = $visTypes . $visTypes . $visTypes;
|
||||||
|
$stmt = $this->conn->prepare($breakdownSql);
|
||||||
|
$stmt->bind_param($tripleTypes, ...$tripleParams);
|
||||||
|
$stmt->execute();
|
||||||
|
$breakdownResult = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
|
} else {
|
||||||
|
$breakdownResult = $this->conn->query($breakdownSql);
|
||||||
|
}
|
||||||
|
|
||||||
$breakdownResult = $this->conn->query($breakdownSql);
|
|
||||||
$byPriority = [];
|
$byPriority = [];
|
||||||
$byStatus = [];
|
$byStatus = [];
|
||||||
$byCategory = [];
|
$byCategory = [];
|
||||||
|
|||||||
+31
-7
@@ -183,14 +183,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<?php if (isset($stats)): ?>
|
<?php if (isset($stats)): ?>
|
||||||
<div class="stats-widgets">
|
<div class="stats-widgets">
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<div class="stat-card stat-open">
|
<div class="stat-card lt-stat-card stat-open" data-filter-key="status" data-filter-val="Open,Pending,In Progress" title="Click to filter by open tickets">
|
||||||
<div class="stat-icon">[ # ]</div>
|
<div class="stat-icon">[ # ]</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value"><?php echo $stats['open_tickets']; ?></div>
|
<div class="stat-value"><?php echo $stats['open_tickets']; ?></div>
|
||||||
<div class="stat-label">Open Tickets</div>
|
<div class="stat-label">Open Tickets</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-critical">
|
<div class="stat-card lt-stat-card stat-critical" data-filter-key="priority" data-filter-val="1" title="Click to filter critical tickets">
|
||||||
<div class="stat-icon">[ ! ]</div>
|
<div class="stat-icon">[ ! ]</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value"><?php echo $stats['critical']; ?></div>
|
<div class="stat-value"><?php echo $stats['critical']; ?></div>
|
||||||
@@ -211,7 +211,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<div class="stat-label">Created Today</div>
|
<div class="stat-label">Created Today</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-resolved">
|
<div class="stat-card lt-stat-card stat-resolved" data-filter-key="status" data-filter-val="Closed" title="Click to filter closed tickets">
|
||||||
<div class="stat-icon">[ OK ]</div>
|
<div class="stat-icon">[ OK ]</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value"><?php echo $stats['closed_today']; ?></div>
|
<div class="stat-value"><?php echo $stats['closed_today']; ?></div>
|
||||||
@@ -385,7 +385,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table>
|
<table id="tickets-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
|
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
|
||||||
@@ -438,12 +438,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
}
|
}
|
||||||
|
|
||||||
echo "<td><a href='/ticket/{$row['ticket_id']}' class='ticket-link'>{$row['ticket_id']}</a></td>";
|
echo "<td><a href='/ticket/{$row['ticket_id']}' class='ticket-link'>{$row['ticket_id']}</a></td>";
|
||||||
echo "<td><span>{$row['priority']}</span></td>";
|
$pNum = (int)$row['priority'];
|
||||||
|
echo "<td><span class='lt-priority lt-p{$pNum}'></span></td>";
|
||||||
echo "<td>" . htmlspecialchars($row['title']) . "</td>";
|
echo "<td>" . htmlspecialchars($row['title']) . "</td>";
|
||||||
echo "<td>" . htmlspecialchars($row['category']) . "</td>";
|
echo "<td>" . htmlspecialchars($row['category']) . "</td>";
|
||||||
echo "<td>" . htmlspecialchars($row['type']) . "</td>";
|
echo "<td>" . htmlspecialchars($row['type']) . "</td>";
|
||||||
$statusSlug = htmlspecialchars(str_replace(' ', '-', $row['status']), ENT_QUOTES);
|
$statusSlug = strtolower(str_replace(' ', '-', $row['status']));
|
||||||
echo "<td><span class='status-" . $statusSlug . "'>" . htmlspecialchars($row['status']) . "</span></td>";
|
echo "<td><span class='lt-status lt-status-{$statusSlug}'>" . htmlspecialchars($row['status']) . "</span></td>";
|
||||||
echo "<td>" . htmlspecialchars($creator) . "</td>";
|
echo "<td>" . htmlspecialchars($creator) . "</td>";
|
||||||
echo "<td>" . htmlspecialchars($assignedTo) . "</td>";
|
echo "<td>" . htmlspecialchars($assignedTo) . "</td>";
|
||||||
echo "<td class='ts-cell' data-ts='" . htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . date('Y-m-d H:i T', strtotime($row['created_at'])) . "'>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>";
|
echo "<td class='ts-cell' data-ts='" . htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . date('Y-m-d H:i T', strtotime($row['created_at'])) . "'>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>";
|
||||||
@@ -838,6 +839,29 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<script nonce="<?php echo $nonce; ?>">
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
// Initialize lt keyboard defaults (ESC closes modals, Ctrl+K focuses search, ? shows help)
|
// Initialize lt keyboard defaults (ESC closes modals, Ctrl+K focuses search, ? shows help)
|
||||||
if (window.lt) lt.keys.initDefaults();
|
if (window.lt) lt.keys.initDefaults();
|
||||||
|
// Enable j/k/Enter keyboard row navigation on the ticket table
|
||||||
|
if (window.lt) lt.tableNav.init('tickets-table');
|
||||||
|
// Enable stat card click-to-filter using lt.statsFilter
|
||||||
|
if (window.lt) lt.statsFilter.init();
|
||||||
|
window.lt_onStatFilter = function(key, val) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (key === null) {
|
||||||
|
// Toggle off — clear filter params
|
||||||
|
url.searchParams.delete('status');
|
||||||
|
url.searchParams.delete('priority_min');
|
||||||
|
url.searchParams.delete('priority_max');
|
||||||
|
} else if (key === 'status') {
|
||||||
|
url.searchParams.set('status', val);
|
||||||
|
url.searchParams.delete('priority_min');
|
||||||
|
url.searchParams.delete('priority_max');
|
||||||
|
} else if (key === 'priority') {
|
||||||
|
url.searchParams.set('priority_min', val);
|
||||||
|
url.searchParams.set('priority_max', val);
|
||||||
|
url.searchParams.delete('status');
|
||||||
|
}
|
||||||
|
url.searchParams.delete('page');
|
||||||
|
window.location.href = url.toString();
|
||||||
|
};
|
||||||
// Event delegation for all data-action handlers
|
// Event delegation for all data-action handlers
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
const target = event.target.closest('[data-action]');
|
const target = event.target.closest('[data-action]');
|
||||||
|
|||||||
Reference in New Issue
Block a user