feat: complete TDS v1.2 redesign across all views

Full application redesign using Terminal Design System v1.2 (lt-* class
system). Introduces shared layout_header/footer partials, upgrades
base.css/base.js to TDS v1.2, and rewrites all views (Dashboard, Ticket,
CreateTicket, and all 7 admin views) with lt-frame, lt-table, lt-modal,
lt-stats-grid, lt-kv-grid, and data-action event delegation patterns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 19:05:42 -04:00
parent 1989bcb8c8
commit 79c2d2b513
16 changed files with 11265 additions and 14094 deletions
+4857 -1068
View File
File diff suppressed because it is too large Load Diff
+188 -6174
View File
File diff suppressed because it is too large Load Diff
+229 -2730
View File
File diff suppressed because it is too large Load Diff
+2546 -392
View File
File diff suppressed because it is too large Load Diff
+294 -323
View File
@@ -1,358 +1,329 @@
<?php <?php
// This file contains the HTML template for creating a new ticket /**
* CreateTicketView.php — New ticket creation form, redesigned for TDS v1.2
* Variables: $templates (array), $allUsers (array), $error (string|null)
*/
require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'New Ticket';
$activeNav = 'dashboard';
$pageStyles = ['/assets/css/ticket.css?v=20260327'];
$pageScripts = [
'/assets/js/keyboard-shortcuts.js?v=20260327',
];
include __DIR__ . '/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <!-- Page header -->
<head> <div class="lt-page-header">
<meta charset="UTF-8"> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<title>Create New Ticket</title> <span class="lt-text-muted lt-text-xs">/</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <span class="lt-text-muted lt-text-xs">New Ticket</span>
<link rel="stylesheet" href="/assets/css/base.css"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <!-- ═══════════════════════════════════════════════════════════
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script> CREATE TICKET FORM
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script> ═══════════════════════════════════════════════════════════ -->
<script nonce="<?php echo $nonce; ?>"> <form method="POST"
// CSRF Token for AJAX requests action="<?= htmlspecialchars($GLOBALS['config']['BASE_URL'], ENT_QUOTES, 'UTF-8') ?>/ticket/create"
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; class="create-ticket-form"
</script> novalidate>
</head>
<body> <?php if (isset($error)): ?>
<div class="user-header"> <div class="lt-msg lt-msg-danger lt-mb-md" role="alert">
<div class="user-header-left"> <strong>Error:</strong> <?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
<a href="/" class="back-link">[ &larr; DASHBOARD ]</a> </div>
</div> <?php endif ?>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <!-- ── SECTION 1: Template ───────────────────────────────── -->
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> <div class="lt-frame lt-mb-md">
<?php if ($GLOBALS['currentUser']['is_admin']): ?> <span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<span class="admin-badge">[ ADMIN ]</span> <div class="lt-section-header">Template (Optional)</div>
<?php endif; ?> <div class="lt-section-body">
<?php endif; ?> <div class="lt-form-group">
</div> <label class="lt-label" for="templateSelect">Use a Template</label>
<select id="templateSelect" class="lt-select" data-action="load-template">
<option value="">— No Template —</option>
<?php if (!empty($templates)): ?>
<?php foreach ($templates as $tpl): ?>
<option value="<?= (int)$tpl['template_id'] ?>">
<?= htmlspecialchars($tpl['template_name']) ?>
</option>
<?php endforeach ?>
<?php endif ?>
</select>
<p class="lt-form-hint">Selecting a template pre-fills the form fields.</p>
</div>
</div> </div>
</div>
<!-- OUTER FRAME: Create Ticket Form Container --> <!-- ── SECTION 2: Title ─────────────────────────────────── -->
<div class="ascii-frame-outer"> <div class="lt-frame lt-mb-md">
<span class="bottom-left-corner"></span> <span class="lt-frame-bl">╚</span><span class="lt-frame-br"></span>
<span class="bottom-right-corner">╝</span> <div class="lt-section-header">Title *</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label lt-sr-only" for="title">Ticket Title</label>
<input type="text"
id="title"
name="title"
class="lt-input"
required
autocomplete="off"
placeholder="Enter a clear, concise title for this ticket"
aria-required="true"
aria-describedby="duplicateWarning">
</div>
<!-- SECTION 1: Form Header --> <!-- Duplicate warning (shown by JS when similar tickets exist) -->
<div class="ascii-section-header">Create New Ticket</div> <div id="duplicateWarning" class="lt-msg lt-msg-warning is-hidden"
<div class="ascii-content"> role="alert" aria-live="polite" aria-atomic="true">
<div class="ascii-frame-inner"> <strong class="lt-text-amber">Possible Duplicates Found</strong>
<div class="ticket-header"> <div id="duplicatesList" aria-live="polite"></div>
<h2>New Ticket Form</h2> </div>
<p class="form-hint">
Complete the form below to create a new ticket
</p>
</div>
</div>
</div>
<?php if (isset($error)): ?>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- ERROR SECTION -->
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="error-message inline-error">
<strong>[ ! ] ERROR:</strong> <?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?>
</div>
</div>
</div>
<?php endif; ?>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<form method="POST" action="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="ticket-form">
<!-- SECTION 2: Template Selection -->
<div class="ascii-section-header">Template Selection</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="templateSelect">Use Template (Optional)</label>
<select id="templateSelect" class="editable" data-action="load-template">
<option value="">-- No Template --</option>
<?php if (isset($templates) && !empty($templates)): ?>
<?php foreach ($templates as $template): ?>
<option value="<?php echo $template['template_id']; ?>">
<?php echo htmlspecialchars($template['template_name']); ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
<p class="form-hint">
Select a template to auto-fill form fields
</p>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 3: Basic Information -->
<div class="ascii-section-header">Basic Information</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="title">Ticket Title *</label>
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
</div>
<!-- Duplicate Warning Area -->
<div id="duplicateWarning" class="inline-warning is-hidden" role="alert" aria-live="polite" aria-atomic="true">
<div class="text-amber fw-bold duplicate-heading">
Possible Duplicates Found
</div>
<div id="duplicatesList" aria-live="polite"></div>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 4: Ticket Metadata -->
<div class="ascii-section-header">Ticket Metadata</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group status-priority-row">
<div class="detail-quarter">
<label for="status">Status</label>
<select id="status" name="status" class="editable">
<option value="Open" selected>Open</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="detail-quarter">
<label for="priority">Priority</label>
<select id="priority" name="priority" class="editable">
<option value="1">P1 - Critical Impact</option>
<option value="2">P2 - High Impact</option>
<option value="3">P3 - Medium Impact</option>
<option value="4" selected>P4 - Low Impact</option>
</select>
</div>
<div class="detail-quarter">
<label for="category">Category</label>
<select id="category" name="category" class="editable">
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
<option value="General" selected>General</option>
</select>
</div>
<div class="detail-quarter">
<label for="type">Type</label>
<select id="type" name="type" class="editable">
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Issue" selected>Issue</option>
</select>
</div>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 4b: Assignment -->
<div class="ascii-section-header">Assignment</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="assigned_to">Assign To (Optional)</label>
<select id="assigned_to" name="assigned_to" class="editable">
<option value="">-- Unassigned --</option>
<?php if (isset($allUsers) && !empty($allUsers)): ?>
<?php foreach ($allUsers as $user): ?>
<option value="<?php echo $user['user_id']; ?>">
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
<p class="form-hint">
Select a user to assign this ticket to
</p>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 5: Visibility Settings -->
<div class="ascii-section-header">Visibility Settings</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="visibility">Ticket Visibility</label>
<select id="visibility" name="visibility" class="editable" data-action="toggle-visibility-groups">
<option value="public" selected>Public - All authenticated users</option>
<option value="internal">Internal - Specific groups only</option>
<option value="confidential">Confidential - Creator, assignee, admins only</option>
</select>
<p class="form-hint">
Controls who can view this ticket
</p>
</div>
<div id="visibilityGroupsContainer" class="detail-group is-hidden">
<label>Allowed Groups</label>
<div class="visibility-groups-list">
<?php
// Get all available groups
require_once __DIR__ . '/../models/UserModel.php';
$userModel = new UserModel($conn);
$allGroups = $userModel->getAllGroups();
foreach ($allGroups as $group):
?>
<label class="group-checkbox-label">
<input type="checkbox" name="visibility_groups[]" value="<?php echo htmlspecialchars($group); ?>" class="visibility-group-checkbox">
<span class="group-badge"><?php echo htmlspecialchars($group); ?></span>
</label>
<?php endforeach; ?>
<?php if (empty($allGroups)): ?>
<span class="text-muted">No groups available</span>
<?php endif; ?>
</div>
<p class="form-hint-warning">
Select which groups can view this ticket
</p>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 6: Detailed Description -->
<div class="ascii-section-header">Detailed Description</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group full-width">
<label for="description">Description *</label>
<textarea id="description" name="description" class="editable" rows="15" required placeholder="Provide a detailed description of the ticket..."></textarea>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 6: Form Actions -->
<div class="ascii-section-header">Form Actions</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="ticket-footer">
<button type="submit" class="btn primary">CREATE TICKET</button>
<button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">CANCEL</button>
</div>
</div>
</div>
</form>
</div> </div>
<!-- END OUTER FRAME --> </div>
<script nonce="<?php echo $nonce; ?>"> <!-- ── SECTION 3: Metadata ──────────────────────────────── -->
// Duplicate detection with debounce <div class="lt-frame lt-mb-md">
let duplicateCheckTimeout = null; <span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Metadata</div>
<div class="lt-section-body">
<div class="create-ticket-meta-grid">
document.getElementById('title').addEventListener('input', function() { <div class="lt-form-group">
clearTimeout(duplicateCheckTimeout); <label class="lt-label" for="status">Status</label>
const title = this.value.trim(); <select id="status" name="status" class="lt-select">
<option value="Open" selected>Open</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="priority">Priority</label>
<select id="priority" name="priority" class="lt-select">
<option value="1">P1 — Critical Impact</option>
<option value="2">P2 — High Impact</option>
<option value="3">P3 — Medium Impact</option>
<option value="4" selected>P4 — Low Impact</option>
<option value="5">P5 — Minimal Impact</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="category">Category</label>
<select id="category" name="category" class="lt-select">
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
<option value="General" selected>General</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="type">Type</label>
<select id="type" name="type" class="lt-select">
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Issue" selected>Issue</option>
<option value="Problem">Problem</option>
</select>
</div>
</div><!-- /.create-ticket-meta-grid -->
</div>
</div>
<!-- ── SECTION 4: Assignment ────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Assignment</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label" for="assigned_to">Assign To</label>
<select id="assigned_to" name="assigned_to" class="lt-select">
<option value="">— Unassigned —</option>
<?php if (!empty($allUsers)): ?>
<?php foreach ($allUsers as $u): ?>
<option value="<?= (int)$u['user_id'] ?>">
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
</option>
<?php endforeach ?>
<?php endif ?>
</select>
<p class="lt-form-hint">Leave blank to create as unassigned.</p>
</div>
</div>
</div>
<!-- ── SECTION 5: Visibility ────────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Visibility</div>
<div class="lt-section-body">
<div class="lt-form-group">
<label class="lt-label" for="visibility">Who can see this ticket?</label>
<select id="visibility" name="visibility" class="lt-select" data-action="toggle-visibility-groups">
<option value="public" selected>Public — All authenticated users</option>
<option value="internal">Internal — Specific groups only</option>
<option value="confidential">Confidential — Creator, assignee, and admins only</option>
</select>
</div>
<div id="visibilityGroupsContainer" class="lt-form-group is-hidden" aria-live="polite">
<label class="lt-label lt-text-cyan">Allowed Groups</label>
<div class="visibility-groups-list lt-flex lt-flex-wrap lt-flex-gap-sm">
<?php
require_once __DIR__ . '/../models/UserModel.php';
$userModel = new UserModel($conn);
$allGroups = $userModel->getAllGroups();
foreach ($allGroups as $group):
?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox visibility-group-checkbox"
name="visibility_groups[]"
value="<?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>">
<span class="lt-badge"><?= htmlspecialchars($group) ?></span>
</label>
<?php endforeach ?>
<?php if (empty($allGroups)): ?>
<span class="lt-text-muted lt-text-sm">No groups available</span>
<?php endif ?>
</div>
<p class="lt-form-hint lt-form-hint--warn">Select at least one group for Internal visibility.</p>
</div>
</div>
</div>
<!-- ── SECTION 6: Description ───────────────────────────── -->
<div class="lt-frame lt-mb-md">
<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="description">Description</label>
<textarea id="description"
name="description"
class="lt-input lt-textarea"
rows="16"
required
aria-required="true"
placeholder="Provide a detailed description of the issue, steps to reproduce, expected vs. actual behavior, and any relevant context&hellip;"></textarea>
</div>
</div>
</div>
<!-- ── SECTION 7: Actions ───────────────────────────────── -->
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Actions</div>
<div class="lt-section-body">
<div class="lt-btn-group">
<button type="submit" class="lt-btn lt-btn-primary">CREATE TICKET</button>
<a href="/" class="lt-btn lt-btn-ghost">CANCEL</a>
</div>
</div>
</div>
</form>
<!-- Page-specific script: duplicate detection + visibility toggle -->
<script nonce="<?= $nonce ?>">
(function () {
'use strict';
// ── Duplicate detection ───────────────────────────────────
var _dupTimer = null;
document.getElementById('title').addEventListener('input', function () {
clearTimeout(_dupTimer);
var title = this.value.trim();
if (title.length < 5) { if (title.length < 5) {
document.getElementById('duplicateWarning').classList.add('is-hidden'); document.getElementById('duplicateWarning').classList.add('is-hidden');
return; return;
} }
_dupTimer = setTimeout(function () { checkDuplicates(title); }, 500);
// Debounce: wait 500ms after user stops typing
duplicateCheckTimeout = setTimeout(() => {
checkForDuplicates(title);
}, 500);
}); });
function checkForDuplicates(title) { function checkDuplicates(title) {
lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title)) lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
.then(data => { .then(function (data) {
const warningDiv = document.getElementById('duplicateWarning'); var warn = document.getElementById('duplicateWarning');
const listDiv = document.getElementById('duplicatesList'); var list = document.getElementById('duplicatesList');
if (data.success && data.duplicates && data.duplicates.length > 0) { if (data.success && data.duplicates && data.duplicates.length > 0) {
let html = '<ul class="duplicate-list">'; var html = '<ul class="duplicate-list lt-text-sm">';
data.duplicates.forEach(dup => { data.duplicates.forEach(function (dup) {
html += `<li> html += '<li><a href="/ticket/' + lt.escHtml(dup.ticket_id) + '" target="_blank">#' +
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank"> lt.escHtml(dup.ticket_id) + '</a> &mdash; ' + lt.escHtml(dup.title) +
#${escapeHtml(dup.ticket_id)} ' <span class="lt-text-muted">(' + dup.similarity + '% match, ' +
</a> lt.escHtml(dup.status) + ')</span></li>';
- ${escapeHtml(dup.title)}
<span class="duplicate-meta">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span>
</li>`;
}); });
html += '</ul>'; html += '</ul><p class="lt-text-xs lt-text-muted lt-mt-sm">Check these before creating a new ticket.</p>';
html += '<p class="duplicate-hint">Consider checking these tickets before creating a new one.</p>'; list.innerHTML = html;
warn.classList.remove('is-hidden');
listDiv.innerHTML = html;
warningDiv.classList.remove('is-hidden');
} else { } else {
warningDiv.classList.add('is-hidden'); warn.classList.add('is-hidden');
} }
}) })
.catch(error => { .catch(function () { /* silent */ });
console.error('Error checking duplicates:', error);
});
} }
// ── Visibility groups toggle ──────────────────────────────
function toggleVisibilityGroups() { function toggleVisibilityGroups() {
const visibility = document.getElementById('visibility').value; var vis = document.getElementById('visibility').value;
const groupsContainer = document.getElementById('visibilityGroupsContainer'); var container = document.getElementById('visibilityGroupsContainer');
if (visibility === 'internal') { if (vis === 'internal') {
groupsContainer.classList.remove('is-hidden'); container.classList.remove('is-hidden');
} else { } else {
groupsContainer.classList.add('is-hidden'); container.classList.add('is-hidden');
document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false); container.querySelectorAll('.visibility-group-checkbox').forEach(function (cb) { cb.checked = false; });
} }
} }
// Event delegation for data-action handlers // ── Template loader ───────────────────────────────────────
document.addEventListener('click', function(event) { function loadTemplate() {
const target = event.target.closest('[data-action]'); var tplId = document.getElementById('templateSelect').value;
if (!tplId) return;
lt.api.get('/api/get_template.php?template_id=' + encodeURIComponent(tplId))
.then(function (data) {
if (!data.success || !data.template) {
lt.toast.error('Failed to load template.');
return;
}
var t = data.template;
if (t.title) document.getElementById('title').value = t.title;
if (t.description) document.getElementById('description').value = t.description;
if (t.priority) document.getElementById('priority').value = t.priority;
if (t.category) document.getElementById('category').value = t.category;
if (t.type) document.getElementById('type').value = t.type;
// Trigger duplicate check after template fill
document.getElementById('title').dispatchEvent(new Event('input'));
lt.toast.success('Template applied.');
})
.catch(function () { lt.toast.error('Could not load template.'); });
}
// ── Event delegation ──────────────────────────────────────
document.addEventListener('change', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return; if (!target) return;
switch (target.getAttribute('data-action')) {
const action = target.dataset.action; case 'load-template': loadTemplate(); break;
if (action === 'navigate') { case 'toggle-visibility-groups': toggleVisibilityGroups(); break;
window.location.href = target.dataset.url;
}
});
document.addEventListener('change', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
if (action === 'load-template') {
loadTemplate();
} else if (action === 'toggle-visibility-groups') {
toggleVisibilityGroups();
} }
}); });
if (window.lt) lt.keys.initDefaults(); if (window.lt) lt.keys.initDefaults();
</script> }());
</body> </script>
</html>
<?php include __DIR__ . '/layout_footer.php'; ?>
+880 -1018
View File
File diff suppressed because it is too large Load Diff
+768 -809
View File
File diff suppressed because it is too large Load Diff
+152 -218
View File
@@ -1,238 +1,172 @@
<?php <?php
// Admin view for managing API keys
// Receives $apiKeys from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'API Keys';
$activeNav = 'admin-api-keys';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <div class="lt-page-header">
<head> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta charset="UTF-8"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <span class="lt-text-muted lt-text-xs">/</span>
<title>API Keys - Admin</title> <span class="lt-text-muted lt-text-xs">Admin: API Keys</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> </div>
<link rel="stylesheet" href="/assets/css/base.css"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320"> <!-- Generate new key -->
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <div class="lt-frame lt-mb-md">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script> <span class="lt-frame-bl">╚</span><span class="lt-frame-br"></span>
<script nonce="<?php echo $nonce; ?>"> <div class="lt-section-header">Generate New API Key</div>
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; <div class="lt-section-body">
</script> <form id="generateKeyForm" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-flex-align-end">
</head> <div class="lt-form-group" style="flex:2;margin:0">
<body> <label class="lt-label" for="keyName">Key Name *</label>
<div class="user-header"> <input type="text" id="keyName" required class="lt-input" placeholder="e.g., CI/CD Pipeline">
<div class="user-header-left"> </div>
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <div class="lt-form-group" style="flex:1;margin:0">
<span class="admin-page-title">Admin: API Keys</span> <label class="lt-label" for="expiresIn">Expires In</label>
</div> <select id="expiresIn" class="lt-select">
<div class="user-header-right"> <option value="">Never</option>
<?php if (isset($GLOBALS['currentUser'])): ?> <option value="30">30 days</option>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> <option value="90">90 days</option>
<span class="admin-badge">[ ADMIN ]</span> <option value="180">180 days</option>
<?php endif; ?> <option value="365">1 year</option>
</div> </select>
</div>
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
</form>
<!-- New key display (hidden by default) -->
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
<div class="lt-subsection-header lt-text-amber">&#x26A0; Copy this key now — you won't see it again!</div>
<div class="lt-flex lt-flex-gap-sm lt-mt-sm">
<input type="text" id="newKeyValue" readonly class="lt-input" style="flex:1;font-family:monospace">
<button type="button" class="lt-btn lt-btn-sm" data-action="copy-api-key">COPY</button>
</div>
</div> </div>
</div>
</div>
<div class="ascii-frame-outer admin-container"> <!-- Existing keys -->
<span class="bottom-left-corner">╚</span> <div class="lt-frame lt-mb-md">
<span class="bottom-right-corner">╝</span> <span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Existing API Keys</div>
<div class="ascii-section-header">API Key Management</div> <div class="lt-section-body">
<div class="ascii-content"> <div class="lt-table-wrap">
<!-- Generate New Key Form --> <table class="lt-table lt-table-responsive" aria-label="API keys">
<div class="ascii-frame-inner"> <thead>
<h3 class="admin-section-title">Generate New API Key</h3> <tr>
<form id="generateKeyForm" class="admin-form-row"> <th scope="col">Name</th>
<div class="admin-form-field"> <th scope="col">Key Prefix</th>
<label class="admin-label" for="keyName">Key Name *</label> <th scope="col">Created By</th>
<input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline" class="admin-input"> <th scope="col">Created</th>
</div> <th scope="col">Expires</th>
<div class="admin-form-field"> <th scope="col">Last Used</th>
<label class="admin-label" for="expiresIn">Expires In</label> <th scope="col">Status</th>
<select id="expiresIn" class="admin-input"> <th scope="col">Actions</th>
<option value="">Never</option> </tr>
<option value="30">30 days</option> </thead>
<option value="90">90 days</option> <tbody>
<option value="180">180 days</option> <?php if (empty($apiKeys)): ?>
<option value="365">1 year</option> <tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
</select> <?php else: foreach ($apiKeys as $key): ?>
</div> <?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
<div> <tr id="key-row-<?= $key['api_key_id'] ?>">
<button type="submit" class="btn">GENERATE KEY</button> <td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
</div> <td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>&hellip;</code></td>
</form> <td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
</div> <td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
<!-- New Key Display (hidden by default) --> <?= $key['expires_at'] ? date('Y-m-d', strtotime($key['expires_at'])) . ($expired ? ' (Expired)' : '') : 'Never' ?>
<div id="newKeyDisplay" class="ascii-frame-inner key-generated-alert is-hidden"> </td>
<h3 class="admin-section-title">New API Key Generated</h3> <td data-label="Last Used" class="lt-text-xs lt-text-muted">
<p class="text-danger text-sm mb-1"> <?= $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never' ?>
Copy this key now. You won't be able to see it again! </td>
</p> <td data-label="Status">
<div class="admin-form-row"> <?php if ($key['is_active']): ?>
<input type="text" id="newKeyValue" readonly class="admin-input"> <span class="lt-status lt-status-open">Active</span>
<button data-action="copy-api-key" class="btn" title="Copy to clipboard">COPY</button> <?php else: ?>
</div> <span class="lt-status lt-status-closed">Revoked</span>
</div> <?php endif ?>
</td>
<!-- Existing Keys Table --> <td data-label="Actions">
<div class="ascii-frame-inner"> <?php if ($key['is_active']): ?>
<h3 class="admin-section-title">Existing API Keys</h3> <button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
<div class="table-wrapper"> data-action="revoke-key" data-id="<?= $key['api_key_id'] ?>">REVOKE</button>
<table> <?php else: ?>
<thead> <span class="lt-text-muted lt-text-xs">—</span>
<tr> <?php endif ?>
<th>Name</th> </td>
<th>Key Prefix</th> </tr>
<th>Created By</th> <?php endforeach; endif ?>
<th>Created At</th> </tbody>
<th>Expires At</th> </table>
<th>Last Used</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($apiKeys)): ?>
<tr>
<td colspan="8" class="empty-state">No API keys found. Generate one above.</td>
</tr>
<?php else: ?>
<?php foreach ($apiKeys as $key): ?>
<tr id="key-row-<?php echo $key['api_key_id']; ?>">
<td><?php echo htmlspecialchars($key['key_name']); ?></td>
<td class="mono">
<code><?php echo htmlspecialchars($key['key_prefix']); ?>...</code>
</td>
<td><?php echo htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown'); ?></td>
<td class="nowrap"><?php echo date('Y-m-d H:i', strtotime($key['created_at'])); ?></td>
<td class="nowrap">
<?php if ($key['expires_at']): ?>
<?php $expired = strtotime($key['expires_at']) < time(); ?>
<span class="<?php echo $expired ? 'text-danger' : ''; ?>">
<?php echo date('Y-m-d', strtotime($key['expires_at'])); ?>
<?php if ($expired): ?> (Expired)<?php endif; ?>
</span>
<?php else: ?>
<span class="text-cyan">Never</span>
<?php endif; ?>
</td>
<td class="nowrap">
<?php echo $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never'; ?>
</td>
<td>
<?php if ($key['is_active']): ?>
<span class="text-open">Active</span>
<?php else: ?>
<span class="text-closed">Revoked</span>
<?php endif; ?>
</td>
<td>
<?php if ($key['is_active']): ?>
<button data-action="revoke-key" data-id="<?php echo $key['api_key_id']; ?>" class="btn btn-secondary btn-small">
REVOKE
</button>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- API Usage Info -->
<div class="ascii-frame-inner">
<h3 class="admin-section-title">API Usage</h3>
<p>Include the API key in your requests using the Authorization header:</p>
<pre class="admin-code-block"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
<p class="text-muted text-sm">
API keys provide programmatic access to create and manage tickets. Keep your keys secure and rotate them regularly.
</p>
</div>
</div>
</div> </div>
</div>
</div>
<script nonce="<?php echo $nonce; ?>"> <!-- API usage -->
// Event delegation for data-action handlers <div class="lt-frame">
document.addEventListener('click', function(event) { <span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
const target = event.target.closest('[data-action]'); <div class="lt-section-header">API Usage</div>
if (!target) return; <div class="lt-section-body">
<p class="lt-text-sm lt-text-muted">Include the API key in your requests using the Authorization header:</p>
<pre class="lt-text-xs lt-text-cyan" style="border:1px solid rgba(0,255,65,0.2);padding:0.5rem;overflow-x:auto"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
<p class="lt-text-xs lt-text-muted">API keys provide programmatic access to create and manage tickets. Keep keys secure and rotate them regularly.</p>
</div>
</div>
const action = target.dataset.action; <script nonce="<?= $nonce ?>">
switch (action) { document.addEventListener('click', function (e) {
case 'copy-api-key': var target = e.target.closest('[data-action]');
copyApiKey(); if (!target) return;
break; switch (target.getAttribute('data-action')) {
case 'revoke-key': case 'copy-api-key': copyApiKey(); break;
revokeKey(target.dataset.id); case 'revoke-key': revokeKey(target.getAttribute('data-id')); break;
break; }
} });
});
document.getElementById('generateKeyForm').addEventListener('submit', async function(e) {
e.preventDefault();
const keyName = document.getElementById('keyName').value.trim();
const expiresIn = document.getElementById('expiresIn').value;
if (!keyName) {
lt.toast.error('Please enter a key name');
return;
}
try {
const data = await lt.api.post('/api/generate_api_key.php', {
key_name: keyName,
expires_in_days: expiresIn || null
});
document.getElementById('generateKeyForm').addEventListener('submit', function (e) {
e.preventDefault();
var keyName = document.getElementById('keyName').value.trim();
var expiresIn = document.getElementById('expiresIn').value;
if (!keyName) { lt.toast.error('Please enter a key name'); return; }
lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null })
.then(function (data) {
if (data.success) { if (data.success) {
// Show the new key
document.getElementById('newKeyValue').value = data.api_key; document.getElementById('newKeyValue').value = data.api_key;
document.getElementById('newKeyDisplay').classList.remove('is-hidden'); document.getElementById('newKeyDisplay').classList.remove('is-hidden');
document.getElementById('keyName').value = ''; document.getElementById('keyName').value = '';
lt.toast.success('API key generated!');
lt.toast.success('API key generated successfully'); setTimeout(function () { location.reload(); }, 5000);
// Reload page after 5 seconds to show new key in table
setTimeout(() => location.reload(), 5000);
} else { } else {
lt.toast.error(data.error || 'Failed to generate API key'); lt.toast.error(data.error || 'Failed to generate API key');
} }
} catch (error) { }).catch(function (err) { lt.toast.error('Error: ' + err.message); });
lt.toast.error('Error generating API key: ' + error.message); });
}
function copyApiKey() {
var input = document.getElementById('newKeyValue');
input.select();
document.execCommand('copy');
lt.toast.success('Copied to clipboard!');
}
function revokeKey(keyId) {
showConfirmModal('Revoke API Key', 'Revoke this API key? This cannot be undone.', 'error', function () {
lt.api.post('/api/revoke_api_key.php', { key_id: keyId })
.then(function (data) {
if (data.success) { lt.toast.success('API key revoked'); location.reload(); }
else lt.toast.error(data.error || 'Failed to revoke');
}).catch(function (err) { lt.toast.error('Error: ' + err.message); });
}); });
}
function copyApiKey() { if (window.lt) lt.keys.initDefaults();
const keyInput = document.getElementById('newKeyValue'); </script>
keyInput.select();
document.execCommand('copy');
lt.toast.success('API key copied to clipboard');
}
function revokeKey(keyId) { <?php include __DIR__ . '/../../views/layout_footer.php'; ?>
showConfirmModal('Revoke API Key', 'Are you sure you want to revoke this API key? This action cannot be undone.', 'error', function() {
lt.api.post('/api/revoke_api_key.php', { key_id: keyId })
.then(data => {
if (data.success) {
lt.toast.success('API key revoked successfully');
location.reload();
} else {
lt.toast.error(data.error || 'Failed to revoke API key');
}
})
.catch(error => {
lt.toast.error('Error revoking API key: ' + error.message);
});
});
}
</script>
</body>
</html>
+122 -159
View File
@@ -1,166 +1,129 @@
<?php <?php
// Admin view for browsing audit logs
// Receives $auditLogs, $totalPages, $page, $filters from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Audit Log';
$activeNav = 'admin-audit-log';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <div class="lt-page-header">
<head> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta charset="UTF-8"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <span class="lt-text-muted lt-text-xs">/</span>
<title>Audit Log - Admin</title> <span class="lt-text-muted lt-text-xs">Admin: Audit Log</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> </div>
<link rel="stylesheet" href="/assets/css/base.css"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320"> <div class="lt-frame">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <span class="lt-frame-bl">╚</span><span class="lt-frame-br"></span>
<script nonce="<?php echo $nonce; ?>"> <div class="lt-section-header">Audit Log Browser</div>
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; <div class="lt-section-body">
</script>
</head> <!-- Filters -->
<body> <form method="GET" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-mb-md" role="search" aria-label="Filter audit logs">
<div class="user-header"> <div class="lt-form-group" style="margin:0">
<div class="user-header-left"> <label class="lt-label" for="action_type">Action Type</label>
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <select name="action_type" id="action_type" class="lt-select lt-select-sm">
<span class="admin-page-title">Admin: Audit Log</span> <option value="">All Actions</option>
</div> <?php foreach (['create','update','delete','comment','assign','status_change','login','security'] as $a): ?>
<div class="user-header-right"> <option value="<?= $a ?>" <?= ($filters['action_type'] ?? '') === $a ? 'selected' : '' ?>><?= ucfirst(str_replace('_',' ',$a)) ?></option>
<?php if (isset($GLOBALS['currentUser'])): ?> <?php endforeach ?>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> </select>
<span class="admin-badge">[ ADMIN ]</span> </div>
<?php endif; ?> <div class="lt-form-group" style="margin:0">
</div> <label class="lt-label" for="user_id">User</label>
<select name="user_id" id="user_id" class="lt-select lt-select-sm">
<option value="">All Users</option>
<?php if (isset($users)): foreach ($users as $u): ?>
<option value="<?= $u['user_id'] ?>" <?= ($filters['user_id'] ?? '') == $u['user_id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($u['display_name'] ?? $u['username']) ?>
</option>
<?php endforeach; endif ?>
</select>
</div>
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_from">Date From</label>
<input type="date" name="date_from" id="date_from" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($filters['date_from'] ?? '') ?>">
</div>
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($filters['date_to'] ?? '') ?>">
</div>
<div class="lt-form-group lt-flex lt-flex-align-center lt-flex-gap-sm" style="margin:0;align-self:flex-end">
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">FILTER</button>
<a href="?" class="lt-btn lt-btn-ghost lt-btn-sm">RESET</a>
</div>
</form>
<!-- Log table -->
<div class="lt-table-wrap">
<table class="lt-table lt-table-responsive" aria-label="Audit log entries">
<thead>
<tr>
<th scope="col">Timestamp</th>
<th scope="col">User</th>
<th scope="col">Action</th>
<th scope="col">Entity</th>
<th scope="col">Entity ID</th>
<th scope="col">Details</th>
<th scope="col">IP Address</th>
</tr>
</thead>
<tbody>
<?php if (empty($auditLogs)): ?>
<tr><td colspan="7" class="lt-empty">No audit log entries found.</td></tr>
<?php else: foreach ($auditLogs as $log): ?>
<tr>
<td data-label="Timestamp" class="lt-text-xs"><?= date('Y-m-d H:i:s', strtotime($log['created_at'])) ?></td>
<td data-label="User"><?= htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System') ?></td>
<td data-label="Action"><span class="lt-text-amber"><?= htmlspecialchars($log['action_type']) ?></span></td>
<td data-label="Entity" class="lt-text-xs"><?= htmlspecialchars($log['entity_type'] ?? '-') ?></td>
<td data-label="Entity ID" class="lt-text-xs">
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
<a href="/ticket/<?= htmlspecialchars($log['entity_id']) ?>"><?= htmlspecialchars($log['entity_id']) ?></a>
<?php else: ?>
<?= htmlspecialchars($log['entity_id'] ?? '-') ?>
<?php endif ?>
</td>
<td data-label="Details" class="lt-text-xs lt-text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis">
<?php
if ($log['details']) {
$det = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
echo '<code>' . htmlspecialchars(is_array($det) ? json_encode($det) : (string)$log['details']) . '</code>';
} else {
echo '-';
}
?>
</td>
<td data-label="IP" class="lt-text-xs lt-text-muted"><?= htmlspecialchars($log['ip_address'] ?? '-') ?></td>
</tr>
<?php endforeach; endif ?>
</tbody>
</table>
</div> </div>
<div class="ascii-frame-outer admin-container-wide"> <!-- Pagination -->
<span class="bottom-left-corner">╚</span> <?php if (($totalPages ?? 1) > 1): ?>
<span class="bottom-right-corner">╝</span> <div class="lt-pagination" role="navigation">
<?php
<div class="ascii-section-header">Audit Log Browser</div> $params = $_GET;
<div class="ascii-content"> for ($i = 1; $i <= min($totalPages, 10); $i++) {
<div class="ascii-frame-inner"> $params['page'] = $i;
<!-- Filters --> $url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
<form method="GET" class="admin-form-row"> $class = ($i == $page) ? ' lt-btn-primary' : '';
<div class="admin-form-field"> echo "<a href='$url' class='lt-btn lt-btn-sm$class'>$i</a> ";
<label class="admin-label" for="action_type">Action Type</label> }
<select name="action_type" id="action_type" class="admin-input"> if ($totalPages > 10) echo '<span class="lt-text-muted lt-text-xs">&hellip;</span>';
<option value="">All Actions</option> ?>
<option value="create" <?php echo ($filters['action_type'] ?? '') === 'create' ? 'selected' : ''; ?>>Create</option>
<option value="update" <?php echo ($filters['action_type'] ?? '') === 'update' ? 'selected' : ''; ?>>Update</option>
<option value="delete" <?php echo ($filters['action_type'] ?? '') === 'delete' ? 'selected' : ''; ?>>Delete</option>
<option value="comment" <?php echo ($filters['action_type'] ?? '') === 'comment' ? 'selected' : ''; ?>>Comment</option>
<option value="assign" <?php echo ($filters['action_type'] ?? '') === 'assign' ? 'selected' : ''; ?>>Assign</option>
<option value="status_change" <?php echo ($filters['action_type'] ?? '') === 'status_change' ? 'selected' : ''; ?>>Status Change</option>
<option value="login" <?php echo ($filters['action_type'] ?? '') === 'login' ? 'selected' : ''; ?>>Login</option>
<option value="security" <?php echo ($filters['action_type'] ?? '') === 'security' ? 'selected' : ''; ?>>Security</option>
</select>
</div>
<div class="admin-form-field">
<label class="admin-label" for="user_id">User</label>
<select name="user_id" id="user_id" class="admin-input">
<option value="">All Users</option>
<?php if (isset($users)): foreach ($users as $user): ?>
<option value="<?php echo $user['user_id']; ?>" <?php echo ($filters['user_id'] ?? '') == $user['user_id'] ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
</option>
<?php endforeach; endif; ?>
</select>
</div>
<div class="admin-form-field">
<label class="admin-label" for="date_from">Date From</label>
<input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="admin-input">
</div>
<div class="admin-form-field">
<label class="admin-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="admin-input">
</div>
<div class="admin-form-actions">
<button type="submit" class="btn">FILTER</button>
<a href="?" class="btn btn-secondary">RESET</a>
</div>
</form>
<!-- Log Table -->
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>User</th>
<th>Action</th>
<th>Entity</th>
<th>Entity ID</th>
<th>Details</th>
<th>IP Address</th>
</tr>
</thead>
<tbody>
<?php if (empty($auditLogs)): ?>
<tr>
<td colspan="7" class="empty-state">No audit log entries found.</td>
</tr>
<?php else: ?>
<?php foreach ($auditLogs as $log): ?>
<tr>
<td class="nowrap"><?php echo date('Y-m-d H:i:s', strtotime($log['created_at'])); ?></td>
<td><?php echo htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System'); ?></td>
<td>
<span class="text-amber"><?php echo htmlspecialchars($log['action_type']); ?></span>
</td>
<td><?php echo htmlspecialchars($log['entity_type'] ?? '-'); ?></td>
<td>
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
<a href="/ticket/<?php echo htmlspecialchars($log['entity_id']); ?>" class="text-green">
<?php echo htmlspecialchars($log['entity_id']); ?>
</a>
<?php else: ?>
<?php echo htmlspecialchars($log['entity_id'] ?? '-'); ?>
<?php endif; ?>
</td>
<td class="td-truncate">
<?php
if ($log['details']) {
$details = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
if (is_array($details)) {
echo '<code>' . htmlspecialchars(json_encode($details)) . '</code>';
} else {
echo htmlspecialchars($log['details']);
}
} else {
echo '-';
}
?>
</td>
<td class="nowrap text-sm"><?php echo htmlspecialchars($log['ip_address'] ?? '-'); ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Pagination -->
<?php if ($totalPages > 1): ?>
<div class="pagination">
<?php
$params = $_GET;
for ($i = 1; $i <= min($totalPages, 10); $i++) {
$params['page'] = $i;
$activeClass = ($i == $page) ? 'active' : '';
$url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> ";
}
if ($totalPages > 10) {
echo "...";
}
?>
</div>
<?php endif; ?>
</div>
</div>
</div> </div>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script> <?php endif ?>
</body>
</html> </div>
</div>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+226 -260
View File
@@ -1,278 +1,244 @@
<?php <?php
// Admin view for managing custom fields
// Receives $customFields from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Custom Fields';
$activeNav = 'admin-custom-fields';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <div class="lt-page-header">
<head> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta charset="UTF-8"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <span class="lt-text-muted lt-text-xs">/</span>
<title>Custom Fields - Admin</title> <span class="lt-text-muted lt-text-xs">Admin: Custom Fields</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> </div>
<link rel="stylesheet" href="/assets/css/base.css"> <button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW FIELD</button>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <div class="lt-frame">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script> <span class="lt-frame-bl">╚</span><span class="lt-frame-br"></span>
<script nonce="<?php echo $nonce; ?>"> <div class="lt-section-header">Custom Field Definitions</div>
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; <div class="lt-section-body">
</script> <p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
</head> Custom fields extend tickets with additional metadata. Fields appear on the ticket form based on category.
<body> </p>
<div class="user-header"> <div class="lt-table-wrap">
<div class="user-header-left"> <table class="lt-table lt-table-responsive" aria-label="Custom fields">
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <thead>
<span class="admin-page-title">Admin: Custom Fields</span> <tr>
</div> <th scope="col">Order</th>
<div class="user-header-right"> <th scope="col">Field Name</th>
<?php if (isset($GLOBALS['currentUser'])): ?> <th scope="col">Label</th>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> <th scope="col">Type</th>
<span class="admin-badge">[ ADMIN ]</span> <th scope="col">Category</th>
<?php endif; ?> <th scope="col">Required</th>
</div> <th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($customFields)): ?>
<tr><td colspan="8" class="lt-empty">No custom fields defined. Create fields to extend ticket metadata.</td></tr>
<?php else: foreach ($customFields as $field): ?>
<tr>
<td data-label="Order" class="lt-text-xs lt-text-muted"><?= (int)$field['display_order'] ?></td>
<td data-label="Field Name"><code class="lt-text-cyan lt-text-xs"><?= htmlspecialchars($field['field_name']) ?></code></td>
<td data-label="Label"><strong><?= htmlspecialchars($field['field_label']) ?></strong></td>
<td data-label="Type" class="lt-text-xs"><?= ucfirst($field['field_type']) ?></td>
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($field['category'] ?? 'All') ?></td>
<td data-label="Required" style="text-align:center">
<?= $field['is_required'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td>
<td data-label="Status">
<span class="lt-status <?= $field['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
<?= $field['is_active'] ? 'Active' : 'Inactive' ?>
</span>
</td>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-field" data-id="<?= $field['field_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-field" data-id="<?= $field['field_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; endif ?>
</tbody>
</table>
</div> </div>
</div>
</div>
<div class="ascii-frame-outer admin-container"> <!-- Create/Edit Modal -->
<span class="bottom-left-corner">╚</span> <div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog"
<span class="bottom-right-corner">╝</span> aria-modal="true" aria-labelledby="cfModalTitle">
<div class="lt-modal">
<div class="ascii-section-header">Custom Fields Management</div> <div class="lt-modal-header">
<div class="ascii-content"> <span class="lt-modal-title" id="cfModalTitle">Create Custom Field</span>
<div class="ascii-frame-inner"> <button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
<div class="admin-header-row">
<h2>Custom Field Definitions</h2>
<button data-action="show-create-modal" class="btn">+ NEW FIELD</button>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Order</th>
<th>Field Name</th>
<th>Label</th>
<th>Type</th>
<th>Category</th>
<th>Required</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($customFields)): ?>
<tr>
<td colspan="8" class="empty-state">No custom fields defined.</td>
</tr>
<?php else: ?>
<?php foreach ($customFields as $field): ?>
<tr>
<td><?php echo $field['display_order']; ?></td>
<td><code><?php echo htmlspecialchars($field['field_name']); ?></code></td>
<td><?php echo htmlspecialchars($field['field_label']); ?></td>
<td><?php echo ucfirst($field['field_type']); ?></td>
<td><?php echo htmlspecialchars($field['category'] ?? 'All'); ?></td>
<td><?php echo $field['is_required'] ? 'Yes' : 'No'; ?></td>
<td>
<span class="<?php echo $field['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $field['is_active'] ? 'Active' : 'Inactive'; ?>
</span>
</td>
<td>
<button data-action="edit-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="delete-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
<form id="fieldForm">
<!-- Create/Edit Modal --> <input type="hidden" id="field_id" name="field_id">
<div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle"> <div class="lt-modal-body">
<div class="lt-modal lt-modal-sm"> <div class="lt-form-group">
<div class="lt-modal-header"> <label class="lt-label" for="field_name">Field Name * <span class="lt-text-muted lt-text-xs">(internal, lowercase_underscore)</span></label>
<span class="lt-modal-title" id="modalTitle">Create Custom Field</span> <input type="text" id="field_name" name="field_name" class="lt-input" required
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button> pattern="[a-z_]+" placeholder="e.g., server_name">
</div>
<form id="fieldForm">
<input type="hidden" id="field_id" name="field_id">
<div class="lt-modal-body">
<div class="setting-row">
<label for="field_name">Field Name * (internal)</label>
<input type="text" id="field_name" name="field_name" required pattern="[a-z_]+" placeholder="e.g., server_name">
</div>
<div class="setting-row">
<label for="field_label">Field Label * (display)</label>
<input type="text" id="field_label" name="field_label" required placeholder="e.g., Server Name">
</div>
<div class="setting-row">
<label for="field_type">Field Type *</label>
<select id="field_type" name="field_type" required data-action="toggle-options-field">
<option value="text">Text</option>
<option value="textarea">Text Area</option>
<option value="select">Dropdown (Select)</option>
<option value="checkbox">Checkbox</option>
<option value="date">Date</option>
<option value="number">Number</option>
</select>
</div>
<div class="setting-row is-hidden" id="options_row">
<label for="field_options">Options (one per line)</label>
<textarea id="field_options" name="field_options" rows="4" placeholder="Option 1&#10;Option 2&#10;Option 3"></textarea>
</div>
<div class="setting-row">
<label for="category">Category (empty = all)</label>
<select id="category" name="category">
<option value="">All Categories</option>
<option value="General">General</option>
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
</select>
</div>
<div class="setting-row">
<label for="display_order">Display Order</label>
<input type="number" id="display_order" name="display_order" value="0" min="0">
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_required" name="is_required"> Required field</label>
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div> </div>
</div> <div class="lt-form-group">
<label class="lt-label" for="field_label">Field Label * <span class="lt-text-muted lt-text-xs">(display name)</span></label>
<input type="text" id="field_label" name="field_label" class="lt-input" required
placeholder="e.g., Server Name">
</div>
<div class="lt-form-group">
<label class="lt-label" for="field_type">Field Type *</label>
<select id="field_type" name="field_type" class="lt-select" required
data-action="toggle-options-field">
<option value="text">Text</option>
<option value="textarea">Text Area</option>
<option value="select">Dropdown (Select)</option>
<option value="checkbox">Checkbox</option>
<option value="date">Date</option>
<option value="number">Number</option>
</select>
</div>
<div class="lt-form-group is-hidden" id="options_row">
<label class="lt-label" for="field_options">Options <span class="lt-text-muted lt-text-xs">(one per line)</span></label>
<textarea id="field_options" name="field_options" class="lt-input lt-textarea"
rows="4" placeholder="Option 1&#10;Option 2&#10;Option 3"></textarea>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cf-category">Category <span class="lt-text-muted lt-text-xs">(empty = all categories)</span></label>
<select id="cf-category" name="category" class="lt-select">
<option value="">All Categories</option>
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
<option value="<?= $c ?>"><?= $c ?></option>
<?php endforeach ?>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="display_order">Display Order</label>
<input type="number" id="display_order" name="display_order" class="lt-input"
value="0" min="0" style="max-width:8rem">
</div>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="is_required" name="is_required">
Required field
</label>
</div>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="cf_is_active" name="is_active" checked>
Active
</label>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?= $nonce ?>">
function showCreateModal() { document.addEventListener('click', function (e) {
document.getElementById('modalTitle').textContent = 'Create Custom Field'; var target = e.target.closest('[data-action]');
document.getElementById('fieldForm').reset(); if (!target) return;
document.getElementById('field_id').value = ''; switch (target.getAttribute('data-action')) {
document.getElementById('is_active').checked = true; case 'show-create-modal': showCreateModal(); break;
toggleOptionsField(); case 'edit-field': editField(target.getAttribute('data-id')); break;
lt.modal.open('fieldModal'); case 'delete-field': deleteField(target.getAttribute('data-id')); break;
} }
});
function closeModal() { document.addEventListener('change', function (e) {
lt.modal.close('fieldModal'); var target = e.target.closest('[data-action]');
} if (!target) return;
if (target.getAttribute('data-action') === 'toggle-options-field') toggleOptionsField();
});
// Event delegation for data-action handlers document.getElementById('fieldForm').addEventListener('submit', function (e) {
document.addEventListener('click', function(event) { saveField(e);
const target = event.target.closest('[data-action]'); });
if (!target) return;
const action = target.dataset.action; if (window.lt) lt.keys.initDefaults();
switch (action) {
case 'show-create-modal':
showCreateModal();
break;
case 'edit-field':
editField(target.dataset.id);
break;
case 'delete-field':
deleteField(target.dataset.id);
break;
}
});
document.addEventListener('change', function(event) { function toggleOptionsField() {
const target = event.target.closest('[data-action]'); var type = document.getElementById('field_type').value;
if (!target) return; document.getElementById('options_row').classList.toggle('is-hidden', type !== 'select');
}
if (target.dataset.action === 'toggle-options-field') { function showCreateModal() {
document.getElementById('cfModalTitle').textContent = 'Create Custom Field';
document.getElementById('fieldForm').reset();
document.getElementById('field_id').value = '';
document.getElementById('cf_is_active').checked = true;
toggleOptionsField();
lt.modal.open('fieldModal');
}
function editField(id) {
lt.api.get('/api/custom_fields.php?id=' + id)
.then(function (data) {
if (data.success && data.field) {
var f = data.field;
document.getElementById('field_id').value = f.field_id;
document.getElementById('field_name').value = f.field_name;
document.getElementById('field_label').value = f.field_label;
document.getElementById('field_type').value = f.field_type;
document.getElementById('cf-category').value = f.category || '';
document.getElementById('display_order').value = f.display_order;
document.getElementById('is_required').checked = f.is_required == 1;
document.getElementById('cf_is_active').checked = f.is_active == 1;
toggleOptionsField(); toggleOptionsField();
} if (f.field_options && f.field_options.options) {
}); document.getElementById('field_options').value = f.field_options.options.join('\n');
// Form submit handler
document.getElementById('fieldForm').addEventListener('submit', function(e) {
saveField(e);
});
if (window.lt) lt.keys.initDefaults();
function toggleOptionsField() {
const type = document.getElementById('field_type').value;
document.getElementById('options_row').classList.toggle('is-hidden', type !== 'select');
}
function saveField(e) {
e.preventDefault();
const form = document.getElementById('fieldForm');
const data = {
field_id: document.getElementById('field_id').value,
field_name: document.getElementById('field_name').value,
field_label: document.getElementById('field_label').value,
field_type: document.getElementById('field_type').value,
category: document.getElementById('category').value || null,
display_order: parseInt(document.getElementById('display_order').value) || 0,
is_required: document.getElementById('is_required').checked ? 1 : 0,
is_active: document.getElementById('is_active').checked ? 1 : 0
};
if (data.field_type === 'select') {
const options = document.getElementById('field_options').value.split('\n').filter(o => o.trim());
data.field_options = { options: options };
}
const url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
const apiCall = data.field_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
lt.toast.error(result.error || 'Failed to save');
} }
}).catch(err => lt.toast.error('Failed to save')); document.getElementById('cfModalTitle').textContent = 'Edit Custom Field';
} lt.modal.open('fieldModal');
}
});
}
function editField(id) { function deleteField(id) {
lt.api.get('/api/custom_fields.php?id=' + id) showConfirmModal('Delete Custom Field', 'Delete this custom field? All values will be lost.', 'error', function () {
.then(data => { lt.api.delete('/api/custom_fields.php?id=' + id)
if (data.success && data.field) { .then(function (data) {
const f = data.field; if (data.success) window.location.reload();
document.getElementById('field_id').value = f.field_id; else lt.toast.error(data.error || 'Failed to delete');
document.getElementById('field_name').value = f.field_name; }).catch(function () { lt.toast.error('Failed to delete'); });
document.getElementById('field_label').value = f.field_label; });
document.getElementById('field_type').value = f.field_type; }
document.getElementById('category').value = f.category || '';
document.getElementById('display_order').value = f.display_order;
document.getElementById('is_required').checked = f.is_required == 1;
document.getElementById('is_active').checked = f.is_active == 1;
toggleOptionsField();
if (f.field_options && f.field_options.options) {
document.getElementById('field_options').value = f.field_options.options.join('\n');
}
document.getElementById('modalTitle').textContent = 'Edit Custom Field';
lt.modal.open('fieldModal');
}
});
}
function deleteField(id) { function saveField(e) {
showConfirmModal('Delete Custom Field', 'Delete this custom field? All values will be lost.', 'error', function() { e.preventDefault();
lt.api.delete('/api/custom_fields.php?id=' + id) var data = {
.then(data => { field_id: document.getElementById('field_id').value,
if (data.success) window.location.reload(); field_name: document.getElementById('field_name').value,
else lt.toast.error(data.error || 'Failed to delete'); field_label: document.getElementById('field_label').value,
}).catch(err => lt.toast.error('Failed to delete')); field_type: document.getElementById('field_type').value,
}); category: document.getElementById('cf-category').value || null,
} display_order: parseInt(document.getElementById('display_order').value) || 0,
</script> is_required: document.getElementById('is_required').checked ? 1 : 0,
</body> is_active: document.getElementById('cf_is_active').checked ? 1 : 0,
</html> };
if (data.field_type === 'select') {
var opts = document.getElementById('field_options').value.split('\n').filter(function (o) { return o.trim(); });
data.field_options = { options: opts };
}
var url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
var apiCall = data.field_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
</script>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+276 -325
View File
@@ -1,346 +1,297 @@
<?php <?php
// Admin view for managing recurring tickets
// Receives $recurringTickets from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Recurring Tickets';
$activeNav = 'admin-recurring';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recurring Tickets - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">[ DASHBOARD ]</a>
<span class="admin-page-title">Admin: Recurring Tickets</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer admin-container"> <div class="lt-page-header">
<span class="bottom-left-corner"></span> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<span class="bottom-right-corner"></span> <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">Admin: Recurring Tickets</span>
</div>
<button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW RECURRING TICKET</button>
</div>
<div class="ascii-section-header">Recurring Tickets Management</div> <div class="lt-frame">
<div class="ascii-content"> <span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<div class="ascii-frame-inner"> <div class="lt-section-header">Scheduled Tickets</div>
<div class="admin-header-row"> <div class="lt-section-body">
<h2>Scheduled Tickets</h2> <p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
<button data-action="show-create-modal" class="btn">+ NEW RECURRING TICKET</button> Recurring tickets are automatically created on a schedule. Use <code>{{date}}</code>, <code>{{month}}</code>, <code>{{year}}</code> in title templates.
</div> </p>
<div class="lt-table-wrap">
<div class="table-wrapper"> <table class="lt-table lt-table-responsive" aria-label="Recurring tickets">
<table> <thead>
<thead> <tr>
<tr> <th scope="col">Title Template</th>
<th>ID</th> <th scope="col">Schedule</th>
<th>Title Template</th> <th scope="col">Category</th>
<th>Schedule</th> <th scope="col">Assigned To</th>
<th>Category</th> <th scope="col">Next Run</th>
<th>Assigned To</th> <th scope="col">Status</th>
<th>Next Run</th> <th scope="col">Actions</th>
<th>Status</th> </tr>
<th>Actions</th> </thead>
</tr> <tbody>
</thead> <?php if (empty($recurringTickets)): ?>
<tbody> <tr><td colspan="7" class="lt-empty">No recurring tickets configured. Create schedules to auto-generate tickets.</td></tr>
<?php if (empty($recurringTickets)): ?> <?php else: foreach ($recurringTickets as $rt): ?>
<tr> <?php
<td colspan="8" class="empty-state">No recurring tickets configured.</td> $schedule = ucfirst($rt['schedule_type']);
</tr> if ($rt['schedule_type'] === 'weekly') {
<?php else: ?> $days = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
<?php foreach ($recurringTickets as $rt): ?> $schedule .= ' (' . ($days[$rt['schedule_day']] ?? '?') . ')';
<tr> } elseif ($rt['schedule_type'] === 'monthly') {
<td><?php echo $rt['recurring_id']; ?></td> $schedule .= ' (Day ' . $rt['schedule_day'] . ')';
<td><?php echo htmlspecialchars($rt['title_template']); ?></td>
<td>
<?php
$schedule = ucfirst($rt['schedule_type']);
if ($rt['schedule_type'] === 'weekly') {
$days = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
$schedule .= ' (' . ($days[$rt['schedule_day']] ?? '?') . ')';
} elseif ($rt['schedule_type'] === 'monthly') {
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
}
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
echo htmlspecialchars($schedule);
?>
</td>
<td><?php echo htmlspecialchars($rt['category']); ?></td>
<td><?php echo htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned'); ?></td>
<td class="nowrap"><?php echo date('M d, Y H:i', strtotime($rt['next_run_at'])); ?></td>
<td>
<span class="<?php echo $rt['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $rt['is_active'] ? 'Active' : 'Inactive'; ?>
</span>
</td>
<td>
<button data-action="edit-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="toggle-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">
<?php echo $rt['is_active'] ? 'DISABLE' : 'ENABLE'; ?>
</button>
<button data-action="delete-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal lt-modal-lg">
<div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Recurring Ticket</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div>
<form id="recurringForm">
<input type="hidden" id="recurring_id" name="recurring_id">
<div class="lt-modal-body">
<div class="setting-row">
<label for="title_template">Title Template *</label>
<input type="text" id="title_template" name="title_template" required placeholder="Use {{date}}, {{month}}, etc.">
</div>
<div class="setting-row">
<label for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" rows="8"></textarea>
</div>
<div class="setting-row">
<label for="schedule_type">Schedule Type *</label>
<select id="schedule_type" name="schedule_type" required data-action="update-schedule-options">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div class="setting-row is-hidden" id="schedule_day_row">
<label for="schedule_day">Schedule Day</label>
<select id="schedule_day" name="schedule_day"></select>
</div>
<div class="setting-row">
<label for="schedule_time">Schedule Time *</label>
<input type="time" id="schedule_time" name="schedule_time" value="09:00" required>
</div>
<div class="setting-grid-2">
<div class="setting-row setting-row-compact">
<label for="category">Category</label>
<select id="category" name="category">
<option value="General">General</option>
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="type">Type</label>
<select id="type" name="type">
<option value="Issue">Issue</option>
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Problem">Problem</option>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="priority">Priority</label>
<select id="priority" name="priority">
<option value="1">P1 - Critical</option>
<option value="2">P2 - High</option>
<option value="3">P3 - Medium</option>
<option value="4" selected>P4 - Low</option>
<option value="5">P5 - Lowest</option>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="assigned_to">Assign To</label>
<select id="assigned_to" name="assigned_to">
<option value="">Unassigned</option>
<!-- Populated by JavaScript -->
</select>
</div>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>">
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
document.getElementById('recurringForm').reset();
document.getElementById('recurring_id').value = '';
updateScheduleOptions();
lt.modal.open('recurringModal');
}
function closeModal() {
lt.modal.close('recurringModal');
}
// Event delegation for data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'show-create-modal':
showCreateModal();
break;
case 'edit-recurring':
editRecurring(target.dataset.id);
break;
case 'toggle-recurring':
toggleRecurring(target.dataset.id);
break;
case 'delete-recurring':
deleteRecurring(target.dataset.id);
break;
} }
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
?>
<tr>
<td data-label="Title"><strong><?= htmlspecialchars($rt['title_template']) ?></strong></td>
<td data-label="Schedule" class="lt-text-xs lt-text-cyan"><?= htmlspecialchars($schedule) ?></td>
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($rt['category']) ?></td>
<td data-label="Assigned To" class="lt-text-xs">
<?= htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned') ?>
</td>
<td data-label="Next Run" class="lt-text-xs lt-text-muted">
<?= date('M d, Y H:i', strtotime($rt['next_run_at'])) ?>
</td>
<td data-label="Status">
<span class="lt-status <?= $rt['is_active'] ? 'lt-status-open' : 'lt-status-closed' ?>">
<?= $rt['is_active'] ? 'Active' : 'Inactive' ?>
</span>
</td>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-recurring" data-id="<?= $rt['recurring_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm"
data-action="toggle-recurring" data-id="<?= $rt['recurring_id'] ?>">
<?= $rt['is_active'] ? 'PAUSE' : 'RESUME' ?>
</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-recurring" data-id="<?= $rt['recurring_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; endif ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog"
aria-modal="true" aria-labelledby="recModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="recModalTitle">Create Recurring Ticket</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="recurringForm">
<input type="hidden" id="recurring_id" name="recurring_id">
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label" for="rec_title_template">Title Template *</label>
<input type="text" id="rec_title_template" name="title_template" class="lt-input" required
placeholder="Use {{date}}, {{month}}, {{year}}">
</div>
<div class="lt-form-group">
<label class="lt-label" for="rec_description_template">Description Template</label>
<textarea id="rec_description_template" name="description_template"
class="lt-input lt-textarea" rows="6"></textarea>
</div>
<div class="lt-form-group">
<label class="lt-label" for="schedule_type">Schedule Type *</label>
<select id="schedule_type" name="schedule_type" class="lt-select" required
data-action="update-schedule-options">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div class="lt-form-group is-hidden" id="schedule_day_row">
<label class="lt-label" for="schedule_day">Schedule Day</label>
<select id="schedule_day" name="schedule_day" class="lt-select"></select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="schedule_time">Schedule Time *</label>
<input type="time" id="schedule_time" name="schedule_time" class="lt-input"
value="09:00" required style="max-width:12rem">
</div>
<div class="create-ticket-meta-grid">
<div class="lt-form-group">
<label class="lt-label" for="rec-category">Category</label>
<select id="rec-category" name="category" class="lt-select">
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
<option value="<?= $c ?>"><?= $c ?></option>
<?php endforeach ?>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="rec-type">Type</label>
<select id="rec-type" name="type" class="lt-select">
<?php foreach (['Issue','Maintenance','Install','Task','Upgrade','Problem'] as $t): ?>
<option value="<?= $t ?>"><?= $t ?></option>
<?php endforeach ?>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="rec-priority">Priority</label>
<select id="rec-priority" name="priority" class="lt-select">
<option value="1">P1 Critical</option>
<option value="2">P2 High</option>
<option value="3">P3 Medium</option>
<option value="4" selected>P4 Low</option>
<option value="5">P5 Lowest</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="assigned_to">Assign To</label>
<select id="assigned_to" name="assigned_to" class="lt-select">
<option value="">Unassigned</option>
<!-- Populated by JS -->
</select>
</div>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?= $nonce ?>">
document.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
switch (target.getAttribute('data-action')) {
case 'show-create-modal': showCreateModal(); break;
case 'edit-recurring': editRecurring(target.getAttribute('data-id')); break;
case 'toggle-recurring': toggleRecurring(target.getAttribute('data-id')); break;
case 'delete-recurring': deleteRecurring(target.getAttribute('data-id')); break;
}
});
document.addEventListener('change', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;
if (target.getAttribute('data-action') === 'update-schedule-options') updateScheduleOptions();
});
document.getElementById('recurringForm').addEventListener('submit', function (e) {
saveRecurring(e);
});
if (window.lt) lt.keys.initDefaults();
function updateScheduleOptions() {
var type = document.getElementById('schedule_type').value;
var dayRow = document.getElementById('schedule_day_row');
var daySelect = document.getElementById('schedule_day');
daySelect.innerHTML = '';
if (type === 'daily') {
dayRow.classList.add('is-hidden');
} else if (type === 'weekly') {
dayRow.classList.remove('is-hidden');
['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'].forEach(function (day, i) {
daySelect.innerHTML += '<option value="' + (i + 1) + '">' + day + '</option>';
}); });
} else if (type === 'monthly') {
dayRow.classList.remove('is-hidden');
for (var i = 1; i <= 28; i++) {
daySelect.innerHTML += '<option value="' + i + '">Day ' + i + '</option>';
}
}
}
document.addEventListener('change', function(event) { function showCreateModal() {
const target = event.target.closest('[data-action]'); document.getElementById('recModalTitle').textContent = 'Create Recurring Ticket';
if (!target) return; document.getElementById('recurringForm').reset();
document.getElementById('recurring_id').value = '';
updateScheduleOptions();
lt.modal.open('recurringModal');
}
if (target.dataset.action === 'update-schedule-options') { function editRecurring(id) {
lt.api.get('/api/manage_recurring.php?id=' + id)
.then(function (data) {
if (data.success && data.recurring) {
var rt = data.recurring;
document.getElementById('recurring_id').value = rt.recurring_id;
document.getElementById('rec_title_template').value = rt.title_template;
document.getElementById('rec_description_template').value = rt.description_template || '';
document.getElementById('schedule_type').value = rt.schedule_type;
updateScheduleOptions(); updateScheduleOptions();
document.getElementById('schedule_day').value = rt.schedule_day || '';
document.getElementById('schedule_time').value = rt.schedule_time ? rt.schedule_time.substring(0, 5) : '09:00';
document.getElementById('rec-category').value = rt.category || 'General';
document.getElementById('rec-type').value = rt.type || 'Issue';
document.getElementById('rec-priority').value = rt.priority || 4;
document.getElementById('assigned_to').value = rt.assigned_to || '';
document.getElementById('recModalTitle').textContent = 'Edit Recurring Ticket';
lt.modal.open('recurringModal');
} }
}); });
}
// Form submit handler function toggleRecurring(id) {
document.getElementById('recurringForm').addEventListener('submit', function(e) { lt.api.post('/api/manage_recurring.php?action=toggle&id=' + id)
saveRecurring(e); .then(function (data) {
}); if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to toggle');
}).catch(function () { lt.toast.error('Failed to toggle'); });
}
if (window.lt) lt.keys.initDefaults(); function deleteRecurring(id) {
showConfirmModal('Delete Schedule', 'Delete this recurring ticket schedule? This cannot be undone.', 'error', function () {
function updateScheduleOptions() { lt.api.delete('/api/manage_recurring.php?id=' + id)
const type = document.getElementById('schedule_type').value; .then(function (data) {
const dayRow = document.getElementById('schedule_day_row');
const daySelect = document.getElementById('schedule_day');
daySelect.innerHTML = '';
if (type === 'daily') {
dayRow.classList.add('is-hidden');
} else if (type === 'weekly') {
dayRow.classList.remove('is-hidden');
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
days.forEach((day, i) => {
daySelect.innerHTML += `<option value="${i + 1}">${day}</option>`;
});
} else if (type === 'monthly') {
dayRow.classList.remove('is-hidden');
for (let i = 1; i <= 28; i++) {
daySelect.innerHTML += `<option value="${i}">Day ${i}</option>`;
}
}
}
function saveRecurring(e) {
e.preventDefault();
const form = new FormData(document.getElementById('recurringForm'));
const data = Object.fromEntries(form);
const url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
const apiCall = data.recurring_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
lt.toast.error(result.error || 'Failed to save');
}
}).catch(err => lt.toast.error('Failed to save'));
}
function toggleRecurring(id) {
lt.api.post('/api/manage_recurring.php?action=toggle&id=' + id)
.then(data => {
if (data.success) window.location.reload(); if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to toggle'); else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to toggle')); }).catch(function () { lt.toast.error('Failed to delete'); });
} });
}
function deleteRecurring(id) { function saveRecurring(e) {
showConfirmModal('Delete Schedule', 'Delete this recurring ticket schedule?', 'error', function() { e.preventDefault();
lt.api.delete('/api/manage_recurring.php?id=' + id) var form = new FormData(document.getElementById('recurringForm'));
.then(data => { var data = {};
if (data.success) window.location.reload(); form.forEach(function (v, k) { data[k] = v; });
else lt.toast.error(data.error || 'Failed to delete'); var url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
}).catch(err => lt.toast.error('Failed to delete')); var apiCall = data.recurring_id ? lt.api.put(url, data) : lt.api.post(url, data);
}); apiCall.then(function (result) {
} if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
function editRecurring(id) { function loadUsers() {
lt.api.get('/api/manage_recurring.php?id=' + id) lt.api.get('/api/get_users.php')
.then(data => { .then(function (data) {
if (data.success && data.recurring) { if (data.success && data.users) {
const rt = data.recurring; var select = document.getElementById('assigned_to');
document.getElementById('recurring_id').value = rt.recurring_id; data.users.forEach(function (user) {
document.getElementById('title_template').value = rt.title_template; var opt = document.createElement('option');
document.getElementById('description_template').value = rt.description_template || ''; opt.value = user.user_id;
document.getElementById('schedule_type').value = rt.schedule_type; opt.textContent = user.display_name || user.username;
updateScheduleOptions(); select.appendChild(opt);
document.getElementById('schedule_day').value = rt.schedule_day || '';
document.getElementById('schedule_time').value = rt.schedule_time ? rt.schedule_time.substring(0, 5) : '09:00';
document.getElementById('category').value = rt.category || 'General';
document.getElementById('type').value = rt.type || 'Issue';
document.getElementById('priority').value = rt.priority || 4;
document.getElementById('assigned_to').value = rt.assigned_to || '';
document.getElementById('modalTitle').textContent = 'Edit Recurring Ticket';
lt.modal.open('recurringModal');
}
}); });
} }
});
}
// Load users for assignee dropdown updateScheduleOptions();
function loadUsers() { loadUsers();
lt.api.get('/api/get_users.php') </script>
.then(data => {
if (data.success && data.users) {
const select = document.getElementById('assigned_to');
data.users.forEach(user => {
const option = document.createElement('option');
option.value = user.user_id;
option.textContent = user.display_name || user.username;
select.appendChild(option);
});
}
});
}
// Initialize <?php include __DIR__ . '/../../views/layout_footer.php'; ?>
updateScheduleOptions();
loadUsers();
</script>
</body>
</html>
+194 -241
View File
@@ -1,258 +1,211 @@
<?php <?php
// Admin view for managing ticket templates
// Receives $templates from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Templates';
$activeNav = 'admin-templates';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <div class="lt-page-header">
<head> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta charset="UTF-8"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <span class="lt-text-muted lt-text-xs">/</span>
<title>Template Management - Admin</title> <span class="lt-text-muted lt-text-xs">Admin: Templates</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> </div>
<link rel="stylesheet" href="/assets/css/base.css"> <button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW TEMPLATE</button>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <div class="lt-frame">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script> <span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<script nonce="<?php echo $nonce; ?>"> <div class="lt-section-header">Ticket Template Management</div>
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; <div class="lt-section-body">
</script> <p class="lt-text-sm lt-text-muted" style="margin-bottom:0.75rem">
</head> Templates pre-fill ticket creation forms with standard content for common ticket types.
<body> </p>
<div class="user-header"> <div class="lt-table-wrap">
<div class="user-header-left"> <table class="lt-table lt-table-responsive" aria-label="Ticket templates">
<a href="/" class="back-link">[ DASHBOARD ]</a> <thead>
<span class="admin-page-title">Admin: Templates</span> <tr>
</div> <th scope="col">Template Name</th>
<div class="user-header-right"> <th scope="col">Category</th>
<?php if (isset($GLOBALS['currentUser'])): ?> <th scope="col">Type</th>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> <th scope="col">Priority</th>
<span class="admin-badge">[ ADMIN ]</span> <th scope="col">Status</th>
<?php endif; ?> <th scope="col">Actions</th>
</div> </tr>
</thead>
<tbody>
<?php if (empty($templates)): ?>
<tr><td colspan="6" class="lt-empty">No templates defined. Create templates to speed up ticket creation.</td></tr>
<?php else: foreach ($templates as $tpl): ?>
<tr>
<td data-label="Name"><strong><?= htmlspecialchars($tpl['template_name']) ?></strong></td>
<td data-label="Category" class="lt-text-xs"><?= htmlspecialchars($tpl['category'] ?? 'Any') ?></td>
<td data-label="Type" class="lt-text-xs"><?= htmlspecialchars($tpl['type'] ?? 'Any') ?></td>
<td data-label="Priority" class="lt-text-xs"><span class="lt-p<?= $tpl['default_priority'] ?? 4 ?>">P<?= $tpl['default_priority'] ?? 4 ?></span></td>
<td data-label="Status">
<span class="lt-status <?= ($tpl['is_active'] ?? 1) ? 'lt-status-open' : 'lt-status-closed' ?>">
<?= ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive' ?>
</span>
</td>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-template" data-id="<?= $tpl['template_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-template" data-id="<?= $tpl['template_id'] ?>">DEL</button>
</div>
</td>
</tr>
<?php endforeach; endif ?>
</tbody>
</table>
</div> </div>
</div>
</div>
<div class="ascii-frame-outer admin-container"> <!-- Create/Edit Modal -->
<span class="bottom-left-corner"></span> <div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog"
<span class="bottom-right-corner"></span> aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal">
<div class="ascii-section-header">Ticket Template Management</div> <div class="lt-modal-header">
<div class="ascii-content"> <span class="lt-modal-title" id="modalTitle">Create Template</span>
<div class="ascii-frame-inner"> <button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
<div class="admin-header-row">
<h2>Ticket Templates</h2>
<button data-action="show-create-modal" class="btn">+ NEW TEMPLATE</button>
</div>
<p class="text-muted-green mb-1">
Templates pre-fill ticket creation forms with standard content for common ticket types.
</p>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Template Name</th>
<th>Category</th>
<th>Type</th>
<th>Priority</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($templates)): ?>
<tr>
<td colspan="6" class="empty-state">No templates defined. Create templates to speed up ticket creation.</td>
</tr>
<?php else: ?>
<?php foreach ($templates as $tpl): ?>
<tr>
<td><strong><?php echo htmlspecialchars($tpl['template_name']); ?></strong></td>
<td><?php echo htmlspecialchars($tpl['category'] ?? 'Any'); ?></td>
<td><?php echo htmlspecialchars($tpl['type'] ?? 'Any'); ?></td>
<td>P<?php echo $tpl['default_priority'] ?? '4'; ?></td>
<td>
<span class="<?php echo ($tpl['is_active'] ?? 1) ? 'text-open' : 'text-closed'; ?>">
<?php echo ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive'; ?>
</span>
</td>
<td>
<button data-action="edit-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="delete-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
<form id="templateForm">
<!-- Create/Edit Modal --> <input type="hidden" id="template_id" name="template_id">
<div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle"> <div class="lt-modal-body">
<div class="lt-modal lt-modal-lg"> <div class="lt-form-group">
<div class="lt-modal-header"> <label class="lt-label" for="template_name">Template Name *</label>
<span class="lt-modal-title" id="modalTitle">Create Template</span> <input type="text" id="template_name" name="template_name" class="lt-input" required>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div>
<form id="templateForm">
<input type="hidden" id="template_id" name="template_id">
<div class="lt-modal-body">
<div class="setting-row">
<label for="template_name">Template Name *</label>
<input type="text" id="template_name" name="template_name" required>
</div>
<div class="setting-row">
<label for="title_template">Title Template</label>
<input type="text" id="title_template" name="title_template" placeholder="Pre-filled title text">
</div>
<div class="setting-row">
<label for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" rows="10" placeholder="Pre-filled description content"></textarea>
</div>
<div class="setting-grid-3">
<div class="setting-row setting-row-compact">
<label for="category">Category</label>
<select id="category" name="category">
<option value="">Any</option>
<option value="General">General</option>
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
<option value="Security">Security</option>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="type">Type</label>
<select id="type" name="type">
<option value="">Any</option>
<option value="Maintenance">Maintenance</option>
<option value="Install">Install</option>
<option value="Task">Task</option>
<option value="Upgrade">Upgrade</option>
<option value="Issue">Issue</option>
<option value="Problem">Problem</option>
</select>
</div>
<div class="setting-row setting-row-compact">
<label for="priority">Priority</label>
<select id="priority" name="priority">
<option value="1">P1</option>
<option value="2">P2</option>
<option value="3">P3</option>
<option value="4" selected>P4</option>
<option value="5">P5</option>
</select>
</div>
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div> </div>
</div> <div class="lt-form-group">
<label class="lt-label" for="title_template">Title Template</label>
<input type="text" id="title_template" name="title_template" class="lt-input"
placeholder="Pre-filled title text">
</div>
<div class="lt-form-group">
<label class="lt-label" for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" class="lt-input lt-textarea"
rows="10" placeholder="Pre-filled description content"></textarea>
</div>
<div class="create-ticket-meta-grid">
<div class="lt-form-group">
<label class="lt-label" for="tpl-category">Category</label>
<select id="tpl-category" name="category" class="lt-select">
<option value="">Any</option>
<?php foreach (['General','Hardware','Software','Network','Security'] as $c): ?>
<option value="<?= $c ?>"><?= $c ?></option>
<?php endforeach ?>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="tpl-type">Type</label>
<select id="tpl-type" name="type" class="lt-select">
<option value="">Any</option>
<?php foreach (['Maintenance','Install','Task','Upgrade','Issue','Problem'] as $t): ?>
<option value="<?= $t ?>"><?= $t ?></option>
<?php endforeach ?>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="tpl-priority">Priority</label>
<select id="tpl-priority" name="priority" class="lt-select">
<?php foreach ([1=>'P1',2=>'P2',3=>'P3',4=>'P4 (default)',5=>'P5'] as $v=>$l): ?>
<option value="<?= $v ?>" <?= $v === 4 ? 'selected' : '' ?>><?= $l ?></option>
<?php endforeach ?>
</select>
</div>
</div>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="is_active" name="is_active" checked>
Active
</label>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?= $nonce ?>">
const templates = <?php echo json_encode($templates ?? []); ?>; var templates = <?= json_encode($templates ?? []) ?>;
function showCreateModal() { document.addEventListener('click', function (e) {
document.getElementById('modalTitle').textContent = 'Create Template'; var target = e.target.closest('[data-action]');
document.getElementById('templateForm').reset(); if (!target) return;
document.getElementById('template_id').value = ''; switch (target.getAttribute('data-action')) {
document.getElementById('is_active').checked = true; case 'show-create-modal': showCreateModal(); break;
lt.modal.open('templateModal'); case 'edit-template': editTemplate(target.getAttribute('data-id')); break;
} case 'delete-template': deleteTemplate(target.getAttribute('data-id')); break;
}
});
function closeModal() { document.getElementById('templateForm').addEventListener('submit', function (e) {
lt.modal.close('templateModal'); saveTemplate(e);
} });
// Event delegation for data-action handlers if (window.lt) lt.keys.initDefaults();
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action; function showCreateModal() {
switch (action) { document.getElementById('modalTitle').textContent = 'Create Template';
case 'show-create-modal': document.getElementById('templateForm').reset();
showCreateModal(); document.getElementById('template_id').value = '';
break; document.getElementById('is_active').checked = true;
case 'edit-template': lt.modal.open('templateModal');
editTemplate(target.dataset.id); }
break;
case 'delete-template':
deleteTemplate(target.dataset.id);
break;
}
});
// Form submit handler function editTemplate(id) {
document.getElementById('templateForm').addEventListener('submit', function(e) { var tpl = templates.find(function (t) { return t.template_id == id; });
saveTemplate(e); if (!tpl) return;
}); document.getElementById('template_id').value = tpl.template_id;
document.getElementById('template_name').value = tpl.template_name;
document.getElementById('title_template').value = tpl.title_template || '';
document.getElementById('description_template').value = tpl.description_template || '';
document.getElementById('tpl-category').value = tpl.category || '';
document.getElementById('tpl-type').value = tpl.type || '';
document.getElementById('tpl-priority').value = tpl.default_priority || 4;
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
document.getElementById('modalTitle').textContent = 'Edit Template';
lt.modal.open('templateModal');
}
if (window.lt) lt.keys.initDefaults(); function deleteTemplate(id) {
showConfirmModal('Delete Template', 'Delete this template? This cannot be undone.', 'error', function () {
lt.api.delete('/api/manage_templates.php?id=' + id)
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(function () { lt.toast.error('Failed to delete'); });
});
}
function saveTemplate(e) { function saveTemplate(e) {
e.preventDefault(); e.preventDefault();
const data = { var data = {
template_id: document.getElementById('template_id').value, template_id: document.getElementById('template_id').value,
template_name: document.getElementById('template_name').value, template_name: document.getElementById('template_name').value,
title_template: document.getElementById('title_template').value, title_template: document.getElementById('title_template').value,
description_template: document.getElementById('description_template').value, description_template: document.getElementById('description_template').value,
category: document.getElementById('category').value || null, category: document.getElementById('tpl-category').value || null,
type: document.getElementById('type').value || null, type: document.getElementById('tpl-type').value || null,
default_priority: parseInt(document.getElementById('priority').value) || 4, default_priority: parseInt(document.getElementById('tpl-priority').value) || 4,
is_active: document.getElementById('is_active').checked ? 1 : 0 is_active: document.getElementById('is_active').checked ? 1 : 0,
}; };
var url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
var apiCall = data.template_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
</script>
const url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : ''); <?php include __DIR__ . '/../../views/layout_footer.php'; ?>
const apiCall = data.template_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
lt.toast.error(result.error || 'Failed to save');
}
}).catch(err => lt.toast.error('Failed to save'));
}
function editTemplate(id) {
const tpl = templates.find(t => t.template_id == id);
if (!tpl) return;
document.getElementById('template_id').value = tpl.template_id;
document.getElementById('template_name').value = tpl.template_name;
document.getElementById('title_template').value = tpl.title_template || '';
document.getElementById('description_template').value = tpl.description_template || '';
document.getElementById('category').value = tpl.category || '';
document.getElementById('type').value = tpl.type || '';
document.getElementById('priority').value = tpl.default_priority || 4;
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
document.getElementById('modalTitle').textContent = 'Edit Template';
lt.modal.open('templateModal');
}
function deleteTemplate(id) {
showConfirmModal('Delete Template', 'Delete this template?', 'error', function() {
lt.api.delete('/api/manage_templates.php?id=' + id)
.then(data => {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
});
}
</script>
</body>
</html>
+107 -126
View File
@@ -1,135 +1,116 @@
<?php <?php
// Admin view for user activity reports
// Receives $userStats, $dateRange from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'User Activity';
$activeNav = 'admin-user-activity';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <div class="lt-page-header">
<head> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta charset="UTF-8"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <span class="lt-text-muted lt-text-xs">/</span>
<title>User Activity - Admin</title> <span class="lt-text-muted lt-text-xs">Admin: User Activity</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> </div>
<link rel="stylesheet" href="/assets/css/base.css"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320"> <div class="lt-frame">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<script nonce="<?php echo $nonce; ?>"> <div class="lt-section-header">User Activity Report</div>
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; <div class="lt-section-body">
</script>
</head> <!-- Date filter -->
<body> <form method="GET" class="lt-flex lt-flex-wrap lt-flex-gap-sm lt-mb-md" role="search">
<div class="user-header"> <div class="lt-form-group" style="margin:0">
<div class="user-header-left"> <label class="lt-label" for="date_from">Date From</label>
<a href="/" class="back-link">[ DASHBOARD ]</a> <input type="date" name="date_from" id="date_from" class="lt-input lt-input-sm"
<span class="admin-page-title">Admin: User Activity</span> value="<?= htmlspecialchars($dateRange['from'] ?? '') ?>">
</div>
<div class="lt-form-group" style="margin:0">
<label class="lt-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" class="lt-input lt-input-sm"
value="<?= htmlspecialchars($dateRange['to'] ?? '') ?>">
</div>
<div class="lt-flex lt-flex-gap-sm lt-flex-align-center" style="align-self:flex-end">
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">APPLY</button>
<a href="?" class="lt-btn lt-btn-ghost lt-btn-sm">RESET</a>
</div>
</form>
<!-- Summary stats -->
<?php if (!empty($userStats)): ?>
<div class="lt-stats-grid lt-mb-md">
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-cyan">[ # ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= count($userStats) ?></div>
<div class="lt-stat-label">Active Users</div>
</div> </div>
<div class="user-header-right"> </div>
<?php if (isset($GLOBALS['currentUser'])): ?> <div class="lt-stat-card">
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> <div class="lt-stat-icon">[ + ]</div>
<span class="admin-badge">[ ADMIN ]</span> <div class="lt-stat-info">
<?php endif; ?> <div class="lt-stat-value"><?= array_sum(array_column($userStats, 'tickets_created')) ?></div>
<div class="lt-stat-label">Total Created</div>
</div> </div>
</div>
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-muted">[ OK ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= array_sum(array_column($userStats, 'tickets_resolved')) ?></div>
<div class="lt-stat-label">Total Resolved</div>
</div>
</div>
<div class="lt-stat-card">
<div class="lt-stat-icon lt-text-amber">[ > ]</div>
<div class="lt-stat-info">
<div class="lt-stat-value"><?= array_sum(array_column($userStats, 'comments_added')) ?></div>
<div class="lt-stat-label">Total Comments</div>
</div>
</div>
</div> </div>
<?php endif ?>
<div class="ascii-frame-outer admin-container"> <!-- User activity table -->
<span class="bottom-left-corner"></span> <div class="lt-table-wrap">
<span class="bottom-right-corner"></span> <table class="lt-table lt-table-responsive" aria-label="User activity">
<thead>
<div class="ascii-section-header">User Activity Report</div> <tr>
<div class="ascii-content"> <th scope="col">User</th>
<div class="ascii-frame-inner"> <th scope="col">Tickets Created</th>
<!-- Date Range Filter --> <th scope="col">Tickets Resolved</th>
<form method="GET" class="admin-form-row"> <th scope="col">Comments</th>
<div class="admin-form-field"> <th scope="col">Assigned</th>
<label class="admin-label" for="date_from">Date From</label> <th scope="col">Last Activity</th>
<input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="admin-input"> </tr>
</div> </thead>
<div class="admin-form-field"> <tbody>
<label class="admin-label" for="date_to">Date To</label> <?php if (empty($userStats)): ?>
<input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="admin-input"> <tr><td colspan="6" class="lt-empty">No user activity data available.</td></tr>
</div> <?php else: foreach ($userStats as $u): ?>
<div class="admin-form-actions"> <tr>
<button type="submit" class="btn">APPLY</button> <td data-label="User">
<a href="?" class="btn btn-secondary">RESET</a> <strong><?= htmlspecialchars($u['display_name'] ?? $u['username']) ?></strong>
</div> <?php if ($u['is_admin']): ?>
</form> <span class="lt-badge lt-badge-admin lt-text-xs">ADMIN</span>
<?php endif ?>
<!-- User Activity Table --> </td>
<div class="table-wrapper"> <td data-label="Created"><span class="lt-text-cyan"><?= (int)($u['tickets_created'] ?? 0) ?></span></td>
<table> <td data-label="Resolved"><span class="lt-text-muted"><?= (int)($u['tickets_resolved'] ?? 0) ?></span></td>
<thead> <td data-label="Comments"><span class="lt-text-amber"><?= (int)($u['comments_added'] ?? 0) ?></span></td>
<tr> <td data-label="Assigned"><?= (int)($u['tickets_assigned'] ?? 0) ?></td>
<th>User</th> <td data-label="Last Activity" class="lt-text-xs lt-text-muted">
<th class="text-center">Tickets Created</th> <?= $u['last_activity'] ? date('M d, Y H:i', strtotime($u['last_activity'])) : 'Never' ?>
<th class="text-center">Tickets Resolved</th> </td>
<th class="text-center">Comments Added</th> </tr>
<th class="text-center">Tickets Assigned</th> <?php endforeach; endif ?>
<th class="text-center">Last Activity</th> </tbody>
</tr> </table>
</thead>
<tbody>
<?php if (empty($userStats)): ?>
<tr>
<td colspan="6" class="empty-state">No user activity data available.</td>
</tr>
<?php else: ?>
<?php foreach ($userStats as $user): ?>
<tr>
<td>
<strong><?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?></strong>
<?php if ($user['is_admin']): ?>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</td>
<td class="text-center">
<span class="text-green fw-bold"><?php echo $user['tickets_created'] ?? 0; ?></span>
</td>
<td class="text-center">
<span class="text-open fw-bold"><?php echo $user['tickets_resolved'] ?? 0; ?></span>
</td>
<td class="text-center">
<span class="text-cyan fw-bold"><?php echo $user['comments_added'] ?? 0; ?></span>
</td>
<td class="text-center">
<span class="text-amber fw-bold"><?php echo $user['tickets_assigned'] ?? 0; ?></span>
</td>
<td class="text-center text-sm">
<?php echo $user['last_activity'] ? date('M d, Y H:i', strtotime($user['last_activity'])) : 'Never'; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Summary Stats -->
<?php if (!empty($userStats)): ?>
<div class="admin-stats-grid">
<div>
<div class="admin-stat-value text-green"><?php echo array_sum(array_column($userStats, 'tickets_created')); ?></div>
<div class="admin-stat-label">Total Created</div>
</div>
<div>
<div class="admin-stat-value text-open"><?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?></div>
<div class="admin-stat-label">Total Resolved</div>
</div>
<div>
<div class="admin-stat-value text-cyan"><?php echo array_sum(array_column($userStats, 'comments_added')); ?></div>
<div class="admin-stat-label">Total Comments</div>
</div>
<div>
<div class="admin-stat-value text-amber"><?php echo count($userStats); ?></div>
<div class="admin-stat-label">Active Users</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div> </div>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script> </div>
</body> </div>
</html>
<?php include __DIR__ . '/../../views/layout_footer.php'; ?>
+207 -251
View File
@@ -1,271 +1,227 @@
<?php <?php
// Admin view for workflow/status transitions designer
// Receives $workflows from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php'; require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php'; require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce(); $nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Workflow Designer';
$activeNav = 'admin-workflow';
$pageStyles = ['/assets/css/dashboard.css?v=20260327'];
$pageScripts = [];
include __DIR__ . '/../../views/layout_header.php';
?> ?>
<!DOCTYPE html>
<html lang="en"> <div class="lt-page-header">
<head> <div class="lt-flex lt-flex-gap-sm lt-flex-align-center">
<meta charset="UTF-8"> <a href="/" class="lt-btn lt-btn-ghost lt-btn-sm">&larr; Dashboard</a>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <span class="lt-text-muted lt-text-xs">/</span>
<title>Workflow Designer - Admin</title> <span class="lt-text-muted lt-text-xs">Admin: Workflow</span>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> </div>
<link rel="stylesheet" href="/assets/css/base.css"> <button type="button" class="lt-btn lt-btn-primary" data-action="show-create-modal">+ NEW TRANSITION</button>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320"> </div>
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <div class="lt-frame lt-mb-md">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script> <span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<script nonce="<?php echo $nonce; ?>"> <div class="lt-section-header">Workflow Diagram</div>
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; <div class="lt-section-body">
</script> <div class="lt-grid-4">
</head> <?php
<body> $statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
<div class="user-header"> foreach ($statuses as $status):
<div class="user-header-left"> $slug = strtolower(str_replace(' ', '-', $status));
<a href="/" class="back-link">[ DASHBOARD ]</a> $toCount = 0;
<span class="admin-page-title">Admin: Workflow Designer</span> if (isset($workflows)) { foreach ($workflows as $w) { if ($w['from_status'] === $status) $toCount++; } }
</div> ?>
<div class="user-header-right"> <div class="lt-card" style="text-align:center">
<?php if (isset($GLOBALS['currentUser'])): ?> <span class="lt-status lt-status-<?= $slug ?>"><?= $status ?></span>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span> <div class="lt-text-xs lt-text-muted lt-mt-sm"> <?= $toCount ?> transition<?= $toCount !== 1 ? 's' : '' ?></div>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div> </div>
<?php endforeach ?>
</div> </div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
Define which status transitions are allowed. This controls what options appear in the status dropdown on tickets.
</p>
</div>
</div>
<div class="ascii-frame-outer admin-container"> <div class="lt-frame">
<span class="bottom-left-corner"></span> <span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
<span class="bottom-right-corner"></span> <div class="lt-section-header">Status Transitions</div>
<div class="lt-section-body">
<div class="ascii-section-header">Status Workflow Designer</div> <div class="lt-table-wrap">
<div class="ascii-content"> <table class="lt-table lt-table-responsive" aria-label="Status transitions">
<div class="ascii-frame-inner"> <thead>
<div class="admin-header-row"> <tr>
<h2>Status Transitions</h2> <th scope="col">From Status</th>
<button data-action="show-create-modal" class="btn">+ NEW TRANSITION</button> <th scope="col">&rarr;</th>
<th scope="col">To Status</th>
<th scope="col">Req. Comment</th>
<th scope="col">Req. Admin</th>
<th scope="col">Active</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($workflows)): ?>
<tr><td colspan="7" class="lt-empty">No transitions defined. Add transitions to enable status changes.</td></tr>
<?php else: foreach ($workflows as $wf): ?>
<?php $fromSlug = strtolower(str_replace(' ', '-', $wf['from_status'])); $toSlug = strtolower(str_replace(' ', '-', $wf['to_status'])); ?>
<tr>
<td data-label="From">
<span class="lt-status lt-status-<?= $fromSlug ?>"><?= htmlspecialchars($wf['from_status']) ?></span>
</td>
<td class="lt-text-amber lt-text-xs" style="text-align:center">&rarr;</td>
<td data-label="To">
<span class="lt-status lt-status-<?= $toSlug ?>"><?= htmlspecialchars($wf['to_status']) ?></span>
</td>
<td data-label="Req. Comment" style="text-align:center">
<?= $wf['requires_comment'] ? '<span class="lt-text-cyan">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td>
<td data-label="Req. Admin" style="text-align:center">
<?= $wf['requires_admin'] ? '<span class="lt-text-amber">✓</span>' : '<span class="lt-text-muted">—</span>' ?>
</td>
<td data-label="Active" style="text-align:center">
<?= $wf['is_active']
? '<span class="lt-text-cyan">✓</span>'
: '<span class="lt-text-danger">✗</span>' ?>
</td>
<td data-label="Actions">
<div class="lt-btn-group">
<button type="button" class="lt-btn lt-btn-sm"
data-action="edit-transition" data-id="<?= $wf['transition_id'] ?>">EDIT</button>
<button type="button" class="lt-btn lt-btn-sm lt-btn-danger"
data-action="delete-transition" data-id="<?= $wf['transition_id'] ?>">DEL</button>
</div> </div>
</td>
<p class="text-muted-green mb-1"> </tr>
Define which status transitions are allowed. This controls what options appear in the status dropdown. <?php endforeach; endif ?>
</p> </tbody>
</table>
<!-- Visual Workflow Diagram -->
<div class="workflow-diagram">
<h4 class="admin-section-title">Workflow Diagram</h4>
<div class="workflow-diagram-nodes">
<?php
$statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
foreach ($statuses as $status):
$statusClass = 'status-' . str_replace(' ', '-', strtolower($status));
?>
<div class="workflow-diagram-node">
<div class="<?php echo $statusClass; ?>">
<?php echo $status; ?>
</div>
<div class="text-muted-green workflow-diagram-node-label">
<?php
$toCount = 0;
if (isset($workflows)) {
foreach ($workflows as $w) {
if ($w['from_status'] === $status) $toCount++;
}
}
echo "$toCount transitions";
?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Transitions Table -->
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>From Status</th>
<th></th>
<th>To Status</th>
<th>Requires Comment</th>
<th>Requires Admin</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($workflows)): ?>
<tr>
<td colspan="7" class="empty-state">No transitions defined. Add transitions to enable status changes.</td>
</tr>
<?php else: ?>
<?php foreach ($workflows as $wf): ?>
<tr>
<td>
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['from_status'])); ?>">
<?php echo htmlspecialchars($wf['from_status']); ?>
</span>
</td>
<td class="text-amber text-center"></td>
<td>
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['to_status'])); ?>">
<?php echo htmlspecialchars($wf['to_status']); ?>
</span>
</td>
<td class="text-center"><?php echo $wf['requires_comment'] ? '✓' : ''; ?></td>
<td class="text-center"><?php echo $wf['requires_admin'] ? '✓' : ''; ?></td>
<td class="text-center">
<span class="<?php echo $wf['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $wf['is_active'] ? '✓' : '✗'; ?>
</span>
</td>
<td>
<button data-action="edit-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="delete-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
</div>
</div>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle"> <div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog"
<div class="lt-modal lt-modal-sm"> aria-modal="true" aria-labelledby="wfModalTitle">
<div class="lt-modal-header"> <div class="lt-modal">
<span class="lt-modal-title" id="modalTitle">Create Transition</span> <div class="lt-modal-header">
<button class="lt-modal-close" data-modal-close aria-label="Close"></button> <span class="lt-modal-title" id="wfModalTitle">Create Transition</span>
</div> <button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
<form id="workflowForm">
<input type="hidden" id="transition_id" name="transition_id">
<div class="lt-modal-body">
<div class="setting-row">
<label for="from_status">From Status *</label>
<select id="from_status" name="from_status" required>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="setting-row">
<label for="to_status">To Status *</label>
<select id="to_status" name="to_status" required>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="setting-row">
<label><input type="checkbox" id="requires_comment" name="requires_comment"> Requires comment</label>
</div>
<div class="setting-row">
<label><input type="checkbox" id="requires_admin" name="requires_admin"> Requires admin privileges</label>
</div>
<div class="setting-row">
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div> </div>
<form id="workflowForm">
<input type="hidden" id="transition_id" name="transition_id">
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label" for="from_status">From Status *</label>
<select id="from_status" name="from_status" class="lt-select" required>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="to_status">To Status *</label>
<select id="to_status" name="to_status" class="lt-select" required>
<option value="Open">Open</option>
<option value="Pending">Pending</option>
<option value="In Progress">In Progress</option>
<option value="Closed">Closed</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="requires_comment" name="requires_comment">
Requires a comment when transitioning
</label>
</div>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="requires_admin" name="requires_admin">
Requires administrator privileges
</label>
</div>
<div class="lt-form-group">
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="wf_is_active" name="is_active" checked>
Active
</label>
</div>
</div>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?= $nonce ?>">
const workflows = <?php echo json_encode($workflows ?? []); ?>; var workflows = <?= json_encode($workflows ?? []) ?>;
function showCreateModal() { document.addEventListener('click', function (e) {
document.getElementById('modalTitle').textContent = 'Create Transition'; var target = e.target.closest('[data-action]');
document.getElementById('workflowForm').reset(); if (!target) return;
document.getElementById('transition_id').value = ''; switch (target.getAttribute('data-action')) {
document.getElementById('is_active').checked = true; case 'show-create-modal': showCreateModal(); break;
lt.modal.open('workflowModal'); case 'edit-transition': editTransition(target.getAttribute('data-id')); break;
} case 'delete-transition': deleteTransition(target.getAttribute('data-id')); break;
}
});
function closeModal() { document.getElementById('workflowForm').addEventListener('submit', function (e) {
lt.modal.close('workflowModal'); saveTransition(e);
} });
// Event delegation for data-action handlers if (window.lt) lt.keys.initDefaults();
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action; function showCreateModal() {
switch (action) { document.getElementById('wfModalTitle').textContent = 'Create Transition';
case 'show-create-modal': document.getElementById('workflowForm').reset();
showCreateModal(); document.getElementById('transition_id').value = '';
break; document.getElementById('wf_is_active').checked = true;
case 'edit-transition': lt.modal.open('workflowModal');
editTransition(target.dataset.id); }
break;
case 'delete-transition':
deleteTransition(target.dataset.id);
break;
}
});
// Form submit handler function editTransition(id) {
document.getElementById('workflowForm').addEventListener('submit', function(e) { var wf = workflows.find(function (w) { return w.transition_id == id; });
saveTransition(e); if (!wf) return;
}); document.getElementById('transition_id').value = wf.transition_id;
document.getElementById('from_status').value = wf.from_status;
document.getElementById('to_status').value = wf.to_status;
document.getElementById('requires_comment').checked = wf.requires_comment == 1;
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
document.getElementById('wf_is_active').checked = wf.is_active == 1;
document.getElementById('wfModalTitle').textContent = 'Edit Transition';
lt.modal.open('workflowModal');
}
if (window.lt) lt.keys.initDefaults(); function deleteTransition(id) {
showConfirmModal('Delete Transition', 'Delete this status transition? This cannot be undone.', 'error', function () {
lt.api.delete('/api/manage_workflows.php?id=' + id)
.then(function (data) {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(function () { lt.toast.error('Failed to delete'); });
});
}
function saveTransition(e) { function saveTransition(e) {
e.preventDefault(); e.preventDefault();
const data = { var data = {
transition_id: document.getElementById('transition_id').value, transition_id: document.getElementById('transition_id').value,
from_status: document.getElementById('from_status').value, from_status: document.getElementById('from_status').value,
to_status: document.getElementById('to_status').value, to_status: document.getElementById('to_status').value,
requires_comment: document.getElementById('requires_comment').checked ? 1 : 0, requires_comment: document.getElementById('requires_comment').checked ? 1 : 0,
requires_admin: document.getElementById('requires_admin').checked ? 1 : 0, requires_admin: document.getElementById('requires_admin').checked ? 1 : 0,
is_active: document.getElementById('is_active').checked ? 1 : 0 is_active: document.getElementById('wf_is_active').checked ? 1 : 0,
}; };
var url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
var apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(function (result) {
if (result.success) window.location.reload();
else lt.toast.error(result.error || 'Failed to save');
}).catch(function () { lt.toast.error('Failed to save'); });
}
</script>
const url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : ''); <?php include __DIR__ . '/../../views/layout_footer.php'; ?>
const apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
lt.toast.error(result.error || 'Failed to save');
}
}).catch(err => lt.toast.error('Failed to save'));
}
function editTransition(id) {
const wf = workflows.find(w => w.transition_id == id);
if (!wf) return;
document.getElementById('transition_id').value = wf.transition_id;
document.getElementById('from_status').value = wf.from_status;
document.getElementById('to_status').value = wf.to_status;
document.getElementById('requires_comment').checked = wf.requires_comment == 1;
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
document.getElementById('is_active').checked = wf.is_active == 1;
document.getElementById('modalTitle').textContent = 'Edit Transition';
lt.modal.open('workflowModal');
}
function deleteTransition(id) {
showConfirmModal('Delete Transition', 'Delete this status transition?', 'error', function() {
lt.api.delete('/api/manage_workflows.php?id=' + id)
.then(data => {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
});
}
</script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* layout_footer.php Shared bottom-of-page partial for all views.
*
* Expected variables available from the including view (set before require):
* string $nonce CSP nonce from SecurityHeadersMiddleware::getNonce()
* array|null $pageScripts Optional array of extra JS paths to load after base.js
* string|null $pageInlineScript Optional raw JS string to run after all scripts load
*
* Globals used:
* $GLOBALS['currentUser'] user array (user_id, username, is_admin)
* $GLOBALS['config'] app config array (TIMEZONE, TIMEZONE_ABBREV)
* CsrfMiddleware::getToken() returns current CSRF token string
*/
// layout_footer.php — JS globals + runtime scripts are loaded here
?>
</main><!-- /#main-content / .lt-main -->
<!-- base.js + utils.js + globals already loaded in <head> via layout_header.php -->
<?php if (!empty($pageScripts)): ?>
<!-- PAGE-SPECIFIC SCRIPTS -->
<?php foreach ($pageScripts as $_ltf_script): ?>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="<?= htmlspecialchars($_ltf_script, ENT_QUOTES, 'UTF-8') ?>"></script>
<?php endforeach; ?>
<?php endif; ?>
<?php if (!empty($pageInlineScript)): ?>
<!-- PAGE INLINE SCRIPT -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
<?= $pageInlineScript ?>
</script>
<?php endif; ?>
<!-- LT INIT runs boot animation once per session, then sets up UI handlers -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
if (window.lt) {
lt.init({ bootName: 'TINKER TICKETS', skipBoot: false });
}
</script>
</body>
</html>
+174
View File
@@ -0,0 +1,174 @@
<?php
/**
* layout_header.php Shared top-of-page partial for all views.
*
* Expected variables set by the including view before require:
* string $pageTitle Page title suffix (e.g. "Dashboard", "Ticket #42")
* string $activeNav Active nav key: 'dashboard', 'tickets', 'admin-*'
* array|null $pageStyles Optional extra CSS hrefs to load
* string $nonce CSP nonce from SecurityHeadersMiddleware::getNonce()
*
* Globals used:
* $GLOBALS['currentUser'] user array (username, display_name, is_admin, groups)
* $GLOBALS['config'] app config array
* CsrfMiddleware::getToken() returns current CSRF token string
*/
$_lt_user = $GLOBALS['currentUser'] ?? [];
$_lt_isAdmin = !empty($_lt_user['is_admin']);
$_lt_navActive = $activeNav ?? 'dashboard';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#030508">
<title><?= htmlspecialchars($pageTitle ?? 'Dashboard', ENT_QUOTES, 'UTF-8') ?> &mdash; Tinker Tickets</title>
<meta name="robots" content="noindex, nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/base.css">
<?php if (!empty($pageStyles)): ?>
<?php foreach ($pageStyles as $_lt_css): ?>
<link rel="stylesheet" href="<?= htmlspecialchars($_lt_css, ENT_QUOTES, 'UTF-8') ?>">
<?php endforeach; ?>
<?php endif; ?>
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
<!-- Base JS loaded in head so lt.* is available for inline view scripts -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/base.js"></script>
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>" src="/assets/js/utils.js"></script>
<!-- Inline JS globals (CSRF, timezone, user) available immediately -->
<script nonce="<?= htmlspecialchars($nonce, ENT_QUOTES, 'UTF-8') ?>">
window.CSRF_TOKEN = <?= json_encode(CsrfMiddleware::getToken(), JSON_UNESCAPED_UNICODE) ?>;
window.APP_TIMEZONE = <?= json_encode($GLOBALS['config']['TIMEZONE'] ?? 'UTC', JSON_UNESCAPED_UNICODE) ?>;
window.APP_TIMEZONE_ABBREV = <?= json_encode($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC', JSON_UNESCAPED_UNICODE) ?>;
window.APP_TIMEZONE_OFFSET = <?= (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0) ?>;
window.CURRENT_USER = <?= json_encode([
'id' => (int)($GLOBALS['currentUser']['user_id'] ?? 0),
'username'=> $GLOBALS['currentUser']['username'] ?? '',
'isAdmin' => !empty($GLOBALS['currentUser']['is_admin']),
], JSON_UNESCAPED_UNICODE) ?>;
</script>
</head>
<body>
<!-- SKIP LINK -->
<a class="lt-skip-link" href="#main-content">Skip to main content</a>
<!-- BOOT OVERLAY controlled by lt.boot() in base.js; shown once per session -->
<div id="lt-boot" class="lt-boot-overlay" data-app-name="TINKER TICKETS" style="display:none" aria-hidden="true">
<pre id="lt-boot-text" class="lt-boot-text"></pre>
</div>
<!-- MOBILE NAV DRAWER -->
<div class="lt-nav-drawer" id="lt-nav-drawer" role="dialog" aria-modal="true" aria-label="Mobile navigation">
<div class="lt-nav-drawer-backdrop" id="lt-nav-drawer-backdrop" data-action="close-nav-drawer" aria-hidden="true"></div>
<nav class="lt-nav-drawer-panel" aria-label="Mobile main navigation">
<div class="lt-nav-drawer-header">
<span class="lt-nav-drawer-title">TINKER TICKETS</span>
<button type="button"
class="lt-nav-drawer-close"
id="lt-nav-drawer-close"
data-action="close-nav-drawer"
aria-label="Close navigation">&#x2715;</button>
</div>
<ul class="lt-nav-drawer-list" role="list">
<li>
<a href="/"
class="lt-nav-drawer-link<?= $_lt_navActive === 'dashboard' ? ' active' : '' ?>"
<?= $_lt_navActive === 'dashboard' ? 'aria-current="page"' : '' ?>>
Dashboard
</a>
</li>
<?php if ($_lt_isAdmin): ?>
<li class="lt-nav-drawer-section-label" aria-hidden="true">Admin</li>
<li><a href="/admin/templates" class="lt-nav-drawer-link<?= $_lt_navActive === 'admin-templates' ? ' active' : '' ?>">Templates</a></li>
<li><a href="/admin/workflow" class="lt-nav-drawer-link<?= $_lt_navActive === 'admin-workflow' ? ' active' : '' ?>">Workflow</a></li>
<li><a href="/admin/recurring-tickets" class="lt-nav-drawer-link<?= $_lt_navActive === 'admin-recurring' ? ' active' : '' ?>">Recurring</a></li>
<li><a href="/admin/custom-fields" class="lt-nav-drawer-link<?= $_lt_navActive === 'admin-custom-fields' ? ' active' : '' ?>">Custom Fields</a></li>
<li><a href="/admin/user-activity" class="lt-nav-drawer-link<?= $_lt_navActive === 'admin-user-activity' ? ' active' : '' ?>">User Activity</a></li>
<li><a href="/admin/audit-log" class="lt-nav-drawer-link<?= $_lt_navActive === 'admin-audit-log' ? ' active' : '' ?>">Audit Log</a></li>
<li><a href="/admin/api-keys" class="lt-nav-drawer-link<?= $_lt_navActive === 'admin-api-keys' ? ' active' : '' ?>">API Keys</a></li>
<?php endif; ?>
</ul>
</nav>
</div><!-- /.lt-nav-drawer -->
<!-- PRIMARY HEADER -->
<header class="lt-header" role="banner">
<div class="lt-header-left">
<!-- Hamburger opens mobile nav drawer -->
<button type="button"
class="lt-menu-btn"
id="lt-menu-btn"
data-action="open-nav-drawer"
aria-label="Open navigation menu"
aria-expanded="false"
aria-controls="lt-nav-drawer">
<span class="lt-menu-btn-bar"></span>
<span class="lt-menu-btn-bar"></span>
<span class="lt-menu-btn-bar"></span>
</button>
<!-- Brand -->
<div class="lt-brand">
<a href="/"
class="lt-brand-title lt-glitch"
data-text="TINKER TICKETS"
style="text-decoration:none"
aria-label="Tinker Tickets home">TINKER TICKETS</a>
<span class="lt-brand-subtitle">LotusGuild Infrastructure</span>
</div>
<!-- Desktop navigation -->
<nav class="lt-nav" aria-label="Main navigation">
<a href="/"
class="lt-nav-link<?= $_lt_navActive === 'dashboard' ? ' active' : '' ?>"
<?= $_lt_navActive === 'dashboard' ? 'aria-current="page"' : '' ?>>
Dashboard
</a>
<?php if ($_lt_isAdmin): ?>
<div class="lt-nav-dropdown" data-action="toggle-nav-dropdown">
<a href="#"
class="lt-nav-link<?= str_starts_with($_lt_navActive, 'admin') ? ' active' : '' ?>"
role="button"
aria-haspopup="true"
aria-expanded="false"
aria-controls="lt-admin-dropdown-menu">
Admin &#x25BE;
</a>
<ul class="lt-nav-dropdown-menu"
id="lt-admin-dropdown-menu"
role="menu"
aria-label="Admin menu">
<li role="none"><a href="/admin/templates" role="menuitem" class="<?= $_lt_navActive === 'admin-templates' ? 'active' : '' ?>">Templates</a></li>
<li role="none"><a href="/admin/workflow" role="menuitem" class="<?= $_lt_navActive === 'admin-workflow' ? 'active' : '' ?>">Workflow</a></li>
<li role="none"><a href="/admin/recurring-tickets" role="menuitem" class="<?= $_lt_navActive === 'admin-recurring' ? 'active' : '' ?>">Recurring</a></li>
<li role="none"><a href="/admin/custom-fields" role="menuitem" class="<?= $_lt_navActive === 'admin-custom-fields' ? 'active' : '' ?>">Custom Fields</a></li>
<li role="none"><a href="/admin/user-activity" role="menuitem" class="<?= $_lt_navActive === 'admin-user-activity' ? 'active' : '' ?>">User Activity</a></li>
<li role="none"><a href="/admin/audit-log" role="menuitem" class="<?= $_lt_navActive === 'admin-audit-log' ? 'active' : '' ?>">Audit Log</a></li>
<li role="none"><a href="/admin/api-keys" role="menuitem" class="<?= $_lt_navActive === 'admin-api-keys' ? 'active' : '' ?>">API Keys</a></li>
</ul>
</div>
<?php endif; ?>
</nav><!-- /.lt-nav -->
</div><!-- /.lt-header-left -->
<div class="lt-header-right">
<?php if (!empty($_lt_user)): ?>
<span class="lt-header-username">
<?= htmlspecialchars($_lt_user['display_name'] ?? $_lt_user['username'] ?? '', ENT_QUOTES, 'UTF-8') ?>
</span>
<?php if ($_lt_isAdmin): ?>
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
<?php endif; ?>
<?php endif; ?>
</div><!-- /.lt-header-right -->
</header><!-- /.lt-header -->
<main class="lt-main lt-container" id="main-content">