Files
tinker_tickets/views/TicketView.php
T
jared c8181e8076 feat: comment pagination, Matrix integration, Synapse mention resolution
Comment pagination:
- CommentModel: add getCommentCount(), paginated getCommentsByTicketId()
  with getThreadedCommentsPaged() for threading + LIMIT/OFFSET
- TicketController: load first 50 root comments + total count on page load
- api/get_comments.php: new AJAX endpoint for Load More (index.php routed)
- TicketView: Load More button + buildCommentEl() JS renderer for AJAX comments;
  passes totalComments/commentOffset/isAdmin to window.ticketData

Matrix integration:
- NotificationHelper: add sendStatusChangeNotification(), sendCommentNotification(),
  sendMentionNotification(), sendAssignmentNotification() alongside existing
  sendTicketNotification(); internal fire() helper replaces duplicated cURL logic
- SynapseHelper: new helper that resolves SSO usernames → Matrix IDs by querying
  Synapse Admin REST API directly (no caching, no stale data)
- config.php: add SYNAPSE_ADMIN_URL, SYNAPSE_ADMIN_TOKEN, MATRIX_NOTIFY_COMMENTS,
  MATRIX_NOTIFY_ASSIGNMENTS config keys (all from .env)
- api/update_ticket.php: fire status-change notification after successful save
- api/add_comment.php: resolve @mentioned usernames via SynapseHelper and fire
  mention notification; fire general comment notification when MATRIX_NOTIFY_COMMENTS=1
- api/assign_ticket.php: fire assignment notification (resolves assignee via Synapse)
  when MATRIX_NOTIFY_ASSIGNMENTS=1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:34:16 -04:00

993 lines
50 KiB
PHP

