Add Activity Timeline feature and database migrations
- Add Activity Timeline tab to ticket view showing chronological history - Create getTicketTimeline() method in AuditLogModel - Update TicketController to load timeline data - Add timeline UI with helper functions for formatting events - Add comprehensive timeline CSS with dark mode support - Create migrations 007-010 for upcoming features: - 007: Ticket assignment functionality - 008: Status workflow transitions - 009: Ticket templates - 010: Bulk operations tracking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -441,4 +441,78 @@ input:checked + .slider:before {
|
|||||||
|
|
||||||
.back-btn:hover {
|
.back-btn:hover {
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Activity Timeline Styles */
|
||||||
|
.timeline-container {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-event {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-event:not(:last-child)::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 30px;
|
||||||
|
bottom: -24px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--border-color, #ddd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--card-bg, #f8f9fa);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-action {
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-date {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-muted, #999);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-details {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary, #555);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--border-color, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .timeline-content {
|
||||||
|
--card-bg: #2d3748;
|
||||||
|
--border-color: #444;
|
||||||
|
--text-muted: #a0aec0;
|
||||||
|
--text-secondary: #cbd5e0;
|
||||||
}
|
}
|
||||||
@@ -2,15 +2,18 @@
|
|||||||
// Use absolute paths for model includes
|
// Use absolute paths for model includes
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
class TicketController {
|
class TicketController {
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
|
private $auditLogModel;
|
||||||
private $envVars;
|
private $envVars;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn) {
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
|
$this->auditLogModel = new AuditLogModel($conn);
|
||||||
|
|
||||||
// Load environment variables for Discord webhook
|
// Load environment variables for Discord webhook
|
||||||
$envPath = dirname(__DIR__) . '/.env';
|
$envPath = dirname(__DIR__) . '/.env';
|
||||||
@@ -55,6 +58,9 @@ class TicketController {
|
|||||||
// Get comments for this ticket using CommentModel
|
// Get comments for this ticket using CommentModel
|
||||||
$comments = $this->commentModel->getCommentsByTicketId($id);
|
$comments = $this->commentModel->getCommentsByTicketId($id);
|
||||||
|
|
||||||
|
// Get timeline for this ticket
|
||||||
|
$timeline = $this->auditLogModel->getTicketTimeline($id);
|
||||||
|
|
||||||
// Load the view
|
// Load the view
|
||||||
include dirname(__DIR__) . '/views/TicketView.php';
|
include dirname(__DIR__) . '/views/TicketView.php';
|
||||||
}
|
}
|
||||||
|
|||||||
13
migrations/007_add_ticket_assignment.sql
Normal file
13
migrations/007_add_ticket_assignment.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration 007: Add ticket assignment functionality
|
||||||
|
-- Adds assigned_to column to tickets table
|
||||||
|
|
||||||
|
-- Add assigned_to column to tickets table
|
||||||
|
ALTER TABLE tickets
|
||||||
|
ADD COLUMN assigned_to INT NULL,
|
||||||
|
ADD CONSTRAINT fk_tickets_assigned_to
|
||||||
|
FOREIGN KEY (assigned_to)
|
||||||
|
REFERENCES users(user_id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add index for performance
|
||||||
|
CREATE INDEX idx_assigned_to ON tickets(assigned_to);
|
||||||
31
migrations/008_add_status_workflows.sql
Normal file
31
migrations/008_add_status_workflows.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-- Migration 008: Add status workflow management
|
||||||
|
-- Creates status_transitions table for workflow validation
|
||||||
|
|
||||||
|
-- Table to define allowed status transitions
|
||||||
|
CREATE TABLE status_transitions (
|
||||||
|
transition_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
from_status VARCHAR(50) NOT NULL,
|
||||||
|
to_status VARCHAR(50) NOT NULL,
|
||||||
|
requires_comment BOOLEAN DEFAULT FALSE,
|
||||||
|
requires_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY unique_transition (from_status, to_status),
|
||||||
|
INDEX idx_from_status (from_status)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert default transitions
|
||||||
|
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
|
||||||
|
('Open', 'In Progress', FALSE),
|
||||||
|
('Open', 'Closed', TRUE),
|
||||||
|
('In Progress', 'Open', FALSE),
|
||||||
|
('In Progress', 'Closed', TRUE),
|
||||||
|
('Closed', 'Open', TRUE),
|
||||||
|
('Closed', 'In Progress', FALSE);
|
||||||
|
|
||||||
|
-- Add new status "Resolved"
|
||||||
|
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
|
||||||
|
('In Progress', 'Resolved', FALSE),
|
||||||
|
('Resolved', 'Closed', FALSE),
|
||||||
|
('Resolved', 'In Progress', TRUE),
|
||||||
|
('Open', 'Resolved', FALSE);
|
||||||
24
migrations/009_add_ticket_templates.sql
Normal file
24
migrations/009_add_ticket_templates.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- Migration 009: Add ticket templates
|
||||||
|
-- Creates ticket_templates table for reusable ticket templates
|
||||||
|
|
||||||
|
CREATE TABLE ticket_templates (
|
||||||
|
template_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
template_name VARCHAR(100) NOT NULL,
|
||||||
|
title_template VARCHAR(255) NOT NULL,
|
||||||
|
description_template TEXT NOT NULL,
|
||||||
|
category VARCHAR(50),
|
||||||
|
type VARCHAR(50),
|
||||||
|
default_priority INT DEFAULT 4,
|
||||||
|
created_by INT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(user_id),
|
||||||
|
INDEX idx_template_name (template_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert default templates
|
||||||
|
INSERT INTO ticket_templates (template_name, title_template, description_template, category, type, default_priority) VALUES
|
||||||
|
('Hardware Failure', 'Hardware Failure: [Device Name]', 'Device: \nIssue: \nError Messages: \nTroubleshooting Done: ', 'Hardware', 'Problem', 2),
|
||||||
|
('Software Installation', 'Install [Software Name]', 'Software: \nVersion: \nLicense Key: \nInstallation Path: ', 'Software', 'Install', 3),
|
||||||
|
('Network Issue', 'Network Issue: [Brief Description]', 'Affected System: \nSymptoms: \nIP Address: \nConnectivity Tests: ', 'Hardware', 'Problem', 2),
|
||||||
|
('Maintenance Request', 'Scheduled Maintenance: [System Name]', 'System: \nMaintenance Type: \nScheduled Date: \nDowntime Expected: ', 'Hardware', 'Maintenance', 4);
|
||||||
19
migrations/010_add_bulk_operations.sql
Normal file
19
migrations/010_add_bulk_operations.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- Migration 010: Add bulk operations tracking
|
||||||
|
-- Creates bulk_operations table for admin bulk actions
|
||||||
|
|
||||||
|
CREATE TABLE bulk_operations (
|
||||||
|
operation_id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
operation_type VARCHAR(50) NOT NULL,
|
||||||
|
ticket_ids TEXT NOT NULL, -- Comma-separated
|
||||||
|
performed_by INT NOT NULL,
|
||||||
|
parameters JSON,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
total_tickets INT,
|
||||||
|
processed_tickets INT DEFAULT 0,
|
||||||
|
failed_tickets INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP NULL,
|
||||||
|
FOREIGN KEY (performed_by) REFERENCES users(user_id),
|
||||||
|
INDEX idx_performed_by (performed_by),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
);
|
||||||
@@ -289,4 +289,36 @@ class AuditLogModel {
|
|||||||
public function logTicketView($userId, $ticketId) {
|
public function logTicketView($userId, $ticketId) {
|
||||||
return $this->log($userId, 'view', 'ticket', $ticketId);
|
return $this->log($userId, 'view', 'ticket', $ticketId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted timeline for a specific ticket
|
||||||
|
* Includes all ticket updates and comments
|
||||||
|
*
|
||||||
|
* @param string $ticketId Ticket ID
|
||||||
|
* @return array Timeline events
|
||||||
|
*/
|
||||||
|
public function getTicketTimeline($ticketId) {
|
||||||
|
$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.entity_type = 'ticket' AND al.entity_id = ?)
|
||||||
|
OR (al.entity_type = 'comment' AND JSON_EXTRACT(al.details, '$.ticket_id') = ?)
|
||||||
|
ORDER BY al.created_at DESC"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("ss", $ticketId, $ticketId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$timeline = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
if ($row['details']) {
|
||||||
|
$row['details'] = json_decode($row['details'], true);
|
||||||
|
}
|
||||||
|
$timeline[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $timeline;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,43 @@
|
|||||||
<?php
|
<?php
|
||||||
// This file contains the HTML template for a ticket
|
// This file contains the HTML template for a ticket
|
||||||
// It receives $ticket and $comments variables from the controller
|
// It receives $ticket, $comments, and $timeline variables from the controller
|
||||||
|
|
||||||
|
// Helper functions for timeline display
|
||||||
|
function getEventIcon($actionType) {
|
||||||
|
$icons = [
|
||||||
|
'create' => '✨',
|
||||||
|
'update' => '📝',
|
||||||
|
'comment' => '💬',
|
||||||
|
'view' => '👁️',
|
||||||
|
'assign' => '👤',
|
||||||
|
'status_change' => '🔄'
|
||||||
|
];
|
||||||
|
return $icons[$actionType] ?? '•';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAction($event) {
|
||||||
|
$actions = [
|
||||||
|
'create' => 'created this ticket',
|
||||||
|
'update' => 'updated this ticket',
|
||||||
|
'comment' => 'added a comment',
|
||||||
|
'view' => 'viewed this ticket',
|
||||||
|
'assign' => 'assigned this ticket',
|
||||||
|
'status_change' => 'changed the status'
|
||||||
|
];
|
||||||
|
return $actions[$event['action_type']] ?? $event['action_type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetails($details, $actionType) {
|
||||||
|
if ($actionType === 'update' && is_array($details)) {
|
||||||
|
$changes = [];
|
||||||
|
foreach ($details as $field => $value) {
|
||||||
|
if ($field === 'old_value' || $field === 'new_value') continue;
|
||||||
|
$changes[] = "<strong>" . htmlspecialchars($field) . ":</strong> " . htmlspecialchars($value);
|
||||||
|
}
|
||||||
|
return implode(', ', $changes);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -144,6 +181,7 @@
|
|||||||
<div class="ticket-tabs">
|
<div class="ticket-tabs">
|
||||||
<button class="tab-btn active" onclick="showTab('description')">Description</button>
|
<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('comments')">Comments</button>
|
||||||
|
<button class="tab-btn" onclick="showTab('activity')">Activity</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="description-tab" class="tab-content active">
|
<div id="description-tab" class="tab-content active">
|
||||||
@@ -204,6 +242,32 @@
|
|||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="activity-tab" class="tab-content">
|
||||||
|
<div class="timeline-container">
|
||||||
|
<?php if (empty($timeline)): ?>
|
||||||
|
<p>No activity recorded yet.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($timeline as $event): ?>
|
||||||
|
<div class="timeline-event">
|
||||||
|
<div class="timeline-icon"><?php echo getEventIcon($event['action_type']); ?></div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="timeline-header">
|
||||||
|
<strong><?php echo htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System'); ?></strong>
|
||||||
|
<span class="timeline-action"><?php echo formatAction($event); ?></span>
|
||||||
|
<span class="timeline-date"><?php echo date('M d, Y H:i', strtotime($event['created_at'])); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php if (!empty($event['details'])): ?>
|
||||||
|
<div class="timeline-details">
|
||||||
|
<?php echo formatDetails($event['details'], $event['action_type']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user