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:
2026-01-01 18:25:19 -05:00
parent 9a12a656aa
commit f9629f60b6
8 changed files with 264 additions and 1 deletions

View File

@@ -442,3 +442,77 @@ 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;
}

View File

@@ -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';
} }

View 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);

View 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);

View 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);

View 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)
);

View File

@@ -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;
}
} }

View File

@@ -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>