<?php
/**
* TicketView.php — Individual ticket view, redesigned for TDS v1.2
* Variables: $ticket, $comments (threaded), $timeline, $allUsers, $allowedTransitions
*/
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Ticket #' . htmlspecialchars($ticket['ticket_id'] ?? '');
$activeNav = 'dashboard';
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/ticket.css?v={$_v}"];
$pageScripts = [
"/assets/js/markdown.js?v={$_v}",
"/assets/js/ticket.js?v={$_v}",
"/assets/js/keyboard-shortcuts.js?v={$_v}",
"/assets/js/settings.js?v={$_v}",
];
// Helper functions
function getEventIcon(string $actionType): string {
return match($actionType) {
'create' => '[ + ]',
'update' => '[ ~ ]',
'comment' => '[ > ]',
'view' => '[ . ]',
'assign' => '[ @ ]',
'status_change' => '[ ! ]',
default => '[ * ]',
};
}
function formatAction(array $event): string {
return match($event['action_type']) {
'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',
default => $event['action_type'],
};
}
// Calculate ticket age
$lastUpdate = !empty($ticket['updated_at']) ? strtotime($ticket['updated_at']) : strtotime($ticket['created_at']);
$ageSeconds = time() - $lastUpdate;
$ageDays = floor($ageSeconds / 86400);
$ageHours = floor(($ageSeconds % 86400) / 3600);
$ageClass = 'lt-text-muted';
if ($ticket['status'] !== 'Closed') {
if ($ageDays >= 10) $ageClass = 'lt-text-danger';
elseif ($ageDays >= 5) $ageClass = 'lt-text-amber';
}
$ageStr = $ageDays > 0
? $ageDays . ' day' . ($ageDays !== 1 ? 's' : '')
: $ageHours . ' hour' . ($ageHours !== 1 ? 's' : '');
$statusSlug = strtolower(str_replace(' ', '-', $ticket['status']));
$priorityNum = (int)($ticket['priority'] ?? 3);
$currentUserId = $GLOBALS['currentUser']['user_id'] ?? null;
$isAdmin = $GLOBALS['currentUser']['is_admin'] ?? false;
$creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System';
// Visibility
$currentVisibility = $ticket['visibility'] ?? 'public';
$currentVisibilityGroups = array_filter(array_map('trim', explode(',', $ticket['visibility_groups'] ?? '')));
require_once __DIR__ . '/../models/UserModel.php';
$visUserModel = new UserModel($conn);
$allAvailableGroups = $visUserModel->getAllGroups();
// JSON-encode ticket fields for the inline script
$json_ticket_id = json_encode($ticket['ticket_id'], JSON_HEX_TAG);
$json_title = json_encode($ticket['title'], JSON_HEX_TAG);
$json_status = json_encode($ticket['status'], JSON_HEX_TAG);
$json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
$json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG);
$json_is_admin = json_encode(!empty($currentUser['is_admin']), JSON_HEX_TAG);
$pageInlineScript = <<<JS
window.ticketData = {
ticket_id: {$json_ticket_id},
title: {$json_title},
status: {$json_status},
priority: {$json_priority},
category: {$json_category},
type: {$json_type},
updated_at: {$json_updated_at},
totalComments: {$json_total_comments},
commentOffset: {$json_comment_page},
commentPageSize:{$json_comment_page},
currentUserId: {$json_current_uid},
isAdmin: {$json_is_admin},
};
window.ticketData.id = window.ticketData.ticket_id;
if (window.lt) lt.keys.initDefaults();
JS;
include __DIR__ . '/layout_header.php';
?>
<!-- Back nav + ticket toolbar -->
<div class="lt-page-header">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<span class="lt-text-muted lt-text-xs">/</span>
<span class="lt-text-muted lt-text-xs">Ticket #<?= htmlspecialchars($ticket['ticket_id']) ?></span>
</div>
<div class="lt-btn-group">
<!-- Status select — always visible, instant workflow change -->
<select id="statusSelect"
class="lt-select lt-select-sm lt-status-select lt-status-<?= $statusSlug ?>"
data-field="status"
data-action="update-ticket-status"
aria-label="Change ticket status">
<option value="<?= htmlspecialchars($ticket['status']) ?>" selected>
<?= htmlspecialchars($ticket['status']) ?> (current)
</option>
<?php foreach ($allowedTransitions as $t): ?>
<option value="<?= htmlspecialchars($t['to_status']) ?>"
data-requires-comment="<?= $t['requires_comment'] ? '1' : '0' ?>"
data-requires-admin="<?= $t['requires_admin'] ? '1' : '0' ?>">
<?= htmlspecialchars($t['to_status']) ?>
<?php if ($t['requires_comment']): ?> *<?php endif ?>
<?php if ($t['requires_admin']): ?> (Admin)<?php endif ?>
</option>
<?php endforeach ?>
</select>
<button type="button" id="editButton" class="lt-btn lt-btn-primary lt-btn-sm">EDIT</button>
<button type="button" id="cloneButton" class="lt-btn lt-btn-sm">CLONE</button>
<a id="exportFullBtn"
href="/api/export_tickets.php?format=full&ticket_id=<?= (int)$ticket['ticket_id'] ?>"
class="lt-btn lt-btn-ghost lt-btn-sm"
title="Export this ticket with all comments and history as JSON">EXPORT</a>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-modal-open="settingsModal">CFG</button>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
TICKET DETAIL FRAME
═══════════════════════════════════════════════════════════ -->
<article class="lt-frame lt-frame-ticket" data-priority="<?= $priorityNum ?>">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Ticket Information</div>
<div class="lt-section-body">
<!-- Title row -->
<div class="ticket-title-row">
<h1 class="ticket-title-heading">
<span class="editable title-input" data-field="title" contenteditable="false"
aria-label="Ticket title"><?= htmlspecialchars($ticket['title']) ?></span>
</h1>
<div class="ticket-id-badge lt-text-cyan lt-text-xs">UUID <?= htmlspecialchars($ticket['ticket_id']) ?></div>
</div>
<!-- Meta grid -->
<div class="lt-kv-grid ticket-meta-grid">
<div class="lt-kv-row">
<span class="lt-kv-label">Priority</span>
<span class="lt-kv-value">
<select id="prioritySelect" class="lt-select lt-select-sm editable-metadata" disabled aria-label="Priority">
<?php foreach ([1=>'P1 - Critical',2=>'P2 - High',3=>'P3 - Medium',4=>'P4 - Low',5=>'P5 - Minimal'] as $v=>$l): ?>
<option value="<?= $v ?>" <?= (int)$ticket['priority'] === $v ? 'selected' : '' ?>><?= $l ?></option>
<?php endforeach ?>
</select>
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label">Category</span>
<span class="lt-kv-value">
<select id="categorySelect" class="lt-select lt-select-sm editable-metadata" disabled aria-label="Category">
<?php foreach (['Hardware','Software','Network','Security','General'] as $c): ?>
<option value="<?= $c ?>" <?= $ticket['category'] === $c ? 'selected' : '' ?>><?= $c ?></option>
<?php endforeach ?>
</select>
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label">Type</span>
<span class="lt-kv-value">
<select id="typeSelect" class="lt-select lt-select-sm editable-metadata" disabled aria-label="Type">
<?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t): ?>
<option value="<?= $t ?>" <?= $ticket['type'] === $t ? 'selected' : '' ?>><?= $t ?></option>
<?php endforeach ?>
</select>
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label">Assigned To</span>
<span class="lt-kv-value">
<select id="assignedToSelect" class="lt-select lt-select-sm" aria-label="Assign ticket">
<option value="">Unassigned</option>
<?php foreach ($allUsers as $u): ?>
<option value="<?= (int)$u['user_id'] ?>"
<?= ((int)$ticket['assigned_to'] === (int)$u['user_id']) ? 'selected' : '' ?>>
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
</option>
<?php endforeach ?>
</select>
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label">Visibility</span>
<span class="lt-kv-value">
<select id="visibilitySelect" class="lt-select lt-select-sm editable-metadata" disabled
data-action="toggle-visibility-groups" aria-label="Visibility">
<option value="public" <?= $currentVisibility === 'public' ? 'selected' : '' ?>>Public</option>
<option value="internal" <?= $currentVisibility === 'internal' ? 'selected' : '' ?>>Internal</option>
<option value="confidential" <?= $currentVisibility === 'confidential' ? 'selected' : '' ?>>Confidential</option>
</select>
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label">Age</span>
<span class="lt-kv-value <?= $ageClass ?>">
<?= $ageStr ?> ago
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label">Created By</span>
<span class="lt-kv-value"><?= htmlspecialchars($creator) ?>
<?php if (!empty($ticket['created_at'])): ?>
<span class="lt-text-muted lt-text-xs"> &mdash;
<span class="ts-cell" data-ts="<?= htmlspecialchars($ticket['created_at'], ENT_QUOTES, 'UTF-8') ?>"
title="<?= date('Y-m-d H:i T', strtotime($ticket['created_at'])) ?>">
<?= date('M d, Y H:i', strtotime($ticket['created_at'])) ?>
</span>
</span>
<?php endif ?>
</span>
</div>
<?php if (!empty($ticket['updater_display_name']) || !empty($ticket['updater_username'])): ?>
<div class="lt-kv-row">
<span class="lt-kv-label">Last Updated</span>
<span class="lt-kv-value">
<?= htmlspecialchars($ticket['updater_display_name'] ?? $ticket['updater_username']) ?>
<?php if (!empty($ticket['updated_at'])): ?>
<span class="lt-text-muted lt-text-xs"> &mdash;
<span class="ts-cell" data-ts="<?= htmlspecialchars($ticket['updated_at'], ENT_QUOTES, 'UTF-8') ?>"
title="<?= date('Y-m-d H:i T', strtotime($ticket['updated_at'])) ?>">
<?= date('M d, Y H:i', strtotime($ticket['updated_at'])) ?>
</span>
</span>
<?php endif ?>
</span>
</div>
<?php endif ?>
</div><!-- /.lt-kv-grid -->
<!-- Visibility groups (shown only when visibility = internal) -->
<div class="ticket-visibility-groups<?= $currentVisibility !== 'internal' ? ' is-hidden' : '' ?>"
id="visibilityGroupsField">
<div class="lt-form-group">
<label class="lt-label lt-text-cyan">Allowed Groups</label>
<div class="visibility-groups-edit lt-flex lt-flex-wrap lt-flex-gap-sm">
<?php foreach ($allAvailableGroups as $group):
$isChecked = in_array($group, $currentVisibilityGroups, true); ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox visibility-group-checkbox editable-metadata" disabled
value="<?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>"
<?= $isChecked ? 'checked' : '' ?>>
<span class="lt-badge"><?= htmlspecialchars($group) ?></span>
</label>
<?php endforeach ?>
<?php if (empty($allAvailableGroups)): ?>
<span class="lt-text-muted">No groups available</span>
<?php endif ?>
</div>
</div>
</div>
</div><!-- /.lt-section-body -->
</article>
<!-- ═══════════════════════════════════════════════════════════
TAB NAVIGATION
═══════════════════════════════════════════════════════════ -->
<div class="lt-tab-bar" role="tablist" aria-label="Ticket content sections">
<button type="button" class="lt-tab active" id="description-tab-btn"
role="tab" data-tab="description-panel" aria-selected="true" aria-controls="description-panel">
Description
</button>
<button type="button" class="lt-tab" id="comments-tab-btn"
role="tab" data-tab="comments-panel" aria-selected="false" aria-controls="comments-panel">
Comments
<?php if (!empty($comments)): ?>
<span class="lt-badge lt-badge-sm"><?= count($comments) ?></span>
<?php endif ?>
</button>
<button type="button" class="lt-tab" id="attachments-tab-btn"
role="tab" data-tab="attachments-panel" aria-selected="false" aria-controls="attachments-panel">
Attachments
</button>
<button type="button" class="lt-tab" id="dependencies-tab-btn"
role="tab" data-tab="dependencies-panel" aria-selected="false" aria-controls="dependencies-panel">
Dependencies
</button>
<button type="button" class="lt-tab" id="activity-tab-btn"
role="tab" data-tab="activity-panel" aria-selected="false" aria-controls="activity-panel">
Activity
</button>
</div>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: DESCRIPTION
═══════════════════════════════════════════════════════════ -->
<div id="description-panel" class="lt-tab-panel active" role="tabpanel" aria-labelledby="description-tab-btn">
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Description</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-sr-only lt-label" for="ticketDescription">Description</label>
<textarea id="ticketDescription"
class="lt-input lt-textarea editable"
data-field="description"
disabled
rows="18"
aria-label="Ticket description"><?= htmlspecialchars($ticket['description'] ?? '') ?></textarea>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: COMMENTS
═══════════════════════════════════════════════════════════ -->
<div id="comments-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="comments-tab-btn">
<!-- Add Comment -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Add Comment</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-sr-only lt-label" for="newComment">New comment</label>
<textarea id="newComment"
class="lt-input lt-textarea"
rows="5"
placeholder="Add a comment... (supports Markdown, @mentions, #TicketID linking)"
aria-label="Add a comment"></textarea>
</div>
<div class="comment-controls lt-flex lt-flex-gap-sm lt-flex-align-center">
<div class="markdown-toggles lt-flex lt-flex-gap-sm">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="markdownMaster" data-action="toggle-markdown-mode">
Markdown
</label>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="markdownToggle" data-action="toggle-preview" disabled>
Preview
</label>
</div>
<button type="button" id="addCommentBtn" class="lt-btn lt-btn-primary lt-btn-sm">POST COMMENT</button>
</div>
<div id="markdownPreview" class="markdown-preview is-hidden" aria-live="polite"></div>
</div>
</div>
<!-- Comment History -->
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Comment History</div>
<div class="lt-section-body">
<div class="comments-list" id="commentsList">
<?php if (empty($comments)): ?>
<div class="lt-empty">No comments yet. Be the first to comment.</div>
<?php else: ?>
<?php
function renderComment(array $comment, ?int $currentUserId, bool $isAdmin, int $depth = 0): void {
$displayName = $comment['display_name_formatted'] ?? $comment['user_name'] ?? 'Unknown User';
$commentId = (int)$comment['comment_id'];
$isOwner = ((int)$comment['user_id'] === (int)$currentUserId);
$canModify = $isOwner || $isAdmin;
$markdownEnabled = (bool)($comment['markdown_enabled'] ?? false);
$threadDepth = (int)($comment['thread_depth'] ?? $depth);
$parentId = $comment['parent_comment_id'] ?? null;
$depthClass = 'thread-depth-' . min($threadDepth, 3);
$threadClass = $parentId ? 'comment-reply' : 'comment-root';
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited lt-text-xs lt-text-muted">(edited)</span>' : '';
// Avatar initials + color (fallback when no photo)
$words = array_filter(explode(' ', $displayName));
$initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($words, 0, 2))));
$avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
$avatarColor = $avatarColors[abs(crc32($displayName)) % count($avatarColors)];
$commentUserId = (int)($comment['user_id'] ?? 0);
?>
<div class="comment <?= $depthClass ?> <?= $threadClass ?>"
data-comment-id="<?= $commentId ?>"
data-markdown-enabled="<?= $markdownEnabled ? '1' : '0' ?>"
data-thread-depth="<?= $threadDepth ?>"
data-parent-id="<?= htmlspecialchars((string)($parentId ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<?php if ($parentId): ?><div class="thread-line" aria-hidden="true"></div><?php endif ?>
<div class="comment-content">
<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">
<div class="lt-avatar lt-avatar--xs <?= $avatarColor ?>" aria-hidden="true">
<?php if ($commentUserId > 0): ?>
<img src="/api/user_avatar.php?user_id=<?= $commentUserId ?>"
alt=""
class="lt-avatar-img"
onerror="this.style.display='none'">
<?php endif ?>
<span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span>
</div>
<span class="comment-user lt-text-amber"><?= htmlspecialchars($displayName) ?></span>
<span class="comment-date lt-text-xs lt-text-muted">
<span class="ts-cell"
data-ts="<?= htmlspecialchars($comment['created_at'], ENT_QUOTES, 'UTF-8') ?>"
title="<?= $dateStr ?>"><?= $dateStr ?></span>
<?= $editedIndicator ?>
</span>
<div class="comment-actions lt-btn-group">
<?php if ($threadDepth < 3): ?>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn reply-btn"
data-action="reply-comment"
data-comment-id="<?= $commentId ?>"
data-user="<?= htmlspecialchars($displayName, ENT_QUOTES) ?>"
aria-label="Reply to comment">Reply</button>
<?php endif ?>
<?php if ($canModify): ?>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn edit-btn"
data-action="edit-comment"
data-comment-id="<?= $commentId ?>"
aria-label="Edit comment">Edit</button>
<button type="button" class="lt-btn lt-btn-danger lt-btn-sm comment-action-btn delete-btn"
data-action="delete-comment"
data-comment-id="<?= $commentId ?>"
aria-label="Delete comment">Del</button>
<?php endif ?>
</div>
</div>
<div class="comment-text" id="comment-text-<?= $commentId ?>"
<?= $markdownEnabled ? 'data-markdown' : '' ?>>
<?= $markdownEnabled
? htmlspecialchars($comment['comment_text'])
: nl2br(htmlspecialchars($comment['comment_text'])) ?>
</div>
<textarea class="lt-input lt-textarea comment-edit-raw"
id="comment-raw-<?= $commentId ?>"
style="display:none"
aria-hidden="true"><?= htmlspecialchars($comment['comment_text']) ?></textarea>
</div>
<?php if (!empty($comment['replies'])): ?>
<div class="comment-replies">
<?php foreach ($comment['replies'] as $reply): ?>
<?php renderComment($reply, $currentUserId, $isAdmin, $threadDepth + 1); ?>
<?php endforeach ?>
</div>
<?php endif ?>
</div>
<?php
}
foreach ($comments as $comment): renderComment($comment, $currentUserId, $isAdmin); endforeach;
?>
<?php if ($totalComments > $commentPageSize): ?>
<div id="loadMoreComments" class="lt-flex lt-flex-center lt-mt-md">
<button type="button" id="loadMoreBtn" class="lt-btn lt-btn-ghost lt-btn-sm">
Load more comments
<span class="lt-text-muted lt-text-xs" id="loadMoreCount">
(<?= (int)$totalComments - count($comments) ?> remaining)
</span>
</button>
</div>
<?php endif ?>
<?php endif ?>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: ATTACHMENTS
═══════════════════════════════════════════════════════════ -->
<div id="attachments-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="attachments-tab-btn">
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Upload Files</div>
<div class="lt-section-body">
<div class="upload-zone" id="uploadZone" role="button" tabindex="0"
aria-label="Drop files here or click to browse">
<div class="upload-zone-content">
<div class="upload-zone-icon lt-text-cyan" aria-hidden="true">[ + ]</div>
<p class="lt-text-sm">Drag &amp; drop files here or click to browse</p>
<p class="lt-text-xs lt-text-muted">Max size: <?= $GLOBALS['config']['MAX_UPLOAD_SIZE'] ? number_format($GLOBALS['config']['MAX_UPLOAD_SIZE'] / 1048576, 0) . ' MB' : '10 MB' ?></p>
<input type="file" id="fileInput" multiple class="lt-sr-only" aria-label="Upload files">
<button type="button" id="browseFilesBtn" class="lt-btn lt-btn-sm lt-mt-sm">Browse Files</button>
</div>
</div>
<div id="uploadProgress" class="upload-progress is-hidden" aria-live="polite">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<p id="uploadStatus" class="lt-text-xs lt-text-muted upload-status-text"></p>
</div>
</div>
</div>
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Attached Files</div>
<div class="lt-section-body">
<div id="attachmentsList" class="attachments-list" aria-live="polite">
<p class="lt-text-muted lt-text-sm">Loading attachments&hellip;</p>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: DEPENDENCIES
═══════════════════════════════════════════════════════════ -->
<div id="dependencies-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="dependencies-tab-btn">
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Add Dependency</div>
<div class="lt-section-body">
<div class="lt-flex lt-flex-gap-sm lt-flex-align-end">
<div class="lt-form-group" style="flex:2;margin:0">
<label class="lt-label" for="dependencyTicketId">Ticket ID</label>
<input type="text" id="dependencyTicketId" class="lt-input"
placeholder="e.g. 123456789" aria-label="Ticket ID for dependency">
</div>
<div class="lt-form-group" style="flex:1;margin:0">
<label class="lt-label" for="dependencyType">Relationship</label>
<select id="dependencyType" class="lt-select" aria-label="Dependency type">
<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>
</div>
<button type="button" id="addDependencyBtn" class="lt-btn lt-btn-primary lt-btn-sm"
style="margin-bottom:0" aria-label="Add ticket dependency">ADD</button>
</div>
</div>
</div>
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Current Dependencies</div>
<div class="lt-section-body">
<div id="dependenciesList" class="dependencies-list" aria-live="polite">
<p class="lt-text-muted lt-text-sm">Loading&hellip;</p>
</div>
</div>
</div>
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Tickets That Depend On This</div>
<div class="lt-section-body">
<div id="dependentsList" class="dependencies-list" aria-live="polite">
<p class="lt-text-muted lt-text-sm">Loading&hellip;</p>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: ACTIVITY
═══════════════════════════════════════════════════════════ -->
<div id="activity-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="activity-tab-btn">
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Activity Timeline</div>
<div class="lt-section-body">
<?php if (empty($timeline)): ?>
<div class="lt-empty">No activity recorded yet.</div>
<?php else: ?>
<div class="lt-timeline">
<?php foreach ($timeline as $event): ?>
<?php
$actor = htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System');
$action = formatAction($event);
$icon = getEventIcon($event['action_type']);
$evtFmt = date('M d, Y H:i', strtotime($event['created_at']));
$tClass = match($event['action_type']) {
'create' => 'lt-timeline-item--green',
'status_change' => 'lt-timeline-item--orange',
'comment' => '',
default => 'lt-timeline-item--dim',
};
?>
<div class="lt-timeline-item <?= $tClass ?>">
<div class="lt-timeline-meta">
<span class="lt-timeline-actor"><?= $actor ?></span>
<span class="lt-timeline-action"><?= htmlspecialchars($action) ?></span>
<span class="lt-timeline-time ts-cell"
data-ts="<?= htmlspecialchars($event['created_at'], ENT_QUOTES, 'UTF-8') ?>"
title="<?= $evtFmt ?>"><?= $evtFmt ?></span>
</div>
<?php if (!empty($event['details'])): ?>
<div class="lt-timeline-body lt-text-xs lt-text-muted">
<?php
$det = $event['details'];
if (is_array($det)) {
$parts = [];
foreach ($det as $k => $v) {
// Delta format: { field: { from: '...', to: '...' } }
if (is_array($v) && isset($v['from'], $v['to'])) {
$label = ucfirst(str_replace('_', ' ', $k));
// Truncate long values (e.g. description)
$from = mb_strlen((string)$v['from']) > 60
? mb_substr((string)$v['from'], 0, 60) . '…'
: (string)$v['from'];
$to = mb_strlen((string)$v['to']) > 60
? mb_substr((string)$v['to'], 0, 60) . '…'
: (string)$v['to'];
$parts[] = '<strong>' . htmlspecialchars($label) . ':</strong> '
. '<span class="lt-text-muted">' . htmlspecialchars($from) . '</span>'
. ' <span class="lt-text-amber">→</span> '
. '<span class="lt-text-cyan">' . htmlspecialchars($to) . '</span>';
} elseif ($k !== 'old_value' && $k !== 'new_value') {
// Legacy flat format fallback
$parts[] = '<strong>' . htmlspecialchars($k) . ':</strong> ' . htmlspecialchars((string)$v);
}
}
echo implode('<br>', $parts);
}
?>
</div>
<?php endif ?>
</div>
<?php endforeach ?>
</div>
<?php endif ?>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
SETTINGS MODAL
═══════════════════════════════════════════════════════════ -->
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="settingsModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="settingsModalTitle">[ CFG ] SYSTEM PREFERENCES</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close settings">&#x2715;</button>
</div>
<div class="lt-modal-body">
<div class="settings-section">
<h4 class="lt-subsection-header">Display</h4>
<div class="lt-kv-grid">
<div class="lt-kv-row">
<label class="lt-kv-label" for="tableDensity">Table density</label>
<span class="lt-kv-value">
<select id="tableDensity" class="lt-select lt-select-sm">
<option value="compact">Compact</option>
<option value="normal" selected>Normal</option>
<option value="comfortable">Comfortable</option>
</select>
</span>
</div>
<div class="lt-kv-row">
<label class="lt-kv-label" for="toastDuration">Toast duration</label>
<span class="lt-kv-value">
<select id="toastDuration" class="lt-select lt-select-sm">
<option value="3000" selected>3s</option>
<option value="5000">5s</option>
<option value="10000">10s</option>
</select>
</span>
</div>
</div>
</div>
<div class="settings-section">
<h4 class="lt-subsection-header">Keyboard Shortcuts</h4>
<div class="shortcuts-list lt-text-xs">
<div class="shortcut-item"><kbd>Ctrl/Cmd+E</kbd> Toggle edit mode</div>
<div class="shortcut-item"><kbd>Ctrl/Cmd+S</kbd> Save changes</div>
<div class="shortcut-item"><kbd>1&ndash;4</kbd> Quick status change</div>
<div class="shortcut-item"><kbd>ESC</kbd> Cancel / close</div>
<div class="shortcut-item"><kbd>?</kbd> Show shortcuts</div>
</div>
</div>
<div class="settings-section">
<h4 class="lt-subsection-header">User</h4>
<div class="lt-kv-grid">
<div class="lt-kv-row"><span class="lt-kv-label">Name</span><span class="lt-kv-value"><?= htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? 'N/A') ?></span></div>
<div class="lt-kv-row"><span class="lt-kv-label">Username</span><span class="lt-kv-value"><?= htmlspecialchars($GLOBALS['currentUser']['username'] ?? '') ?></span></div>
<div class="lt-kv-row"><span class="lt-kv-label">Email</span><span class="lt-kv-value"><?= htmlspecialchars($GLOBALS['currentUser']['email'] ?? 'N/A') ?></span></div>
<div class="lt-kv-row"><span class="lt-kv-label">Role</span><span class="lt-kv-value"><?= ($GLOBALS['currentUser']['is_admin'] ?? false) ? 'Administrator' : 'User' ?></span></div>
<div class="lt-kv-row">
<span class="lt-kv-label">Groups</span>
<span class="lt-kv-value">
<?php
$groups = array_filter(array_map('trim', explode(',', $GLOBALS['currentUser']['groups'] ?? '')));
if ($groups): foreach ($groups as $g): ?>
<span class="lt-badge lt-badge-sm"><?= htmlspecialchars($g) ?></span>
<?php endforeach; else: ?>
<span class="lt-text-muted">None</span>
<?php endif ?>
</span>
</div>
</div>
</div>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn lt-btn-primary" id="saveSettingsBtn">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" id="cancelSettingsBtn" data-modal-close>CANCEL</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
PAGE-SPECIFIC EVENT WIRING
═══════════════════════════════════════════════════════════ -->
<script nonce="<?= $nonce ?>">
document.addEventListener('DOMContentLoaded', function () {
// Tab initialization (lt.tabs handles data-tab / lt-tab-panel automatically)
// Fallback manual tab wiring for ticket tabs if needed
document.querySelectorAll('.lt-tab[data-tab]').forEach(function (btn) {
btn.addEventListener('click', function () {
var targetId = this.getAttribute('data-tab');
if (typeof showTab === 'function') {
showTab(targetId.replace('-panel', ''));
}
});
});
// Edit button
var editBtn = document.getElementById('editButton');
if (editBtn) {
editBtn.addEventListener('click', function () {
if (typeof toggleEditMode === 'function') toggleEditMode();
});
}
// Clone button
var cloneBtn = document.getElementById('cloneButton');
if (cloneBtn) {
cloneBtn.addEventListener('click', function () {
showConfirmModal(
'Clone Ticket',
'Create a copy of this ticket? The new ticket will inherit title, description, priority, category, and type.',
'warning',
function () {
cloneBtn.disabled = true;
cloneBtn.textContent = 'Cloning\u2026';
lt.api.post('/api/clone_ticket.php', { ticket_id: window.ticketData.ticket_id })
.then(function (data) {
if (data.success) {
lt.toast.success('Ticket cloned!');
setTimeout(function () { window.location.href = '/ticket/' + data.new_ticket_id; }, 1000);
} else {
lt.toast.error('Failed: ' + (data.error || 'Unknown error'));
cloneBtn.disabled = false;
cloneBtn.textContent = 'CLONE';
}
})
.catch(function (err) {
lt.toast.error('Failed: ' + err.message);
cloneBtn.disabled = false;
cloneBtn.textContent = 'CLONE';
});
}
);
});
}
// Add comment button
var addCommentBtn = document.getElementById('addCommentBtn');
if (addCommentBtn) {
addCommentBtn.addEventListener('click', function () {
if (typeof addComment === 'function') addComment();
});
}
// Browse files button
var browseBtn = document.getElementById('browseFilesBtn');
if (browseBtn) {
browseBtn.addEventListener('click', function () {
document.getElementById('fileInput').click();
});
}
// Add dependency button
var addDepBtn = document.getElementById('addDependencyBtn');
if (addDepBtn) {
addDepBtn.addEventListener('click', function () {
if (typeof addDependency === 'function') addDependency();
});
}
// Settings save/cancel
var saveSettingsBtn = document.getElementById('saveSettingsBtn');
if (saveSettingsBtn) {
saveSettingsBtn.addEventListener('click', function () {
if (typeof saveSettings === 'function') saveSettings();
});
}
// Change event delegation
document.addEventListener('change', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'update-ticket-status':
if (typeof updateTicketStatus === 'function') updateTicketStatus(); break;
case 'toggle-visibility-groups':
if (typeof toggleVisibilityGroupsEdit === 'function') toggleVisibilityGroupsEdit(); break;
case 'toggle-markdown-mode':
if (typeof toggleMarkdownMode === 'function') toggleMarkdownMode(); break;
case 'toggle-preview':
if (typeof togglePreview === 'function') togglePreview(); break;
}
});
// Click delegation for comment actions
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
var action = target.getAttribute('data-action');
var commentId = target.getAttribute('data-comment-id');
if (action === 'edit-comment' && commentId) {
if (typeof editComment === 'function') editComment(parseInt(commentId, 10));
} else if (action === 'delete-comment' && commentId) {
if (typeof deleteComment === 'function') deleteComment(parseInt(commentId, 10));
}
});
// Load more comments
var loadMoreBtn = document.getElementById('loadMoreBtn');
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', function () {
var td = window.ticketData;
loadMoreBtn.disabled = true;
loadMoreBtn.textContent = 'Loading\u2026';
var url = '/api/get_comments.php?ticket_id=' + td.ticket_id +
'&offset=' + td.commentOffset +
'&limit=' + td.commentPageSize;
lt.api.get(url).then(function (data) {
if (!data.success) {
lt.toast.error('Failed to load comments: ' + (data.error || 'Unknown error'));
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments';
return;
}
var list = document.getElementById('commentsList');
var wrap = document.getElementById('loadMoreComments');
data.comments.forEach(function (c) {
list.insertBefore(buildCommentEl(c, td.currentUserId, td.isAdmin), wrap);
});
td.commentOffset += data.comments.length;
var remaining = td.totalComments - td.commentOffset;
if (data.has_more && remaining > 0) {
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments <span class="lt-text-muted lt-text-xs">(' + remaining + ' remaining)</span>';
} else {
wrap.remove();
}
// Re-render markdown in newly added comments
if (typeof parseMarkdown === 'function') {
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
if (!el.dataset.rendered) {
el.innerHTML = parseMarkdown(el.textContent);
el.dataset.rendered = '1';
}
});
}
}).catch(function (err) {
lt.toast.error('Failed to load comments');
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments';
});
});
}
/**
* Build a comment DOM element from a comment object returned by the API.
* Mirrors the PHP renderComment() output for root-level comments and replies.
*/
function buildCommentEl(c, currentUserId, isAdmin) {
var displayName = c.display_name_formatted || c.display_name || c.user_name || 'Unknown User';
var commentId = c.comment_id;
var isOwner = (parseInt(c.user_id, 10) === parseInt(currentUserId, 10));
var canModify = isOwner || isAdmin;
var mdEnabled = c.markdown_enabled == 1 || c.markdown_enabled === true;
var depth = parseInt(c.thread_depth, 10) || 0;
var parentId = c.parent_comment_id || null;
var depthClass = 'thread-depth-' + Math.min(depth, 3);
var threadClass = parentId ? 'comment-reply' : 'comment-root';
// Avatar initials
var words = displayName.trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
// Avatar color (same modulo logic as PHP: crc32 mod 4)
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var hash = 0;
for (var i = 0; i < displayName.length; i++) {
hash = ((hash << 5) - hash + displayName.charCodeAt(i)) | 0;
}
var avatarColor = avatarColors[Math.abs(hash) % 4];
// Format date
var dateStr = c.created_at || '';
try {
var d = new Date(c.created_at);
if (!isNaN(d)) {
dateStr = d.toLocaleString('en-US', { month:'short', day:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit', hour12:false }).replace(',', '');
}
} catch (e) {}
// Comment text
var rawText = c.comment_text || '';
var commentText;
if (mdEnabled) {
commentText = typeof parseMarkdown === 'function' ? parseMarkdown(rawText) : lt.escHtml(rawText);
} else {
var highlighted = lt.escHtml(rawText).replace(/\n/g, '<br>');
commentText = typeof highlightMentions === 'function' ? highlightMentions(highlighted) : highlighted;
}
var escapedRaw = lt.escHtml(rawText);
var editedHtml = c.updated_at ? '<span class="comment-edited lt-text-xs lt-text-muted">(edited)</span>' : '';
var userId = parseInt(c.user_id, 10) || 0;
var replyBtn = depth < 3 ?
'<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn reply-btn"' +
' data-action="reply-comment" data-comment-id="' + commentId + '" data-user="' + lt.escHtml(displayName) + '"' +
' aria-label="Reply to comment">Reply</button>' : '';
var modBtns = canModify ?
'<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn edit-btn"' +
' data-action="edit-comment" data-comment-id="' + commentId + '" aria-label="Edit comment">Edit</button>' +
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm comment-action-btn delete-btn"' +
' data-action="delete-comment" data-comment-id="' + commentId + '" aria-label="Delete comment">Del</button>' : '';
var threadLine = parentId ? '<div class="thread-line" aria-hidden="true"></div>' : '';
var avatarImg = userId > 0 ?
'<img src="/api/user_avatar.php?user_id=' + userId + '" alt="" class="lt-avatar-img" onerror="this.style.display=\'none\'">' : '';
var div = document.createElement('div');
div.className = 'comment ' + depthClass + ' ' + threadClass;
div.dataset.commentId = commentId;
div.dataset.markdownEnabled = mdEnabled ? '1' : '0';
div.dataset.threadDepth = depth;
div.dataset.parentId = parentId || '';
div.innerHTML =
threadLine +
'<div class="comment-content">' +
'<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">' +
'<div class="lt-avatar lt-avatar--xs ' + avatarColor + '" aria-hidden="true">' +
avatarImg +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
'</div>' +
'<span class="comment-user lt-text-amber">' + lt.escHtml(displayName) + '</span>' +
'<span class="comment-date lt-text-xs lt-text-muted">' +
'<span class="ts-cell" data-ts="' + lt.escHtml(c.created_at || '') + '">' + lt.escHtml(dateStr) + '</span>' +
editedHtml +
'</span>' +
'<div class="comment-actions lt-btn-group">' + replyBtn + modBtns + '</div>' +
'</div>' +
'<div class="comment-text" id="comment-text-' + commentId + '"' + (mdEnabled ? ' data-markdown data-rendered="1"' : '') + '>' +
commentText +
'</div>' +
'<textarea class="lt-input lt-textarea comment-edit-raw" id="comment-raw-' + commentId + '" style="display:none" aria-hidden="true">' +
escapedRaw +
'</textarea>' +
'</div>';
// Append replies if any (threaded)
if (c.replies && c.replies.length) {
var repliesDiv = document.createElement('div');
repliesDiv.className = 'comment-replies';
c.replies.forEach(function (r) {
repliesDiv.appendChild(buildCommentEl(r, currentUserId, isAdmin));
});
div.appendChild(repliesDiv);
}
return div;
}
});
</script>
<?php include __DIR__ . '/layout_footer.php'; ?>