Implement comprehensive improvement plan (Phases 1-6)
Security (Phase 1-2): - Add SecurityHeadersMiddleware with CSP, X-Frame-Options, etc. - Add RateLimitMiddleware for API rate limiting - Add security event logging to AuditLogModel - Add ResponseHelper for standardized API responses - Update config.php with security constants Database (Phase 3): - Add migration 014 for additional indexes - Add migration 015 for ticket dependencies - Add migration 016 for ticket attachments - Add migration 017 for recurring tickets - Add migration 018 for custom fields Features (Phase 4-5): - Add ticket dependencies with DependencyModel and API - Add duplicate detection with check_duplicates API - Add file attachments with AttachmentModel and upload/download APIs - Add @mentions with autocomplete and highlighting - Add quick actions on dashboard rows Collaboration (Phase 5): - Add mention extraction in CommentModel - Add mention autocomplete dropdown in ticket.js - Add mention highlighting CSS styles Admin & Export (Phase 6): - Add StatsModel for dashboard widgets - Add dashboard stats cards (open, critical, unassigned, etc.) - Add CSV/JSON export via export_tickets API - Add rich text editor toolbar in markdown.js - Add RecurringTicketModel with cron job - Add CustomFieldModel for per-category fields - Add admin views: RecurringTickets, CustomFields, Workflow, Templates, AuditLog, UserActivity - Add admin APIs: manage_workflows, manage_templates, manage_recurring, custom_fields, get_users - Add admin routes in index.php Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
.env
|
||||
debug.log
|
||||
.claude
|
||||
settings.local.json
|
||||
@@ -3,6 +3,10 @@
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Start output buffering to capture any errors
|
||||
ob_start();
|
||||
|
||||
@@ -70,12 +74,39 @@ try {
|
||||
$commentModel = new CommentModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// Extract @mentions from comment text
|
||||
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
|
||||
$mentionedUsers = [];
|
||||
if (!empty($mentions)) {
|
||||
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
|
||||
}
|
||||
|
||||
// Add comment with user tracking
|
||||
$result = $commentModel->addComment($ticketId, $data, $userId);
|
||||
|
||||
// Log comment creation to audit log
|
||||
if ($result['success'] && isset($result['comment_id'])) {
|
||||
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
|
||||
|
||||
// Log mentions to audit log
|
||||
foreach ($mentionedUsers as $mentionedUser) {
|
||||
$auditLog->log(
|
||||
$userId,
|
||||
'mention',
|
||||
'user',
|
||||
(string)$mentionedUser['user_id'],
|
||||
[
|
||||
'ticket_id' => $ticketId,
|
||||
'comment_id' => $result['comment_id'],
|
||||
'mentioned_username' => $mentionedUser['username']
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Add mentioned users to result for frontend
|
||||
$result['mentions'] = array_map(function($u) {
|
||||
return $u['username'];
|
||||
}, $mentionedUsers);
|
||||
}
|
||||
|
||||
// Add user display name to result for frontend
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<?php
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
@@ -50,6 +55,7 @@ if ($conn->connect_error) {
|
||||
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$auditLogModel = new AuditLogModel($conn);
|
||||
$userModel = new UserModel($conn);
|
||||
|
||||
if ($assignedTo === null || $assignedTo === '') {
|
||||
// Unassign ticket
|
||||
@@ -58,6 +64,15 @@ if ($assignedTo === null || $assignedTo === '') {
|
||||
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
|
||||
}
|
||||
} else {
|
||||
// Validate assigned_to is a valid user ID
|
||||
$assignedTo = (int)$assignedTo;
|
||||
$targetUser = $userModel->getUserById($assignedTo);
|
||||
if (!$targetUser) {
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
|
||||
$conn->close();
|
||||
exit;
|
||||
}
|
||||
|
||||
// Assign ticket
|
||||
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
|
||||
if ($success) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<?php
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
|
||||
|
||||
117
api/check_duplicates.php
Normal file
117
api/check_duplicates.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
/**
|
||||
* Check for duplicate tickets API
|
||||
*
|
||||
* Searches for tickets with similar titles using LIKE and SOUNDEX
|
||||
*/
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
// Only accept GET requests
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
ResponseHelper::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
// Get title parameter
|
||||
$title = isset($_GET['title']) ? trim($_GET['title']) : '';
|
||||
|
||||
if (strlen($title) < 5) {
|
||||
ResponseHelper::success(['duplicates' => []]);
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
ResponseHelper::serverError('Database connection failed');
|
||||
}
|
||||
|
||||
// Search for similar titles
|
||||
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
|
||||
$duplicates = [];
|
||||
|
||||
// Prepare search term for LIKE
|
||||
$searchTerm = '%' . $conn->real_escape_string($title) . '%';
|
||||
|
||||
// Get SOUNDEX of title
|
||||
$soundexTitle = soundex($title);
|
||||
|
||||
// First, search for exact substring matches (case-insensitive)
|
||||
$sql = "SELECT ticket_id, title, status, priority, created_at
|
||||
FROM tickets
|
||||
WHERE (
|
||||
title LIKE ?
|
||||
OR SOUNDEX(title) = ?
|
||||
)
|
||||
AND status != 'Closed'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10";
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->bind_param("ss", $searchTerm, $soundexTitle);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Calculate similarity score
|
||||
$similarity = 0;
|
||||
|
||||
// Check for exact substring match
|
||||
if (stripos($row['title'], $title) !== false) {
|
||||
$similarity = 90;
|
||||
}
|
||||
// Check SOUNDEX match
|
||||
elseif (soundex($row['title']) === $soundexTitle) {
|
||||
$similarity = 70;
|
||||
}
|
||||
// Check word overlap
|
||||
else {
|
||||
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
|
||||
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
|
||||
$matchingWords = array_intersect($titleWords, $rowWords);
|
||||
$similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60;
|
||||
}
|
||||
|
||||
if ($similarity >= 30) {
|
||||
$duplicates[] = [
|
||||
'ticket_id' => $row['ticket_id'],
|
||||
'title' => $row['title'],
|
||||
'status' => $row['status'],
|
||||
'priority' => $row['priority'],
|
||||
'created_at' => $row['created_at'],
|
||||
'similarity' => round($similarity)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
|
||||
// Sort by similarity descending
|
||||
usort($duplicates, function($a, $b) {
|
||||
return $b['similarity'] - $a['similarity'];
|
||||
});
|
||||
|
||||
// Limit to top 5
|
||||
$duplicates = array_slice($duplicates, 0, 5);
|
||||
|
||||
$conn->close();
|
||||
|
||||
ResponseHelper::success(['duplicates' => $duplicates]);
|
||||
111
api/custom_fields.php
Normal file
111
api/custom_fields.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom Fields Management API
|
||||
* CRUD operations for custom field definitions
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed");
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$model = new CustomFieldModel($conn);
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
$field = $model->getDefinition($id);
|
||||
echo json_encode(['success' => (bool)$field, 'field' => $field]);
|
||||
} else {
|
||||
// Get all definitions, optionally filtered by category
|
||||
$activeOnly = !isset($_GET['include_inactive']);
|
||||
$fields = $model->getAllDefinitions($category, $activeOnly);
|
||||
echo json_encode(['success' => true, 'fields' => $fields]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$result = $model->createDefinition($data);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$result = $model->updateDefinition($id, $data);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = $model->deleteDefinition($id);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
102
api/delete_attachment.php
Normal file
102
api/delete_attachment.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
/**
|
||||
* Delete Attachment API
|
||||
*
|
||||
* Handles deletion of ticket attachments
|
||||
*/
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
// Only accept DELETE or POST requests
|
||||
if (!in_array($_SERVER['REQUEST_METHOD'], ['DELETE', 'POST'])) {
|
||||
ResponseHelper::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
// Get request body
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = array_merge($_POST, $input ?? []);
|
||||
}
|
||||
|
||||
// Verify CSRF token
|
||||
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
$auditLog = new AuditLogModel();
|
||||
$auditLog->logCsrfFailure($_SESSION['user']['user_id'] ?? null, 'delete_attachment');
|
||||
ResponseHelper::forbidden('Invalid CSRF token');
|
||||
}
|
||||
|
||||
// Get attachment ID
|
||||
$attachmentId = $input['attachment_id'] ?? null;
|
||||
if (!$attachmentId || !is_numeric($attachmentId)) {
|
||||
ResponseHelper::error('Valid attachment ID is required');
|
||||
}
|
||||
|
||||
$attachmentId = (int)$attachmentId;
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel();
|
||||
|
||||
// Get attachment details
|
||||
$attachment = $attachmentModel->getAttachment($attachmentId);
|
||||
if (!$attachment) {
|
||||
ResponseHelper::notFound('Attachment not found');
|
||||
}
|
||||
|
||||
// Check permission
|
||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||
if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) {
|
||||
ResponseHelper::forbidden('You do not have permission to delete this attachment');
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
if (!unlink($filePath)) {
|
||||
ResponseHelper::serverError('Failed to delete file');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
if (!$attachmentModel->deleteAttachment($attachmentId)) {
|
||||
ResponseHelper::serverError('Failed to delete attachment record');
|
||||
}
|
||||
|
||||
// Log the deletion
|
||||
$auditLog = new AuditLogModel();
|
||||
$auditLog->log(
|
||||
$_SESSION['user']['user_id'],
|
||||
'attachment_delete',
|
||||
'ticket_attachments',
|
||||
$attachmentId,
|
||||
json_encode([
|
||||
'ticket_id' => $attachment['ticket_id'],
|
||||
'filename' => $attachment['original_filename'],
|
||||
'size' => $attachment['file_size']
|
||||
]),
|
||||
null
|
||||
);
|
||||
|
||||
ResponseHelper::success([], 'Attachment deleted successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
ResponseHelper::serverError('Failed to delete attachment');
|
||||
}
|
||||
101
api/download_attachment.php
Normal file
101
api/download_attachment.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* Download Attachment API
|
||||
*
|
||||
* Serves file downloads for ticket attachments
|
||||
*/
|
||||
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get attachment ID
|
||||
$attachmentId = $_GET['id'] ?? null;
|
||||
if (!$attachmentId || !is_numeric($attachmentId)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$attachmentId = (int)$attachmentId;
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel();
|
||||
|
||||
// Get attachment details
|
||||
$attachment = $attachmentModel->getAttachment($attachmentId);
|
||||
if (!$attachment) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Attachment not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Build file path
|
||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
|
||||
|
||||
// Check if file exists
|
||||
if (!file_exists($filePath)) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'File not found on server']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Determine if we should display inline or force download
|
||||
$inline = isset($_GET['inline']) && $_GET['inline'] === '1';
|
||||
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
|
||||
|
||||
// Set headers
|
||||
$disposition = ($inline && in_array($attachment['mime_type'], $inlineTypes)) ? 'inline' : 'attachment';
|
||||
|
||||
// Sanitize filename for Content-Disposition
|
||||
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
||||
|
||||
header('Content-Type: ' . $attachment['mime_type']);
|
||||
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
||||
header('Content-Length: ' . $attachment['file_size']);
|
||||
header('Cache-Control: private, max-age=3600');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
// Prevent PHP from timing out on large files
|
||||
set_time_limit(0);
|
||||
|
||||
// Clear output buffer
|
||||
if (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
// Stream file
|
||||
$handle = fopen($filePath, 'rb');
|
||||
if ($handle === false) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to open file']);
|
||||
exit;
|
||||
}
|
||||
|
||||
while (!feof($handle)) {
|
||||
echo fread($handle, 8192);
|
||||
flush();
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
exit;
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to download attachment']);
|
||||
exit;
|
||||
}
|
||||
148
api/export_tickets.php
Normal file
148
api/export_tickets.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
/**
|
||||
* Export Tickets API
|
||||
*
|
||||
* Exports tickets to CSV format with optional filtering
|
||||
*/
|
||||
|
||||
// Disable error display in the output
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
// Include required files
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$currentUser = $_SESSION['user'];
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed");
|
||||
}
|
||||
|
||||
// Get filter parameters
|
||||
$status = isset($_GET['status']) ? $_GET['status'] : null;
|
||||
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||
$type = isset($_GET['type']) ? $_GET['type'] : null;
|
||||
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
|
||||
$format = isset($_GET['format']) ? $_GET['format'] : 'csv';
|
||||
|
||||
// Initialize model
|
||||
$ticketModel = new TicketModel($conn);
|
||||
|
||||
// Get all tickets (no pagination for export)
|
||||
$result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search);
|
||||
$tickets = $result['tickets'];
|
||||
|
||||
if ($format === 'csv') {
|
||||
// CSV Export
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.csv"');
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
// Create output stream
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// Add BOM for Excel UTF-8 compatibility
|
||||
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
|
||||
// CSV Headers
|
||||
$headers = [
|
||||
'Ticket ID',
|
||||
'Title',
|
||||
'Status',
|
||||
'Priority',
|
||||
'Category',
|
||||
'Type',
|
||||
'Created By',
|
||||
'Assigned To',
|
||||
'Created At',
|
||||
'Updated At',
|
||||
'Description'
|
||||
];
|
||||
fputcsv($output, $headers);
|
||||
|
||||
// CSV Data
|
||||
foreach ($tickets as $ticket) {
|
||||
$row = [
|
||||
$ticket['ticket_id'],
|
||||
$ticket['title'],
|
||||
$ticket['status'],
|
||||
'P' . $ticket['priority'],
|
||||
$ticket['category'],
|
||||
$ticket['type'],
|
||||
$ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
|
||||
$ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
|
||||
$ticket['created_at'],
|
||||
$ticket['updated_at'],
|
||||
$ticket['description']
|
||||
];
|
||||
fputcsv($output, $row);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
exit;
|
||||
|
||||
} elseif ($format === 'json') {
|
||||
// JSON Export
|
||||
header('Content-Type: application/json');
|
||||
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.json"');
|
||||
|
||||
echo json_encode([
|
||||
'exported_at' => date('c'),
|
||||
'total_tickets' => count($tickets),
|
||||
'tickets' => array_map(function($t) {
|
||||
return [
|
||||
'ticket_id' => $t['ticket_id'],
|
||||
'title' => $t['title'],
|
||||
'status' => $t['status'],
|
||||
'priority' => $t['priority'],
|
||||
'category' => $t['category'],
|
||||
'type' => $t['type'],
|
||||
'description' => $t['description'],
|
||||
'created_by' => $t['creator_display_name'] ?? $t['creator_username'],
|
||||
'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'],
|
||||
'created_at' => $t['created_at'],
|
||||
'updated_at' => $t['updated_at']
|
||||
];
|
||||
}, $tickets)
|
||||
], JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
|
||||
} else {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv or json.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
@@ -1,33 +1,57 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
/**
|
||||
* Get Users API
|
||||
* Returns list of users for @mentions autocomplete
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||
exit;
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed");
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Get all active users for mentions
|
||||
$sql = "SELECT user_id, username, display_name FROM users WHERE is_active = 1 ORDER BY display_name, username";
|
||||
$result = $conn->query($sql);
|
||||
|
||||
$users = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$users[] = [
|
||||
'user_id' => $row['user_id'],
|
||||
'username' => $row['username'],
|
||||
'display_name' => $row['display_name']
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'users' => $users]);
|
||||
|
||||
$conn->close();
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get all users
|
||||
$userModel = new UserModel($conn);
|
||||
$users = $userModel->getAllUsers();
|
||||
|
||||
$conn->close();
|
||||
|
||||
echo json_encode(['success' => true, 'users' => $users]);
|
||||
|
||||
169
api/manage_recurring.php
Normal file
169
api/manage_recurring.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
/**
|
||||
* Recurring Tickets Management API
|
||||
* CRUD operations for recurring_tickets table
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$currentUserId = $_SESSION['user']['user_id'];
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed");
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$model = new RecurringTicketModel($conn);
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
$action = isset($_GET['action']) ? $_GET['action'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
$recurring = $model->getById($id);
|
||||
echo json_encode(['success' => (bool)$recurring, 'recurring' => $recurring]);
|
||||
} else {
|
||||
$all = $model->getAll(true);
|
||||
echo json_encode(['success' => true, 'recurring_tickets' => $all]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
if ($action === 'toggle' && $id) {
|
||||
$result = $model->toggleActive($id);
|
||||
echo json_encode($result);
|
||||
} else {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Calculate next run time
|
||||
$nextRun = calculateNextRun(
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'] ?? null,
|
||||
$data['schedule_time'] ?? '09:00'
|
||||
);
|
||||
|
||||
$data['next_run_at'] = $nextRun;
|
||||
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
|
||||
$data['created_by'] = $currentUserId;
|
||||
|
||||
$result = $model->create($data);
|
||||
echo json_encode($result);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Recalculate next run time if schedule changed
|
||||
$nextRun = calculateNextRun(
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'] ?? null,
|
||||
$data['schedule_time'] ?? '09:00'
|
||||
);
|
||||
$data['next_run_at'] = $nextRun;
|
||||
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
|
||||
|
||||
$result = $model->update($id, $data);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = $model->delete($id);
|
||||
echo json_encode($result);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime) {
|
||||
$now = new DateTime();
|
||||
$time = $scheduleTime ?: '09:00';
|
||||
|
||||
switch ($scheduleType) {
|
||||
case 'daily':
|
||||
$next = new DateTime('tomorrow ' . $time);
|
||||
break;
|
||||
|
||||
case 'weekly':
|
||||
$days = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
$dayName = $days[$scheduleDay] ?? 'Monday';
|
||||
$next = new DateTime("next {$dayName} " . $time);
|
||||
break;
|
||||
|
||||
case 'monthly':
|
||||
$day = max(1, min(28, (int)$scheduleDay));
|
||||
$next = new DateTime();
|
||||
$next->modify('first day of next month');
|
||||
$next->setDate($next->format('Y'), $next->format('m'), $day);
|
||||
list($h, $m) = explode(':', $time);
|
||||
$next->setTime((int)$h, (int)$m, 0);
|
||||
break;
|
||||
|
||||
default:
|
||||
$next = new DateTime('tomorrow ' . $time);
|
||||
}
|
||||
|
||||
return $next->format('Y-m-d H:i:s');
|
||||
}
|
||||
153
api/manage_templates.php
Normal file
153
api/manage_templates.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Management API
|
||||
* CRUD operations for ticket_templates table
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed");
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
// Get single template
|
||||
$stmt = $conn->prepare("SELECT * FROM ticket_templates WHERE template_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$template = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
echo json_encode(['success' => true, 'template' => $template]);
|
||||
} else {
|
||||
// Get all templates
|
||||
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
|
||||
$templates = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$templates[] = $row;
|
||||
}
|
||||
echo json_encode(['success' => true, 'templates' => $templates]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO ticket_templates
|
||||
(template_name, title_template, description_template, category, type, priority, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('sssssii',
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['priority'] ?? 4,
|
||||
$data['is_active'] ?? 1
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['success' => true, 'template_id' => $conn->insert_id]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'error' => $stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("UPDATE ticket_templates SET
|
||||
template_name = ?, title_template = ?, description_template = ?,
|
||||
category = ?, type = ?, priority = ?, is_active = ?
|
||||
WHERE template_id = ?");
|
||||
$stmt->bind_param('sssssiii',
|
||||
$data['template_name'],
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['priority'] ?? 4,
|
||||
$data['is_active'] ?? 1,
|
||||
$id
|
||||
);
|
||||
|
||||
echo json_encode(['success' => $stmt->execute()]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM ticket_templates WHERE template_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
echo json_encode(['success' => $stmt->execute()]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
147
api/manage_workflows.php
Normal file
147
api/manage_workflows.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
/**
|
||||
* Workflow/Status Transitions Management API
|
||||
* CRUD operations for status_transitions table
|
||||
*/
|
||||
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
try {
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
|
||||
// Check authentication
|
||||
session_start();
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Check admin privileges
|
||||
if (!$_SESSION['user']['is_admin']) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF Protection for write operations
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed");
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
if ($id) {
|
||||
// Get single transition
|
||||
$stmt = $conn->prepare("SELECT * FROM status_transitions WHERE transition_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$transition = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
echo json_encode(['success' => true, 'transition' => $transition]);
|
||||
} else {
|
||||
// Get all transitions
|
||||
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
|
||||
$transitions = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$transitions[] = $row;
|
||||
}
|
||||
echo json_encode(['success' => true, 'transitions' => $transitions]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
|
||||
VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('ssiii',
|
||||
$data['from_status'],
|
||||
$data['to_status'],
|
||||
$data['requires_comment'] ?? 0,
|
||||
$data['requires_admin'] ?? 0,
|
||||
$data['is_active'] ?? 1
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['success' => true, 'transition_id' => $conn->insert_id]);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'error' => $stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$stmt = $conn->prepare("UPDATE status_transitions SET
|
||||
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
|
||||
WHERE transition_id = ?");
|
||||
$stmt->bind_param('ssiiii',
|
||||
$data['from_status'],
|
||||
$data['to_status'],
|
||||
$data['requires_comment'] ?? 0,
|
||||
$data['requires_admin'] ?? 0,
|
||||
$data['is_active'] ?? 1,
|
||||
$id
|
||||
);
|
||||
|
||||
echo json_encode(['success' => $stmt->execute()]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if (!$id) {
|
||||
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM status_transitions WHERE transition_id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
echo json_encode(['success' => $stmt->execute()]);
|
||||
$stmt->close();
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
143
api/ticket_dependencies.php
Normal file
143
api/ticket_dependencies.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
/**
|
||||
* Ticket Dependencies API
|
||||
*
|
||||
* GET: Get dependencies for a ticket
|
||||
* POST: Add a new dependency
|
||||
* DELETE: Remove a dependency
|
||||
*/
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
|
||||
// CSRF Protection for POST/DELETE
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
ResponseHelper::forbidden('Invalid CSRF token');
|
||||
}
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
ResponseHelper::serverError('Database connection failed');
|
||||
}
|
||||
|
||||
$dependencyModel = new DependencyModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
// Get dependencies for a ticket
|
||||
$ticketId = $_GET['ticket_id'] ?? null;
|
||||
|
||||
if (!$ticketId) {
|
||||
ResponseHelper::error('Ticket ID required');
|
||||
}
|
||||
|
||||
$dependencies = $dependencyModel->getDependencies($ticketId);
|
||||
$dependents = $dependencyModel->getDependentTickets($ticketId);
|
||||
|
||||
ResponseHelper::success([
|
||||
'dependencies' => $dependencies,
|
||||
'dependents' => $dependents
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
// Add a new dependency
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$ticketId = $data['ticket_id'] ?? null;
|
||||
$dependsOnId = $data['depends_on_id'] ?? null;
|
||||
$type = $data['dependency_type'] ?? 'blocks';
|
||||
|
||||
if (!$ticketId || !$dependsOnId) {
|
||||
ResponseHelper::error('Both ticket_id and depends_on_id are required');
|
||||
}
|
||||
|
||||
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
|
||||
|
||||
if ($result['success']) {
|
||||
// Log to audit
|
||||
$auditLog->log($userId, 'create', 'dependency', (string)$result['dependency_id'], [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $dependsOnId,
|
||||
'type' => $type
|
||||
]);
|
||||
|
||||
ResponseHelper::created($result);
|
||||
} else {
|
||||
ResponseHelper::error($result['error']);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
// Remove a dependency
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$dependencyId = $data['dependency_id'] ?? null;
|
||||
|
||||
// Alternative: delete by ticket IDs
|
||||
if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) {
|
||||
$ticketId = $data['ticket_id'];
|
||||
$dependsOnId = $data['depends_on_id'];
|
||||
$type = $data['dependency_type'] ?? 'blocks';
|
||||
|
||||
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
|
||||
|
||||
if ($result) {
|
||||
$auditLog->log($userId, 'delete', 'dependency', null, [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $dependsOnId,
|
||||
'type' => $type
|
||||
]);
|
||||
ResponseHelper::success([], 'Dependency removed');
|
||||
} else {
|
||||
ResponseHelper::error('Failed to remove dependency');
|
||||
}
|
||||
} elseif ($dependencyId) {
|
||||
$result = $dependencyModel->removeDependency($dependencyId);
|
||||
|
||||
if ($result) {
|
||||
$auditLog->log($userId, 'delete', 'dependency', (string)$dependencyId);
|
||||
ResponseHelper::success([], 'Dependency removed');
|
||||
} else {
|
||||
ResponseHelper::error('Failed to remove dependency');
|
||||
}
|
||||
} else {
|
||||
ResponseHelper::error('Dependency ID or ticket IDs required');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
ResponseHelper::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
@@ -3,22 +3,17 @@
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 0); // Don't display errors in the response
|
||||
|
||||
// Define a debug log function
|
||||
function debug_log($message) {
|
||||
file_put_contents('/tmp/api_debug.log', date('Y-m-d H:i:s') . " - $message\n", FILE_APPEND);
|
||||
}
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
// Start output buffering to capture any errors
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
debug_log("Script started");
|
||||
|
||||
// Load config
|
||||
$configPath = dirname(__DIR__) . '/config/config.php';
|
||||
debug_log("Loading config from: $configPath");
|
||||
require_once $configPath;
|
||||
debug_log("Config loaded successfully");
|
||||
|
||||
// Load environment variables (for Discord webhook)
|
||||
$envPath = dirname(__DIR__) . '/.env';
|
||||
@@ -38,7 +33,6 @@ try {
|
||||
$envVars[$key] = $value;
|
||||
}
|
||||
}
|
||||
debug_log("Environment variables loaded");
|
||||
}
|
||||
|
||||
// Load models directly with absolute paths
|
||||
@@ -47,12 +41,10 @@ try {
|
||||
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
$workflowModelPath = dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||
|
||||
debug_log("Loading models from: $ticketModelPath and $commentModelPath");
|
||||
require_once $ticketModelPath;
|
||||
require_once $commentModelPath;
|
||||
require_once $auditLogModelPath;
|
||||
require_once $workflowModelPath;
|
||||
debug_log("Models loaded successfully");
|
||||
|
||||
// Check authentication via session
|
||||
session_start();
|
||||
@@ -75,7 +67,6 @@ try {
|
||||
$currentUser = $_SESSION['user'];
|
||||
$userId = $currentUser['user_id'];
|
||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||
debug_log("User authenticated: " . $currentUser['username'] . " (admin: " . ($isAdmin ? 'yes' : 'no') . ")");
|
||||
|
||||
// Updated controller class that handles partial updates
|
||||
class ApiTicketController {
|
||||
@@ -98,8 +89,6 @@ try {
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
debug_log("ApiTicketController->update called with ID: $id and data: " . json_encode($data));
|
||||
|
||||
// First, get the current ticket data to fill in missing fields
|
||||
$currentTicket = $this->ticketModel->getTicketById($id);
|
||||
if (!$currentTicket) {
|
||||
@@ -109,8 +98,6 @@ try {
|
||||
];
|
||||
}
|
||||
|
||||
debug_log("Current ticket data: " . json_encode($currentTicket));
|
||||
|
||||
// Merge current data with updates, keeping existing values for missing fields
|
||||
$updateData = [
|
||||
'ticket_id' => $id,
|
||||
@@ -122,8 +109,6 @@ try {
|
||||
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
|
||||
];
|
||||
|
||||
debug_log("Merged update data: " . json_encode($updateData));
|
||||
|
||||
// Validate required fields
|
||||
if (empty($updateData['title'])) {
|
||||
return [
|
||||
@@ -156,13 +141,9 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
debug_log("Validation passed, calling ticketModel->updateTicket");
|
||||
|
||||
// Update ticket with user tracking
|
||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId);
|
||||
|
||||
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
|
||||
|
||||
if ($result) {
|
||||
// Log ticket update to audit log
|
||||
if ($this->userId) {
|
||||
@@ -188,12 +169,10 @@ try {
|
||||
|
||||
private function sendDiscordWebhook($ticketId, $oldData, $newData, $changedFields) {
|
||||
if (!isset($this->envVars['DISCORD_WEBHOOK_URL']) || empty($this->envVars['DISCORD_WEBHOOK_URL'])) {
|
||||
debug_log("Discord webhook URL not configured, skipping webhook");
|
||||
return;
|
||||
}
|
||||
|
||||
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
|
||||
debug_log("Sending Discord webhook to: $webhookUrl");
|
||||
|
||||
// Determine what fields actually changed
|
||||
$changes = [];
|
||||
@@ -211,7 +190,6 @@ try {
|
||||
}
|
||||
|
||||
if (empty($changes)) {
|
||||
debug_log("No actual changes detected, skipping webhook");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -249,15 +227,12 @@ try {
|
||||
'embeds' => [$embed]
|
||||
];
|
||||
|
||||
debug_log("Discord payload: " . json_encode($payload));
|
||||
|
||||
// Send webhook
|
||||
$ch = curl_init($webhookUrl);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
|
||||
$webhookResult = curl_exec($ch);
|
||||
@@ -265,18 +240,11 @@ try {
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
debug_log("Discord webhook cURL error: $curlError");
|
||||
} else {
|
||||
debug_log("Discord webhook sent. HTTP Code: $httpCode, Response: $webhookResult");
|
||||
}
|
||||
// Silently handle errors - webhook is optional
|
||||
}
|
||||
}
|
||||
|
||||
debug_log("Controller defined successfully");
|
||||
|
||||
// Create database connection
|
||||
debug_log("Creating database connection");
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
@@ -287,7 +255,6 @@ try {
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||
}
|
||||
debug_log("Database connection successful");
|
||||
|
||||
// Check request method
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
@@ -297,8 +264,6 @@ try {
|
||||
// Get POST data
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
debug_log("Received raw input: " . $input);
|
||||
debug_log("Decoded data: " . json_encode($data));
|
||||
|
||||
if (!$data) {
|
||||
throw new Exception("Invalid JSON data received: " . $input);
|
||||
@@ -309,17 +274,12 @@ try {
|
||||
}
|
||||
|
||||
$ticketId = (int)$data['ticket_id'];
|
||||
debug_log("Processing ticket ID: $ticketId");
|
||||
|
||||
// Initialize controller
|
||||
debug_log("Initializing controller");
|
||||
$controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin);
|
||||
debug_log("Controller initialized");
|
||||
|
||||
// Update ticket
|
||||
debug_log("Calling controller update method");
|
||||
$result = $controller->update($ticketId, $data);
|
||||
debug_log("Update completed with result: " . json_encode($result));
|
||||
|
||||
// Close database connection
|
||||
$conn->close();
|
||||
@@ -330,12 +290,8 @@ try {
|
||||
// Return response
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($result);
|
||||
debug_log("Response sent successfully");
|
||||
|
||||
} catch (Exception $e) {
|
||||
debug_log("Error: " . $e->getMessage());
|
||||
debug_log("Stack trace: " . $e->getTraceAsString());
|
||||
|
||||
// Discard any output that might have been generated
|
||||
ob_end_clean();
|
||||
|
||||
@@ -346,6 +302,5 @@ try {
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
debug_log("Error response sent");
|
||||
}
|
||||
?>
|
||||
201
api/upload_attachment.php
Normal file
201
api/upload_attachment.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
/**
|
||||
* Upload Attachment API
|
||||
*
|
||||
* Handles file uploads for ticket attachments
|
||||
*/
|
||||
|
||||
// Apply rate limiting
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api');
|
||||
|
||||
session_start();
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Check authentication
|
||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
// Handle GET requests to list attachments
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$ticketId = $_GET['ticket_id'] ?? '';
|
||||
|
||||
if (empty($ticketId)) {
|
||||
ResponseHelper::error('Ticket ID is required');
|
||||
}
|
||||
|
||||
// Validate ticket ID format
|
||||
if (!preg_match('/^[A-Z]{3}\d{6}$/', $ticketId)) {
|
||||
ResponseHelper::error('Invalid ticket ID format');
|
||||
}
|
||||
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel();
|
||||
$attachments = $attachmentModel->getAttachments($ticketId);
|
||||
|
||||
// Add formatted file size and icon to each attachment
|
||||
foreach ($attachments as &$att) {
|
||||
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
|
||||
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
||||
}
|
||||
|
||||
ResponseHelper::success(['attachments' => $attachments]);
|
||||
} catch (Exception $e) {
|
||||
ResponseHelper::serverError('Failed to load attachments');
|
||||
}
|
||||
}
|
||||
|
||||
// Only accept POST requests for uploads
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
ResponseHelper::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
// Verify CSRF token
|
||||
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
$auditLog = new AuditLogModel();
|
||||
$auditLog->logCsrfFailure($_SESSION['user']['user_id'] ?? null, 'upload_attachment');
|
||||
ResponseHelper::forbidden('Invalid CSRF token');
|
||||
}
|
||||
|
||||
// Get ticket ID
|
||||
$ticketId = $_POST['ticket_id'] ?? '';
|
||||
if (empty($ticketId)) {
|
||||
ResponseHelper::error('Ticket ID is required');
|
||||
}
|
||||
|
||||
// Validate ticket ID format
|
||||
if (!preg_match('/^[A-Z]{3}\d{6}$/', $ticketId)) {
|
||||
ResponseHelper::error('Invalid ticket ID format');
|
||||
}
|
||||
|
||||
// Check if file was uploaded
|
||||
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
|
||||
ResponseHelper::error('No file uploaded');
|
||||
}
|
||||
|
||||
$file = $_FILES['file'];
|
||||
|
||||
// Check for upload errors
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$errorMessages = [
|
||||
UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize directive',
|
||||
UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive',
|
||||
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
|
||||
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
|
||||
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
|
||||
UPLOAD_ERR_EXTENSION => 'File upload stopped by extension'
|
||||
];
|
||||
$message = $errorMessages[$file['error']] ?? 'Unknown upload error';
|
||||
ResponseHelper::error($message);
|
||||
}
|
||||
|
||||
// Check file size
|
||||
$maxSize = $GLOBALS['config']['MAX_UPLOAD_SIZE'] ?? 10485760; // 10MB default
|
||||
if ($file['size'] > $maxSize) {
|
||||
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
|
||||
}
|
||||
|
||||
// Get MIME type
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->file($file['tmp_name']);
|
||||
|
||||
// Validate file type
|
||||
if (!AttachmentModel::isAllowedType($mimeType)) {
|
||||
ResponseHelper::error('File type not allowed: ' . $mimeType);
|
||||
}
|
||||
|
||||
// Create upload directory if it doesn't exist
|
||||
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||
if (!is_dir($uploadDir)) {
|
||||
if (!mkdir($uploadDir, 0755, true)) {
|
||||
ResponseHelper::serverError('Failed to create upload directory');
|
||||
}
|
||||
}
|
||||
|
||||
// Create ticket subdirectory
|
||||
$ticketDir = $uploadDir . '/' . $ticketId;
|
||||
if (!is_dir($ticketDir)) {
|
||||
if (!mkdir($ticketDir, 0755, true)) {
|
||||
ResponseHelper::serverError('Failed to create ticket upload directory');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
|
||||
$safeExtension = preg_replace('/[^a-zA-Z0-9]/', '', $extension);
|
||||
$uniqueFilename = uniqid('att_', true) . ($safeExtension ? '.' . $safeExtension : '');
|
||||
$targetPath = $ticketDir . '/' . $uniqueFilename;
|
||||
|
||||
// Move uploaded file
|
||||
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||
ResponseHelper::serverError('Failed to move uploaded file');
|
||||
}
|
||||
|
||||
// Sanitize original filename
|
||||
$originalFilename = basename($file['name']);
|
||||
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
|
||||
if (empty($originalFilename)) {
|
||||
$originalFilename = 'attachment' . ($safeExtension ? '.' . $safeExtension : '');
|
||||
}
|
||||
|
||||
// Save to database
|
||||
try {
|
||||
$attachmentModel = new AttachmentModel();
|
||||
$attachmentId = $attachmentModel->addAttachment(
|
||||
$ticketId,
|
||||
$uniqueFilename,
|
||||
$originalFilename,
|
||||
$file['size'],
|
||||
$mimeType,
|
||||
$_SESSION['user']['user_id']
|
||||
);
|
||||
|
||||
if (!$attachmentId) {
|
||||
// Clean up file if database insert fails
|
||||
unlink($targetPath);
|
||||
ResponseHelper::serverError('Failed to save attachment record');
|
||||
}
|
||||
|
||||
// Log the upload
|
||||
$auditLog = new AuditLogModel();
|
||||
$auditLog->log(
|
||||
$_SESSION['user']['user_id'],
|
||||
'attachment_upload',
|
||||
'ticket_attachments',
|
||||
$attachmentId,
|
||||
null,
|
||||
json_encode([
|
||||
'ticket_id' => $ticketId,
|
||||
'filename' => $originalFilename,
|
||||
'size' => $file['size'],
|
||||
'mime_type' => $mimeType
|
||||
])
|
||||
);
|
||||
|
||||
ResponseHelper::created([
|
||||
'attachment_id' => $attachmentId,
|
||||
'filename' => $originalFilename,
|
||||
'file_size' => $file['size'],
|
||||
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
|
||||
'mime_type' => $mimeType,
|
||||
'icon' => AttachmentModel::getFileIcon($mimeType),
|
||||
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
||||
'uploaded_at' => date('Y-m-d H:i:s')
|
||||
], 'File uploaded successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Clean up file on error
|
||||
if (file_exists($targetPath)) {
|
||||
unlink($targetPath);
|
||||
}
|
||||
ResponseHelper::serverError('Failed to process attachment');
|
||||
}
|
||||
@@ -3000,3 +3000,169 @@ code.inline-code {
|
||||
margin-top: 0.25rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ===== QUICK ACTIONS ===== */
|
||||
.quick-actions-cell {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
tr:hover .quick-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.quick-action-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--terminal-green);
|
||||
color: var(--terminal-green);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.quick-action-btn:hover {
|
||||
background: var(--terminal-green);
|
||||
color: var(--bg-primary);
|
||||
box-shadow: var(--glow-green);
|
||||
}
|
||||
|
||||
/* ===== DASHBOARD STATS WIDGETS ===== */
|
||||
.stats-widgets {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--terminal-green);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--terminal-amber);
|
||||
box-shadow: var(--glow-amber);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 1.5rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--terminal-green);
|
||||
line-height: 1;
|
||||
text-shadow: var(--glow-green);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--terminal-green-dim, #008822);
|
||||
margin-top: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Stat card color variations */
|
||||
.stat-critical .stat-value {
|
||||
color: var(--priority-1);
|
||||
text-shadow: var(--glow-red);
|
||||
}
|
||||
|
||||
.stat-critical {
|
||||
border-color: var(--priority-1);
|
||||
}
|
||||
|
||||
.stat-unassigned .stat-value {
|
||||
color: var(--terminal-amber);
|
||||
text-shadow: var(--glow-amber);
|
||||
}
|
||||
|
||||
.stat-unassigned {
|
||||
border-color: var(--terminal-amber);
|
||||
}
|
||||
|
||||
/* Mobile responsive stats */
|
||||
@media (max-width: 768px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== EXPORT DROPDOWN ===== */
|
||||
.export-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.export-dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: var(--bg-primary);
|
||||
border: 2px solid var(--terminal-green);
|
||||
min-width: 120px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 0 10px rgba(0, 255, 65, 0.3);
|
||||
}
|
||||
|
||||
.export-dropdown:hover .export-dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.export-dropdown-content a {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--terminal-green);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.export-dropdown-content a:hover {
|
||||
background: rgba(0, 255, 65, 0.1);
|
||||
color: var(--terminal-amber);
|
||||
}
|
||||
|
||||
@@ -1179,3 +1179,323 @@ body.dark-mode .editable {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== ATTACHMENT STYLES ===== */
|
||||
|
||||
/* Upload Zone */
|
||||
.upload-zone {
|
||||
border: 3px dashed var(--terminal-green);
|
||||
border-radius: 0;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: var(--terminal-amber);
|
||||
background: rgba(0, 255, 65, 0.05);
|
||||
}
|
||||
|
||||
.upload-zone.drag-over {
|
||||
border-color: var(--terminal-amber);
|
||||
background: rgba(241, 196, 15, 0.1);
|
||||
box-shadow: 0 0 20px rgba(241, 196, 15, 0.3);
|
||||
}
|
||||
|
||||
.upload-zone-content {
|
||||
color: var(--terminal-green);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--terminal-green-dim);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
border: 2px solid var(--terminal-green);
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--terminal-green);
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.2) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: progress-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* Attachments Grid */
|
||||
.attachments-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--terminal-green);
|
||||
background: var(--bg-primary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.attachment-item:hover {
|
||||
border-color: var(--terminal-amber);
|
||||
background: rgba(0, 255, 65, 0.05);
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attachment-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.attachment-name a {
|
||||
color: var(--terminal-green);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.attachment-name a:hover {
|
||||
color: var(--terminal-amber);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.attachment-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--terminal-green-dim);
|
||||
font-family: var(--font-mono);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--priority-1);
|
||||
border-color: var(--priority-1);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--priority-1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness for attachments */
|
||||
@media (max-width: 768px) {
|
||||
.attachment-item {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.attachment-info {
|
||||
flex-basis: calc(100% - 4rem);
|
||||
}
|
||||
|
||||
.attachment-actions {
|
||||
flex-basis: 100%;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== @MENTION HIGHLIGHTING STYLES ===== */
|
||||
|
||||
/* Mention styling in comments */
|
||||
.mention {
|
||||
color: var(--terminal-cyan);
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mention:hover {
|
||||
background: rgba(0, 255, 255, 0.2);
|
||||
text-shadow: 0 0 5px var(--terminal-cyan);
|
||||
}
|
||||
|
||||
.mention::before {
|
||||
content: '@';
|
||||
}
|
||||
|
||||
/* Mention Autocomplete Dropdown */
|
||||
.mention-autocomplete {
|
||||
position: absolute;
|
||||
background: var(--bg-primary);
|
||||
border: 2px solid var(--terminal-green);
|
||||
border-radius: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
min-width: 200px;
|
||||
box-shadow: 0 0 10px rgba(0, 255, 65, 0.3);
|
||||
}
|
||||
|
||||
.mention-autocomplete.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mention-option {
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--terminal-green);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mention-option:hover,
|
||||
.mention-option.selected {
|
||||
background: rgba(0, 255, 65, 0.1);
|
||||
color: var(--terminal-amber);
|
||||
}
|
||||
|
||||
.mention-option .mention-username {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mention-option .mention-displayname {
|
||||
color: var(--terminal-green-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ===== RICH TEXT EDITOR TOOLBAR ===== */
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
border: 2px solid var(--terminal-green);
|
||||
border-bottom: none;
|
||||
background: var(--bg-secondary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-toolbar button {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--terminal-green);
|
||||
color: var(--terminal-green);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.editor-toolbar button:hover {
|
||||
background: rgba(0, 255, 65, 0.1);
|
||||
color: var(--terminal-amber);
|
||||
border-color: var(--terminal-amber);
|
||||
}
|
||||
|
||||
.editor-toolbar button:active {
|
||||
background: rgba(0, 255, 65, 0.2);
|
||||
}
|
||||
|
||||
.editor-toolbar .toolbar-separator {
|
||||
width: 1px;
|
||||
background: var(--terminal-green-dim);
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Connect toolbar to textarea */
|
||||
.editor-with-toolbar textarea {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* ===== EXPORT BUTTON ===== */
|
||||
|
||||
.export-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.export-dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: var(--bg-primary);
|
||||
border: 2px solid var(--terminal-green);
|
||||
min-width: 150px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 0 10px rgba(0, 255, 65, 0.3);
|
||||
}
|
||||
|
||||
.export-dropdown:hover .export-dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.export-dropdown-content a {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--terminal-green);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.export-dropdown-content a:hover {
|
||||
background: rgba(0, 255, 65, 0.1);
|
||||
color: var(--terminal-amber);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
// XSS prevention helper
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Main initialization
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOM loaded, initializing dashboard...');
|
||||
@@ -999,6 +1006,10 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
|
||||
};
|
||||
const icon = icons[type] || icons.warning;
|
||||
|
||||
// Escape user-provided content to prevent XSS
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeMessage = escapeHtml(message);
|
||||
|
||||
const modalHtml = `
|
||||
<div class="modal-overlay" id="${modalId}">
|
||||
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
|
||||
@@ -1006,14 +1017,14 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header" style="color: ${color};">
|
||||
${icon} ${title}
|
||||
${icon} ${safeTitle}
|
||||
</div>
|
||||
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="modal-body" style="padding: 1.5rem; text-align: center;">
|
||||
<p style="color: var(--terminal-green); white-space: pre-line;">
|
||||
${message}
|
||||
${safeMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1070,6 +1081,11 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
|
||||
const modalId = 'inputModal' + Date.now();
|
||||
const inputId = modalId + '_input';
|
||||
|
||||
// Escape user-provided content to prevent XSS
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeLabel = escapeHtml(label);
|
||||
const safePlaceholder = escapeHtml(placeholder);
|
||||
|
||||
const modalHtml = `
|
||||
<div class="modal-overlay" id="${modalId}">
|
||||
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
|
||||
@@ -1077,20 +1093,20 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">
|
||||
${title}
|
||||
${safeTitle}
|
||||
</div>
|
||||
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="modal-body" style="padding: 1.5rem;">
|
||||
<label for="${inputId}" style="display: block; margin-bottom: 0.5rem; color: var(--terminal-green);">
|
||||
${label}
|
||||
${safeLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="${inputId}"
|
||||
class="terminal-input"
|
||||
placeholder="${placeholder}"
|
||||
placeholder="${safePlaceholder}"
|
||||
style="width: 100%; padding: 0.5rem; background: var(--bg-primary); border: 1px solid var(--terminal-green); color: var(--terminal-green); font-family: var(--font-mono);"
|
||||
/>
|
||||
</div>
|
||||
@@ -1149,3 +1165,177 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// QUICK ACTIONS
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Quick status change from dashboard
|
||||
*/
|
||||
function quickStatusChange(ticketId, currentStatus) {
|
||||
const statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
const otherStatuses = statuses.filter(s => s !== currentStatus);
|
||||
|
||||
const modalHtml = `
|
||||
<div class="modal-overlay" id="quickStatusModal">
|
||||
<div class="modal-content ascii-frame-outer" style="max-width: 400px;">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Quick Status Change</div>
|
||||
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="modal-body" style="padding: 1rem;">
|
||||
<p style="margin-bottom: 1rem;">Ticket #${escapeHtml(ticketId)}</p>
|
||||
<p style="margin-bottom: 0.5rem; color: var(--terminal-amber);">Current: ${escapeHtml(currentStatus)}</p>
|
||||
<label for="quickStatusSelect">New Status:</label>
|
||||
<select id="quickStatusSelect" class="editable" style="width: 100%; margin-top: 0.5rem;">
|
||||
${otherStatuses.map(s => `<option value="${s}">${s}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<div class="ascii-content">
|
||||
<div class="modal-footer">
|
||||
<button onclick="performQuickStatusChange('${ticketId}')" class="btn btn-primary">Update</button>
|
||||
<button onclick="closeQuickStatusModal()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
}
|
||||
|
||||
function closeQuickStatusModal() {
|
||||
const modal = document.getElementById('quickStatusModal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
|
||||
function performQuickStatusChange(ticketId) {
|
||||
const newStatus = document.getElementById('quickStatusSelect').value;
|
||||
|
||||
fetch('/api/update_ticket.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticket_id: ticketId,
|
||||
status: newStatus
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
closeQuickStatusModal();
|
||||
if (data.success) {
|
||||
toast.success(`Status updated to ${newStatus}`, 3000);
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
closeQuickStatusModal();
|
||||
console.error('Error:', error);
|
||||
toast.error('Error updating status', 4000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick assign from dashboard
|
||||
*/
|
||||
function quickAssign(ticketId) {
|
||||
const modalHtml = `
|
||||
<div class="modal-overlay" id="quickAssignModal">
|
||||
<div class="modal-content ascii-frame-outer" style="max-width: 400px;">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Quick Assign</div>
|
||||
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div class="modal-body" style="padding: 1rem;">
|
||||
<p style="margin-bottom: 1rem;">Ticket #${escapeHtml(ticketId)}</p>
|
||||
<label for="quickAssignSelect">Assign to:</label>
|
||||
<select id="quickAssignSelect" class="editable" style="width: 100%; margin-top: 0.5rem;">
|
||||
<option value="">Unassigned</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-divider"></div>
|
||||
|
||||
<div class="ascii-content">
|
||||
<div class="modal-footer">
|
||||
<button onclick="performQuickAssign('${ticketId}')" class="btn btn-primary">Assign</button>
|
||||
<button onclick="closeQuickAssignModal()" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// Load users
|
||||
fetch('/api/get_users.php')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.users) {
|
||||
const select = document.getElementById('quickAssignSelect');
|
||||
data.users.forEach(user => {
|
||||
const option = document.createElement('option');
|
||||
option.value = user.user_id;
|
||||
option.textContent = user.display_name || user.username;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error loading users:', error));
|
||||
}
|
||||
|
||||
function closeQuickAssignModal() {
|
||||
const modal = document.getElementById('quickAssignModal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
|
||||
function performQuickAssign(ticketId) {
|
||||
const assignedTo = document.getElementById('quickAssignSelect').value || null;
|
||||
|
||||
fetch('/api/assign_ticket.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticket_id: ticketId,
|
||||
assigned_to: assignedTo
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
closeQuickAssignModal();
|
||||
if (data.success) {
|
||||
toast.success('Assignment updated', 3000);
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
closeQuickAssignModal();
|
||||
console.error('Error:', error);
|
||||
toast.error('Error updating assignment', 4000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,8 +27,15 @@ function parseMarkdown(markdown) {
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||
|
||||
// Links [text](url)
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
// Links [text](url) - only allow safe protocols
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
|
||||
// Only allow http, https, mailto protocols
|
||||
if (/^(https?:|mailto:|\/)/i.test(url)) {
|
||||
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + text + '</a>';
|
||||
}
|
||||
// Block potentially dangerous protocols (javascript:, data:, etc.)
|
||||
return text;
|
||||
});
|
||||
|
||||
// Headers (# H1, ## H2, etc.)
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
@@ -75,3 +82,191 @@ document.addEventListener('DOMContentLoaded', renderMarkdownElements);
|
||||
// Expose for manual use
|
||||
window.parseMarkdown = parseMarkdown;
|
||||
window.renderMarkdownElements = renderMarkdownElements;
|
||||
|
||||
// ========================================
|
||||
// Rich Text Editor Toolbar Functions
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Insert markdown formatting around selection
|
||||
*/
|
||||
function insertMarkdownFormat(textareaId, prefix, suffix) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const selectedText = text.substring(start, end);
|
||||
|
||||
// Insert formatting
|
||||
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
|
||||
textarea.value = newText;
|
||||
|
||||
// Set cursor position
|
||||
if (selectedText) {
|
||||
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
|
||||
} else {
|
||||
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
||||
}
|
||||
|
||||
textarea.focus();
|
||||
|
||||
// Trigger input event to update preview if enabled
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert markdown at cursor position
|
||||
*/
|
||||
function insertMarkdownText(textareaId, text) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const value = textarea.value;
|
||||
|
||||
textarea.value = value.substring(0, start) + text + value.substring(start);
|
||||
textarea.setSelectionRange(start + text.length, start + text.length);
|
||||
textarea.focus();
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar button handlers
|
||||
*/
|
||||
function toolbarBold(textareaId) {
|
||||
insertMarkdownFormat(textareaId, '**', '**');
|
||||
}
|
||||
|
||||
function toolbarItalic(textareaId) {
|
||||
insertMarkdownFormat(textareaId, '_', '_');
|
||||
}
|
||||
|
||||
function toolbarCode(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
||||
|
||||
// Use code block for multi-line, inline code for single line
|
||||
if (selectedText.includes('\n')) {
|
||||
insertMarkdownFormat(textareaId, '```\n', '\n```');
|
||||
} else {
|
||||
insertMarkdownFormat(textareaId, '`', '`');
|
||||
}
|
||||
}
|
||||
|
||||
function toolbarLink(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
||||
|
||||
if (selectedText) {
|
||||
// Wrap selected text as link text
|
||||
insertMarkdownFormat(textareaId, '[', '](url)');
|
||||
} else {
|
||||
insertMarkdownText(textareaId, '[link text](url)');
|
||||
}
|
||||
}
|
||||
|
||||
function toolbarList(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
|
||||
// Find start of current line
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||||
lineStart--;
|
||||
}
|
||||
|
||||
// Insert list marker at beginning of line
|
||||
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
|
||||
textarea.setSelectionRange(start + 2, start + 2);
|
||||
textarea.focus();
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function toolbarHeading(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
|
||||
// Find start of current line
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||||
lineStart--;
|
||||
}
|
||||
|
||||
// Insert heading marker at beginning of line
|
||||
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
|
||||
textarea.setSelectionRange(start + 3, start + 3);
|
||||
textarea.focus();
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function toolbarQuote(textareaId) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
|
||||
// Find start of current line
|
||||
let lineStart = start;
|
||||
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||||
lineStart--;
|
||||
}
|
||||
|
||||
// Insert quote marker at beginning of line
|
||||
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
|
||||
textarea.setSelectionRange(start + 2, start + 2);
|
||||
textarea.focus();
|
||||
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and insert toolbar HTML for a textarea
|
||||
*/
|
||||
function createEditorToolbar(textareaId, containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'editor-toolbar';
|
||||
toolbar.innerHTML = `
|
||||
<button type="button" onclick="toolbarBold('${textareaId}')" title="Bold (Ctrl+B)"><b>B</b></button>
|
||||
<button type="button" onclick="toolbarItalic('${textareaId}')" title="Italic (Ctrl+I)"><i>I</i></button>
|
||||
<button type="button" onclick="toolbarCode('${textareaId}')" title="Code"></></button>
|
||||
<span class="toolbar-separator"></span>
|
||||
<button type="button" onclick="toolbarHeading('${textareaId}')" title="Heading">H</button>
|
||||
<button type="button" onclick="toolbarList('${textareaId}')" title="List">≡</button>
|
||||
<button type="button" onclick="toolbarQuote('${textareaId}')" title="Quote">"</button>
|
||||
<span class="toolbar-separator"></span>
|
||||
<button type="button" onclick="toolbarLink('${textareaId}')" title="Link">🔗</button>
|
||||
`;
|
||||
|
||||
container.insertBefore(toolbar, container.firstChild);
|
||||
}
|
||||
|
||||
// Expose toolbar functions globally
|
||||
window.toolbarBold = toolbarBold;
|
||||
window.toolbarItalic = toolbarItalic;
|
||||
window.toolbarCode = toolbarCode;
|
||||
window.toolbarLink = toolbarLink;
|
||||
window.toolbarList = toolbarList;
|
||||
window.toolbarHeading = toolbarHeading;
|
||||
window.toolbarQuote = toolbarQuote;
|
||||
window.createEditorToolbar = createEditorToolbar;
|
||||
window.insertMarkdownFormat = insertMarkdownFormat;
|
||||
window.insertMarkdownText = insertMarkdownText;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
// XSS prevention helper
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function saveTicket() {
|
||||
const editables = document.querySelectorAll('.editable');
|
||||
const data = {};
|
||||
@@ -167,8 +174,8 @@ function addComment() {
|
||||
// Format the comment text for display
|
||||
let displayText;
|
||||
if (isMarkdownEnabled) {
|
||||
// For markdown, use marked.parse
|
||||
displayText = marked.parse(commentText);
|
||||
// For markdown, use parseMarkdown (sanitizes HTML)
|
||||
displayText = parseMarkdown(commentText);
|
||||
} else {
|
||||
// For non-markdown, convert line breaks to <br> and escape HTML
|
||||
displayText = commentText
|
||||
@@ -521,6 +528,8 @@ function showTab(tabName) {
|
||||
// Hide all tab contents
|
||||
const descriptionTab = document.getElementById('description-tab');
|
||||
const commentsTab = document.getElementById('comments-tab');
|
||||
const attachmentsTab = document.getElementById('attachments-tab');
|
||||
const dependenciesTab = document.getElementById('dependencies-tab');
|
||||
const activityTab = document.getElementById('activity-tab');
|
||||
|
||||
if (!descriptionTab || !commentsTab) {
|
||||
@@ -531,6 +540,12 @@ function showTab(tabName) {
|
||||
// Hide all tabs
|
||||
descriptionTab.style.display = 'none';
|
||||
commentsTab.style.display = 'none';
|
||||
if (attachmentsTab) {
|
||||
attachmentsTab.style.display = 'none';
|
||||
}
|
||||
if (dependenciesTab) {
|
||||
dependenciesTab.style.display = 'none';
|
||||
}
|
||||
if (activityTab) {
|
||||
activityTab.style.display = 'none';
|
||||
}
|
||||
@@ -543,4 +558,627 @@ function showTab(tabName) {
|
||||
// Show selected tab and activate its button
|
||||
document.getElementById(`${tabName}-tab`).style.display = 'block';
|
||||
document.querySelector(`[onclick="showTab('${tabName}')"]`).classList.add('active');
|
||||
|
||||
// Load attachments when tab is shown
|
||||
if (tabName === 'attachments') {
|
||||
loadAttachments();
|
||||
initializeUploadZone();
|
||||
}
|
||||
|
||||
// Load dependencies when tab is shown
|
||||
if (tabName === 'dependencies') {
|
||||
loadDependencies();
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Dependency Management Functions
|
||||
// ========================================
|
||||
|
||||
function loadDependencies() {
|
||||
const ticketId = window.ticketData.id;
|
||||
|
||||
fetch(`/api/ticket_dependencies.php?ticket_id=${ticketId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
renderDependencies(data.dependencies);
|
||||
renderDependents(data.dependents);
|
||||
} else {
|
||||
console.error('Error loading dependencies:', data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading dependencies:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function renderDependencies(dependencies) {
|
||||
const container = document.getElementById('dependenciesList');
|
||||
if (!container) return;
|
||||
|
||||
const typeLabels = {
|
||||
'blocks': 'Blocks',
|
||||
'blocked_by': 'Blocked By',
|
||||
'relates_to': 'Relates To',
|
||||
'duplicates': 'Duplicates'
|
||||
};
|
||||
|
||||
let html = '';
|
||||
let hasAny = false;
|
||||
|
||||
for (const [type, items] of Object.entries(dependencies)) {
|
||||
if (items.length > 0) {
|
||||
hasAny = true;
|
||||
html += `<div class="dependency-group">
|
||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0;">${typeLabels[type]}</h4>`;
|
||||
|
||||
items.forEach(dep => {
|
||||
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
|
||||
html += `<div class="dependency-item" style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px dashed var(--terminal-green-dim);">
|
||||
<div>
|
||||
<a href="/ticket/${escapeHtml(dep.depends_on_id)}" style="color: var(--terminal-green);">
|
||||
#${escapeHtml(dep.depends_on_id)}
|
||||
</a>
|
||||
<span style="margin-left: 0.5rem;">${escapeHtml(dep.title)}</span>
|
||||
<span class="status-badge ${statusClass}" style="margin-left: 0.5rem; font-size: 0.8rem;">${escapeHtml(dep.status)}</span>
|
||||
</div>
|
||||
<button onclick="removeDependency('${dep.dependency_id}')" class="btn btn-small" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;">Remove</button>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAny) {
|
||||
html = '<p style="color: var(--terminal-green-dim);">No dependencies configured.</p>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderDependents(dependents) {
|
||||
const container = document.getElementById('dependentsList');
|
||||
if (!container) return;
|
||||
|
||||
if (dependents.length === 0) {
|
||||
container.innerHTML = '<p style="color: var(--terminal-green-dim);">No tickets depend on this one.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
dependents.forEach(dep => {
|
||||
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
|
||||
html += `<div class="dependency-item" style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px dashed var(--terminal-green-dim);">
|
||||
<div>
|
||||
<a href="/ticket/${escapeHtml(dep.ticket_id)}" style="color: var(--terminal-green);">
|
||||
#${escapeHtml(dep.ticket_id)}
|
||||
</a>
|
||||
<span style="margin-left: 0.5rem;">${escapeHtml(dep.title)}</span>
|
||||
<span class="status-badge ${statusClass}" style="margin-left: 0.5rem; font-size: 0.8rem;">${escapeHtml(dep.status)}</span>
|
||||
<span style="margin-left: 0.5rem; color: var(--terminal-amber);">(${escapeHtml(dep.dependency_type)})</span>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function addDependency() {
|
||||
const ticketId = window.ticketData.id;
|
||||
const dependsOnId = document.getElementById('dependencyTicketId').value.trim();
|
||||
const dependencyType = document.getElementById('dependencyType').value;
|
||||
|
||||
if (!dependsOnId) {
|
||||
toast.warning('Please enter a ticket ID', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/ticket_dependencies.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticket_id: ticketId,
|
||||
depends_on_id: dependsOnId,
|
||||
dependency_type: dependencyType
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
toast.success('Dependency added', 3000);
|
||||
document.getElementById('dependencyTicketId').value = '';
|
||||
loadDependencies();
|
||||
} else {
|
||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error adding dependency:', error);
|
||||
toast.error('Error adding dependency', 4000);
|
||||
});
|
||||
}
|
||||
|
||||
function removeDependency(dependencyId) {
|
||||
if (!confirm('Are you sure you want to remove this dependency?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/ticket_dependencies.php', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dependency_id: dependencyId
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
toast.success('Dependency removed', 3000);
|
||||
loadDependencies();
|
||||
} else {
|
||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error removing dependency:', error);
|
||||
toast.error('Error removing dependency', 4000);
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Attachment Management Functions
|
||||
// ========================================
|
||||
|
||||
let uploadZoneInitialized = false;
|
||||
|
||||
function initializeUploadZone() {
|
||||
if (uploadZoneInitialized) return;
|
||||
|
||||
const uploadZone = document.getElementById('uploadZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
|
||||
if (!uploadZone || !fileInput) return;
|
||||
|
||||
// Drag and drop events
|
||||
uploadZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
uploadZone.classList.add('drag-over');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
uploadZone.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
uploadZone.classList.remove('drag-over');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files);
|
||||
}
|
||||
});
|
||||
|
||||
// File input change event
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
handleFileUpload(e.target.files);
|
||||
}
|
||||
});
|
||||
|
||||
// Click on upload zone to trigger file input
|
||||
uploadZone.addEventListener('click', (e) => {
|
||||
if (e.target.tagName !== 'BUTTON' && e.target.tagName !== 'INPUT') {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
uploadZoneInitialized = true;
|
||||
}
|
||||
|
||||
function handleFileUpload(files) {
|
||||
const ticketId = window.ticketData.id;
|
||||
const progressDiv = document.getElementById('uploadProgress');
|
||||
const progressFill = document.getElementById('progressFill');
|
||||
const statusText = document.getElementById('uploadStatus');
|
||||
|
||||
let uploadedCount = 0;
|
||||
const totalFiles = files.length;
|
||||
|
||||
progressDiv.style.display = 'block';
|
||||
statusText.textContent = `Uploading 0 of ${totalFiles} files...`;
|
||||
progressFill.style.width = '0%';
|
||||
|
||||
Array.from(files).forEach((file, index) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('ticket_id', ticketId);
|
||||
formData.append('csrf_token', window.CSRF_TOKEN);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const fileProgress = (e.loaded / e.total) * 100;
|
||||
const overallProgress = ((uploadedCount * 100) + fileProgress) / totalFiles;
|
||||
progressFill.style.width = overallProgress + '%';
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
uploadedCount++;
|
||||
statusText.textContent = `Uploading ${uploadedCount} of ${totalFiles} files...`;
|
||||
progressFill.style.width = ((uploadedCount / totalFiles) * 100) + '%';
|
||||
|
||||
if (xhr.status === 200 || xhr.status === 201) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
if (response.success) {
|
||||
if (uploadedCount === totalFiles) {
|
||||
toast.success(`${totalFiles} file(s) uploaded successfully`, 3000);
|
||||
loadAttachments();
|
||||
resetUploadUI();
|
||||
}
|
||||
} else {
|
||||
toast.error(`Error uploading ${file.name}: ${response.error}`, 4000);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(`Error parsing response for ${file.name}`, 4000);
|
||||
}
|
||||
} else {
|
||||
toast.error(`Error uploading ${file.name}: Server error`, 4000);
|
||||
}
|
||||
|
||||
if (uploadedCount === totalFiles) {
|
||||
setTimeout(resetUploadUI, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
uploadedCount++;
|
||||
toast.error(`Error uploading ${file.name}: Network error`, 4000);
|
||||
if (uploadedCount === totalFiles) {
|
||||
setTimeout(resetUploadUI, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.open('POST', '/api/upload_attachment.php');
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function resetUploadUI() {
|
||||
const progressDiv = document.getElementById('uploadProgress');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
|
||||
progressDiv.style.display = 'none';
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function loadAttachments() {
|
||||
const ticketId = window.ticketData.id;
|
||||
const container = document.getElementById('attachmentsList');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
fetch(`/api/upload_attachment.php?ticket_id=${ticketId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
renderAttachments(data.attachments || []);
|
||||
} else {
|
||||
container.innerHTML = '<p style="color: var(--terminal-green-dim);">Error loading attachments.</p>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading attachments:', error);
|
||||
container.innerHTML = '<p style="color: var(--terminal-green-dim);">Error loading attachments.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderAttachments(attachments) {
|
||||
const container = document.getElementById('attachmentsList');
|
||||
if (!container) return;
|
||||
|
||||
if (attachments.length === 0) {
|
||||
container.innerHTML = '<p style="color: var(--terminal-green-dim);">No files attached to this ticket.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="attachments-grid">';
|
||||
|
||||
attachments.forEach(att => {
|
||||
const uploaderName = att.display_name || att.username || 'Unknown';
|
||||
const uploadDate = new Date(att.uploaded_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
html += `<div class="attachment-item" data-id="${att.attachment_id}">
|
||||
<div class="attachment-icon">${escapeHtml(att.icon || '📎')}</div>
|
||||
<div class="attachment-info">
|
||||
<div class="attachment-name" title="${escapeHtml(att.original_filename)}">
|
||||
<a href="/api/download_attachment.php?id=${att.attachment_id}" target="_blank" style="color: var(--terminal-green);">
|
||||
${escapeHtml(att.original_filename)}
|
||||
</a>
|
||||
</div>
|
||||
<div class="attachment-meta">
|
||||
${escapeHtml(att.file_size_formatted || formatFileSize(att.file_size))} • ${escapeHtml(uploaderName)} • ${escapeHtml(uploadDate)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="attachment-actions">
|
||||
<a href="/api/download_attachment.php?id=${att.attachment_id}" class="btn btn-small" title="Download">⬇</a>
|
||||
<button onclick="deleteAttachment(${att.attachment_id})" class="btn btn-small btn-danger" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
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) {
|
||||
if (!confirm('Are you sure you want to delete this attachment?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/delete_attachment.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
attachment_id: attachmentId,
|
||||
csrf_token: window.CSRF_TOKEN
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
toast.success('Attachment deleted', 3000);
|
||||
loadAttachments();
|
||||
} else {
|
||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting attachment:', error);
|
||||
toast.error('Error deleting attachment', 4000);
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// @Mention Autocomplete Functions
|
||||
// ========================================
|
||||
|
||||
let mentionAutocomplete = null;
|
||||
let mentionUsers = [];
|
||||
let mentionStartPos = -1;
|
||||
let selectedMentionIndex = 0;
|
||||
|
||||
/**
|
||||
* Initialize mention autocomplete for a textarea
|
||||
*/
|
||||
function initMentionAutocomplete() {
|
||||
const textarea = document.getElementById('newComment');
|
||||
if (!textarea) return;
|
||||
|
||||
// Create autocomplete dropdown
|
||||
mentionAutocomplete = document.createElement('div');
|
||||
mentionAutocomplete.className = 'mention-autocomplete';
|
||||
mentionAutocomplete.id = 'mentionAutocomplete';
|
||||
textarea.parentElement.style.position = 'relative';
|
||||
textarea.parentElement.appendChild(mentionAutocomplete);
|
||||
|
||||
// Fetch users list
|
||||
fetchMentionUsers();
|
||||
|
||||
// Input event to detect @ symbol
|
||||
textarea.addEventListener('input', handleMentionInput);
|
||||
textarea.addEventListener('keydown', handleMentionKeydown);
|
||||
textarea.addEventListener('blur', () => {
|
||||
// Delay hiding to allow click on option
|
||||
setTimeout(hideMentionAutocomplete, 200);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available users for mentions
|
||||
*/
|
||||
function fetchMentionUsers() {
|
||||
fetch('/api/get_users.php')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.users) {
|
||||
mentionUsers = data.users;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching users for mentions:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input events to detect @ mentions
|
||||
*/
|
||||
function handleMentionInput(e) {
|
||||
const textarea = e.target;
|
||||
const text = textarea.value;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
|
||||
// Find @ symbol before cursor
|
||||
let atPos = -1;
|
||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||
const char = text[i];
|
||||
if (char === '@') {
|
||||
atPos = i;
|
||||
break;
|
||||
}
|
||||
if (char === ' ' || char === '\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (atPos >= 0) {
|
||||
const query = text.substring(atPos + 1, cursorPos).toLowerCase();
|
||||
mentionStartPos = atPos;
|
||||
showMentionSuggestions(query, textarea);
|
||||
} else {
|
||||
hideMentionAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation in autocomplete
|
||||
*/
|
||||
function handleMentionKeydown(e) {
|
||||
if (!mentionAutocomplete || !mentionAutocomplete.classList.contains('active')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = mentionAutocomplete.querySelectorAll('.mention-option');
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
selectedMentionIndex = Math.min(selectedMentionIndex + 1, options.length - 1);
|
||||
updateMentionSelection(options);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
selectedMentionIndex = Math.max(selectedMentionIndex - 1, 0);
|
||||
updateMentionSelection(options);
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
if (options[selectedMentionIndex]) {
|
||||
selectMention(options[selectedMentionIndex].dataset.username);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
hideMentionAutocomplete();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visual selection in autocomplete
|
||||
*/
|
||||
function updateMentionSelection(options) {
|
||||
options.forEach((opt, i) => {
|
||||
opt.classList.toggle('selected', i === selectedMentionIndex);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show mention suggestions
|
||||
*/
|
||||
function showMentionSuggestions(query, textarea) {
|
||||
const filtered = mentionUsers.filter(user => {
|
||||
const username = (user.username || '').toLowerCase();
|
||||
const displayName = (user.display_name || '').toLowerCase();
|
||||
return username.includes(query) || displayName.includes(query);
|
||||
}).slice(0, 5);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
hideMentionAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
filtered.forEach((user, index) => {
|
||||
const isSelected = index === 0 ? 'selected' : '';
|
||||
html += `<div class="mention-option ${isSelected}" data-username="${escapeHtml(user.username)}" onclick="selectMention('${escapeHtml(user.username)}')">
|
||||
<span class="mention-username">@${escapeHtml(user.username)}</span>
|
||||
${user.display_name ? `<span class="mention-displayname">${escapeHtml(user.display_name)}</span>` : ''}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
mentionAutocomplete.innerHTML = html;
|
||||
mentionAutocomplete.classList.add('active');
|
||||
selectedMentionIndex = 0;
|
||||
|
||||
// Position dropdown below cursor
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
mentionAutocomplete.style.left = '0';
|
||||
mentionAutocomplete.style.top = (textarea.offsetTop + textarea.offsetHeight) + 'px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide mention autocomplete
|
||||
*/
|
||||
function hideMentionAutocomplete() {
|
||||
if (mentionAutocomplete) {
|
||||
mentionAutocomplete.classList.remove('active');
|
||||
}
|
||||
mentionStartPos = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a mention from autocomplete
|
||||
*/
|
||||
function selectMention(username) {
|
||||
const textarea = document.getElementById('newComment');
|
||||
if (!textarea || mentionStartPos < 0) return;
|
||||
|
||||
const text = textarea.value;
|
||||
const before = text.substring(0, mentionStartPos);
|
||||
const after = text.substring(textarea.selectionStart);
|
||||
|
||||
textarea.value = before + '@' + username + ' ' + after;
|
||||
textarea.focus();
|
||||
const newPos = mentionStartPos + username.length + 2;
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
|
||||
hideMentionAutocomplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight mentions in comment text
|
||||
*/
|
||||
function highlightMentions(text) {
|
||||
return text.replace(/@([a-zA-Z0-9_-]+)/g, '<span class="mention">$1</span>');
|
||||
}
|
||||
|
||||
// Initialize mention autocomplete when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initMentionAutocomplete();
|
||||
|
||||
// Highlight existing mentions in comments
|
||||
document.querySelectorAll('.comment-text').forEach(el => {
|
||||
if (!el.hasAttribute('data-markdown')) {
|
||||
el.innerHTML = highlightMentions(el.innerHTML);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,12 +17,54 @@ if ($envVars) {
|
||||
|
||||
// Global configuration
|
||||
$GLOBALS['config'] = [
|
||||
// Database settings
|
||||
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
||||
'DB_USER' => $envVars['DB_USER'] ?? 'root',
|
||||
'DB_PASS' => $envVars['DB_PASS'] ?? '',
|
||||
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
|
||||
|
||||
// URL settings
|
||||
'BASE_URL' => '', // Empty since we're serving from document root
|
||||
'ASSETS_URL' => '/assets', // Assets URL
|
||||
'API_URL' => '/api' // API URL
|
||||
'API_URL' => '/api', // API URL
|
||||
|
||||
// Session settings
|
||||
'SESSION_TIMEOUT' => 3600, // 1 hour in seconds
|
||||
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
|
||||
|
||||
// CSRF settings
|
||||
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
|
||||
|
||||
// Pagination settings
|
||||
'PAGINATION_DEFAULT' => 15, // Default items per page
|
||||
'PAGINATION_MAX' => 100, // Maximum items per page
|
||||
|
||||
// File upload settings
|
||||
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
|
||||
'ALLOWED_FILE_TYPES' => [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'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-7z-compressed',
|
||||
'application/x-tar',
|
||||
'application/gzip'
|
||||
],
|
||||
'UPLOAD_DIR' => __DIR__ . '/../uploads',
|
||||
|
||||
// Rate limiting
|
||||
'RATE_LIMIT_DEFAULT' => 100, // Requests per minute for general
|
||||
'RATE_LIMIT_API' => 60, // Requests per minute for API
|
||||
|
||||
// Audit log settings
|
||||
'AUDIT_LOG_RETENTION_DAYS' => 90
|
||||
];
|
||||
?>
|
||||
@@ -1,16 +1,19 @@
|
||||
<?php
|
||||
require_once 'models/TicketModel.php';
|
||||
require_once 'models/UserPreferencesModel.php';
|
||||
require_once 'models/StatsModel.php';
|
||||
|
||||
class DashboardController {
|
||||
private $ticketModel;
|
||||
private $prefsModel;
|
||||
private $statsModel;
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->prefsModel = new UserPreferencesModel($conn);
|
||||
$this->statsModel = new StatsModel($conn);
|
||||
}
|
||||
|
||||
public function index() {
|
||||
@@ -72,27 +75,36 @@ class DashboardController {
|
||||
$totalTickets = $result['total'];
|
||||
$totalPages = $result['pages'];
|
||||
|
||||
// Load dashboard statistics
|
||||
$stats = $this->statsModel->getAllStats();
|
||||
|
||||
// Load the dashboard view
|
||||
include 'views/DashboardView.php';
|
||||
}
|
||||
|
||||
private function getCategories() {
|
||||
$sql = "SELECT DISTINCT category FROM tickets WHERE category IS NOT NULL ORDER BY category";
|
||||
$result = $this->conn->query($sql);
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$categories = [];
|
||||
while($row = $result->fetch_assoc()) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$categories[] = $row['category'];
|
||||
}
|
||||
$stmt->close();
|
||||
return $categories;
|
||||
}
|
||||
|
||||
private function getTypes() {
|
||||
$sql = "SELECT DISTINCT type FROM tickets WHERE type IS NOT NULL ORDER BY type";
|
||||
$result = $this->conn->query($sql);
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$types = [];
|
||||
while($row = $result->fetch_assoc()) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$types[] = $row['type'];
|
||||
}
|
||||
$stmt->close();
|
||||
return $types;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +246,6 @@ class TicketController {
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
|
||||
$webhookResult = curl_exec($ch);
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
header('Content-Type: application/json');
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
|
||||
ini_set('display_errors', 0);
|
||||
|
||||
// Load environment variables with error check
|
||||
$envFile = __DIR__ . '/.env';
|
||||
@@ -64,7 +63,6 @@ try {
|
||||
}
|
||||
|
||||
$userId = $systemUser['user_id'];
|
||||
file_put_contents('debug.log', "Authenticated as system user ID: $userId\n", FILE_APPEND);
|
||||
|
||||
// Create tickets table with hash column if not exists
|
||||
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
|
||||
|
||||
135
cron/create_recurring_tickets.php
Normal file
135
cron/create_recurring_tickets.php
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Recurring Tickets Cron Job
|
||||
*
|
||||
* Run this script via cron to automatically create tickets from recurring schedules.
|
||||
* Recommended: Run every 5-15 minutes
|
||||
*
|
||||
* Example crontab entry:
|
||||
* */10 * * * * /usr/bin/php /path/to/cron/create_recurring_tickets.php >> /var/log/recurring_tickets.log 2>&1
|
||||
*/
|
||||
|
||||
// Change to project root directory
|
||||
chdir(dirname(__DIR__));
|
||||
|
||||
// Include required files
|
||||
require_once 'config/config.php';
|
||||
require_once 'models/RecurringTicketModel.php';
|
||||
require_once 'models/TicketModel.php';
|
||||
require_once 'models/AuditLogModel.php';
|
||||
|
||||
// Log function
|
||||
function logMessage($message) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
|
||||
}
|
||||
|
||||
logMessage("Starting recurring tickets cron job");
|
||||
|
||||
try {
|
||||
// Create database connection
|
||||
$conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
// Initialize models
|
||||
$recurringModel = new RecurringTicketModel($conn);
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// Get all due recurring tickets
|
||||
$dueTickets = $recurringModel->getDueRecurringTickets();
|
||||
logMessage("Found " . count($dueTickets) . " recurring tickets due for creation");
|
||||
|
||||
$created = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($dueTickets as $recurring) {
|
||||
logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']);
|
||||
|
||||
try {
|
||||
// Prepare ticket data
|
||||
$ticketData = [
|
||||
'title' => processTemplate($recurring['title_template']),
|
||||
'description' => processTemplate($recurring['description_template']),
|
||||
'category' => $recurring['category'],
|
||||
'type' => $recurring['type'],
|
||||
'priority' => $recurring['priority'],
|
||||
'status' => 'Open'
|
||||
];
|
||||
|
||||
// Create the ticket
|
||||
$result = $ticketModel->createTicket($ticketData, $recurring['created_by']);
|
||||
|
||||
if ($result['success']) {
|
||||
$ticketId = $result['ticket_id'];
|
||||
logMessage("Created ticket: " . $ticketId);
|
||||
|
||||
// Assign to user if specified
|
||||
if ($recurring['assigned_to']) {
|
||||
$ticketModel->updateTicket($ticketId, ['assigned_to' => $recurring['assigned_to']]);
|
||||
}
|
||||
|
||||
// Log to audit
|
||||
$auditLog->log(
|
||||
$recurring['created_by'],
|
||||
'create',
|
||||
'ticket',
|
||||
$ticketId,
|
||||
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
|
||||
);
|
||||
|
||||
// Update the recurring ticket's next run time
|
||||
$recurringModel->updateAfterRun($recurring['recurring_id']);
|
||||
|
||||
$created++;
|
||||
} else {
|
||||
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
||||
$errors++;
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
logMessage("Completed: Created $created tickets, $errors errors");
|
||||
|
||||
$conn->close();
|
||||
|
||||
} catch (Exception $e) {
|
||||
logMessage("FATAL ERROR: " . $e->getMessage());
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process template variables
|
||||
*/
|
||||
function processTemplate($template) {
|
||||
if (empty($template)) {
|
||||
return $template;
|
||||
}
|
||||
|
||||
$replacements = [
|
||||
'{{date}}' => date('Y-m-d'),
|
||||
'{{time}}' => date('H:i:s'),
|
||||
'{{datetime}}' => date('Y-m-d H:i:s'),
|
||||
'{{week}}' => date('W'),
|
||||
'{{month}}' => date('F'),
|
||||
'{{year}}' => date('Y'),
|
||||
'{{day_of_week}}' => date('l'),
|
||||
'{{day}}' => date('d'),
|
||||
];
|
||||
|
||||
return str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
}
|
||||
|
||||
logMessage("Cron job finished");
|
||||
116
helpers/ResponseHelper.php
Normal file
116
helpers/ResponseHelper.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
/**
|
||||
* ResponseHelper - Standardized JSON response formatting
|
||||
*
|
||||
* Provides consistent API response structure across all endpoints.
|
||||
*/
|
||||
class ResponseHelper {
|
||||
/**
|
||||
* Send a success response
|
||||
*
|
||||
* @param array $data Additional data to include
|
||||
* @param string $message Success message
|
||||
* @param int $code HTTP status code
|
||||
*/
|
||||
public static function success($data = [], $message = 'Success', $code = 200) {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(array_merge([
|
||||
'success' => true,
|
||||
'message' => $message
|
||||
], $data));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error response
|
||||
*
|
||||
* @param string $message Error message
|
||||
* @param int $code HTTP status code
|
||||
* @param array $data Additional data to include
|
||||
*/
|
||||
public static function error($message, $code = 400, $data = []) {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(array_merge([
|
||||
'success' => false,
|
||||
'error' => $message
|
||||
], $data));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an unauthorized response (401)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function unauthorized($message = 'Authentication required') {
|
||||
self::error($message, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a forbidden response (403)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function forbidden($message = 'Access denied') {
|
||||
self::error($message, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a not found response (404)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function notFound($message = 'Resource not found') {
|
||||
self::error($message, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a validation error response (422)
|
||||
*
|
||||
* @param array $errors Validation errors
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function validationError($errors, $message = 'Validation failed') {
|
||||
self::error($message, 422, ['validation_errors' => $errors]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a server error response (500)
|
||||
*
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function serverError($message = 'Internal server error') {
|
||||
self::error($message, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a rate limit exceeded response (429)
|
||||
*
|
||||
* @param int $retryAfter Seconds until retry is allowed
|
||||
* @param string $message Error message
|
||||
*/
|
||||
public static function rateLimitExceeded($retryAfter = 60, $message = 'Rate limit exceeded') {
|
||||
header('Retry-After: ' . $retryAfter);
|
||||
self::error($message, 429, ['retry_after' => $retryAfter]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a created response (201)
|
||||
*
|
||||
* @param array $data Resource data
|
||||
* @param string $message Success message
|
||||
*/
|
||||
public static function created($data = [], $message = 'Resource created') {
|
||||
self::success($data, $message, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a no content response (204)
|
||||
*/
|
||||
public static function noContent() {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
183
index.php
183
index.php
@@ -1,9 +1,13 @@
|
||||
<?php
|
||||
// Main entry point for the application
|
||||
require_once 'config/config.php';
|
||||
require_once 'middleware/SecurityHeadersMiddleware.php';
|
||||
require_once 'middleware/AuthMiddleware.php';
|
||||
require_once 'models/AuditLogModel.php';
|
||||
|
||||
// Apply security headers early
|
||||
SecurityHeadersMiddleware::apply();
|
||||
|
||||
// Parse the URL - no need to remove base path since we're at document root
|
||||
$request = $_SERVER['REQUEST_URI'];
|
||||
|
||||
@@ -63,6 +67,185 @@ switch (true) {
|
||||
require_once 'api/add_comment.php';
|
||||
break;
|
||||
|
||||
// Admin Routes - require admin privileges
|
||||
case $requestPath == '/admin/recurring-tickets':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
require_once 'models/RecurringTicketModel.php';
|
||||
$recurringModel = new RecurringTicketModel($conn);
|
||||
$recurringTickets = $recurringModel->getAll(true);
|
||||
include 'views/admin/RecurringTicketsView.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/custom-fields':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
require_once 'models/CustomFieldModel.php';
|
||||
$fieldModel = new CustomFieldModel($conn);
|
||||
$customFields = $fieldModel->getAllDefinitions(null, false);
|
||||
include 'views/admin/CustomFieldsView.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/workflow':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
|
||||
$workflows = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$workflows[] = $row;
|
||||
}
|
||||
include 'views/admin/WorkflowDesignerView.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/templates':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
|
||||
$templates = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$templates[] = $row;
|
||||
}
|
||||
include 'views/admin/TemplatesView.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/audit-log':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
|
||||
$perPage = 50;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$filters = [];
|
||||
$whereConditions = [];
|
||||
$params = [];
|
||||
$types = '';
|
||||
|
||||
if (!empty($_GET['action_type'])) {
|
||||
$whereConditions[] = "al.action_type = ?";
|
||||
$params[] = $_GET['action_type'];
|
||||
$types .= 's';
|
||||
$filters['action_type'] = $_GET['action_type'];
|
||||
}
|
||||
if (!empty($_GET['user_id'])) {
|
||||
$whereConditions[] = "al.user_id = ?";
|
||||
$params[] = (int)$_GET['user_id'];
|
||||
$types .= 'i';
|
||||
$filters['user_id'] = $_GET['user_id'];
|
||||
}
|
||||
if (!empty($_GET['date_from'])) {
|
||||
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||
$params[] = $_GET['date_from'];
|
||||
$types .= 's';
|
||||
$filters['date_from'] = $_GET['date_from'];
|
||||
}
|
||||
if (!empty($_GET['date_to'])) {
|
||||
$whereConditions[] = "DATE(al.created_at) <= ?";
|
||||
$params[] = $_GET['date_to'];
|
||||
$types .= 's';
|
||||
$filters['date_to'] = $_GET['date_to'];
|
||||
}
|
||||
|
||||
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
|
||||
|
||||
$countSql = "SELECT COUNT(*) as total FROM audit_log al $where";
|
||||
if (!empty($params)) {
|
||||
$stmt = $conn->prepare($countSql);
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
$countResult = $stmt->get_result();
|
||||
} else {
|
||||
$countResult = $conn->query($countSql);
|
||||
}
|
||||
$totalLogs = $countResult->fetch_assoc()['total'];
|
||||
$totalPages = ceil($totalLogs / $perPage);
|
||||
|
||||
$sql = "SELECT al.*, u.display_name, u.username
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
$where
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT $perPage OFFSET $offset";
|
||||
|
||||
if (!empty($params)) {
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
} else {
|
||||
$result = $conn->query($sql);
|
||||
}
|
||||
|
||||
$auditLogs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$auditLogs[] = $row;
|
||||
}
|
||||
|
||||
$usersResult = $conn->query("SELECT user_id, username, display_name FROM users ORDER BY display_name");
|
||||
$users = [];
|
||||
while ($row = $usersResult->fetch_assoc()) {
|
||||
$users[] = $row;
|
||||
}
|
||||
|
||||
include 'views/admin/AuditLogView.php';
|
||||
break;
|
||||
|
||||
case $requestPath == '/admin/user-activity':
|
||||
if (!$currentUser || !$currentUser['is_admin']) {
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
echo 'Admin access required';
|
||||
break;
|
||||
}
|
||||
|
||||
$dateRange = [
|
||||
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
|
||||
'to' => $_GET['date_to'] ?? date('Y-m-d')
|
||||
];
|
||||
|
||||
$sql = "SELECT
|
||||
u.user_id, u.username, u.display_name, u.is_admin,
|
||||
(SELECT COUNT(*) FROM tickets t WHERE t.created_by = u.user_id AND DATE(t.created_at) BETWEEN ? AND ?) as tickets_created,
|
||||
(SELECT COUNT(*) FROM tickets t WHERE t.assigned_to = u.user_id AND t.status = 'Closed' AND DATE(t.updated_at) BETWEEN ? AND ?) as tickets_resolved,
|
||||
(SELECT COUNT(*) FROM ticket_comments tc WHERE tc.user_id = u.user_id AND DATE(tc.created_at) BETWEEN ? AND ?) as comments_added,
|
||||
(SELECT COUNT(*) FROM tickets t WHERE t.assigned_to = u.user_id AND DATE(t.created_at) BETWEEN ? AND ?) as tickets_assigned,
|
||||
(SELECT MAX(al.created_at) FROM audit_log al WHERE al.user_id = u.user_id) as last_activity
|
||||
FROM users u
|
||||
WHERE u.is_active = 1
|
||||
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->bind_param('ssssssss',
|
||||
$dateRange['from'], $dateRange['to'],
|
||||
$dateRange['from'], $dateRange['to'],
|
||||
$dateRange['from'], $dateRange['to'],
|
||||
$dateRange['from'], $dateRange['to']
|
||||
);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$userStats = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$userStats[] = $row;
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
include 'views/admin/UserActivityView.php';
|
||||
break;
|
||||
|
||||
// Legacy support for old URLs
|
||||
case $requestPath == '/dashboard.php':
|
||||
header("Location: /");
|
||||
|
||||
127
middleware/RateLimitMiddleware.php
Normal file
127
middleware/RateLimitMiddleware.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
/**
|
||||
* Rate Limiting Middleware
|
||||
*
|
||||
* Implements session-based rate limiting to prevent abuse.
|
||||
*/
|
||||
class RateLimitMiddleware {
|
||||
// Default limits
|
||||
const DEFAULT_LIMIT = 100; // requests per window
|
||||
const API_LIMIT = 60; // API requests per window
|
||||
const WINDOW_SECONDS = 60; // 1 minute window
|
||||
|
||||
/**
|
||||
* Check rate limit for current request
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
*/
|
||||
public static function check($type = 'default') {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
$key = 'rate_limit_' . $type;
|
||||
$now = time();
|
||||
|
||||
// Initialize rate limit tracking
|
||||
if (!isset($_SESSION[$key])) {
|
||||
$_SESSION[$key] = [
|
||||
'count' => 0,
|
||||
'window_start' => $now
|
||||
];
|
||||
}
|
||||
|
||||
$rateData = &$_SESSION[$key];
|
||||
|
||||
// Check if window has expired
|
||||
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||
// Reset for new window
|
||||
$rateData['count'] = 0;
|
||||
$rateData['window_start'] = $now;
|
||||
}
|
||||
|
||||
// Increment request count
|
||||
$rateData['count']++;
|
||||
|
||||
// Check if over limit
|
||||
if ($rateData['count'] > $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply rate limiting and send error response if exceeded
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
*/
|
||||
public static function apply($type = 'default') {
|
||||
if (!self::check($type)) {
|
||||
http_response_code(429);
|
||||
header('Content-Type: application/json');
|
||||
header('Retry-After: ' . self::WINDOW_SECONDS);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Rate limit exceeded. Please try again later.',
|
||||
'retry_after' => self::WINDOW_SECONDS
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
* @return array Rate limit status
|
||||
*/
|
||||
public static function getStatus($type = 'default') {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||
$key = 'rate_limit_' . $type;
|
||||
$now = time();
|
||||
|
||||
if (!isset($_SESSION[$key])) {
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => $limit,
|
||||
'reset' => $now + self::WINDOW_SECONDS
|
||||
];
|
||||
}
|
||||
|
||||
$rateData = $_SESSION[$key];
|
||||
|
||||
// Check if window has expired
|
||||
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => $limit,
|
||||
'reset' => $now + self::WINDOW_SECONDS
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => max(0, $limit - $rateData['count']),
|
||||
'reset' => $rateData['window_start'] + self::WINDOW_SECONDS
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add rate limit headers to response
|
||||
*
|
||||
* @param string $type 'default' or 'api'
|
||||
*/
|
||||
public static function addHeaders($type = 'default') {
|
||||
$status = self::getStatus($type);
|
||||
header('X-RateLimit-Limit: ' . $status['limit']);
|
||||
header('X-RateLimit-Remaining: ' . $status['remaining']);
|
||||
header('X-RateLimit-Reset: ' . $status['reset']);
|
||||
}
|
||||
}
|
||||
30
middleware/SecurityHeadersMiddleware.php
Normal file
30
middleware/SecurityHeadersMiddleware.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
/**
|
||||
* Security Headers Middleware
|
||||
*
|
||||
* Applies security-related HTTP headers to all responses.
|
||||
*/
|
||||
class SecurityHeadersMiddleware {
|
||||
/**
|
||||
* Apply security headers to the response
|
||||
*/
|
||||
public static function apply() {
|
||||
// Content Security Policy - restricts where resources can be loaded from
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';");
|
||||
|
||||
// Prevent clickjacking by disallowing framing
|
||||
header("X-Frame-Options: DENY");
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
header("X-Content-Type-Options: nosniff");
|
||||
|
||||
// Enable XSS filtering in older browsers
|
||||
header("X-XSS-Protection: 1; mode=block");
|
||||
|
||||
// Control referrer information sent with requests
|
||||
header("Referrer-Policy: strict-origin-when-cross-origin");
|
||||
|
||||
// Permissions Policy - disable unnecessary browser features
|
||||
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
|
||||
}
|
||||
}
|
||||
23
migrations/014_add_additional_indexes.sql
Normal file
23
migrations/014_add_additional_indexes.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Migration: Add additional indexes for improved query performance
|
||||
-- Version: 014
|
||||
|
||||
-- Index for audit log queries by user and date (activity reports)
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_user_created ON audit_log(user_id, created_at DESC);
|
||||
|
||||
-- Index for audit log queries by action type (security monitoring)
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action_type ON audit_log(action_type, created_at DESC);
|
||||
|
||||
-- Index for tickets by status only (status filtering)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
|
||||
-- Composite index for common dashboard queries (status + priority + created_at)
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status_priority_created ON tickets(status, priority, created_at DESC);
|
||||
|
||||
-- Index for ticket comments by ticket_id and date (comment listing)
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_ticket_created ON ticket_comments(ticket_id, created_at DESC);
|
||||
|
||||
-- Index for API keys by key value (authentication lookups)
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key_value ON api_keys(key_value);
|
||||
|
||||
-- Index for user preferences lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_user_preferences_user_key ON user_preferences(user_id, preference_key);
|
||||
15
migrations/015_ticket_dependencies.sql
Normal file
15
migrations/015_ticket_dependencies.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Migration: Create ticket dependencies table
|
||||
-- Version: 015
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_dependencies (
|
||||
dependency_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id VARCHAR(9) NOT NULL,
|
||||
depends_on_id VARCHAR(9) NOT NULL,
|
||||
dependency_type ENUM('blocks', 'blocked_by', 'relates_to', 'duplicates') DEFAULT 'blocks',
|
||||
created_by INT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_dependency (ticket_id, depends_on_id, dependency_type),
|
||||
INDEX idx_ticket_id (ticket_id),
|
||||
INDEX idx_depends_on_id (depends_on_id),
|
||||
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
18
migrations/016_ticket_attachments.sql
Normal file
18
migrations/016_ticket_attachments.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Migration: Create ticket attachments table
|
||||
-- Date: 2026-01-19
|
||||
-- Description: Adds support for file attachments on tickets
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_attachments (
|
||||
attachment_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id VARCHAR(9) NOT NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
file_size INT NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
uploaded_by INT NULL,
|
||||
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (uploaded_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
INDEX idx_attachments_ticket (ticket_id),
|
||||
INDEX idx_attachments_uploaded_by (uploaded_by)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
29
migrations/017_recurring_tickets.sql
Normal file
29
migrations/017_recurring_tickets.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Migration: Create recurring tickets table
|
||||
-- Description: Enables automatic ticket creation on a schedule
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recurring_tickets (
|
||||
recurring_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title_template VARCHAR(255) NOT NULL,
|
||||
description_template TEXT,
|
||||
category VARCHAR(50) DEFAULT 'General',
|
||||
type VARCHAR(50) DEFAULT 'Task',
|
||||
priority INT DEFAULT 4,
|
||||
assigned_to INT NULL,
|
||||
schedule_type ENUM('daily', 'weekly', 'monthly') NOT NULL,
|
||||
schedule_day INT NULL COMMENT 'Day of week (1-7) for weekly, day of month (1-31) for monthly',
|
||||
schedule_time TIME DEFAULT '09:00:00',
|
||||
next_run_at TIMESTAMP NOT NULL,
|
||||
last_run_at TIMESTAMP NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_by INT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (assigned_to) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
INDEX idx_recurring_next_run (next_run_at, is_active),
|
||||
INDEX idx_recurring_active (is_active)
|
||||
);
|
||||
|
||||
-- Sample recurring ticket for testing (commented out)
|
||||
-- INSERT INTO recurring_tickets (title_template, description_template, category, type, schedule_type, schedule_day, next_run_at)
|
||||
-- VALUES ('Weekly Server Maintenance Check', 'Perform weekly server health check and maintenance tasks.', 'Maintenance', 'Task', 'weekly', 1, NOW());
|
||||
39
migrations/018_custom_fields.sql
Normal file
39
migrations/018_custom_fields.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- Migration: Create custom fields tables
|
||||
-- Description: Enables custom field definitions per category and stores field values
|
||||
|
||||
-- Custom field definitions
|
||||
CREATE TABLE IF NOT EXISTS custom_field_definitions (
|
||||
field_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
field_name VARCHAR(100) NOT NULL,
|
||||
field_label VARCHAR(255) NOT NULL,
|
||||
field_type ENUM('text', 'textarea', 'select', 'checkbox', 'date', 'number') NOT NULL,
|
||||
field_options JSON NULL COMMENT 'Options for select fields: {"options": ["Option 1", "Option 2"]}',
|
||||
category VARCHAR(50) NULL COMMENT 'NULL = applies to all categories',
|
||||
is_required BOOLEAN DEFAULT FALSE,
|
||||
display_order INT DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_custom_fields_category (category, is_active),
|
||||
INDEX idx_custom_fields_order (display_order)
|
||||
);
|
||||
|
||||
-- Custom field values for tickets
|
||||
CREATE TABLE IF NOT EXISTS custom_field_values (
|
||||
value_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id VARCHAR(9) NOT NULL,
|
||||
field_id INT NOT NULL,
|
||||
field_value TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_ticket_field (ticket_id, field_id),
|
||||
FOREIGN KEY (field_id) REFERENCES custom_field_definitions(field_id) ON DELETE CASCADE,
|
||||
INDEX idx_custom_values_ticket (ticket_id)
|
||||
);
|
||||
|
||||
-- Sample custom field definitions (commented out)
|
||||
-- INSERT INTO custom_field_definitions (field_name, field_label, field_type, category, is_required)
|
||||
-- VALUES
|
||||
-- ('affected_server', 'Affected Server', 'text', 'Hardware', false),
|
||||
-- ('incident_date', 'Incident Date', 'date', 'Security', true),
|
||||
-- ('software_version', 'Software Version', 'text', 'Software', false);
|
||||
212
models/AttachmentModel.php
Normal file
212
models/AttachmentModel.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
/**
|
||||
* AttachmentModel - Handles ticket file attachments
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
class AttachmentModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct() {
|
||||
$this->conn = new mysqli(
|
||||
$GLOBALS['config']['DB_HOST'],
|
||||
$GLOBALS['config']['DB_USER'],
|
||||
$GLOBALS['config']['DB_PASS'],
|
||||
$GLOBALS['config']['DB_NAME']
|
||||
);
|
||||
|
||||
if ($this->conn->connect_error) {
|
||||
throw new Exception('Database connection failed: ' . $this->conn->connect_error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all attachments for a ticket
|
||||
*/
|
||||
public function getAttachments($ticketId) {
|
||||
$sql = "SELECT a.*, u.username, u.display_name
|
||||
FROM ticket_attachments a
|
||||
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
||||
WHERE a.ticket_id = ?
|
||||
ORDER BY a.uploaded_at DESC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$attachments = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$attachments[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single attachment by ID
|
||||
*/
|
||||
public function getAttachment($attachmentId) {
|
||||
$sql = "SELECT a.*, u.username, u.display_name
|
||||
FROM ticket_attachments a
|
||||
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
||||
WHERE a.attachment_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $attachmentId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$attachment = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new attachment record
|
||||
*/
|
||||
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy) {
|
||||
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
|
||||
$result = $stmt->execute();
|
||||
|
||||
if ($result) {
|
||||
$attachmentId = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
return $attachmentId;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attachment record
|
||||
*/
|
||||
public function deleteAttachment($attachmentId) {
|
||||
$sql = "DELETE FROM ticket_attachments WHERE attachment_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $attachmentId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total attachment size for a ticket
|
||||
*/
|
||||
public function getTotalSizeForTicket($ticketId) {
|
||||
$sql = "SELECT COALESCE(SUM(file_size), 0) as total_size
|
||||
FROM ticket_attachments
|
||||
WHERE ticket_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
return (int)$row['total_size'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attachment count for a ticket
|
||||
*/
|
||||
public function getAttachmentCount($ticketId) {
|
||||
$sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can delete attachment (owner or admin)
|
||||
*/
|
||||
public function canUserDelete($attachmentId, $userId, $isAdmin = false) {
|
||||
if ($isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$attachment = $this->getAttachment($attachmentId);
|
||||
return $attachment && $attachment['uploaded_by'] == $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
public static function formatFileSize($bytes) {
|
||||
if ($bytes >= 1073741824) {
|
||||
return number_format($bytes / 1073741824, 2) . ' GB';
|
||||
} elseif ($bytes >= 1048576) {
|
||||
return number_format($bytes / 1048576, 2) . ' MB';
|
||||
} elseif ($bytes >= 1024) {
|
||||
return number_format($bytes / 1024, 2) . ' KB';
|
||||
} else {
|
||||
return $bytes . ' bytes';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file icon based on mime type
|
||||
*/
|
||||
public static function getFileIcon($mimeType) {
|
||||
if (strpos($mimeType, 'image/') === 0) {
|
||||
return '🖼️';
|
||||
} elseif (strpos($mimeType, 'video/') === 0) {
|
||||
return '🎬';
|
||||
} elseif (strpos($mimeType, 'audio/') === 0) {
|
||||
return '🎵';
|
||||
} elseif ($mimeType === 'application/pdf') {
|
||||
return '📄';
|
||||
} elseif (strpos($mimeType, 'text/') === 0) {
|
||||
return '📝';
|
||||
} elseif (in_array($mimeType, ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', 'application/gzip'])) {
|
||||
return '📦';
|
||||
} elseif (in_array($mimeType, ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'])) {
|
||||
return '📘';
|
||||
} elseif (in_array($mimeType, ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])) {
|
||||
return '📊';
|
||||
} else {
|
||||
return '📎';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file type against allowed types
|
||||
*/
|
||||
public static function isAllowedType($mimeType) {
|
||||
$allowedTypes = $GLOBALS['config']['ALLOWED_FILE_TYPES'] ?? [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'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', 'application/x-7z-compressed',
|
||||
'application/json', 'application/xml'
|
||||
];
|
||||
|
||||
return in_array($mimeType, $allowedTypes);
|
||||
}
|
||||
|
||||
public function __destruct() {
|
||||
if ($this->conn) {
|
||||
$this->conn->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,110 @@ class AuditLogModel {
|
||||
return $this->log($userId, 'view', 'ticket', $ticketId);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Security Event Logging Methods
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Log a security event
|
||||
*
|
||||
* @param string $eventType Type of security event
|
||||
* @param array $details Additional details
|
||||
* @param int|null $userId User ID if known
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logSecurityEvent($eventType, $details = [], $userId = null) {
|
||||
$details['event_type'] = $eventType;
|
||||
$details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
|
||||
return $this->log($userId, 'security_event', 'security', null, $details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a failed authentication attempt
|
||||
*
|
||||
* @param string $username Username attempted
|
||||
* @param string $reason Reason for failure
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logFailedAuth($username, $reason = 'Invalid credentials') {
|
||||
return $this->logSecurityEvent('failed_auth', [
|
||||
'username' => $username,
|
||||
'reason' => $reason
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a CSRF token failure
|
||||
*
|
||||
* @param string $endpoint The endpoint that was accessed
|
||||
* @param int|null $userId User ID if session exists
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logCsrfFailure($endpoint, $userId = null) {
|
||||
return $this->logSecurityEvent('csrf_failure', [
|
||||
'endpoint' => $endpoint,
|
||||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown'
|
||||
], $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a rate limit exceeded event
|
||||
*
|
||||
* @param string $endpoint The endpoint that was rate limited
|
||||
* @param int|null $userId User ID if session exists
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logRateLimitExceeded($endpoint, $userId = null) {
|
||||
return $this->logSecurityEvent('rate_limit_exceeded', [
|
||||
'endpoint' => $endpoint
|
||||
], $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an unauthorized access attempt
|
||||
*
|
||||
* @param string $resource The resource that was accessed
|
||||
* @param int|null $userId User ID if session exists
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function logUnauthorizedAccess($resource, $userId = null) {
|
||||
return $this->logSecurityEvent('unauthorized_access', [
|
||||
'resource' => $resource
|
||||
], $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security events (for admin review)
|
||||
*
|
||||
* @param int $limit Maximum number of events
|
||||
* @param int $offset Offset for pagination
|
||||
* @return array Security events
|
||||
*/
|
||||
public function getSecurityEvents($limit = 100, $offset = 0) {
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
WHERE al.action_type = 'security_event'
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ? OFFSET ?"
|
||||
);
|
||||
$stmt->bind_param("ii", $limit, $offset);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$events = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['details']) {
|
||||
$row['details'] = json_decode($row['details'], true);
|
||||
}
|
||||
$events[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted timeline for a specific ticket
|
||||
* Includes all ticket updates and comments
|
||||
|
||||
@@ -6,6 +6,50 @@ class CommentModel {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract @mentions from comment text
|
||||
*
|
||||
* @param string $text Comment text
|
||||
* @return array Array of mentioned usernames
|
||||
*/
|
||||
public function extractMentions($text) {
|
||||
$mentions = [];
|
||||
// Match @username patterns (alphanumeric, underscores, hyphens)
|
||||
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
|
||||
$mentions = array_unique($matches[1]);
|
||||
}
|
||||
return $mentions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user IDs for mentioned usernames
|
||||
*
|
||||
* @param array $usernames Array of usernames
|
||||
* @return array Array of user records with user_id, username, display_name
|
||||
*/
|
||||
public function getMentionedUsers($usernames) {
|
||||
if (empty($usernames)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
|
||||
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
|
||||
$types = str_repeat('s', count($usernames));
|
||||
$stmt->bind_param($types, ...$usernames);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$users = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$users[] = $row;
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
public function getCommentsByTicketId($ticketId) {
|
||||
$sql = "SELECT tc.*, u.display_name, u.username
|
||||
FROM ticket_comments tc
|
||||
|
||||
230
models/CustomFieldModel.php
Normal file
230
models/CustomFieldModel.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
/**
|
||||
* CustomFieldModel - Manages custom field definitions and values
|
||||
*/
|
||||
|
||||
class CustomFieldModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Field Definitions
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get all field definitions
|
||||
*/
|
||||
public function getAllDefinitions($category = null, $activeOnly = true) {
|
||||
$sql = "SELECT * FROM custom_field_definitions WHERE 1=1";
|
||||
$params = [];
|
||||
$types = '';
|
||||
|
||||
if ($activeOnly) {
|
||||
$sql .= " AND is_active = 1";
|
||||
}
|
||||
|
||||
if ($category !== null) {
|
||||
$sql .= " AND (category = ? OR category IS NULL)";
|
||||
$params[] = $category;
|
||||
$types .= 's';
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY display_order ASC, field_id ASC";
|
||||
|
||||
if (!empty($params)) {
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
} else {
|
||||
$result = $this->conn->query($sql);
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['field_options']) {
|
||||
$row['field_options'] = json_decode($row['field_options'], true);
|
||||
}
|
||||
$fields[] = $row;
|
||||
}
|
||||
|
||||
if (isset($stmt)) {
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single field definition
|
||||
*/
|
||||
public function getDefinition($fieldId) {
|
||||
$sql = "SELECT * FROM custom_field_definitions WHERE field_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $fieldId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if ($row && $row['field_options']) {
|
||||
$row['field_options'] = json_decode($row['field_options'], true);
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new field definition
|
||||
*/
|
||||
public function createDefinition($data) {
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
}
|
||||
|
||||
$sql = "INSERT INTO custom_field_definitions
|
||||
(field_name, field_label, field_type, field_options, category, is_required, display_order, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sssssiii',
|
||||
$data['field_name'],
|
||||
$data['field_label'],
|
||||
$data['field_type'],
|
||||
$options,
|
||||
$data['category'],
|
||||
$data['is_required'] ?? 0,
|
||||
$data['display_order'] ?? 0,
|
||||
$data['is_active'] ?? 1
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
return ['success' => true, 'field_id' => $id];
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a field definition
|
||||
*/
|
||||
public function updateDefinition($fieldId, $data) {
|
||||
$options = null;
|
||||
if (isset($data['field_options']) && !empty($data['field_options'])) {
|
||||
$options = json_encode($data['field_options']);
|
||||
}
|
||||
|
||||
$sql = "UPDATE custom_field_definitions SET
|
||||
field_name = ?, field_label = ?, field_type = ?, field_options = ?,
|
||||
category = ?, is_required = ?, display_order = ?, is_active = ?
|
||||
WHERE field_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sssssiiiii',
|
||||
$data['field_name'],
|
||||
$data['field_label'],
|
||||
$data['field_type'],
|
||||
$options,
|
||||
$data['category'],
|
||||
$data['is_required'] ?? 0,
|
||||
$data['display_order'] ?? 0,
|
||||
$data['is_active'] ?? 1,
|
||||
$fieldId
|
||||
);
|
||||
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a field definition
|
||||
*/
|
||||
public function deleteDefinition($fieldId) {
|
||||
// This will cascade delete all values due to FK constraint
|
||||
$sql = "DELETE FROM custom_field_definitions WHERE field_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $fieldId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Field Values
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get all field values for a ticket
|
||||
*/
|
||||
public function getValuesForTicket($ticketId) {
|
||||
$sql = "SELECT cfv.*, cfd.field_name, cfd.field_label, cfd.field_type, cfd.field_options
|
||||
FROM custom_field_values cfv
|
||||
JOIN custom_field_definitions cfd ON cfv.field_id = cfd.field_id
|
||||
WHERE cfv.ticket_id = ?
|
||||
ORDER BY cfd.display_order ASC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('s', $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$values = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($row['field_options']) {
|
||||
$row['field_options'] = json_decode($row['field_options'], true);
|
||||
}
|
||||
$values[$row['field_name']] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a field value for a ticket (insert or update)
|
||||
*/
|
||||
public function setValue($ticketId, $fieldId, $value) {
|
||||
$sql = "INSERT INTO custom_field_values (ticket_id, field_id, field_value)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE field_value = VALUES(field_value), updated_at = CURRENT_TIMESTAMP";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('sis', $ticketId, $fieldId, $value);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple field values for a ticket
|
||||
*/
|
||||
public function setValues($ticketId, $values) {
|
||||
$results = [];
|
||||
foreach ($values as $fieldId => $value) {
|
||||
$results[$fieldId] = $this->setValue($ticketId, $fieldId, $value);
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all field values for a ticket
|
||||
*/
|
||||
public function deleteValuesForTicket($ticketId) {
|
||||
$sql = "DELETE FROM custom_field_values WHERE ticket_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('s', $ticketId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
}
|
||||
?>
|
||||
254
models/DependencyModel.php
Normal file
254
models/DependencyModel.php
Normal file
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
/**
|
||||
* DependencyModel - Manages ticket dependencies
|
||||
*/
|
||||
class DependencyModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dependencies for a ticket
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return array Dependencies grouped by type
|
||||
*/
|
||||
public function getDependencies($ticketId) {
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
JOIN tickets t ON d.depends_on_id = t.ticket_id
|
||||
WHERE d.ticket_id = ?
|
||||
ORDER BY d.dependency_type, d.created_at DESC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$dependencies = [
|
||||
'blocks' => [],
|
||||
'blocked_by' => [],
|
||||
'relates_to' => [],
|
||||
'duplicates' => []
|
||||
];
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$dependencies[$row['dependency_type']][] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets that depend on this ticket
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @return array Dependent tickets
|
||||
*/
|
||||
public function getDependentTickets($ticketId) {
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
JOIN tickets t ON d.ticket_id = t.ticket_id
|
||||
WHERE d.depends_on_id = ?
|
||||
ORDER BY d.dependency_type, d.created_at DESC";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $ticketId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$dependents = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$dependents[] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $dependents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a dependency between tickets
|
||||
*
|
||||
* @param string $ticketId Source ticket ID
|
||||
* @param string $dependsOnId Target ticket ID
|
||||
* @param string $type Dependency type
|
||||
* @param int $createdBy User ID who created the dependency
|
||||
* @return array Result with success status
|
||||
*/
|
||||
public function addDependency($ticketId, $dependsOnId, $type = 'blocks', $createdBy = null) {
|
||||
// Validate dependency type
|
||||
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
||||
if (!in_array($type, $validTypes)) {
|
||||
return ['success' => false, 'error' => 'Invalid dependency type'];
|
||||
}
|
||||
|
||||
// Prevent self-reference
|
||||
if ($ticketId === $dependsOnId) {
|
||||
return ['success' => false, 'error' => 'A ticket cannot depend on itself'];
|
||||
}
|
||||
|
||||
// Check if dependency already exists
|
||||
$checkSql = "SELECT dependency_id FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||
$checkStmt = $this->conn->prepare($checkSql);
|
||||
$checkStmt->bind_param("sss", $ticketId, $dependsOnId, $type);
|
||||
$checkStmt->execute();
|
||||
$checkResult = $checkStmt->get_result();
|
||||
|
||||
if ($checkResult->num_rows > 0) {
|
||||
$checkStmt->close();
|
||||
return ['success' => false, 'error' => 'Dependency already exists'];
|
||||
}
|
||||
$checkStmt->close();
|
||||
|
||||
// Check for circular dependency
|
||||
if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) {
|
||||
return ['success' => false, 'error' => 'This would create a circular dependency'];
|
||||
}
|
||||
|
||||
// Insert the dependency
|
||||
$sql = "INSERT INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
|
||||
VALUES (?, ?, ?, ?)";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sssi", $ticketId, $dependsOnId, $type, $createdBy);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$dependencyId = $stmt->insert_id;
|
||||
$stmt->close();
|
||||
return ['success' => true, 'dependency_id' => $dependencyId];
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a dependency
|
||||
*
|
||||
* @param int $dependencyId Dependency ID
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function removeDependency($dependencyId) {
|
||||
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $dependencyId);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dependency by ticket IDs and type
|
||||
*
|
||||
* @param string $ticketId Source ticket ID
|
||||
* @param string $dependsOnId Target ticket ID
|
||||
* @param string $type Dependency type
|
||||
* @return bool Success status
|
||||
*/
|
||||
public function removeDependencyByTickets($ticketId, $dependsOnId, $type) {
|
||||
$sql = "DELETE FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sss", $ticketId, $dependsOnId, $type);
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if adding a dependency would create a cycle
|
||||
*
|
||||
* @param string $ticketId Source ticket ID
|
||||
* @param string $dependsOnId Target ticket ID
|
||||
* @param string $type Dependency type
|
||||
* @return bool True if it would create a cycle
|
||||
*/
|
||||
private function wouldCreateCycle($ticketId, $dependsOnId, $type) {
|
||||
// Only check for cycles in blocking relationships
|
||||
if (!in_array($type, ['blocks', 'blocked_by'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if dependsOnId already has ticketId in its dependency chain
|
||||
$visited = [];
|
||||
return $this->hasDependencyPath($dependsOnId, $ticketId, $visited);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a dependency path from source to target
|
||||
*
|
||||
* @param string $source Source ticket ID
|
||||
* @param string $target Target ticket ID
|
||||
* @param array $visited Already visited tickets
|
||||
* @return bool True if path exists
|
||||
*/
|
||||
private function hasDependencyPath($source, $target, &$visited) {
|
||||
if ($source === $target) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($source, $visited)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$visited[] = $source;
|
||||
|
||||
$sql = "SELECT depends_on_id FROM ticket_dependencies
|
||||
WHERE ticket_id = ? AND dependency_type IN ('blocks', 'blocked_by')";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("s", $source);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ($this->hasDependencyPath($row['depends_on_id'], $target, $visited)) {
|
||||
$stmt->close();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all dependencies for multiple tickets (batch)
|
||||
*
|
||||
* @param array $ticketIds Array of ticket IDs
|
||||
* @return array Dependencies indexed by ticket ID
|
||||
*/
|
||||
public function getDependenciesBatch($ticketIds) {
|
||||
if (empty($ticketIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = str_repeat('?,', count($ticketIds) - 1) . '?';
|
||||
$sql = "SELECT d.*, t.title, t.status, t.priority
|
||||
FROM ticket_dependencies d
|
||||
JOIN tickets t ON d.depends_on_id = t.ticket_id
|
||||
WHERE d.ticket_id IN ($placeholders)
|
||||
ORDER BY d.ticket_id, d.dependency_type";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$types = str_repeat('s', count($ticketIds));
|
||||
$stmt->bind_param($types, ...$ticketIds);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
$dependencies = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ticketId = $row['ticket_id'];
|
||||
if (!isset($dependencies[$ticketId])) {
|
||||
$dependencies[$ticketId] = [];
|
||||
}
|
||||
$dependencies[$ticketId][] = $row;
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
return $dependencies;
|
||||
}
|
||||
}
|
||||
210
models/RecurringTicketModel.php
Normal file
210
models/RecurringTicketModel.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
/**
|
||||
* RecurringTicketModel - Manages recurring ticket schedules
|
||||
*/
|
||||
|
||||
class RecurringTicketModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recurring tickets
|
||||
*/
|
||||
public function getAll($includeInactive = false) {
|
||||
$sql = "SELECT rt.*, u1.display_name as assigned_name, u1.username as assigned_username,
|
||||
u2.display_name as creator_name, u2.username as creator_username
|
||||
FROM recurring_tickets rt
|
||||
LEFT JOIN users u1 ON rt.assigned_to = u1.user_id
|
||||
LEFT JOIN users u2 ON rt.created_by = u2.user_id";
|
||||
|
||||
if (!$includeInactive) {
|
||||
$sql .= " WHERE rt.is_active = 1";
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY rt.next_run_at ASC";
|
||||
|
||||
$result = $this->conn->query($sql);
|
||||
$items = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$items[] = $row;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single recurring ticket by ID
|
||||
*/
|
||||
public function getById($recurringId) {
|
||||
$sql = "SELECT * FROM recurring_tickets WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$row = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new recurring ticket
|
||||
*/
|
||||
public function create($data) {
|
||||
$sql = "INSERT INTO recurring_tickets
|
||||
(title_template, description_template, category, type, priority, assigned_to,
|
||||
schedule_type, schedule_day, schedule_time, next_run_at, is_active, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('ssssiiisssis',
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['priority'],
|
||||
$data['assigned_to'],
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'],
|
||||
$data['schedule_time'],
|
||||
$data['next_run_at'],
|
||||
$data['is_active'],
|
||||
$data['created_by']
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $this->conn->insert_id;
|
||||
$stmt->close();
|
||||
return ['success' => true, 'recurring_id' => $id];
|
||||
}
|
||||
|
||||
$error = $stmt->error;
|
||||
$stmt->close();
|
||||
return ['success' => false, 'error' => $error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a recurring ticket
|
||||
*/
|
||||
public function update($recurringId, $data) {
|
||||
$sql = "UPDATE recurring_tickets SET
|
||||
title_template = ?, description_template = ?, category = ?, type = ?,
|
||||
priority = ?, assigned_to = ?, schedule_type = ?, schedule_day = ?,
|
||||
schedule_time = ?, next_run_at = ?, is_active = ?
|
||||
WHERE recurring_id = ?";
|
||||
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('ssssiissssii',
|
||||
$data['title_template'],
|
||||
$data['description_template'],
|
||||
$data['category'],
|
||||
$data['type'],
|
||||
$data['priority'],
|
||||
$data['assigned_to'],
|
||||
$data['schedule_type'],
|
||||
$data['schedule_day'],
|
||||
$data['schedule_time'],
|
||||
$data['next_run_at'],
|
||||
$data['is_active'],
|
||||
$recurringId
|
||||
);
|
||||
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recurring ticket
|
||||
*/
|
||||
public function delete($recurringId) {
|
||||
$sql = "DELETE FROM recurring_tickets WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recurring tickets due for execution
|
||||
*/
|
||||
public function getDueRecurringTickets() {
|
||||
$sql = "SELECT * FROM recurring_tickets WHERE is_active = 1 AND next_run_at <= NOW()";
|
||||
$result = $this->conn->query($sql);
|
||||
$items = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$items[] = $row;
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last run and calculate next run time
|
||||
*/
|
||||
public function updateAfterRun($recurringId) {
|
||||
$recurring = $this->getById($recurringId);
|
||||
if (!$recurring) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$nextRun = $this->calculateNextRunTime(
|
||||
$recurring['schedule_type'],
|
||||
$recurring['schedule_day'],
|
||||
$recurring['schedule_time']
|
||||
);
|
||||
|
||||
$sql = "UPDATE recurring_tickets SET last_run_at = NOW(), next_run_at = ? WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('si', $nextRun, $recurringId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next run time based on schedule
|
||||
*/
|
||||
private function calculateNextRunTime($scheduleType, $scheduleDay, $scheduleTime) {
|
||||
$now = new DateTime();
|
||||
$time = new DateTime($scheduleTime);
|
||||
|
||||
switch ($scheduleType) {
|
||||
case 'daily':
|
||||
$next = new DateTime('tomorrow ' . $scheduleTime);
|
||||
break;
|
||||
|
||||
case 'weekly':
|
||||
$dayName = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][$scheduleDay] ?? 'Monday';
|
||||
$next = new DateTime("next {$dayName} " . $scheduleTime);
|
||||
break;
|
||||
|
||||
case 'monthly':
|
||||
$day = max(1, min(28, $scheduleDay)); // Limit to 28 for safety
|
||||
$next = new DateTime();
|
||||
$next->modify('first day of next month');
|
||||
$next->setDate($next->format('Y'), $next->format('m'), $day);
|
||||
$next->setTime($time->format('H'), $time->format('i'), 0);
|
||||
break;
|
||||
|
||||
default:
|
||||
$next = new DateTime('tomorrow ' . $scheduleTime);
|
||||
}
|
||||
|
||||
return $next->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active status
|
||||
*/
|
||||
public function toggleActive($recurringId) {
|
||||
$sql = "UPDATE recurring_tickets SET is_active = NOT is_active WHERE recurring_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $recurringId);
|
||||
$success = $stmt->execute();
|
||||
$stmt->close();
|
||||
return ['success' => $success];
|
||||
}
|
||||
}
|
||||
?>
|
||||
186
models/StatsModel.php
Normal file
186
models/StatsModel.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
/**
|
||||
* StatsModel - Dashboard statistics and metrics
|
||||
*
|
||||
* Provides various ticket statistics for dashboard widgets
|
||||
*/
|
||||
|
||||
class StatsModel {
|
||||
private $conn;
|
||||
|
||||
public function __construct($conn) {
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of open tickets
|
||||
*/
|
||||
public function getOpenTicketCount() {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status IN ('Open', 'Pending', 'In Progress')";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of closed tickets
|
||||
*/
|
||||
public function getClosedTicketCount() {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed'";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets grouped by priority
|
||||
*/
|
||||
public function getTicketsByPriority() {
|
||||
$sql = "SELECT priority, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY priority ORDER BY priority";
|
||||
$result = $this->conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data['P' . $row['priority']] = (int)$row['count'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets grouped by status
|
||||
*/
|
||||
public function getTicketsByStatus() {
|
||||
$sql = "SELECT status, COUNT(*) as count FROM tickets GROUP BY status ORDER BY FIELD(status, 'Open', 'Pending', 'In Progress', 'Closed')";
|
||||
$result = $this->conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[$row['status']] = (int)$row['count'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets grouped by category
|
||||
*/
|
||||
public function getTicketsByCategory() {
|
||||
$sql = "SELECT category, COUNT(*) as count FROM tickets WHERE status != 'Closed' GROUP BY category ORDER BY count DESC";
|
||||
$result = $this->conn->query($sql);
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[$row['category']] = (int)$row['count'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average resolution time in hours
|
||||
*/
|
||||
public function getAverageResolutionTime() {
|
||||
$sql = "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, updated_at)) as avg_hours
|
||||
FROM tickets
|
||||
WHERE status = 'Closed'
|
||||
AND created_at IS NOT NULL
|
||||
AND updated_at IS NOT NULL
|
||||
AND updated_at > created_at";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return $row['avg_hours'] ? round($row['avg_hours'], 1) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of tickets created today
|
||||
*/
|
||||
public function getTicketsCreatedToday() {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE DATE(created_at) = CURDATE()";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of tickets created this week
|
||||
*/
|
||||
public function getTicketsCreatedThisWeek() {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE YEARWEEK(created_at, 1) = YEARWEEK(CURDATE(), 1)";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of tickets closed today
|
||||
*/
|
||||
public function getTicketsClosedToday() {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE status = 'Closed' AND DATE(updated_at) = CURDATE()";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets by assignee (top 5)
|
||||
*/
|
||||
public function getTicketsByAssignee($limit = 5) {
|
||||
$sql = "SELECT
|
||||
u.display_name,
|
||||
u.username,
|
||||
COUNT(t.ticket_id) as ticket_count
|
||||
FROM tickets t
|
||||
JOIN users u ON t.assigned_to = u.user_id
|
||||
WHERE t.status != 'Closed'
|
||||
GROUP BY t.assigned_to
|
||||
ORDER BY ticket_count DESC
|
||||
LIMIT ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param('i', $limit);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$name = $row['display_name'] ?: $row['username'];
|
||||
$data[$name] = (int)$row['ticket_count'];
|
||||
}
|
||||
$stmt->close();
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unassigned ticket count
|
||||
*/
|
||||
public function getUnassignedTicketCount() {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE assigned_to IS NULL AND status != 'Closed'";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get critical (P1) ticket count
|
||||
*/
|
||||
public function getCriticalTicketCount() {
|
||||
$sql = "SELECT COUNT(*) as count FROM tickets WHERE priority = 1 AND status != 'Closed'";
|
||||
$result = $this->conn->query($sql);
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)$row['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stats as a single array
|
||||
*/
|
||||
public function getAllStats() {
|
||||
return [
|
||||
'open_tickets' => $this->getOpenTicketCount(),
|
||||
'closed_tickets' => $this->getClosedTicketCount(),
|
||||
'created_today' => $this->getTicketsCreatedToday(),
|
||||
'created_this_week' => $this->getTicketsCreatedThisWeek(),
|
||||
'closed_today' => $this->getTicketsClosedToday(),
|
||||
'unassigned' => $this->getUnassignedTicketCount(),
|
||||
'critical' => $this->getCriticalTicketCount(),
|
||||
'avg_resolution_hours' => $this->getAverageResolutionTime(),
|
||||
'by_priority' => $this->getTicketsByPriority(),
|
||||
'by_status' => $this->getTicketsByStatus(),
|
||||
'by_category' => $this->getTicketsByCategory(),
|
||||
'by_assignee' => $this->getTicketsByAssignee()
|
||||
];
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -223,18 +223,6 @@ class TicketModel {
|
||||
}
|
||||
|
||||
public function updateTicket($ticketData, $updatedBy = null) {
|
||||
// Debug function
|
||||
$debug = function($message, $data = null) {
|
||||
$log_message = date('Y-m-d H:i:s') . " - [Model] " . $message;
|
||||
if ($data !== null) {
|
||||
$log_message .= ": " . (is_string($data) ? $data : json_encode($data));
|
||||
}
|
||||
$log_message .= "\n";
|
||||
file_put_contents('/tmp/api_debug.log', $log_message, FILE_APPEND);
|
||||
};
|
||||
|
||||
$debug("updateTicket called with data", $ticketData);
|
||||
|
||||
$sql = "UPDATE tickets SET
|
||||
title = ?,
|
||||
priority = ?,
|
||||
@@ -246,43 +234,27 @@ class TicketModel {
|
||||
updated_at = NOW()
|
||||
WHERE ticket_id = ?";
|
||||
|
||||
$debug("SQL query", $sql);
|
||||
|
||||
try {
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
$debug("Prepare statement failed", $this->conn->error);
|
||||
return false;
|
||||
}
|
||||
|
||||
$debug("Binding parameters");
|
||||
$stmt->bind_param(
|
||||
"sissssii",
|
||||
$ticketData['title'],
|
||||
$ticketData['priority'],
|
||||
$ticketData['status'],
|
||||
$ticketData['description'],
|
||||
$ticketData['category'],
|
||||
$ticketData['type'],
|
||||
$updatedBy,
|
||||
$ticketData['ticket_id']
|
||||
);
|
||||
|
||||
$debug("Executing statement");
|
||||
$result = $stmt->execute();
|
||||
|
||||
if (!$result) {
|
||||
$debug("Execute failed", $stmt->error);
|
||||
return false;
|
||||
}
|
||||
|
||||
$debug("Update successful");
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
$debug("Exception", $e->getMessage());
|
||||
$debug("Stack trace", $e->getTraceAsString());
|
||||
throw $e;
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt->bind_param(
|
||||
"sissssii",
|
||||
$ticketData['title'],
|
||||
$ticketData['priority'],
|
||||
$ticketData['status'],
|
||||
$ticketData['description'],
|
||||
$ticketData['category'],
|
||||
$ticketData['type'],
|
||||
$updatedBy,
|
||||
$ticketData['ticket_id']
|
||||
);
|
||||
|
||||
$result = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function createTicket($ticketData, $createdBy = null) {
|
||||
|
||||
30
uploads/.htaccess
Normal file
30
uploads/.htaccess
Normal file
@@ -0,0 +1,30 @@
|
||||
# Deny direct access to uploaded files
|
||||
# All downloads must go through download_attachment.php
|
||||
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
|
||||
# Disable script execution
|
||||
<IfModule mod_php.c>
|
||||
php_flag engine off
|
||||
</IfModule>
|
||||
|
||||
# Prevent directory listing
|
||||
Options -Indexes
|
||||
|
||||
# Block common executable extensions
|
||||
<FilesMatch "\.(php|phtml|php3|php4|php5|php7|phps|phar|cgi|pl|py|sh|bash|exe|com|bat|cmd|vbs|js|html|htm|asp|aspx|jsp)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
@@ -105,6 +105,13 @@
|
||||
<label for="title">Ticket Title *</label>
|
||||
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
|
||||
</div>
|
||||
<!-- Duplicate Warning Area -->
|
||||
<div id="duplicateWarning" style="display: none; margin-top: 1rem; padding: 1rem; border: 2px solid var(--terminal-amber); background: rgba(241, 196, 15, 0.1);">
|
||||
<div style="color: var(--terminal-amber); font-weight: bold; margin-bottom: 0.5rem;">
|
||||
Possible Duplicates Found
|
||||
</div>
|
||||
<div id="duplicatesList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,5 +194,63 @@
|
||||
</form>
|
||||
</div>
|
||||
<!-- END OUTER FRAME -->
|
||||
|
||||
<script>
|
||||
// Duplicate detection with debounce
|
||||
let duplicateCheckTimeout = null;
|
||||
|
||||
document.getElementById('title').addEventListener('input', function() {
|
||||
clearTimeout(duplicateCheckTimeout);
|
||||
const title = this.value.trim();
|
||||
|
||||
if (title.length < 5) {
|
||||
document.getElementById('duplicateWarning').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce: wait 500ms after user stops typing
|
||||
duplicateCheckTimeout = setTimeout(() => {
|
||||
checkForDuplicates(title);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
function checkForDuplicates(title) {
|
||||
fetch('/api/check_duplicates.php?title=' + encodeURIComponent(title))
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const warningDiv = document.getElementById('duplicateWarning');
|
||||
const listDiv = document.getElementById('duplicatesList');
|
||||
|
||||
if (data.success && data.duplicates && data.duplicates.length > 0) {
|
||||
let html = '<ul style="margin: 0; padding-left: 1.5rem; color: var(--terminal-green);">';
|
||||
data.duplicates.forEach(dup => {
|
||||
html += `<li style="margin-bottom: 0.5rem;">
|
||||
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank" style="color: var(--terminal-green);">
|
||||
#${escapeHtml(dup.ticket_id)}
|
||||
</a>
|
||||
- ${escapeHtml(dup.title)}
|
||||
<span style="color: var(--terminal-amber);">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span>
|
||||
</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
html += '<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--terminal-green-dim);">Consider checking these tickets before creating a new one.</p>';
|
||||
|
||||
listDiv.innerHTML = html;
|
||||
warningDiv.style.display = 'block';
|
||||
} else {
|
||||
warningDiv.style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error checking duplicates:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -175,6 +175,56 @@
|
||||
<!-- Main Content Area -->
|
||||
<main class="dashboard-main">
|
||||
|
||||
<!-- Dashboard Stats Widgets -->
|
||||
<?php if (isset($stats)): ?>
|
||||
<div class="stats-widgets">
|
||||
<div class="stats-row">
|
||||
<div class="stat-card stat-open">
|
||||
<div class="stat-icon">📂</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['open_tickets']; ?></div>
|
||||
<div class="stat-label">Open Tickets</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-critical">
|
||||
<div class="stat-icon">🔥</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['critical']; ?></div>
|
||||
<div class="stat-label">Critical (P1)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-unassigned">
|
||||
<div class="stat-icon">👤</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['unassigned']; ?></div>
|
||||
<div class="stat-label">Unassigned</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-today">
|
||||
<div class="stat-icon">📅</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['created_today']; ?></div>
|
||||
<div class="stat-label">Created Today</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-resolved">
|
||||
<div class="stat-icon">✓</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['closed_today']; ?></div>
|
||||
<div class="stat-label">Closed Today</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-time">
|
||||
<div class="stat-icon">⏱</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value"><?php echo $stats['avg_resolution_hours']; ?>h</div>
|
||||
<div class="stat-label">Avg Resolution</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- CONDENSED TOOLBAR: Combined Header, Search, Actions, Pagination -->
|
||||
<div class="dashboard-toolbar">
|
||||
<!-- Left: Title + Search -->
|
||||
@@ -214,6 +264,13 @@
|
||||
<!-- Center: Actions + Count -->
|
||||
<div class="toolbar-center">
|
||||
<button onclick="window.location.href='<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create'" class="btn create-ticket">+ New Ticket</button>
|
||||
<div class="export-dropdown">
|
||||
<button class="btn">↓ Export</button>
|
||||
<div class="export-dropdown-content">
|
||||
<a href="/api/export_tickets.php?format=csv<?php echo isset($_GET['status']) ? '&status=' . urlencode($_GET['status']) : ''; ?><?php echo isset($_GET['category']) ? '&category=' . urlencode($_GET['category']) : ''; ?>">CSV</a>
|
||||
<a href="/api/export_tickets.php?format=json<?php echo isset($_GET['status']) ? '&status=' . urlencode($_GET['status']) : ''; ?><?php echo isset($_GET['category']) ? '&category=' . urlencode($_GET['category']) : ''; ?>">JSON</a>
|
||||
</div>
|
||||
</div>
|
||||
<span class="ticket-count">Total: <?php echo $totalTickets; ?></span>
|
||||
</div>
|
||||
|
||||
@@ -296,16 +353,20 @@
|
||||
'created_by' => 'Created By',
|
||||
'assigned_to' => 'Assigned To',
|
||||
'created_at' => 'Created',
|
||||
'updated_at' => 'Updated'
|
||||
'updated_at' => 'Updated',
|
||||
'_actions' => 'Actions'
|
||||
];
|
||||
|
||||
foreach($columns as $col => $label) {
|
||||
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
||||
$sortClass = ($currentSort === $col) ? "sort-$currentDir" : '';
|
||||
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]);
|
||||
$sortUrl = '?' . http_build_query($sortParams);
|
||||
|
||||
echo "<th class='$sortClass' onclick='window.location.href=\"$sortUrl\"'>$label</th>";
|
||||
if ($col === '_actions') {
|
||||
echo "<th style='width: 100px; text-align: center;'>$label</th>";
|
||||
} else {
|
||||
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
|
||||
$sortClass = ($currentSort === $col) ? "sort-$currentDir" : '';
|
||||
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]);
|
||||
$sortUrl = '?' . http_build_query($sortParams);
|
||||
echo "<th class='$sortClass' onclick='window.location.href=\"$sortUrl\"'>$label</th>";
|
||||
}
|
||||
}
|
||||
?>
|
||||
</tr>
|
||||
@@ -333,10 +394,18 @@
|
||||
echo "<td>" . htmlspecialchars($assignedTo) . "</td>";
|
||||
echo "<td>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>";
|
||||
echo "<td>" . date('Y-m-d H:i', strtotime($row['updated_at'])) . "</td>";
|
||||
// Quick actions column
|
||||
echo "<td class='quick-actions-cell'>";
|
||||
echo "<div class='quick-actions'>";
|
||||
echo "<button onclick=\"event.stopPropagation(); window.location.href='/ticket/{$row['ticket_id']}'\" class='quick-action-btn' title='View'>👁</button>";
|
||||
echo "<button onclick=\"event.stopPropagation(); quickStatusChange('{$row['ticket_id']}', '{$row['status']}')\" class='quick-action-btn' title='Change Status'>🔄</button>";
|
||||
echo "<button onclick=\"event.stopPropagation(); quickAssign('{$row['ticket_id']}')\" class='quick-action-btn' title='Assign'>👤</button>";
|
||||
echo "</div>";
|
||||
echo "</td>";
|
||||
echo "</tr>";
|
||||
}
|
||||
} else {
|
||||
$colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '11' : '10';
|
||||
$colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '12' : '11';
|
||||
echo "<tr><td colspan='$colspan' style='text-align: center; padding: 3rem;'>";
|
||||
echo "<pre style='color: var(--terminal-green); text-shadow: var(--glow-green); font-size: 0.8rem; line-height: 1.2;'>";
|
||||
echo "╔════════════════════════════════════════╗\n";
|
||||
|
||||
@@ -197,6 +197,8 @@ function formatDetails($details, $actionType) {
|
||||
<div class="ticket-tabs">
|
||||
<button class="tab-btn active" onclick="showTab('description')">Description</button>
|
||||
<button class="tab-btn" onclick="showTab('comments')">Comments</button>
|
||||
<button class="tab-btn" onclick="showTab('attachments')">Attachments</button>
|
||||
<button class="tab-btn" onclick="showTab('dependencies')">Dependencies</button>
|
||||
<button class="tab-btn" onclick="showTab('activity')">Activity</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,6 +279,76 @@ function formatDetails($details, $actionType) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="attachments-tab" class="tab-content">
|
||||
<div class="ascii-subsection-header">File Attachments</div>
|
||||
<div class="attachments-container">
|
||||
<!-- Upload Form -->
|
||||
<div class="ascii-frame-inner" style="margin-bottom: 1rem;">
|
||||
<h3>Upload Files</h3>
|
||||
<div class="upload-zone" id="uploadZone">
|
||||
<div class="upload-zone-content">
|
||||
<div class="upload-icon">📁</div>
|
||||
<p>Drag and drop files here or click to browse</p>
|
||||
<p class="upload-hint">Max file size: <?php echo $GLOBALS['config']['MAX_UPLOAD_SIZE'] ? number_format($GLOBALS['config']['MAX_UPLOAD_SIZE'] / 1048576, 0) . 'MB' : '10MB'; ?></p>
|
||||
<input type="file" id="fileInput" multiple style="display: none;">
|
||||
<button type="button" onclick="document.getElementById('fileInput').click();" class="btn" style="margin-top: 1rem;">Browse Files</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="uploadProgress" style="display: none; margin-top: 1rem;">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<p id="uploadStatus" style="margin-top: 0.5rem; color: var(--terminal-green); font-family: var(--font-mono); font-size: 0.85rem;"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachment List -->
|
||||
<div class="ascii-frame-inner">
|
||||
<h3>Attached Files</h3>
|
||||
<div id="attachmentsList" class="attachments-list">
|
||||
<p class="loading-text">Loading attachments...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dependencies-tab" class="tab-content">
|
||||
<div class="ascii-subsection-header">Ticket Dependencies</div>
|
||||
<div class="dependencies-container">
|
||||
<!-- Add Dependency Form -->
|
||||
<div class="ascii-frame-inner" style="margin-bottom: 1rem;">
|
||||
<h3>Add Dependency</h3>
|
||||
<div class="add-dependency-form" style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
||||
<input type="text" id="dependencyTicketId" placeholder="Ticket ID (e.g., 123456789)"
|
||||
style="flex: 1; min-width: 150px; padding: 0.5rem; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
|
||||
<select id="dependencyType" style="padding: 0.5rem; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
|
||||
<option value="blocks">Blocks</option>
|
||||
<option value="blocked_by">Blocked By</option>
|
||||
<option value="relates_to">Relates To</option>
|
||||
<option value="duplicates">Duplicates</option>
|
||||
</select>
|
||||
<button onclick="addDependency()" class="btn">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Dependencies -->
|
||||
<div class="ascii-frame-inner">
|
||||
<h3>Current Dependencies</h3>
|
||||
<div id="dependenciesList" class="dependencies-list">
|
||||
<p class="loading-text">Loading dependencies...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dependent Tickets -->
|
||||
<div class="ascii-frame-inner" style="margin-top: 1rem;">
|
||||
<h3>Tickets That Depend On This</h3>
|
||||
<div id="dependentsList" class="dependencies-list">
|
||||
<p class="loading-text">Loading dependents...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="activity-tab" class="tab-content">
|
||||
<div class="ascii-subsection-header">Activity Timeline</div>
|
||||
<div class="timeline-container">
|
||||
|
||||
157
views/admin/AuditLogView.php
Normal file
157
views/admin/AuditLogView.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
// Admin view for browsing audit logs
|
||||
// Receives $auditLogs, $totalPages, $page, $filters from controller
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Audit Log - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Audit Log</span>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer" style="max-width: 1400px; margin: 2rem auto;">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Audit Log Browser</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<!-- Filters -->
|
||||
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Action Type</label>
|
||||
<select name="action_type" class="setting-select">
|
||||
<option value="">All Actions</option>
|
||||
<option value="create" <?php echo ($filters['action_type'] ?? '') === 'create' ? 'selected' : ''; ?>>Create</option>
|
||||
<option value="update" <?php echo ($filters['action_type'] ?? '') === 'update' ? 'selected' : ''; ?>>Update</option>
|
||||
<option value="delete" <?php echo ($filters['action_type'] ?? '') === 'delete' ? 'selected' : ''; ?>>Delete</option>
|
||||
<option value="comment" <?php echo ($filters['action_type'] ?? '') === 'comment' ? 'selected' : ''; ?>>Comment</option>
|
||||
<option value="assign" <?php echo ($filters['action_type'] ?? '') === 'assign' ? 'selected' : ''; ?>>Assign</option>
|
||||
<option value="status_change" <?php echo ($filters['action_type'] ?? '') === 'status_change' ? 'selected' : ''; ?>>Status Change</option>
|
||||
<option value="login" <?php echo ($filters['action_type'] ?? '') === 'login' ? 'selected' : ''; ?>>Login</option>
|
||||
<option value="security" <?php echo ($filters['action_type'] ?? '') === 'security' ? 'selected' : ''; ?>>Security</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">User</label>
|
||||
<select name="user_id" class="setting-select">
|
||||
<option value="">All Users</option>
|
||||
<?php if (isset($users)): foreach ($users as $user): ?>
|
||||
<option value="<?php echo $user['user_id']; ?>" <?php echo ($filters['user_id'] ?? '') == $user['user_id'] ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
|
||||
</option>
|
||||
<?php endforeach; endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label>
|
||||
<input type="date" name="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="setting-select">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label>
|
||||
<input type="date" name="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="setting-select">
|
||||
</div>
|
||||
<div style="display: flex; align-items: flex-end;">
|
||||
<button type="submit" class="btn">Filter</button>
|
||||
<a href="?" class="btn btn-secondary" style="margin-left: 0.5rem;">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Log Table -->
|
||||
<table style="width: 100%; font-size: 0.9rem;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Entity</th>
|
||||
<th>Entity ID</th>
|
||||
<th>Details</th>
|
||||
<th>IP Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($auditLogs)): ?>
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
||||
No audit log entries found.
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($auditLogs as $log): ?>
|
||||
<tr>
|
||||
<td style="white-space: nowrap;"><?php echo date('Y-m-d H:i:s', strtotime($log['created_at'])); ?></td>
|
||||
<td><?php echo htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System'); ?></td>
|
||||
<td>
|
||||
<span style="color: var(--terminal-amber);"><?php echo htmlspecialchars($log['action_type']); ?></span>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($log['entity_type'] ?? '-'); ?></td>
|
||||
<td>
|
||||
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
|
||||
<a href="/ticket/<?php echo htmlspecialchars($log['entity_id']); ?>" style="color: var(--terminal-green);">
|
||||
<?php echo htmlspecialchars($log['entity_id']); ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<?php echo htmlspecialchars($log['entity_id'] ?? '-'); ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">
|
||||
<?php
|
||||
if ($log['details']) {
|
||||
$details = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
|
||||
if (is_array($details)) {
|
||||
echo '<code style="font-size: 0.8rem;">' . htmlspecialchars(json_encode($details)) . '</code>';
|
||||
} else {
|
||||
echo htmlspecialchars($log['details']);
|
||||
}
|
||||
} else {
|
||||
echo '-';
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
<td style="white-space: nowrap; font-size: 0.85rem;"><?php echo htmlspecialchars($log['ip_address'] ?? '-'); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<div class="pagination" style="margin-top: 1rem; text-align: center;">
|
||||
<?php
|
||||
$params = $_GET;
|
||||
for ($i = 1; $i <= min($totalPages, 10); $i++) {
|
||||
$params['page'] = $i;
|
||||
$activeClass = ($i == $page) ? 'active' : '';
|
||||
$url = '?' . http_build_query($params);
|
||||
echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> ";
|
||||
}
|
||||
if ($totalPages > 10) {
|
||||
echo "...";
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
254
views/admin/CustomFieldsView.php
Normal file
254
views/admin/CustomFieldsView.php
Normal file
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
// Admin view for managing custom fields
|
||||
// Receives $customFields from controller
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Custom Fields - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script>
|
||||
window.CSRF_TOKEN = '<?php
|
||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
echo CsrfMiddleware::getToken();
|
||||
?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Custom Fields</span>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Custom Fields Management</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Custom Field Definitions</h2>
|
||||
<button onclick="showCreateModal()" class="btn">+ New Field</button>
|
||||
</div>
|
||||
|
||||
<table style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Field Name</th>
|
||||
<th>Label</th>
|
||||
<th>Type</th>
|
||||
<th>Category</th>
|
||||
<th>Required</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($customFields)): ?>
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
||||
No custom fields defined.
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($customFields as $field): ?>
|
||||
<tr>
|
||||
<td><?php echo $field['display_order']; ?></td>
|
||||
<td><code><?php echo htmlspecialchars($field['field_name']); ?></code></td>
|
||||
<td><?php echo htmlspecialchars($field['field_label']); ?></td>
|
||||
<td><?php echo ucfirst($field['field_type']); ?></td>
|
||||
<td><?php echo htmlspecialchars($field['category'] ?? 'All'); ?></td>
|
||||
<td><?php echo $field['is_required'] ? 'Yes' : 'No'; ?></td>
|
||||
<td>
|
||||
<span style="color: <?php echo $field['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
|
||||
<?php echo $field['is_active'] ? 'Active' : 'Inactive'; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button onclick="editField(<?php echo $field['field_id']; ?>)" class="btn btn-small">Edit</button>
|
||||
<button onclick="deleteField(<?php echo $field['field_id']; ?>)" class="btn btn-small btn-danger">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div class="settings-modal" id="fieldModal" style="display: none;">
|
||||
<div class="settings-content" style="max-width: 500px;">
|
||||
<div class="settings-header">
|
||||
<h3 id="modalTitle">Create Custom Field</h3>
|
||||
<button class="close-settings" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<form id="fieldForm" onsubmit="saveField(event)">
|
||||
<input type="hidden" id="field_id" name="field_id">
|
||||
<div class="settings-body">
|
||||
<div class="setting-row">
|
||||
<label for="field_name">Field Name * (internal)</label>
|
||||
<input type="text" id="field_name" name="field_name" required pattern="[a-z_]+" placeholder="e.g., server_name">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="field_label">Field Label * (display)</label>
|
||||
<input type="text" id="field_label" name="field_label" required placeholder="e.g., Server Name">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="field_type">Field Type *</label>
|
||||
<select id="field_type" name="field_type" required onchange="toggleOptionsField()">
|
||||
<option value="text">Text</option>
|
||||
<option value="textarea">Text Area</option>
|
||||
<option value="select">Dropdown (Select)</option>
|
||||
<option value="checkbox">Checkbox</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="number">Number</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row" id="options_row" style="display: none;">
|
||||
<label for="field_options">Options (one per line)</label>
|
||||
<textarea id="field_options" name="field_options" rows="4" placeholder="Option 1 Option 2 Option 3"></textarea>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="category">Category (empty = all)</label>
|
||||
<select id="category" name="category">
|
||||
<option value="">All Categories</option>
|
||||
<option value="General">General</option>
|
||||
<option value="Hardware">Hardware</option>
|
||||
<option value="Software">Software</option>
|
||||
<option value="Network">Network</option>
|
||||
<option value="Security">Security</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="display_order">Display Order</label>
|
||||
<input type="number" id="display_order" name="display_order" value="0" min="0">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><input type="checkbox" id="is_required" name="is_required"> Required field</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script>
|
||||
function showCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = 'Create Custom Field';
|
||||
document.getElementById('fieldForm').reset();
|
||||
document.getElementById('field_id').value = '';
|
||||
document.getElementById('is_active').checked = true;
|
||||
toggleOptionsField();
|
||||
document.getElementById('fieldModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('fieldModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function toggleOptionsField() {
|
||||
const type = document.getElementById('field_type').value;
|
||||
document.getElementById('options_row').style.display = type === 'select' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function saveField(e) {
|
||||
e.preventDefault();
|
||||
const form = document.getElementById('fieldForm');
|
||||
const data = {
|
||||
field_id: document.getElementById('field_id').value,
|
||||
field_name: document.getElementById('field_name').value,
|
||||
field_label: document.getElementById('field_label').value,
|
||||
field_type: document.getElementById('field_type').value,
|
||||
category: document.getElementById('category').value || null,
|
||||
display_order: parseInt(document.getElementById('display_order').value) || 0,
|
||||
is_required: document.getElementById('is_required').checked ? 1 : 0,
|
||||
is_active: document.getElementById('is_active').checked ? 1 : 0
|
||||
};
|
||||
|
||||
if (data.field_type === 'select') {
|
||||
const options = document.getElementById('field_options').value.split('\n').filter(o => o.trim());
|
||||
data.field_options = { options: options };
|
||||
}
|
||||
|
||||
const method = data.field_id ? 'PUT' : 'POST';
|
||||
const url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to save');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function editField(id) {
|
||||
fetch('/api/custom_fields.php?id=' + id)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success && data.field) {
|
||||
const f = data.field;
|
||||
document.getElementById('field_id').value = f.field_id;
|
||||
document.getElementById('field_name').value = f.field_name;
|
||||
document.getElementById('field_label').value = f.field_label;
|
||||
document.getElementById('field_type').value = f.field_type;
|
||||
document.getElementById('category').value = f.category || '';
|
||||
document.getElementById('display_order').value = f.display_order;
|
||||
document.getElementById('is_required').checked = f.is_required == 1;
|
||||
document.getElementById('is_active').checked = f.is_active == 1;
|
||||
toggleOptionsField();
|
||||
if (f.field_options && f.field_options.options) {
|
||||
document.getElementById('field_options').value = f.field_options.options.join('\n');
|
||||
}
|
||||
document.getElementById('modalTitle').textContent = 'Edit Custom Field';
|
||||
document.getElementById('fieldModal').style.display = 'flex';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteField(id) {
|
||||
if (!confirm('Delete this custom field? All values will be lost.')) return;
|
||||
fetch('/api/custom_fields.php?id=' + id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
283
views/admin/RecurringTicketsView.php
Normal file
283
views/admin/RecurringTicketsView.php
Normal file
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
// Admin view for managing recurring tickets
|
||||
// Receives $recurringTickets from controller
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Recurring Tickets - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script>
|
||||
window.CSRF_TOKEN = '<?php
|
||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
echo CsrfMiddleware::getToken();
|
||||
?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Recurring Tickets</span>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Recurring Tickets Management</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Scheduled Tickets</h2>
|
||||
<button onclick="showCreateModal()" class="btn">+ New Recurring Ticket</button>
|
||||
</div>
|
||||
|
||||
<table style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title Template</th>
|
||||
<th>Schedule</th>
|
||||
<th>Category</th>
|
||||
<th>Assigned To</th>
|
||||
<th>Next Run</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($recurringTickets)): ?>
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
||||
No recurring tickets configured.
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($recurringTickets as $rt): ?>
|
||||
<tr>
|
||||
<td><?php echo $rt['recurring_id']; ?></td>
|
||||
<td><?php echo htmlspecialchars($rt['title_template']); ?></td>
|
||||
<td>
|
||||
<?php
|
||||
$schedule = ucfirst($rt['schedule_type']);
|
||||
if ($rt['schedule_type'] === 'weekly') {
|
||||
$days = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
$schedule .= ' (' . ($days[$rt['schedule_day']] ?? '?') . ')';
|
||||
} elseif ($rt['schedule_type'] === 'monthly') {
|
||||
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
|
||||
}
|
||||
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
|
||||
echo $schedule;
|
||||
?>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($rt['category']); ?></td>
|
||||
<td><?php echo htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned'); ?></td>
|
||||
<td><?php echo date('M d, Y H:i', strtotime($rt['next_run_at'])); ?></td>
|
||||
<td>
|
||||
<span style="color: <?php echo $rt['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
|
||||
<?php echo $rt['is_active'] ? 'Active' : 'Inactive'; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button onclick="editRecurring(<?php echo $rt['recurring_id']; ?>)" class="btn btn-small">Edit</button>
|
||||
<button onclick="toggleRecurring(<?php echo $rt['recurring_id']; ?>)" class="btn btn-small">
|
||||
<?php echo $rt['is_active'] ? 'Disable' : 'Enable'; ?>
|
||||
</button>
|
||||
<button onclick="deleteRecurring(<?php echo $rt['recurring_id']; ?>)" class="btn btn-small btn-danger">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div class="settings-modal" id="recurringModal" style="display: none;">
|
||||
<div class="settings-content" style="max-width: 600px;">
|
||||
<div class="settings-header">
|
||||
<h3 id="modalTitle">Create Recurring Ticket</h3>
|
||||
<button class="close-settings" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<form id="recurringForm" onsubmit="saveRecurring(event)">
|
||||
<input type="hidden" id="recurring_id" name="recurring_id">
|
||||
<div class="settings-body">
|
||||
<div class="setting-row">
|
||||
<label for="title_template">Title Template *</label>
|
||||
<input type="text" id="title_template" name="title_template" required style="width: 100%;" placeholder="Use {{date}}, {{month}}, etc.">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="description_template">Description Template</label>
|
||||
<textarea id="description_template" name="description_template" rows="4" style="width: 100%;"></textarea>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="schedule_type">Schedule Type *</label>
|
||||
<select id="schedule_type" name="schedule_type" required onchange="updateScheduleOptions()">
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row" id="schedule_day_row" style="display: none;">
|
||||
<label for="schedule_day">Schedule Day</label>
|
||||
<select id="schedule_day" name="schedule_day"></select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="schedule_time">Schedule Time *</label>
|
||||
<input type="time" id="schedule_time" name="schedule_time" value="09:00" required>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="category">Category</label>
|
||||
<select id="category" name="category">
|
||||
<option value="General">General</option>
|
||||
<option value="Hardware">Hardware</option>
|
||||
<option value="Software">Software</option>
|
||||
<option value="Network">Network</option>
|
||||
<option value="Security">Security</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="priority">Priority</label>
|
||||
<select id="priority" name="priority">
|
||||
<option value="1">P1 - Critical</option>
|
||||
<option value="2">P2 - High</option>
|
||||
<option value="3">P3 - Medium</option>
|
||||
<option value="4" selected>P4 - Low</option>
|
||||
<option value="5">P5 - Lowest</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script>
|
||||
function showCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
|
||||
document.getElementById('recurringForm').reset();
|
||||
document.getElementById('recurring_id').value = '';
|
||||
updateScheduleOptions();
|
||||
document.getElementById('recurringModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('recurringModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function updateScheduleOptions() {
|
||||
const type = document.getElementById('schedule_type').value;
|
||||
const dayRow = document.getElementById('schedule_day_row');
|
||||
const daySelect = document.getElementById('schedule_day');
|
||||
|
||||
daySelect.innerHTML = '';
|
||||
|
||||
if (type === 'daily') {
|
||||
dayRow.style.display = 'none';
|
||||
} else if (type === 'weekly') {
|
||||
dayRow.style.display = 'flex';
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
days.forEach((day, i) => {
|
||||
daySelect.innerHTML += `<option value="${i + 1}">${day}</option>`;
|
||||
});
|
||||
} else if (type === 'monthly') {
|
||||
dayRow.style.display = 'flex';
|
||||
for (let i = 1; i <= 28; i++) {
|
||||
daySelect.innerHTML += `<option value="${i}">Day ${i}</option>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveRecurring(e) {
|
||||
e.preventDefault();
|
||||
const form = new FormData(document.getElementById('recurringForm'));
|
||||
const data = Object.fromEntries(form);
|
||||
|
||||
const method = data.recurring_id ? 'PUT' : 'POST';
|
||||
const url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to save');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleRecurring(id) {
|
||||
fetch('/api/manage_recurring.php?action=toggle&id=' + id, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function deleteRecurring(id) {
|
||||
if (!confirm('Delete this recurring ticket schedule?')) return;
|
||||
fetch('/api/manage_recurring.php?id=' + id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function editRecurring(id) {
|
||||
fetch('/api/manage_recurring.php?id=' + id)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success && data.recurring) {
|
||||
const rt = data.recurring;
|
||||
document.getElementById('recurring_id').value = rt.recurring_id;
|
||||
document.getElementById('title_template').value = rt.title_template;
|
||||
document.getElementById('description_template').value = rt.description_template || '';
|
||||
document.getElementById('schedule_type').value = rt.schedule_type;
|
||||
updateScheduleOptions();
|
||||
document.getElementById('schedule_day').value = rt.schedule_day || '';
|
||||
document.getElementById('schedule_time').value = rt.schedule_time ? rt.schedule_time.substring(0, 5) : '09:00';
|
||||
document.getElementById('category').value = rt.category || 'General';
|
||||
document.getElementById('priority').value = rt.priority || 4;
|
||||
document.getElementById('modalTitle').textContent = 'Edit Recurring Ticket';
|
||||
document.getElementById('recurringModal').style.display = 'flex';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize
|
||||
updateScheduleOptions();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
241
views/admin/TemplatesView.php
Normal file
241
views/admin/TemplatesView.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
// Admin view for managing ticket templates
|
||||
// Receives $templates from controller
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Template Management - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script>
|
||||
window.CSRF_TOKEN = '<?php
|
||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
echo CsrfMiddleware::getToken();
|
||||
?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Templates</span>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Ticket Template Management</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Ticket Templates</h2>
|
||||
<button onclick="showCreateModal()" class="btn">+ New Template</button>
|
||||
</div>
|
||||
|
||||
<p style="color: var(--terminal-green-dim); margin-bottom: 1rem;">
|
||||
Templates pre-fill ticket creation forms with standard content for common ticket types.
|
||||
</p>
|
||||
|
||||
<table style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Template Name</th>
|
||||
<th>Category</th>
|
||||
<th>Type</th>
|
||||
<th>Priority</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($templates)): ?>
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
||||
No templates defined. Create templates to speed up ticket creation.
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($templates as $tpl): ?>
|
||||
<tr>
|
||||
<td><strong><?php echo htmlspecialchars($tpl['template_name']); ?></strong></td>
|
||||
<td><?php echo htmlspecialchars($tpl['category'] ?? 'Any'); ?></td>
|
||||
<td><?php echo htmlspecialchars($tpl['type'] ?? 'Any'); ?></td>
|
||||
<td>P<?php echo $tpl['priority'] ?? '4'; ?></td>
|
||||
<td>
|
||||
<span style="color: <?php echo ($tpl['is_active'] ?? 1) ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
|
||||
<?php echo ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive'; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button onclick="editTemplate(<?php echo $tpl['template_id']; ?>)" class="btn btn-small">Edit</button>
|
||||
<button onclick="deleteTemplate(<?php echo $tpl['template_id']; ?>)" class="btn btn-small btn-danger">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div class="settings-modal" id="templateModal" style="display: none;">
|
||||
<div class="settings-content" style="max-width: 600px;">
|
||||
<div class="settings-header">
|
||||
<h3 id="modalTitle">Create Template</h3>
|
||||
<button class="close-settings" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<form id="templateForm" onsubmit="saveTemplate(event)">
|
||||
<input type="hidden" id="template_id" name="template_id">
|
||||
<div class="settings-body">
|
||||
<div class="setting-row">
|
||||
<label for="template_name">Template Name *</label>
|
||||
<input type="text" id="template_name" name="template_name" required style="width: 100%;">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="title_template">Title Template</label>
|
||||
<input type="text" id="title_template" name="title_template" style="width: 100%;" placeholder="Pre-filled title text">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="description_template">Description Template</label>
|
||||
<textarea id="description_template" name="description_template" rows="6" style="width: 100%;" placeholder="Pre-filled description content"></textarea>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;">
|
||||
<div class="setting-row">
|
||||
<label for="category">Category</label>
|
||||
<select id="category" name="category">
|
||||
<option value="">Any</option>
|
||||
<option value="General">General</option>
|
||||
<option value="Hardware">Hardware</option>
|
||||
<option value="Software">Software</option>
|
||||
<option value="Network">Network</option>
|
||||
<option value="Security">Security</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="type">Type</label>
|
||||
<select id="type" name="type">
|
||||
<option value="">Any</option>
|
||||
<option value="Maintenance">Maintenance</option>
|
||||
<option value="Install">Install</option>
|
||||
<option value="Task">Task</option>
|
||||
<option value="Upgrade">Upgrade</option>
|
||||
<option value="Issue">Issue</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="priority">Priority</label>
|
||||
<select id="priority" name="priority">
|
||||
<option value="1">P1</option>
|
||||
<option value="2">P2</option>
|
||||
<option value="3">P3</option>
|
||||
<option value="4" selected>P4</option>
|
||||
<option value="5">P5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script>
|
||||
const templates = <?php echo json_encode($templates ?? []); ?>;
|
||||
|
||||
function showCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = 'Create Template';
|
||||
document.getElementById('templateForm').reset();
|
||||
document.getElementById('template_id').value = '';
|
||||
document.getElementById('is_active').checked = true;
|
||||
document.getElementById('templateModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('templateModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function saveTemplate(e) {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
template_id: document.getElementById('template_id').value,
|
||||
template_name: document.getElementById('template_name').value,
|
||||
title_template: document.getElementById('title_template').value,
|
||||
description_template: document.getElementById('description_template').value,
|
||||
category: document.getElementById('category').value || null,
|
||||
type: document.getElementById('type').value || null,
|
||||
priority: parseInt(document.getElementById('priority').value) || 4,
|
||||
is_active: document.getElementById('is_active').checked ? 1 : 0
|
||||
};
|
||||
|
||||
const method = data.template_id ? 'PUT' : 'POST';
|
||||
const url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to save');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function editTemplate(id) {
|
||||
const tpl = templates.find(t => t.template_id == id);
|
||||
if (!tpl) return;
|
||||
|
||||
document.getElementById('template_id').value = tpl.template_id;
|
||||
document.getElementById('template_name').value = tpl.template_name;
|
||||
document.getElementById('title_template').value = tpl.title_template || '';
|
||||
document.getElementById('description_template').value = tpl.description_template || '';
|
||||
document.getElementById('category').value = tpl.category || '';
|
||||
document.getElementById('type').value = tpl.type || '';
|
||||
document.getElementById('priority').value = tpl.priority || 4;
|
||||
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
|
||||
document.getElementById('modalTitle').textContent = 'Edit Template';
|
||||
document.getElementById('templateModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function deleteTemplate(id) {
|
||||
if (!confirm('Delete this template?')) return;
|
||||
fetch('/api/manage_templates.php?id=' + id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
135
views/admin/UserActivityView.php
Normal file
135
views/admin/UserActivityView.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
// Admin view for user activity reports
|
||||
// Receives $userStats, $dateRange from controller
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Activity - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: User Activity</span>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">User Activity Report</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<!-- Date Range Filter -->
|
||||
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: flex-end;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label>
|
||||
<input type="date" name="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="setting-select">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label>
|
||||
<input type="date" name="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="setting-select">
|
||||
</div>
|
||||
<button type="submit" class="btn">Apply</button>
|
||||
<a href="?" class="btn btn-secondary">Reset</a>
|
||||
</form>
|
||||
|
||||
<!-- User Activity Table -->
|
||||
<table style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th style="text-align: center;">Tickets Created</th>
|
||||
<th style="text-align: center;">Tickets Resolved</th>
|
||||
<th style="text-align: center;">Comments Added</th>
|
||||
<th style="text-align: center;">Tickets Assigned</th>
|
||||
<th style="text-align: center;">Last Activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($userStats)): ?>
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
||||
No user activity data available.
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($userStats as $user): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?></strong>
|
||||
<?php if ($user['is_admin']): ?>
|
||||
<span class="admin-badge" style="font-size: 0.7rem;">Admin</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
<span style="color: var(--terminal-green); font-weight: bold;"><?php echo $user['tickets_created'] ?? 0; ?></span>
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
<span style="color: var(--status-open); font-weight: bold;"><?php echo $user['tickets_resolved'] ?? 0; ?></span>
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
<span style="color: var(--terminal-cyan); font-weight: bold;"><?php echo $user['comments_added'] ?? 0; ?></span>
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
<span style="color: var(--terminal-amber); font-weight: bold;"><?php echo $user['tickets_assigned'] ?? 0; ?></span>
|
||||
</td>
|
||||
<td style="text-align: center; font-size: 0.9rem;">
|
||||
<?php echo $user['last_activity'] ? date('M d, Y H:i', strtotime($user['last_activity'])) : 'Never'; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<?php if (!empty($userStats)): ?>
|
||||
<div style="margin-top: 2rem; padding: 1rem; border: 1px solid var(--terminal-green);">
|
||||
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Summary</h4>
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; text-align: center;">
|
||||
<div>
|
||||
<div style="font-size: 1.5rem; color: var(--terminal-green); font-weight: bold;">
|
||||
<?php echo array_sum(array_column($userStats, 'tickets_created')); ?>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Created</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 1.5rem; color: var(--status-open); font-weight: bold;">
|
||||
<?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Resolved</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 1.5rem; color: var(--terminal-cyan); font-weight: bold;">
|
||||
<?php echo array_sum(array_column($userStats, 'comments_added')); ?>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Comments</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 1.5rem; color: var(--terminal-amber); font-weight: bold;">
|
||||
<?php echo count($userStats); ?>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Active Users</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
255
views/admin/WorkflowDesignerView.php
Normal file
255
views/admin/WorkflowDesignerView.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
// Admin view for workflow/status transitions designer
|
||||
// Receives $workflows from controller
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Workflow Designer - Admin</title>
|
||||
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
||||
<script>
|
||||
window.CSRF_TOKEN = '<?php
|
||||
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||
echo CsrfMiddleware::getToken();
|
||||
?>';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="user-header">
|
||||
<div class="user-header-left">
|
||||
<a href="/" class="back-link">← Dashboard</a>
|
||||
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Workflow Designer</span>
|
||||
</div>
|
||||
<div class="user-header-right">
|
||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
||||
<span class="admin-badge">Admin</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
|
||||
<span class="bottom-left-corner">╚</span>
|
||||
<span class="bottom-right-corner">╝</span>
|
||||
|
||||
<div class="ascii-section-header">Status Workflow Designer</div>
|
||||
<div class="ascii-content">
|
||||
<div class="ascii-frame-inner">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Status Transitions</h2>
|
||||
<button onclick="showCreateModal()" class="btn">+ New Transition</button>
|
||||
</div>
|
||||
|
||||
<p style="color: var(--terminal-green-dim); margin-bottom: 1rem;">
|
||||
Define which status transitions are allowed. This controls what options appear in the status dropdown.
|
||||
</p>
|
||||
|
||||
<!-- Visual Workflow Diagram -->
|
||||
<div style="margin-bottom: 2rem; padding: 1rem; border: 1px solid var(--terminal-green); background: var(--bg-secondary);">
|
||||
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Workflow Diagram</h4>
|
||||
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap;">
|
||||
<?php
|
||||
$statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
foreach ($statuses as $status):
|
||||
$statusClass = 'status-' . str_replace(' ', '-', strtolower($status));
|
||||
?>
|
||||
<div style="text-align: center;">
|
||||
<div class="<?php echo $statusClass; ?>" style="padding: 0.5rem 1rem; display: inline-block;">
|
||||
<?php echo $status; ?>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--terminal-green-dim); margin-top: 0.5rem;">
|
||||
<?php
|
||||
$toCount = 0;
|
||||
if (isset($workflows)) {
|
||||
foreach ($workflows as $w) {
|
||||
if ($w['from_status'] === $status) $toCount++;
|
||||
}
|
||||
}
|
||||
echo "→ $toCount transitions";
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transitions Table -->
|
||||
<table style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>From Status</th>
|
||||
<th>→</th>
|
||||
<th>To Status</th>
|
||||
<th>Requires Comment</th>
|
||||
<th>Requires Admin</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($workflows)): ?>
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
|
||||
No transitions defined. Add transitions to enable status changes.
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($workflows as $wf): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['from_status'])); ?>">
|
||||
<?php echo htmlspecialchars($wf['from_status']); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align: center; color: var(--terminal-amber);">→</td>
|
||||
<td>
|
||||
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['to_status'])); ?>">
|
||||
<?php echo htmlspecialchars($wf['to_status']); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align: center;"><?php echo $wf['requires_comment'] ? '✓' : '−'; ?></td>
|
||||
<td style="text-align: center;"><?php echo $wf['requires_admin'] ? '✓' : '−'; ?></td>
|
||||
<td style="text-align: center;">
|
||||
<span style="color: <?php echo $wf['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
|
||||
<?php echo $wf['is_active'] ? '✓' : '✗'; ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button onclick="editTransition(<?php echo $wf['transition_id']; ?>)" class="btn btn-small">Edit</button>
|
||||
<button onclick="deleteTransition(<?php echo $wf['transition_id']; ?>)" class="btn btn-small btn-danger">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div class="settings-modal" id="workflowModal" style="display: none;">
|
||||
<div class="settings-content" style="max-width: 450px;">
|
||||
<div class="settings-header">
|
||||
<h3 id="modalTitle">Create Transition</h3>
|
||||
<button class="close-settings" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<form id="workflowForm" onsubmit="saveTransition(event)">
|
||||
<input type="hidden" id="transition_id" name="transition_id">
|
||||
<div class="settings-body">
|
||||
<div class="setting-row">
|
||||
<label for="from_status">From Status *</label>
|
||||
<select id="from_status" name="from_status" required>
|
||||
<option value="Open">Open</option>
|
||||
<option value="Pending">Pending</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="to_status">To Status *</label>
|
||||
<select id="to_status" name="to_status" required>
|
||||
<option value="Open">Open</option>
|
||||
<option value="Pending">Pending</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><input type="checkbox" id="requires_comment" name="requires_comment"> Requires comment</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><input type="checkbox" id="requires_admin" name="requires_admin"> Requires admin privileges</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
||||
<script>
|
||||
const workflows = <?php echo json_encode($workflows ?? []); ?>;
|
||||
|
||||
function showCreateModal() {
|
||||
document.getElementById('modalTitle').textContent = 'Create Transition';
|
||||
document.getElementById('workflowForm').reset();
|
||||
document.getElementById('transition_id').value = '';
|
||||
document.getElementById('is_active').checked = true;
|
||||
document.getElementById('workflowModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('workflowModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function saveTransition(e) {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
transition_id: document.getElementById('transition_id').value,
|
||||
from_status: document.getElementById('from_status').value,
|
||||
to_status: document.getElementById('to_status').value,
|
||||
requires_comment: document.getElementById('requires_comment').checked ? 1 : 0,
|
||||
requires_admin: document.getElementById('requires_admin').checked ? 1 : 0,
|
||||
is_active: document.getElementById('is_active').checked ? 1 : 0
|
||||
};
|
||||
|
||||
const method = data.transition_id ? 'PUT' : 'POST';
|
||||
const url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.CSRF_TOKEN
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to save');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function editTransition(id) {
|
||||
const wf = workflows.find(w => w.transition_id == id);
|
||||
if (!wf) return;
|
||||
|
||||
document.getElementById('transition_id').value = wf.transition_id;
|
||||
document.getElementById('from_status').value = wf.from_status;
|
||||
document.getElementById('to_status').value = wf.to_status;
|
||||
document.getElementById('requires_comment').checked = wf.requires_comment == 1;
|
||||
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
|
||||
document.getElementById('is_active').checked = wf.is_active == 1;
|
||||
document.getElementById('modalTitle').textContent = 'Edit Transition';
|
||||
document.getElementById('workflowModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function deleteTransition(id) {
|
||||
if (!confirm('Delete this status transition?')) return;
|
||||
fetch('/api/manage_workflows.php?id=' + id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) window.location.reload();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user