Files
tinker_tickets/views/TicketView.php
T
jared 538baadd57 Add comment skeleton loaders, workflow validation, monthly schedule fix
- TicketView.php: Show 3 lt-skeleton-card placeholders in the comment list
  while "Load more" fetches; skeletons are removed on resolve or error
- ticket.css: Add .comment-skeleton margin spacing
- WorkflowDesignerView.php + manage_workflows.php: Prevent creating/editing
  status transitions where from_status === to_status (client + server check)
- RecurringTicketsView.php: Expand monthly day picker from 28 to 31 days
  (days 29-31 labelled "last day in short months")
- RecurringTicketModel.php: Clamp monthly schedule day to last day of target
  month using format('t') instead of hard-capping at 28

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 18:09:53 -04:00

1319 lines
67 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' => '[!]',
'attachment' => '[^]',
'delete' => '[x]',
default => '[*]',
};
}
function formatAction(array $event): string {
$det = $event['details'] ?? [];
switch ($event['action_type']) {
case 'create': return 'created this ticket';
case 'comment': return 'posted a comment';
case 'view': return 'viewed this ticket';
case 'attachment': return 'uploaded a file';
case 'delete': return 'deleted a comment';
case 'assign':
if (is_array($det) && isset($det['assigned_to']['to'])) {
$to = $det['assigned_to']['to'] ?: 'Unassigned';
return 'assigned to ' . htmlspecialchars($to);
}
return 'assigned this ticket';
case 'status_change':
if (is_array($det) && isset($det['status']['from'], $det['status']['to'])) {
return htmlspecialchars($det['status']['from']) . ' → ' . htmlspecialchars($det['status']['to']);
}
return 'changed the status';
case 'update':
if (is_array($det)) {
$fields = array_keys(array_filter($det, fn($v) => is_array($v) && isset($v['from'], $v['to'])));
if ($fields) return 'updated ' . implode(', ', array_map(fn($f) => str_replace('_', ' ', $f), $fields));
}
return 'updated this ticket';
default:
return $event['action_type'];
}
}
// Calculate ticket age from creation (not last update)
$ageSeconds = time() - strtotime($ticket['created_at']);
$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();
// Track recently viewed tickets for command palette
(function() {
try {
var tid = String(window.ticketData.ticket_id);
var key = 'lt_recent_tickets';
var r = JSON.parse(localStorage.getItem(key) || '[]');
r = [tid].concat(r.filter(function(x){ return x !== tid; })).slice(0, 5);
localStorage.setItem(key, JSON.stringify(r));
} catch(_) {}
})();
JS;
include __DIR__ . '/layout_header.php';
?>
<!-- Back nav + ticket toolbar -->
<div class="lt-page-header">
<nav class="lt-breadcrumb" aria-label="Breadcrumb">
<a href="/" class="lt-breadcrumb-item">Dashboard</a>
<span class="lt-breadcrumb-sep" aria-hidden="true">/</span>
<span class="lt-breadcrumb-item active" aria-current="page"
title="<?= htmlspecialchars($ticket['title'], ENT_QUOTES, 'UTF-8') ?>">
#<?= htmlspecialchars($ticket['ticket_id']) ?> &mdash;
<?= htmlspecialchars(mb_strimwidth($ticket['title'], 0, 45, '…')) ?>
</span>
</nav>
<div class="lt-btn-group">
<!-- Status dot indicator -->
<?php
$dotClass = match($ticket['status']) {
'Open' => 'lt-dot-up',
'In Progress' => 'lt-dot-warn',
'Pending' => 'lt-dot--orange',
'Closed' => 'lt-dot-idle',
default => 'lt-dot-idle',
};
?>
<span class="lt-dot <?= $dotClass ?>" aria-hidden="true" title="<?= htmlspecialchars($ticket['status']) ?>"></span>
<!-- 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>
<span id="watcherAvatarGroup" class="lt-avatar-group lt-avatar-group--sm" aria-label="Watchers" style="display:none"></span>
<button type="button" id="watchButton" class="lt-btn lt-btn-ghost lt-btn-sm"
title="Watch this ticket to receive Matrix notifications on updates">WATCH</button>
<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>
<?php if ($priorityNum <= 2 && $ticket['status'] !== 'Closed'): ?>
<?php
$slaTargetHours = match($priorityNum) { 1 => 8, 2 => 24, default => 72 };
$elapsedSeconds = time() - strtotime($ticket['created_at']);
$elapsedHours = round($elapsedSeconds / 3600, 1);
$slaPct = min(100, round(($elapsedSeconds / ($slaTargetHours * 3600)) * 100));
$slaBreached = $elapsedSeconds >= ($slaTargetHours * 3600);
$alertClass = $priorityNum === 1 ? 'lt-alert--error' : 'lt-alert--warning';
$alertIcon = $priorityNum === 1 ? '[ ! ]' : '[ ~ ]';
$alertLabel = $priorityNum === 1 ? 'CRITICAL — P1 Ticket' : 'HIGH PRIORITY — P2 Ticket';
$progressClass = $slaBreached ? 'lt-progress--red' : ($slaPct >= 75 ? 'lt-progress--red' : 'lt-progress--green');
?>
<!-- Priority alert banner — P1/P2 only, dismissible per session -->
<div class="lt-alert <?= $alertClass ?>" id="priorityAlertBanner"
role="alert" aria-live="polite"
data-alert-id="priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>"
data-created-at="<?= (int)strtotime($ticket['created_at']) ?>"
data-sla-hours="<?= $slaTargetHours ?>"
style="margin-bottom:0.75rem">
<span class="lt-alert-icon" aria-hidden="true"><?= $alertIcon ?></span>
<div class="lt-alert-body">
<div class="lt-alert-title"><?= $alertLabel ?></div>
<div class="lt-alert-msg">
SLA target: <strong><?= $slaTargetHours ?>h</strong> &mdash;
Elapsed: <strong id="slaElapsedTimer"><?= $elapsedHours ?>h</strong>
<?php if (!$slaBreached): ?>
&mdash; Remaining: <strong id="slaCountdownTimer" class="lt-text-cyan"></strong>
<?php else: ?>
&mdash; <span class="lt-text-danger" id="slaCountdownTimer">SLA BREACHED (+<strong id="slaOverrunTimer"><?= round(($elapsedSeconds - $slaTargetHours * 3600) / 3600, 1) ?>h</strong>)</span>
<?php endif ?>
<div class="lt-progress lt-progress--sm <?= $progressClass ?>" id="slaProgress" style="margin-top:0.35rem"
aria-label="SLA progress <?= $slaPct ?>%">
<div class="lt-progress-bar" id="slaProgressBar" style="width:<?= $slaPct ?>%"></div>
</div>
</div>
</div>
<button type="button" class="lt-alert-close" data-action="dismiss-priority-banner" aria-label="Dismiss">&#x2715;</button>
</div>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
(function(){
var banner = document.getElementById('priorityAlertBanner');
var id = 'priority-banner-<?= htmlspecialchars($ticket['ticket_id']) ?>';
try { if(sessionStorage.getItem('lt_dismissed_'+id)) banner.classList.add('dismissed'); } catch(e) {}
// Live SLA timers — start after base.js initialises lt
document.addEventListener('DOMContentLoaded', function() {
if (!banner || banner.classList.contains('dismissed')) return;
var createdAt = parseInt(banner.dataset.createdAt, 10) * 1000;
var slaMs = parseInt(banner.dataset.slaHours, 10) * 3600 * 1000;
var deadline = new Date(createdAt + slaMs);
var elapsedEl = document.getElementById('slaElapsedTimer');
var countdownEl = document.getElementById('slaCountdownTimer');
var overrunEl = document.getElementById('slaOverrunTimer');
var progressBar = document.getElementById('slaProgressBar');
var progressWrap = document.getElementById('slaProgress');
function fmtHMS(ms) {
var s = Math.floor(Math.abs(ms) / 1000);
var h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
return [h, m, ss].map(function(n){ return String(n).padStart(2,'0'); }).join(':');
}
function tick() {
var now = Date.now();
var elapsed = now - createdAt;
var remaining = deadline - now;
var pct = Math.min(100, Math.round((elapsed / slaMs) * 100));
if (elapsedEl) elapsedEl.textContent = fmtHMS(elapsed);
if (progressBar) progressBar.style.width = pct + '%';
if (progressWrap) progressWrap.setAttribute('aria-label', 'SLA progress ' + pct + '%');
if (remaining > 0) {
// SLA not yet breached
if (countdownEl) {
countdownEl.textContent = fmtHMS(remaining) + ' remaining';
countdownEl.className = pct >= 75 ? 'lt-text-danger' : 'lt-text-cyan';
}
if (progressWrap && pct >= 75) {
progressWrap.className = progressWrap.className.replace('lt-progress--green','lt-progress--red');
}
} else {
// Breached
if (countdownEl && !overrunEl) {
countdownEl.innerHTML = 'SLA BREACHED (+' + fmtHMS(-remaining) + ')';
countdownEl.className = 'lt-text-danger';
} else if (overrunEl) {
overrunEl.textContent = fmtHMS(-remaining);
}
if (progressWrap && !progressWrap.classList.contains('lt-progress--red')) {
progressWrap.className = progressWrap.className.replace('lt-progress--green','').replace('lt-progress--red','') + ' lt-progress--red';
}
}
}
tick();
setInterval(tick, 1000);
});
})();
</script>
<?php endif ?>
<!-- ═══════════════════════════════════════════════════════════
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 lt-display-field" 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">
<?php $catColor = match($ticket['category']) { 'Hardware'=>'lt-tag--orange','Software'=>'lt-tag--cyan','Network'=>'lt-tag--purple','Security'=>'lt-tag--red',default=>'' }; ?>
<!-- Read mode tag — hidden in edit mode via CSS -->
<span class="lt-tag <?= $catColor ?> read-mode-tag" id="categoryTag"
aria-label="Category: <?= htmlspecialchars($ticket['category']) ?>"><?= htmlspecialchars($ticket['category']) ?></span>
<!-- Edit mode select — shown only when editing -->
<select id="categorySelect" class="lt-select lt-select-sm editable-metadata edit-mode-field" style="display:none" 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">
<?php $typeColor = match($ticket['type']) { 'Maintenance'=>'lt-tag--orange','Issue'=>'lt-tag--red','Problem'=>'lt-tag--red','Upgrade'=>'lt-tag--purple','Install'=>'lt-tag--cyan',default=>'' }; ?>
<!-- Read mode tag — hidden in edit mode via CSS -->
<span class="lt-tag <?= $typeColor ?> read-mode-tag" id="typeTag"
aria-label="Type: <?= htmlspecialchars($ticket['type']) ?>"><?= htmlspecialchars($ticket['type']) ?></span>
<!-- Edit mode select — shown only when editing -->
<select id="typeSelect" class="lt-select lt-select-sm editable-metadata edit-mode-field" style="display:none" 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 lt-display-field"
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 lt-display-field"
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>
<!-- Read view: shown when not editing — uses lt-markdown for readable typography -->
<div id="ticketDescriptionView"
class="lt-markdown ticket-description-view"
aria-label="Ticket description"></div>
<!-- Edit view: shown only when editing -->
<textarea id="ticketDescription"
class="lt-input lt-textarea editable"
data-field="description"
disabled
rows="18"
style="display:none"
aria-label="Ticket description (edit)"><?= 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 lt-flex-align-center">
<label class="lt-toggle lt-toggle--sm" title="Enable Markdown formatting">
<input type="checkbox" id="markdownMaster" data-action="toggle-markdown-mode">
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
<span class="lt-toggle-label lt-text-xs">MD</span>
</label>
<label class="lt-toggle lt-toggle--sm" title="Preview rendered Markdown">
<input type="checkbox" id="markdownToggle" data-action="toggle-preview" disabled>
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
<span class="lt-toggle-label lt-text-xs">Preview</span>
</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">
<?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 is-hidden"
id="comment-raw-<?= $commentId ?>"
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>
<!-- Potential Duplicates (loaded on first tab activation) -->
<div class="lt-frame lt-mb-md" id="potentialDupsFrame" style="display:none">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header lt-text-amber">Potential Duplicates</div>
<div class="lt-section-body" id="potentialDupsList" aria-live="polite"></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--cyan',
'comment' => 'lt-timeline-item--green',
'assign' => 'lt-timeline-item--orange',
'attachment' => 'lt-timeline-item--orange',
'update' => '',
'delete' => 'lt-timeline-item--red',
default => 'lt-timeline-item--dim',
};
?>
<div class="lt-timeline-item <?= $tClass ?>">
<div class="lt-timeline-meta">
<span class="lt-timeline-icon lt-text-xs" aria-hidden="true"><?= $icon ?></span>
<span class="lt-timeline-actor"><?= $actor ?></span>
<span class="lt-timeline-action"><?= $action /* already escaped in formatAction for dynamic parts */ ?></span>
<span class="lt-timeline-time ts-cell lt-text-muted lt-text-xs"
data-ts="<?= htmlspecialchars($event['created_at'], ENT_QUOTES, 'UTF-8') ?>"
title="<?= $evtFmt ?>"><?= $evtFmt ?></span>
</div>
<?php if (!empty($event['details']) && !in_array($event['action_type'], ['status_change', 'assign', 'comment', 'view'], true)): ?>
<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) {
if (is_array($v) && isset($v['from'], $v['to'])) {
$label = ucfirst(str_replace('_', ' ', $k));
$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 (!in_array($k, ['old_value', 'new_value'], true)) {
$parts[] = '<strong>' . htmlspecialchars($k) . ':</strong> ' . htmlspecialchars((string)$v);
}
}
if ($parts) 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">Preferences</h4>
<div class="lt-kv-grid">
<div class="lt-kv-row">
<span class="lt-kv-label" id="settingNotifLabel">Notifications</span>
<span class="lt-kv-value">
<label class="lt-toggle" aria-labelledby="settingNotifLabel">
<input type="checkbox" id="settingNotificationsEnabled" checked>
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
<span class="lt-toggle-label lt-text-xs">Receive in-app notifications</span>
</label>
</span>
</div>
<div class="lt-kv-row">
<span class="lt-kv-label" id="settingSoundLabel">Sound effects</span>
<span class="lt-kv-value">
<label class="lt-toggle" aria-labelledby="settingSoundLabel">
<input type="checkbox" id="settingSoundEffects">
<span class="lt-toggle-track"><span class="lt-toggle-thumb"></span></span>
<span class="lt-toggle-label lt-text-xs">UI sound feedback</span>
</label>
</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';
});
}
);
});
}
// Watch / Unwatch button
var watchBtn = document.getElementById('watchButton');
var watcherGroup = document.getElementById('watcherAvatarGroup');
function _renderWatcherAvatars(watchers) {
if (!watcherGroup) return;
if (!watchers || !watchers.length) { watcherGroup.style.display = 'none'; return; }
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var html = '';
var shown = watchers.slice(0, 4);
shown.forEach(function (w) {
var words = (w.display_name || '').trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function (x) { return x[0].toUpperCase(); }).join('');
var hash = 0;
for (var i = 0; i < (w.display_name || '').length; i++) hash = ((hash << 5) - hash + (w.display_name || '').charCodeAt(i)) | 0;
var color = avatarColors[Math.abs(hash) % 4];
html += '<div class="lt-avatar lt-avatar--xs ' + color + '" title="' + lt.escHtml(w.display_name) + '" aria-label="' + lt.escHtml(w.display_name) + '">' +
'<img src="/api/user_avatar.php?user_id=' + w.user_id + '" alt="" class="lt-avatar-img">' +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
'</div>';
});
if (watchers.length > 4) {
html += '<div class="lt-avatar lt-avatar--xs lt-avatar--overflow" title="' + (watchers.length - 4) + ' more watchers">+' + (watchers.length - 4) + '</div>';
}
watcherGroup.innerHTML = html;
watcherGroup.style.display = 'flex';
}
if (watchBtn) {
var _watching = false;
// Fetch initial state
lt.api.get('/api/watch_ticket.php?ticket_id=' + window.ticketData.ticket_id)
.then(function (d) {
if (d.success) {
_watching = d.watching;
watchBtn.textContent = _watching ? 'UNWATCH' : 'WATCH';
watchBtn.title = _watching
? 'You are watching this ticket. Click to stop.'
: 'Watch this ticket for Matrix notifications on updates.';
if (_watching) watchBtn.classList.add('lt-btn-active');
_renderWatcherAvatars(d.watchers || []);
}
})
.catch(function () {});
watchBtn.addEventListener('click', function () {
var action = _watching ? 'unwatch' : 'watch';
watchBtn.disabled = true;
lt.api.post('/api/watch_ticket.php', { ticket_id: window.ticketData.ticket_id, action: action })
.then(function (d) {
if (d.success) {
_watching = d.watching;
watchBtn.textContent = _watching ? 'UNWATCH' : 'WATCH';
watchBtn.title = _watching
? 'You are watching this ticket. Click to stop.'
: 'Watch this ticket for Matrix notifications on updates.';
watchBtn.classList.toggle('lt-btn-active', _watching);
lt.toast.success(_watching ? 'Watching ticket' : 'Stopped watching ticket');
// Refresh watcher avatars from server
lt.api.get('/api/watch_ticket.php?ticket_id=' + window.ticketData.ticket_id)
.then(function (d2) { if (d2.success) _renderWatcherAvatars(d2.watchers || []); })
.catch(function () {});
} else {
lt.toast.error('Failed: ' + (d.error || 'Unknown error'));
}
watchBtn.disabled = false;
})
.catch(function () {
lt.toast.error('Failed to update watch status');
watchBtn.disabled = false;
});
});
}
// 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
// Load user preference toggles on settings modal open
(function() {
fetch('/api/user_preferences.php', { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.success || !d.preferences) return;
var notifEl = document.getElementById('settingNotificationsEnabled');
var soundEl = document.getElementById('settingSoundEffects');
if (notifEl && d.preferences.notifications_enabled !== undefined)
notifEl.checked = d.preferences.notifications_enabled == '1' || d.preferences.notifications_enabled === true;
if (soundEl && d.preferences.sound_effects !== undefined)
soundEl.checked = d.preferences.sound_effects == '1' || d.preferences.sound_effects === true;
}).catch(function() {});
})();
var saveSettingsBtn = document.getElementById('saveSettingsBtn');
if (saveSettingsBtn) {
saveSettingsBtn.addEventListener('click', function () {
// Save lt-toggle preferences
var notifEl = document.getElementById('settingNotificationsEnabled');
var soundEl = document.getElementById('settingSoundEffects');
var prefsToSave = {};
if (notifEl) prefsToSave.notifications_enabled = notifEl.checked ? '1' : '0';
if (soundEl) prefsToSave.sound_effects = soundEl.checked ? '1' : '0';
if (Object.keys(prefsToSave).length) {
fetch('/api/user_preferences.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.CSRF_TOKEN || '' },
body: JSON.stringify({ preferences: prefsToSave })
}).catch(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 — only handles actions NOT covered by ticket.js
// (edit-comment, delete-comment, remove-dependency, delete-attachment, select-mention,
// save/cancel-edit-comment, reply-comment, close-reply, submit-reply are in ticket.js)
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
var action = target.getAttribute('data-action');
if (action === 'dismiss-priority-banner') {
var banner = target.closest('[data-alert-id]');
if (banner) {
try { sessionStorage.setItem('lt_dismissed_' + banner.dataset.alertId, '1'); } catch(ex) {}
banner.classList.add('dismissed');
}
}
});
// 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';
// Insert skeleton placeholders while fetching
var list = document.getElementById('commentsList');
var wrap = document.getElementById('loadMoreComments');
var skeletons = [];
for (var s = 0; s < 3; s++) {
var sk = document.createElement('div');
sk.className = 'lt-skeleton-card comment-skeleton';
sk.setAttribute('aria-hidden', 'true');
sk.innerHTML =
'<div style="display:flex;gap:0.5rem;align-items:flex-start">' +
'<div class="lt-skeleton lt-skeleton-avatar"></div>' +
'<div style="flex:1">' +
'<div class="lt-skeleton lt-skeleton-title" style="width:35%"></div>' +
'<div class="lt-skeleton lt-skeleton-text"></div>' +
'<div class="lt-skeleton lt-skeleton-text" style="width:75%"></div>' +
'</div></div>';
list.insertBefore(sk, wrap);
skeletons.push(sk);
}
var url = '/api/get_comments.php?ticket_id=' + td.ticket_id +
'&offset=' + td.commentOffset +
'&limit=' + td.commentPageSize;
lt.api.get(url).then(function (data) {
// Remove skeleton placeholders
skeletons.forEach(function (sk) { if (sk.parentNode) sk.parentNode.removeChild(sk); });
if (!data.success) {
lt.toast.error('Failed to load comments: ' + (data.error || 'Unknown error'));
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments';
return;
}
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) {
skeletons.forEach(function (sk) { if (sk.parentNode) sk.parentNode.removeChild(sk); });
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">' : '';
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 is-hidden" id="comment-raw-' + commentId + '" 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'; ?>