Accessibility pass: ARIA roles, label associations, CSS class migrations

- Add role=dialog/aria-modal/aria-labelledby to all 12 modal overlays (JS + PHP)
- Add aria-label="Close" to all 14 modal close buttons
- Add full ARIA combobox pattern to @mention autocomplete (listbox, option, aria-selected, aria-expanded)
- Add for= attributes to admin filter form labels (AuditLog, UserActivity, ApiKeys)
- Remove dead closeOnAdvancedSearchBackdropClick() from advanced-search.js

CSS/JS style cleanup:
- Move .ascii-banner static styles from JS inline to CSS class; add .ascii-banner--glow
- Add .ascii-banner-cursor, .loading-overlay--hiding, .has-overlay, tr[data-clickable]
- Add .animate-fadein/.animate-fadeout/.comment--deleting to ticket.css
- Add .lt-toast--hiding to base.css; remove opacity/transition inline JS
- Remove redundant cursor:pointer JS (already in th{} CSS rule)
- Remove trailing space in lt-select class attributes

Bug fixes:
- base.js: boot overlay opacity inline style was overriding .fade-out class opacity via
  specificity (1000 vs 20), preventing the fade-out animation — removed
- ascii-banner.js: cursor used blink-caret (border-color only) instead of blink-cursor
  (opacity-based), so the █ cursor never actually blinked — fixed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 20:29:58 -04:00
parent 11f75fd823
commit 7695c6134c
21 changed files with 929 additions and 610 deletions

View File

@@ -259,8 +259,8 @@ tinker_tickets/
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe) │ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
│ │ ├── settings.js # User preferences │ │ ├── settings.js # User preferences
│ │ ├── ticket.js # Ticket + comments + visibility │ │ ├── ticket.js # Ticket + comments + visibility
│ │ ├── toast.js # Backwards-compat shim → delegates to lt.toast │ │ ├── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
│ │ └── utils.js # escapeHtml (→ lt.escHtml) + getTicketIdFromUrl │ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
│ └── images/ │ └── images/
│ └── favicon.png │ └── favicon.png
├── config/ ├── config/
@@ -397,6 +397,12 @@ Key conventions and gotchas for working with this codebase:
17. **Rate limiting**: Both session-based AND IP-based limits are enforced 17. **Rate limiting**: Both session-based AND IP-based limits are enforced
18. **Relative timestamps**: Dashboard dates use `lt.time.ago()` and refresh every 60s; full date is always in the `title` attribute for hover 18. **Relative timestamps**: Dashboard dates use `lt.time.ago()` and refresh every 60s; full date is always in the `title` attribute for hover
19. **Boot sequence**: Shows ASCII banner then boot messages on first page visit per session (`sessionStorage.getItem('booted')`); removed on subsequent loads 19. **Boot sequence**: Shows ASCII banner then boot messages on first page visit per session (`sessionStorage.getItem('booted')`); removed on subsequent loads
20. **No raw `fetch()`**: All AJAX calls use `lt.api.get/post/put/delete()` — never use raw `fetch()`. The wrapper auto-adds `Content-Type: application/json` and `X-CSRF-Token`, auto-parses JSON, and throws on non-2xx.
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
24. **CSS utility classes** (dashboard.css): Use `.text-green`, `.text-amber`, `.text-cyan`, `.text-danger`, `.text-open`, `.text-closed`, `.text-muted`, `.text-muted-green`, `.text-sm`, `.text-center`, `.nowrap`, `.mono`, `.fw-bold`, `.mb-1` for typography. Use `.admin-container` (1200px) or `.admin-container-wide` (1400px) for admin page max-widths. Use `.admin-header-row` for title+button header rows. Use `.admin-form-row`, `.admin-form-field`, `.admin-label`, `.admin-input` for filter forms. Use `.admin-form-actions` for filter form button groups. Use `.table-wrapper` + `td.empty-state` for tables. Use `.setting-grid-2` and `.setting-grid-3` for modal form grids. Use `.lt-modal-sm` or `.lt-modal-lg` for modal sizing.
25. **CSS utility classes** (ticket.css): Use `.form-hint` (green helper text), `.form-hint-warning` (amber helper text), `.visibility-groups-list` (checkbox row), `.group-checkbox-label` (checkbox label) for ticket forms.
## File Reference ## File Reference

View File

@@ -922,7 +922,8 @@ pre {
.lt-table-wrap { overflow-x: auto; border: 2px solid var(--terminal-green); } .lt-table-wrap { overflow-x: auto; border: 2px solid var(--terminal-green); }
/* Sortable column header */ /* Sortable column header */
.lt-table th[data-sort] { cursor: pointer; } .lt-table th[data-sort],
th[data-sort-key] { cursor: pointer; }
.lt-table th[data-sort]:hover { color: var(--terminal-green); text-shadow: var(--glow-green); } .lt-table th[data-sort]:hover { color: var(--terminal-green); text-shadow: var(--glow-green); }
.lt-table th[data-sort="asc"]::after { content: ' ▲'; color: var(--terminal-green); } .lt-table th[data-sort="asc"]::after { content: ' ▲'; color: var(--terminal-green); }
.lt-table th[data-sort="desc"]::after { content: ' ▼'; color: var(--terminal-green); } .lt-table th[data-sort="desc"]::after { content: ' ▼'; color: var(--terminal-green); }
@@ -1177,6 +1178,7 @@ pre {
.lt-toast-close::before, .lt-toast-close::before,
.lt-toast-close::after { content: ''; } .lt-toast-close::after { content: ''; }
.lt-toast-close:hover { opacity: 1; transform: none; } .lt-toast-close:hover { opacity: 1; transform: none; }
.lt-toast--hiding { opacity: 0; transition: opacity 0.3s ease; }
/* ---------------------------------------------------------------- /* ----------------------------------------------------------------
15. TAB NAVIGATION 15. TAB NAVIGATION

View File

@@ -284,6 +284,8 @@ tbody tr {
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
} }
tr[data-clickable="true"] { cursor: pointer; }
/* Button press effect */ /* Button press effect */
.btn { .btn {
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
@@ -1405,8 +1407,12 @@ h1 {
z-index: var(--z-dropdown); z-index: var(--z-dropdown);
font-family: var(--font-mono); font-family: var(--font-mono);
color: var(--terminal-green); color: var(--terminal-green);
transition: opacity 0.3s;
} }
.loading-overlay--hiding { opacity: 0; }
.has-overlay { position: relative; }
.loading-overlay .loading-text { .loading-overlay .loading-text {
margin-top: 1rem; margin-top: 1rem;
animation: blink-cursor 1s step-end infinite; animation: blink-cursor 1s step-end infinite;
@@ -1504,6 +1510,27 @@ h1 {
opacity: 0.7; opacity: 0.7;
} }
/* Inside table cells: compact version — no ASCII art, reduced padding */
td.empty-state {
padding: 2rem;
color: var(--terminal-green-dim);
}
td.empty-state::before {
display: none;
}
/* Admin views max-width container */
.admin-container {
max-width: 1200px;
margin: 2rem auto;
}
.admin-container-wide {
max-width: 1400px;
margin: 2rem auto;
}
/* ===== LAYOUT COMPONENTS ===== */ /* ===== LAYOUT COMPONENTS ===== */
.dashboard-header { .dashboard-header {
display: flex; display: flex;
@@ -1580,11 +1607,6 @@ button {
transform: translateY(0); transform: translateY(0);
} }
@keyframes pulse-glow-box {
0%, 100% { box-shadow: 0 0 8px currentColor, 0 0 16px currentColor; }
50% { box-shadow: 0 0 12px currentColor, 0 0 24px currentColor, 0 0 32px currentColor; }
}
/* Terminal prompt for primary action buttons */ /* Terminal prompt for primary action buttons */
.btn.create-ticket::before, .btn.create-ticket::before,
.btn.primary::before { .btn.primary::before {
@@ -2217,6 +2239,19 @@ input[type="checkbox"]:checked {
} }
/* ===== COLLAPSIBLE ASCII BANNER ===== */ /* ===== COLLAPSIBLE ASCII BANNER ===== */
.ascii-banner {
margin: 0;
font-family: var(--font-mono);
color: var(--terminal-green);
line-height: 1.2;
white-space: pre;
overflow: visible;
text-align: center;
}
.ascii-banner--glow { text-shadow: var(--glow-green); }
.ascii-banner-cursor { margin-left: 5px; animation: blink-cursor 0.75s step-end infinite; }
.ascii-banner-wrapper { .ascii-banner-wrapper {
max-width: 100%; max-width: 100%;
margin: 0 1rem 1rem 1rem; margin: 0 1rem 1rem 1rem;
@@ -2774,6 +2809,8 @@ input[type="checkbox"]:checked {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
justify-content: center;
margin-top: 1rem;
} }
.pagination button { .pagination button {
@@ -3905,6 +3942,279 @@ body.modal-open {
height: 100%; height: 100%;
} }
/* ========================================
ADMIN FORM UTILITIES
Shared across all admin views (WorkflowDesigner, CustomFields,
Templates, RecurringTickets, ApiKeys).
======================================== */
.admin-section-title {
color: var(--terminal-amber);
margin-bottom: 1rem;
}
/* Horizontal flex row for form fields */
.admin-form-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: 1rem;
}
/* Growable field within .admin-form-row */
.admin-form-field {
flex: 1;
min-width: 150px;
}
/* Compact label above admin inputs */
.admin-label {
display: block;
font-size: 0.8rem;
color: var(--terminal-amber);
margin-bottom: 0.25rem;
}
/* Styled input / select / textarea for admin forms */
.admin-input {
width: 100%;
padding: 0.5rem;
border: 2px solid var(--terminal-green);
border-radius: 0;
background: var(--bg-primary);
color: var(--terminal-green);
font-family: var(--font-mono);
}
.admin-input:focus {
outline: none;
border-color: var(--terminal-amber);
}
/* Admin page title in user-header (breadcrumb-style label) */
.admin-page-title {
margin-left: 1rem;
color: var(--terminal-amber);
}
/* Utility: prevent text wrap (e.g., date columns in tables) */
.nowrap { white-space: nowrap; }
/* Utility: monospace font */
.mono { font-family: var(--font-mono); }
/* Utility: color helpers for status / accent text */
.text-green { color: var(--terminal-green); }
.text-amber { color: var(--terminal-amber); }
.text-cyan { color: var(--terminal-cyan); }
.text-danger { color: var(--priority-1); }
.text-open { color: var(--status-open); }
.text-closed { color: var(--status-closed); }
.text-muted-green { color: var(--terminal-green-dim); }
/* Pre / code display block in admin UI */
.admin-code-block {
background: var(--bg-primary);
padding: 1rem;
border: 1px solid var(--terminal-green);
overflow-x: auto;
font-family: var(--font-mono);
}
/* Summary stats grid (4 columns, used in UserActivityView) */
.admin-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
text-align: center;
margin-top: 2rem;
padding: 1rem;
border: 1px solid var(--terminal-green);
}
.admin-stat-value {
font-size: 1.5rem;
font-weight: bold;
}
.admin-stat-label {
font-size: 0.8rem;
color: var(--terminal-green-dim);
}
/* Row with title on left and action button on right */
.admin-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.admin-header-row h2 {
margin: 0;
}
/* Font weight utility */
.fw-bold { font-weight: bold; }
/* Small button variant (shared with ticket.css) */
.btn-small {
padding: 0.4rem 0.75rem !important;
font-size: 0.85rem !important;
min-height: auto !important;
}
/* Boot banner centering */
#boot-banner {
text-align: center;
margin-bottom: 1rem;
}
/* Empty state for dashboard table (PHP-generated) */
.dashboard-empty-state {
text-align: center;
padding: 3rem;
}
/* Fixed-width table columns */
.col-checkbox { width: 40px; }
.col-actions { width: 100px; }
.dashboard-empty-pre {
color: var(--terminal-green);
text-shadow: var(--glow-green);
font-size: 0.8rem;
line-height: 1.2;
}
/* Grid layouts for modal form bodies */
.setting-grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; }
.setting-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
/* Workflow status diagram container */
.workflow-diagram {
margin-bottom: 2rem;
padding: 1rem;
border: 1px solid var(--terminal-green);
background: var(--bg-secondary);
}
.workflow-diagram-nodes {
display: flex;
justify-content: center;
gap: 2rem;
flex-wrap: wrap;
}
.workflow-diagram-node {
text-align: center;
}
.workflow-diagram-node-label {
font-size: 0.8rem;
margin-top: 0.5rem;
}
/* Inline paragraph spacing utility */
.mb-1 { margin-bottom: 1rem; }
/* Smaller modal size variants (complement lt-modal-lg from base.css) */
.lt-modal-sm { width: 460px; }
/* Half-rem margin-bottom utility */
.mb-half { margin-bottom: 0.5rem; }
/* Modal form label (block display with bottom spacing) */
.lt-modal-body label {
display: block;
margin-bottom: 0.5rem;
color: var(--terminal-green);
}
/* Modal form select/input top spacing */
.lt-modal-body .lt-select,
.lt-modal-body .lt-input {
margin-top: 0.5rem;
}
/* Extra-small modal size (400px) */
.lt-modal-xs { width: 400px; }
/* Confirmation modal body message */
.modal-message {
color: var(--terminal-green);
white-space: pre-line;
}
/* Danger modal header variant */
.lt-modal-header--danger {
color: var(--status-closed) !important;
}
/* Danger action button */
.lt-btn-danger {
background: var(--status-closed);
border-color: var(--status-closed);
color: var(--bg-primary);
}
.lt-btn-danger:hover {
background: var(--priority-1);
border-color: var(--priority-1);
}
/* Modal body text sizing */
.modal-warning-text {
color: var(--terminal-amber);
font-size: 1.1rem;
margin-bottom: 1rem;
}
/* Keyboard shortcuts modal */
.kb-section-heading {
color: var(--terminal-amber);
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
}
.kb-shortcuts-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.kb-shortcuts-table.no-margin {
margin-bottom: 0;
}
.kb-shortcuts-table td {
padding: 0.4rem;
border-bottom: 1px solid var(--border-color);
}
.kb-shortcuts-table tr:last-child td {
border-bottom: none;
}
/* Button/action group aligned to bottom (used in filter forms) */
.admin-form-actions {
display: flex;
align-items: flex-end;
gap: 0.5rem;
}
/* Truncated table cell for long content */
.td-truncate {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Small text utility */
.text-sm { font-size: 0.85rem; }
/* ======================================== /* ========================================
MARKDOWN FORMATTING STYLES MARKDOWN FORMATTING STYLES
======================================== */ ======================================== */

View File

@@ -466,6 +466,50 @@ textarea[data-field="description"]:not(:disabled)::after {
} }
/* Form Elements */ /* Form Elements */
/* Helper text below form fields */
.form-hint {
color: var(--terminal-green);
font-family: var(--font-mono);
font-size: 0.85rem;
margin-top: 0.5rem;
}
.form-hint-warning {
color: var(--terminal-amber);
font-family: var(--font-mono);
font-size: 0.85rem;
margin-top: 0.5rem;
}
/* Visibility group checkbox row */
.visibility-groups-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.5rem;
}
.group-checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
/* Duplicate warning box and visibility groups (JS-toggled, need margin when visible) */
#duplicateWarning {
margin-top: 1rem;
}
#visibilityGroupsContainer {
margin-top: 1rem;
}
/* Duplicate found heading */
.duplicate-heading {
margin-bottom: 0.5rem;
}
.detail-group { .detail-group {
margin-bottom: 30px; margin-bottom: 30px;
padding: 15px; padding: 15px;
@@ -944,6 +988,10 @@ textarea.editable {
} }
} }
.animate-fadein { animation: fadeIn 0.3s ease; }
.animate-fadeout { animation: fadeIn 0.2s ease reverse; }
.comment--deleting { opacity: 0; transform: translateX(-20px); transition: opacity 0.3s, transform 0.3s; }
.reply-form-container .reply-header { .reply-form-container .reply-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@@ -19,14 +19,6 @@ function closeAdvancedSearch() {
lt.modal.close('advancedSearchModal'); lt.modal.close('advancedSearchModal');
} }
// Close modal when clicking on backdrop
function closeOnAdvancedSearchBackdropClick(event) {
const modal = document.getElementById('advancedSearchModal');
if (event.target === modal) {
closeAdvancedSearch();
}
}
// Load users for dropdown // Load users for dropdown
async function loadUsersForSearch() { async function loadUsersForSearch() {
try { try {

View File

@@ -65,20 +65,8 @@ function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = tru
// Create pre element for ASCII art // Create pre element for ASCII art
const pre = document.createElement('pre'); const pre = document.createElement('pre');
pre.className = 'ascii-banner'; pre.className = addGlow ? 'ascii-banner ascii-banner--glow' : 'ascii-banner';
pre.style.margin = '0';
pre.style.fontFamily = 'var(--font-mono)';
pre.style.color = 'var(--terminal-green)';
if (addGlow) {
pre.style.textShadow = 'var(--glow-green)';
}
pre.style.fontSize = getBannerFontSize(bannerId); pre.style.fontSize = getBannerFontSize(bannerId);
pre.style.lineHeight = '1.2';
pre.style.whiteSpace = 'pre';
pre.style.overflow = 'visible';
pre.style.textAlign = 'center';
container.appendChild(pre); container.appendChild(pre);
@@ -178,8 +166,7 @@ function animatedWelcome(containerSelector) {
banner.addEventListener('bannerComplete', () => { banner.addEventListener('bannerComplete', () => {
const cursor = document.createElement('span'); const cursor = document.createElement('span');
cursor.textContent = '█'; cursor.textContent = '█';
cursor.style.animation = 'blink-caret 0.75s step-end infinite'; cursor.className = 'ascii-banner-cursor';
cursor.style.marginLeft = '5px';
banner.appendChild(cursor); banner.appendChild(cursor);
}); });
} }

View File

@@ -124,8 +124,7 @@
function _dismissToast(toast) { function _dismissToast(toast) {
if (!toast || !toast.parentNode) return; if (!toast || !toast.parentNode) return;
clearTimeout(toast._lt_timer); clearTimeout(toast._lt_timer);
toast.style.opacity = '0'; toast.classList.add('lt-toast--hiding');
toast.style.transition = 'opacity 0.3s ease';
setTimeout(() => { setTimeout(() => {
if (toast.parentNode) toast.parentNode.removeChild(toast); if (toast.parentNode) toast.parentNode.removeChild(toast);
_toastActive = false; _toastActive = false;
@@ -176,11 +175,11 @@
lt.modal.closeAll(); lt.modal.closeAll();
HTML contract: HTML contract:
<div id="my-modal-id" class="lt-modal-overlay"> <div id="my-modal-id" class="lt-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="myModalTitle">
<div class="lt-modal"> <div class="lt-modal">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title">Title</span> <span class="lt-modal-title" id="myModalTitle">Title</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body">…</div> <div class="lt-modal-body">…</div>
<div class="lt-modal-footer">…</div> <div class="lt-modal-footer">…</div>
@@ -295,7 +294,6 @@
if (!overlay || !pre) return; if (!overlay || !pre) return;
overlay.style.display = 'flex'; overlay.style.display = 'flex';
overlay.style.opacity = '1';
const name = (appName || 'TERMINAL').toUpperCase(); const name = (appName || 'TERMINAL').toUpperCase();
const titleStr = name + ' v1.0'; const titleStr = name + ' v1.0';
@@ -653,7 +651,6 @@
const ths = Array.from(table.querySelectorAll('th[data-sort-key]')); const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
ths.forEach((th, colIdx) => { ths.forEach((th, colIdx) => {
th.style.cursor = 'pointer';
let dir = 'asc'; let dir = 'asc';
th.addEventListener('click', () => { th.addEventListener('click', () => {

View File

@@ -261,7 +261,6 @@ function clearAllFilters() {
function initTableSorting() { function initTableSorting() {
const tableHeaders = document.querySelectorAll('th'); const tableHeaders = document.querySelectorAll('th');
tableHeaders.forEach((header, index) => { tableHeaders.forEach((header, index) => {
header.style.cursor = 'pointer';
header.addEventListener('click', () => { header.addEventListener('click', () => {
const table = header.closest('table'); const table = header.closest('table');
sortTable(table, index); sortTable(table, index);
@@ -764,15 +763,15 @@ function showBulkAssignModal() {
// Create modal HTML // Create modal HTML
const modalHtml = ` const modalHtml = `
<div class="lt-modal-overlay" id="bulkAssignModal" aria-hidden="true"> <div class="lt-modal-overlay" id="bulkAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkAssignModalTitle">
<div class="lt-modal"> <div class="lt-modal">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title">Assign ${ticketIds.length} Ticket(s)</span> <span class="lt-modal-title" id="bulkAssignModalTitle">Assign ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body"> <div class="lt-modal-body">
<label for="bulkAssignUser">Assign to:</label> <label for="bulkAssignUser">Assign to:</label>
<select id="bulkAssignUser" class="lt-select" style="width:100%;margin-top:0.5rem;"> <select id="bulkAssignUser" class="lt-select">
<option value="">Select User...</option> <option value="">Select User...</option>
</select> </select>
</div> </div>
@@ -852,15 +851,15 @@ function showBulkPriorityModal() {
} }
const modalHtml = ` const modalHtml = `
<div class="lt-modal-overlay" id="bulkPriorityModal" aria-hidden="true"> <div class="lt-modal-overlay" id="bulkPriorityModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkPriorityModalTitle">
<div class="lt-modal"> <div class="lt-modal">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title">Change Priority for ${ticketIds.length} Ticket(s)</span> <span class="lt-modal-title" id="bulkPriorityModalTitle">Change Priority for ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body"> <div class="lt-modal-body">
<label for="bulkPriority">Priority:</label> <label for="bulkPriority">Priority:</label>
<select id="bulkPriority" class="lt-select" style="width:100%;margin-top:0.5rem;"> <select id="bulkPriority" class="lt-select">
<option value="">Select Priority...</option> <option value="">Select Priority...</option>
<option value="1">P1 - Critical Impact</option> <option value="1">P1 - Critical Impact</option>
<option value="2">P2 - High Impact</option> <option value="2">P2 - High Impact</option>
@@ -927,7 +926,6 @@ document.addEventListener('DOMContentLoaded', function() {
if (row.dataset.clickable) return; if (row.dataset.clickable) return;
row.dataset.clickable = 'true'; row.dataset.clickable = 'true';
row.style.cursor = 'pointer';
row.addEventListener('click', function(e) { row.addEventListener('click', function(e) {
// Don't navigate if clicking on a link, button, checkbox, or select // Don't navigate if clicking on a link, button, checkbox, or select
@@ -960,15 +958,15 @@ function showBulkStatusModal() {
} }
const modalHtml = ` const modalHtml = `
<div class="lt-modal-overlay" id="bulkStatusModal" aria-hidden="true"> <div class="lt-modal-overlay" id="bulkStatusModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkStatusModalTitle">
<div class="lt-modal"> <div class="lt-modal">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title">Change Status for ${ticketIds.length} Ticket(s)</span> <span class="lt-modal-title" id="bulkStatusModalTitle">Change Status for ${ticketIds.length} Ticket(s)</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body"> <div class="lt-modal-body">
<label for="bulkStatus">New Status:</label> <label for="bulkStatus">New Status:</label>
<select id="bulkStatus" class="lt-select" style="width:100%;margin-top:0.5rem;"> <select id="bulkStatus" class="lt-select">
<option value="">Select Status...</option> <option value="">Select Status...</option>
<option value="Open">Open</option> <option value="Open">Open</option>
<option value="Pending">Pending</option> <option value="Pending">Pending</option>
@@ -1036,18 +1034,18 @@ function showBulkDeleteModal() {
} }
const modalHtml = ` const modalHtml = `
<div class="lt-modal-overlay" id="bulkDeleteModal" aria-hidden="true"> <div class="lt-modal-overlay" id="bulkDeleteModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkDeleteModalTitle">
<div class="lt-modal"> <div class="lt-modal lt-modal-sm">
<div class="lt-modal-header" style="color: var(--status-closed);"> <div class="lt-modal-header lt-modal-header--danger">
<span class="lt-modal-title">[ ! ] DELETE ${ticketIds.length} TICKET(S)</span> <span class="lt-modal-title" id="bulkDeleteModalTitle">[ ! ] DELETE ${ticketIds.length} TICKET(S)</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body" style="text-align:center;"> <div class="lt-modal-body text-center">
<p style="color: var(--terminal-amber); font-size: 1.1rem; margin-bottom: 1rem;">This action cannot be undone!</p> <p class="modal-warning-text">This action cannot be undone!</p>
<p style="color: var(--terminal-green);">You are about to permanently delete ${ticketIds.length} ticket(s).<br>All associated comments and history will be lost.</p> <p class="text-green">You are about to permanently delete ${ticketIds.length} ticket(s).<br>All associated comments and history will be lost.</p>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button data-action="perform-bulk-delete" class="lt-btn lt-btn-primary" style="background: var(--status-closed); border-color: var(--status-closed);">DELETE PERMANENTLY</button> <button data-action="perform-bulk-delete" class="lt-btn lt-btn-danger">DELETE PERMANENTLY</button>
<button data-action="close-bulk-delete-modal" class="lt-btn lt-btn-ghost">CANCEL</button> <button data-action="close-bulk-delete-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
</div> </div>
</div> </div>
@@ -1121,14 +1119,14 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
const safeMessage = lt.escHtml(message); const safeMessage = lt.escHtml(message);
const modalHtml = ` const modalHtml = `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true"> <div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
<div class="lt-modal" style="max-width: 500px;"> <div class="lt-modal lt-modal-sm">
<div class="lt-modal-header" style="color: ${color};"> <div class="lt-modal-header" style="color: ${color};">
<span class="lt-modal-title">${icon} ${safeTitle}</span> <span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body" style="text-align: center;"> <div class="lt-modal-body text-center">
<p style="color: var(--terminal-green); white-space: pre-line;">${safeMessage}</p> <p class="modal-message">${safeMessage}</p>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button> <button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
@@ -1172,15 +1170,15 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
const safePlaceholder = lt.escHtml(placeholder); const safePlaceholder = lt.escHtml(placeholder);
const modalHtml = ` const modalHtml = `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true"> <div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
<div class="lt-modal" style="max-width: 500px;"> <div class="lt-modal lt-modal-sm">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title">${safeTitle}</span> <span class="lt-modal-title" id="${modalId}_title">${safeTitle}</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body"> <div class="lt-modal-body">
<label for="${inputId}" style="display: block; margin-bottom: 0.5rem; color: var(--terminal-green);">${safeLabel}</label> <label for="${inputId}">${safeLabel}</label>
<input type="text" id="${inputId}" class="lt-input" placeholder="${safePlaceholder}" style="width: 100%;" /> <input type="text" id="${inputId}" class="lt-input" placeholder="${safePlaceholder}" />
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_submit">SAVE</button> <button class="lt-btn lt-btn-primary" id="${modalId}_submit">SAVE</button>
@@ -1224,17 +1222,17 @@ function quickStatusChange(ticketId, currentStatus) {
const otherStatuses = statuses.filter(s => s !== currentStatus); const otherStatuses = statuses.filter(s => s !== currentStatus);
const modalHtml = ` const modalHtml = `
<div class="lt-modal-overlay" id="quickStatusModal" aria-hidden="true"> <div class="lt-modal-overlay" id="quickStatusModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="quickStatusModalTitle">
<div class="lt-modal" style="max-width:400px;"> <div class="lt-modal lt-modal-xs">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title">Quick Status Change</span> <span class="lt-modal-title" id="quickStatusModalTitle">Quick Status Change</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body"> <div class="lt-modal-body">
<p style="margin-bottom:0.5rem;">Ticket #${lt.escHtml(ticketId)}</p> <p class="mb-half">Ticket #${lt.escHtml(ticketId)}</p>
<p style="margin-bottom:0.5rem;color:var(--terminal-amber);">Current: ${lt.escHtml(currentStatus)}</p> <p class="text-amber mb-half">Current: ${lt.escHtml(currentStatus)}</p>
<label for="quickStatusSelect">New Status:</label> <label for="quickStatusSelect">New Status:</label>
<select id="quickStatusSelect" class="lt-select" style="width:100%;margin-top:0.5rem;"> <select id="quickStatusSelect" class="lt-select">
${otherStatuses.map(s => `<option value="${s}">${s}</option>`).join('')} ${otherStatuses.map(s => `<option value="${s}">${s}</option>`).join('')}
</select> </select>
</div> </div>
@@ -1280,16 +1278,16 @@ function performQuickStatusChange(ticketId) {
*/ */
function quickAssign(ticketId) { function quickAssign(ticketId) {
const modalHtml = ` const modalHtml = `
<div class="lt-modal-overlay" id="quickAssignModal" aria-hidden="true"> <div class="lt-modal-overlay" id="quickAssignModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="quickAssignModalTitle">
<div class="lt-modal" style="max-width:400px;"> <div class="lt-modal lt-modal-xs">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title">Quick Assign</span> <span class="lt-modal-title" id="quickAssignModalTitle">Quick Assign</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body"> <div class="lt-modal-body">
<p style="margin-bottom:0.5rem;">Ticket #${lt.escHtml(ticketId)}</p> <p class="mb-half">Ticket #${lt.escHtml(ticketId)}</p>
<label for="quickAssignSelect">Assign to:</label> <label for="quickAssignSelect">Assign to:</label>
<select id="quickAssignSelect" class="lt-select" style="width:100%;margin-top:0.5rem;"> <select id="quickAssignSelect" class="lt-select">
<option value="">Unassigned</option> <option value="">Unassigned</option>
</select> </select>
</div> </div>
@@ -1697,7 +1695,7 @@ function showLoadingOverlay(element, message = 'Loading...') {
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
<div class="loading-text">${message}</div> <div class="loading-text">${message}</div>
`; `;
element.style.position = 'relative'; element.classList.add('has-overlay');
element.appendChild(overlay); element.appendChild(overlay);
} }
@@ -1707,9 +1705,11 @@ function showLoadingOverlay(element, message = 'Loading...') {
function hideLoadingOverlay(element) { function hideLoadingOverlay(element) {
const overlay = element.querySelector('.loading-overlay'); const overlay = element.querySelector('.loading-overlay');
if (overlay) { if (overlay) {
overlay.style.opacity = '0'; overlay.classList.add('loading-overlay--hiding');
overlay.style.transition = 'opacity 0.3s'; setTimeout(() => {
setTimeout(() => overlay.remove(), 300); overlay.remove();
element.classList.remove('has-overlay');
}, 300);
} }
} }

View File

@@ -33,43 +33,46 @@ function showKeyboardHelp() {
modal.id = 'keyboardHelpModal'; modal.id = 'keyboardHelpModal';
modal.className = 'lt-modal-overlay'; modal.className = 'lt-modal-overlay';
modal.setAttribute('aria-hidden', 'true'); modal.setAttribute('aria-hidden', 'true');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'keyboardHelpModalTitle');
modal.innerHTML = ` modal.innerHTML = `
<div class="lt-modal" style="max-width: 500px;"> <div class="lt-modal lt-modal-sm">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title">KEYBOARD SHORTCUTS</span> <span class="lt-modal-title" id="keyboardHelpModalTitle">KEYBOARD SHORTCUTS</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<div class="lt-modal-body"> <div class="lt-modal-body">
<h4 style="color: var(--terminal-amber); margin: 0 0 0.5rem 0; font-size: 0.9rem;">Navigation</h4> <h4 class="kb-section-heading">Navigation</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;"> <table class="kb-shortcuts-table">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>J</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Next ticket in list</td></tr> <tr><td><kbd>J</kbd></td><td>Next ticket in list</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Previous ticket in list</td></tr> <tr><td><kbd>K</kbd></td><td>Previous ticket in list</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Enter</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Open selected ticket</td></tr> <tr><td><kbd>Enter</kbd></td><td>Open selected ticket</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>G</kbd> then <kbd>D</kbd></td><td style="padding: 0.4rem;">Go to Dashboard</td></tr> <tr><td><kbd>G</kbd> then <kbd>D</kbd></td><td>Go to Dashboard</td></tr>
</table> </table>
<h4 style="color: var(--terminal-amber); margin: 0 0 0.5rem 0; font-size: 0.9rem;">Actions</h4> <h4 class="kb-section-heading">Actions</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;"> <table class="kb-shortcuts-table">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>N</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">New ticket</td></tr> <tr><td><kbd>N</kbd></td><td>New ticket</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>C</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus comment box</td></tr> <tr><td><kbd>C</kbd></td><td>Focus comment box</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd+E</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Toggle Edit Mode</td></tr> <tr><td><kbd>Ctrl/Cmd+E</kbd></td><td>Toggle Edit Mode</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>Ctrl/Cmd+S</kbd></td><td style="padding: 0.4rem;">Save Changes</td></tr> <tr><td><kbd>Ctrl/Cmd+S</kbd></td><td>Save Changes</td></tr>
</table> </table>
<h4 style="color: var(--terminal-amber); margin: 0 0 0.5rem 0; font-size: 0.9rem;">Quick Status (Ticket Page)</h4> <h4 class="kb-section-heading">Quick Status (Ticket Page)</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;"> <table class="kb-shortcuts-table">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>1</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Open</td></tr> <tr><td><kbd>1</kbd></td><td>Set Open</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>2</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Pending</td></tr> <tr><td><kbd>2</kbd></td><td>Set Pending</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>3</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set In Progress</td></tr> <tr><td><kbd>3</kbd></td><td>Set In Progress</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>4</kbd></td><td style="padding: 0.4rem;">Set Closed</td></tr> <tr><td><kbd>4</kbd></td><td>Set Closed</td></tr>
</table> </table>
<h4 style="color: var(--terminal-amber); margin: 0 0 0.5rem 0; font-size: 0.9rem;">Other</h4> <h4 class="kb-section-heading">Other</h4>
<table style="width: 100%; border-collapse: collapse;"> <table class="kb-shortcuts-table no-margin">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd+K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus Search</td></tr> <tr><td><kbd>Ctrl/Cmd+K</kbd></td><td>Focus Search</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>ESC</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Close Modal / Cancel</td></tr> <tr><td><kbd>ESC</kbd></td><td>Close Modal / Cancel</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>?</kbd></td><td style="padding: 0.4rem;">Show This Help</td></tr> <tr><td><kbd>?</kbd></td><td>Show This Help</td></tr>
</table> </table>
</div> </div>
<div class="lt-modal-footer"> <div class="lt-modal-footer">
<button class="lt-btn lt-btn-ghost" data-modal-close>Close</button> <button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
</div> </div>
</div> </div>
`; `;

View File

@@ -58,9 +58,11 @@ function saveTicket() {
} }
lt.toast.success('Ticket updated successfully'); lt.toast.success('Ticket updated successfully');
} else { } else {
lt.toast.error('Error saving ticket: ' + (data.error || 'Unknown error'));
} }
}) })
.catch(error => { .catch(error => {
lt.toast.error('Error saving ticket: ' + error.message);
}); });
} }
@@ -511,10 +513,10 @@ function showDependencyError(message) {
const dependentsList = document.getElementById('dependentsList'); const dependentsList = document.getElementById('dependentsList');
if (dependenciesList) { if (dependenciesList) {
dependenciesList.innerHTML = `<p style="color: var(--terminal-amber);">${lt.escHtml(message)}</p>`; dependenciesList.innerHTML = `<p class="text-amber">${lt.escHtml(message)}</p>`;
} }
if (dependentsList) { if (dependentsList) {
dependentsList.innerHTML = `<p style="color: var(--terminal-amber);">${lt.escHtml(message)}</p>`; dependentsList.innerHTML = `<p class="text-amber">${lt.escHtml(message)}</p>`;
} }
} }
@@ -557,7 +559,7 @@ function renderDependencies(dependencies) {
} }
if (!hasAny) { if (!hasAny) {
html = '<p style="color: var(--terminal-green-dim);">No dependencies configured.</p>'; html = '<p class="text-muted-green">No dependencies configured.</p>';
} }
container.innerHTML = html; container.innerHTML = html;
@@ -568,21 +570,21 @@ function renderDependents(dependents) {
if (!container) return; if (!container) return;
if (dependents.length === 0) { if (dependents.length === 0) {
container.innerHTML = '<p style="color: var(--terminal-green-dim);">No tickets depend on this one.</p>'; container.innerHTML = '<p class="text-muted-green">No tickets depend on this one.</p>';
return; return;
} }
let html = ''; let html = '';
dependents.forEach(dep => { dependents.forEach(dep => {
const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-'); const statusClass = 'status-' + dep.status.toLowerCase().replace(/ /g, '-');
html += `<div class="dependency-item" style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px dashed var(--terminal-green-dim);"> html += `<div class="dependency-item">
<div> <div>
<a href="/ticket/${lt.escHtml(dep.ticket_id)}" style="color: var(--terminal-green);"> <a href="/ticket/${lt.escHtml(dep.ticket_id)}">
#${lt.escHtml(dep.ticket_id)} #${lt.escHtml(dep.ticket_id)}
</a> </a>
<span style="margin-left: 0.5rem;">${lt.escHtml(dep.title)}</span> <span class="dependency-title">${lt.escHtml(dep.title)}</span>
<span class="status-badge ${statusClass}" style="margin-left: 0.5rem; font-size: 0.8rem;">${lt.escHtml(dep.status)}</span> <span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span>
<span style="margin-left: 0.5rem; color: var(--terminal-amber);">(${lt.escHtml(dep.dependency_type)})</span> <span class="dependency-title text-amber">(${lt.escHtml(dep.dependency_type)})</span>
</div> </div>
</div>`; </div>`;
}); });
@@ -623,10 +625,11 @@ function addDependency() {
} }
function removeDependency(dependencyId) { function removeDependency(dependencyId) {
if (!confirm('Are you sure you want to remove this dependency?')) { showConfirmModal(
return; 'Remove Dependency',
} 'Are you sure you want to remove this dependency?',
'warning',
function() {
lt.api.delete('/api/ticket_dependencies.php', { dependency_id: dependencyId }) lt.api.delete('/api/ticket_dependencies.php', { dependency_id: dependencyId })
.then(data => { .then(data => {
if (data.success) { if (data.success) {
@@ -640,6 +643,8 @@ function removeDependency(dependencyId) {
lt.toast.error('Error removing dependency', 4000); lt.toast.error('Error removing dependency', 4000);
}); });
} }
);
}
// ======================================== // ========================================
// Attachment Management Functions // Attachment Management Functions
@@ -789,11 +794,11 @@ function loadAttachments() {
if (data.success) { if (data.success) {
renderAttachments(data.attachments || []); renderAttachments(data.attachments || []);
} else { } else {
container.innerHTML = '<p style="color: var(--terminal-green-dim);">Error loading attachments.</p>'; container.innerHTML = '<p class="text-muted-green">Error loading attachments.</p>';
} }
}) })
.catch(error => { .catch(error => {
container.innerHTML = '<p style="color: var(--terminal-green-dim);">Error loading attachments.</p>'; container.innerHTML = '<p class="text-muted-green">Error loading attachments.</p>';
}); });
} }
@@ -802,7 +807,7 @@ function renderAttachments(attachments) {
if (!container) return; if (!container) return;
if (attachments.length === 0) { if (attachments.length === 0) {
container.innerHTML = '<p style="color: var(--terminal-green-dim);">No files attached to this ticket.</p>'; container.innerHTML = '<p class="text-muted-green">No files attached to this ticket.</p>';
return; return;
} }
@@ -823,7 +828,7 @@ function renderAttachments(attachments) {
<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div> <div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>
<div class="attachment-info"> <div class="attachment-info">
<div class="attachment-name" title="${lt.escHtml(att.original_filename)}"> <div class="attachment-name" title="${lt.escHtml(att.original_filename)}">
<a href="/api/download_attachment.php?id=${att.attachment_id}" target="_blank" style="color: var(--terminal-green);"> <a href="/api/download_attachment.php?id=${att.attachment_id}" target="_blank">
${lt.escHtml(att.original_filename)} ${lt.escHtml(att.original_filename)}
</a> </a>
</div> </div>
@@ -855,10 +860,11 @@ function formatFileSize(bytes) {
} }
function deleteAttachment(attachmentId) { function deleteAttachment(attachmentId) {
if (!confirm('Are you sure you want to delete this attachment?')) { showConfirmModal(
return; 'Delete Attachment',
} 'Are you sure you want to delete this attachment?',
'warning',
function() {
lt.api.post('/api/delete_attachment.php', { attachment_id: attachmentId }) lt.api.post('/api/delete_attachment.php', { attachment_id: attachmentId })
.then(data => { .then(data => {
if (data.success) { if (data.success) {
@@ -872,6 +878,8 @@ function deleteAttachment(attachmentId) {
lt.toast.error('Error deleting attachment', 4000); lt.toast.error('Error deleting attachment', 4000);
}); });
} }
);
}
// ======================================== // ========================================
// @Mention Autocomplete Functions // @Mention Autocomplete Functions
@@ -893,7 +901,12 @@ function initMentionAutocomplete() {
mentionAutocomplete = document.createElement('div'); mentionAutocomplete = document.createElement('div');
mentionAutocomplete.className = 'mention-autocomplete'; mentionAutocomplete.className = 'mention-autocomplete';
mentionAutocomplete.id = 'mentionAutocomplete'; mentionAutocomplete.id = 'mentionAutocomplete';
textarea.parentElement.style.position = 'relative'; mentionAutocomplete.setAttribute('role', 'listbox');
mentionAutocomplete.setAttribute('aria-label', 'User suggestions');
textarea.setAttribute('aria-autocomplete', 'list');
textarea.setAttribute('aria-controls', 'mentionAutocomplete');
textarea.setAttribute('aria-expanded', 'false');
textarea.parentElement.classList.add('has-overlay');
textarea.parentElement.appendChild(mentionAutocomplete); textarea.parentElement.appendChild(mentionAutocomplete);
// Fetch users list // Fetch users list
@@ -990,7 +1003,9 @@ function handleMentionKeydown(e) {
*/ */
function updateMentionSelection(options) { function updateMentionSelection(options) {
options.forEach((opt, i) => { options.forEach((opt, i) => {
opt.classList.toggle('selected', i === selectedMentionIndex); const isSelected = i === selectedMentionIndex;
opt.classList.toggle('selected', isSelected);
opt.setAttribute('aria-selected', isSelected ? 'true' : 'false');
}); });
} }
@@ -1012,7 +1027,8 @@ function showMentionSuggestions(query, textarea) {
let html = ''; let html = '';
filtered.forEach((user, index) => { filtered.forEach((user, index) => {
const isSelected = index === 0 ? 'selected' : ''; const isSelected = index === 0 ? 'selected' : '';
html += `<div class="mention-option ${isSelected}" data-username="${lt.escHtml(user.username)}" data-action="select-mention"> const ariaSelected = index === 0 ? 'true' : 'false';
html += `<div class="mention-option ${isSelected}" role="option" aria-selected="${ariaSelected}" data-username="${lt.escHtml(user.username)}" data-action="select-mention">
<span class="mention-username">@${lt.escHtml(user.username)}</span> <span class="mention-username">@${lt.escHtml(user.username)}</span>
${user.display_name ? `<span class="mention-displayname">${lt.escHtml(user.display_name)}</span>` : ''} ${user.display_name ? `<span class="mention-displayname">${lt.escHtml(user.display_name)}</span>` : ''}
</div>`; </div>`;
@@ -1020,6 +1036,8 @@ function showMentionSuggestions(query, textarea) {
mentionAutocomplete.innerHTML = html; mentionAutocomplete.innerHTML = html;
mentionAutocomplete.classList.add('active'); mentionAutocomplete.classList.add('active');
const textarea = document.getElementById('newComment');
if (textarea) textarea.setAttribute('aria-expanded', 'true');
selectedMentionIndex = 0; selectedMentionIndex = 0;
// Position dropdown below cursor // Position dropdown below cursor
@@ -1034,6 +1052,8 @@ function showMentionSuggestions(query, textarea) {
function hideMentionAutocomplete() { function hideMentionAutocomplete() {
if (mentionAutocomplete) { if (mentionAutocomplete) {
mentionAutocomplete.classList.remove('active'); mentionAutocomplete.classList.remove('active');
const textarea = document.getElementById('newComment');
if (textarea) textarea.setAttribute('aria-expanded', 'false');
} }
mentionStartPos = -1; mentionStartPos = -1;
} }
@@ -1257,19 +1277,17 @@ function cancelEditComment(commentId) {
* Delete a comment * Delete a comment
*/ */
function deleteComment(commentId) { function deleteComment(commentId) {
if (!confirm('Are you sure you want to delete this comment? This cannot be undone.')) { showConfirmModal(
return; 'Delete Comment',
} 'Are you sure you want to delete this comment? This cannot be undone.',
'warning',
function() {
lt.api.post('/api/delete_comment.php', { comment_id: commentId }) lt.api.post('/api/delete_comment.php', { comment_id: commentId })
.then(data => { .then(data => {
if (data.success) { if (data.success) {
// Remove the comment from the DOM
const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`); const commentDiv = document.querySelector(`.comment[data-comment-id="${commentId}"]`);
if (commentDiv) { if (commentDiv) {
commentDiv.style.transition = 'opacity 0.3s, transform 0.3s'; commentDiv.classList.add('comment--deleting');
commentDiv.style.opacity = '0';
commentDiv.style.transform = 'translateX(-20px)';
setTimeout(() => commentDiv.remove(), 300); setTimeout(() => commentDiv.remove(), 300);
} }
lt.toast.success('Comment deleted successfully'); lt.toast.success('Comment deleted successfully');
@@ -1281,6 +1299,8 @@ function deleteComment(commentId) {
lt.toast.error('Failed to delete comment'); lt.toast.error('Failed to delete comment');
}); });
} }
);
}
// ======================================== // ========================================
// Comment Reply Functions // Comment Reply Functions
@@ -1331,7 +1351,7 @@ function showReplyForm(commentId, userName) {
*/ */
function closeReplyForm() { function closeReplyForm() {
document.querySelectorAll('.reply-form-container').forEach(form => { document.querySelectorAll('.reply-form-container').forEach(form => {
form.style.animation = 'fadeIn 0.2s ease reverse'; form.classList.add('animate-fadeout');
setTimeout(() => form.remove(), 200); setTimeout(() => form.remove(), 200);
}); });
} }
@@ -1420,7 +1440,7 @@ function submitReply(parentCommentId) {
`; `;
// Add animation // Add animation
replyDiv.style.animation = 'fadeIn 0.3s ease'; replyDiv.classList.add('animate-fadein');
repliesContainer.appendChild(replyDiv); repliesContainer.appendChild(replyDiv);
} }

View File

@@ -10,3 +10,49 @@ function getTicketIdFromUrl() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
return params.get('id'); return params.get('id');
} }
/**
* Show a terminal-style confirmation modal using the lt.modal system.
* Falls back gracefully if dashboard.js has already defined this function.
* @param {string} title - Modal title
* @param {string} message - Confirmation message
* @param {string} type - 'warning' | 'error' | 'info'
* @param {Function} onConfirm - Called when user confirms
* @param {Function|null} onCancel - Called when user cancels
*/
if (typeof showConfirmModal === 'undefined') {
window.showConfirmModal = function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
const modalId = 'confirmModal' + Date.now();
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
const color = colors[type] || colors.warning;
const icon = icons[type] || icons.warning;
const safeTitle = lt.escHtml(title);
const safeMessage = lt.escHtml(message);
document.body.insertAdjacentHTML('beforeend', `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header" style="color:${color};">
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body text-center">
<p class="modal-message">${safeMessage}</p>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
</div>
</div>
</div>
`);
const modal = document.getElementById(modalId);
lt.modal.open(modalId);
const cleanup = (cb) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (cb) cb(); };
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
};
}

View File

@@ -12,11 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<title>Create New Ticket</title> <title>Create New Ticket</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260319d"> <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=20260319d"> <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="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.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; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests // CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
@@ -48,7 +48,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<div class="ticket-header"> <div class="ticket-header">
<h2>New Ticket Form</h2> <h2>New Ticket Form</h2>
<p style="color: var(--terminal-green); font-family: var(--font-mono); font-size: 0.9rem; margin-top: 0.5rem;"> <p class="form-hint">
Complete the form below to create a new ticket Complete the form below to create a new ticket
</p> </p>
</div> </div>
@@ -90,7 +90,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</select> </select>
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);"> <p class="form-hint">
Select a template to auto-fill form fields Select a template to auto-fill form fields
</p> </p>
</div> </div>
@@ -109,8 +109,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket"> <input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
</div> </div>
<!-- Duplicate Warning Area --> <!-- Duplicate Warning Area -->
<div id="duplicateWarning" class="inline-warning" role="alert" aria-live="polite" aria-atomic="true" style="display: none; margin-top: 1rem;"> <div id="duplicateWarning" class="inline-warning" role="alert" aria-live="polite" aria-atomic="true" style="display: none;">
<div style="color: var(--terminal-amber); font-weight: bold; margin-bottom: 0.5rem;"> <div class="text-amber fw-bold duplicate-heading">
Possible Duplicates Found Possible Duplicates Found
</div> </div>
<div id="duplicatesList" aria-live="polite"></div> <div id="duplicatesList" aria-live="polite"></div>
@@ -185,7 +185,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</select> </select>
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);"> <p class="form-hint">
Select a user to assign this ticket to Select a user to assign this ticket to
</p> </p>
</div> </div>
@@ -206,13 +206,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="internal">Internal - Specific groups only</option> <option value="internal">Internal - Specific groups only</option>
<option value="confidential">Confidential - Creator, assignee, admins only</option> <option value="confidential">Confidential - Creator, assignee, admins only</option>
</select> </select>
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);"> <p class="form-hint">
Controls who can view this ticket Controls who can view this ticket
</p> </p>
</div> </div>
<div id="visibilityGroupsContainer" class="detail-group" style="display: none; margin-top: 1rem;"> <div id="visibilityGroupsContainer" class="detail-group" style="display: none;">
<label>Allowed Groups</label> <label>Allowed Groups</label>
<div class="visibility-groups-list" style="display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 0.5rem;"> <div class="visibility-groups-list">
<?php <?php
// Get all available groups // Get all available groups
require_once __DIR__ . '/../models/UserModel.php'; require_once __DIR__ . '/../models/UserModel.php';
@@ -220,16 +220,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$allGroups = $userModel->getAllGroups(); $allGroups = $userModel->getAllGroups();
foreach ($allGroups as $group): foreach ($allGroups as $group):
?> ?>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;"> <label class="group-checkbox-label">
<input type="checkbox" name="visibility_groups[]" value="<?php echo htmlspecialchars($group); ?>" class="visibility-group-checkbox"> <input type="checkbox" name="visibility_groups[]" value="<?php echo htmlspecialchars($group); ?>" class="visibility-group-checkbox">
<span class="group-badge"><?php echo htmlspecialchars($group); ?></span> <span class="group-badge"><?php echo htmlspecialchars($group); ?></span>
</label> </label>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($allGroups)): ?> <?php if (empty($allGroups)): ?>
<span style="color: var(--text-muted);">No groups available</span> <span class="text-muted">No groups available</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<p style="color: var(--terminal-amber); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);"> <p class="form-hint-warning">
Select which groups can view this ticket Select which groups can view this ticket
</p> </p>
</div> </div>
@@ -288,8 +288,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
}); });
function checkForDuplicates(title) { function checkForDuplicates(title) {
fetch('/api/check_duplicates.php?title=' + encodeURIComponent(title)) lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
.then(response => response.json())
.then(data => { .then(data => {
const warningDiv = document.getElementById('duplicateWarning'); const warningDiv = document.getElementById('duplicateWarning');
const listDiv = document.getElementById('duplicatesList'); const listDiv = document.getElementById('duplicatesList');
@@ -352,6 +351,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
toggleVisibilityGroups(); toggleVisibilityGroups();
} }
}); });
if (window.lt) lt.keys.initDefaults();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -13,13 +13,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<title>Ticket Dashboard</title> <title>Ticket Dashboard</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260319d"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script> <script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.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; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests // CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
@@ -33,7 +32,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<!-- Terminal Boot Sequence --> <!-- Terminal Boot Sequence -->
<div id="boot-sequence" class="boot-overlay"> <div id="boot-sequence" class="boot-overlay">
<div id="boot-banner" style="text-align:center; margin-bottom: 1rem;"></div> <div id="boot-banner"></div>
<pre id="boot-text"></pre> <pre id="boot-text"></pre>
</div> </div>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
@@ -102,7 +101,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<button class="btn" style="font-size:0.75rem; padding: 0.2rem 0.5rem;" data-action="manual-refresh" title="Refresh now (auto-refreshes every 5 min)" aria-label="Refresh dashboard">REFRESH</button> <button class="btn btn-small" data-action="manual-refresh" title="Refresh now (auto-refreshes every 5 min)" aria-label="Refresh dashboard">REFRESH</button>
<button class="settings-icon" title="Settings (Alt+S)" data-action="open-settings" aria-label="Settings">[ CFG ]</button> <button class="settings-icon" title="Settings (Alt+S)" data-action="open-settings" aria-label="Settings">[ CFG ]</button>
<?php endif; ?> <?php endif; ?>
</div> </div>
@@ -390,7 +389,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<thead> <thead>
<tr> <tr>
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?> <?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
<th style="width: 40px;" scope="col"><input type="checkbox" id="selectAllCheckbox" data-action="toggle-select-all" aria-label="Select all tickets"></th> <th class="col-checkbox" scope="col"><input type="checkbox" id="selectAllCheckbox" data-action="toggle-select-all" aria-label="Select all tickets"></th>
<?php endif; ?> <?php endif; ?>
<?php <?php
$currentSort = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id'; $currentSort = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
@@ -412,7 +411,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
foreach($columns as $col => $label) { foreach($columns as $col => $label) {
if ($col === '_actions') { if ($col === '_actions') {
echo "<th scope='col' style='width: 100px; text-align: center;'>$label</th>"; echo "<th scope='col' class='col-actions text-center'>$label</th>";
} else { } else {
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc'; $newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
$sortClass = ($currentSort === $col) ? "sort-$currentDir" : ''; $sortClass = ($currentSort === $col) ? "sort-$currentDir" : '';
@@ -460,8 +459,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
} }
} else { } else {
$colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '12' : '11'; $colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '12' : '11';
echo "<tr><td colspan='$colspan' style='text-align: center; padding: 3rem;'>"; echo "<tr><td colspan='$colspan' class='dashboard-empty-state'>";
echo "<pre style='color: var(--terminal-green); text-shadow: var(--glow-green); font-size: 0.8rem; line-height: 1.2;'>"; echo "<pre class='dashboard-empty-pre'>";
echo "╔════════════════════════════════════════╗\n"; echo "╔════════════════════════════════════════╗\n";
echo "║ ║\n"; echo "║ ║\n";
echo "║ NO TICKETS FOUND ║\n"; echo "║ NO TICKETS FOUND ║\n";
@@ -832,9 +831,9 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
// Initialize lt keyboard defaults (ESC closes modals, Ctrl+K focuses search, ? shows help) // Initialize lt keyboard defaults (ESC closes modals, Ctrl+K focuses search, ? shows help)
if (window.lt) lt.keys.initDefaults(); if (window.lt) lt.keys.initDefaults();

View File

@@ -51,14 +51,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<title>Ticket #<?php echo $ticket['ticket_id']; ?></title> <title>Ticket #<?php echo $ticket['ticket_id']; ?></title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260319d"> <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=20260319d"> <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="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.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; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260131e"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests // CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
@@ -428,7 +427,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<p>Drag and drop files here or click to browse</p> <p>Drag and drop files here or click to browse</p>
<p class="upload-hint">Max file size: <?php echo $GLOBALS['config']['MAX_UPLOAD_SIZE'] ? number_format($GLOBALS['config']['MAX_UPLOAD_SIZE'] / 1048576, 0) . 'MB' : '10MB'; ?></p> <p class="upload-hint">Max file size: <?php echo $GLOBALS['config']['MAX_UPLOAD_SIZE'] ? number_format($GLOBALS['config']['MAX_UPLOAD_SIZE'] / 1048576, 0) . 'MB' : '10MB'; ?></p>
<input type="file" id="fileInput" multiple class="sr-only" aria-label="Upload files"> <input type="file" id="fileInput" multiple class="sr-only" aria-label="Upload files">
<button type="button" id="browseFilesBtn" class="btn upload-browse-btn">Browse Files</button> <button type="button" id="browseFilesBtn" class="btn upload-browse-btn">BROWSE FILES</button>
</div> </div>
</div> </div>
<div id="uploadProgress" class="upload-progress" style="display: none;"> <div id="uploadProgress" class="upload-progress" style="display: none;">
@@ -561,39 +560,36 @@ $nonce = SecurityHeadersMiddleware::getNonce();
var cloneBtn = document.getElementById('cloneButton'); var cloneBtn = document.getElementById('cloneButton');
if (cloneBtn) { if (cloneBtn) {
cloneBtn.addEventListener('click', function() { cloneBtn.addEventListener('click', function() {
if (confirm('Create a copy of this ticket? The new ticket will have the same title, description, priority, category, and type.')) { showConfirmModal(
'Clone Ticket',
'Create a copy of this ticket? The new ticket will have the same title, description, priority, category, and type.',
'warning',
function() {
cloneBtn.disabled = true; cloneBtn.disabled = true;
cloneBtn.textContent = 'Cloning...'; cloneBtn.textContent = 'Cloning...';
fetch('/api/clone_ticket.php', { lt.api.post('/api/clone_ticket.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
ticket_id: window.ticketData.ticket_id ticket_id: window.ticketData.ticket_id
}) })
})
.then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
toast.success('Ticket cloned successfully!'); lt.toast.success('Ticket cloned successfully!');
setTimeout(function() { setTimeout(function() {
window.location.href = '/ticket/' + data.new_ticket_id; window.location.href = '/ticket/' + data.new_ticket_id;
}, 1000); }, 1000);
} else { } else {
toast.error('Failed to clone ticket: ' + (data.error || 'Unknown error')); lt.toast.error('Failed to clone ticket: ' + (data.error || 'Unknown error'));
cloneBtn.disabled = false; cloneBtn.disabled = false;
cloneBtn.textContent = 'Clone'; cloneBtn.textContent = 'Clone';
} }
}) })
.catch(function(error) { .catch(function(error) {
toast.error('Failed to clone ticket: ' + error.message); lt.toast.error('Failed to clone ticket: ' + error.message);
cloneBtn.disabled = false; cloneBtn.disabled = false;
cloneBtn.textContent = 'Clone'; cloneBtn.textContent = 'Clone';
}); });
} }
);
}); });
} }
@@ -817,8 +813,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script> <script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body> </body>
</html> </html>

View File

@@ -13,10 +13,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<title>API Keys - Admin</title> <title>API Keys - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.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"> <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="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.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; ?>"> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script> </script>
@@ -25,7 +25,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: API Keys</span> <span class="admin-page-title">Admin: API Keys</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?php if (isset($GLOBALS['currentUser'])): ?>
@@ -35,24 +35,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;"> <div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span> <span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">API Key Management</div> <div class="ascii-section-header">API Key Management</div>
<div class="ascii-content"> <div class="ascii-content">
<!-- Generate New Key Form --> <!-- Generate New Key Form -->
<div class="ascii-frame-inner" style="margin-bottom: 1.5rem;"> <div class="ascii-frame-inner">
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">Generate New API Key</h3> <h3 class="admin-section-title">Generate New API Key</h3>
<form id="generateKeyForm" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end;"> <form id="generateKeyForm" class="admin-form-row">
<div style="flex: 1; min-width: 200px;"> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber); margin-bottom: 0.25rem;">Key Name *</label> <label class="admin-label" for="keyName">Key Name *</label>
<input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline" <input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline" class="admin-input">
style="width: 100%; padding: 0.5rem; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
</div> </div>
<div style="min-width: 150px;"> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber); margin-bottom: 0.25rem;">Expires In</label> <label class="admin-label" for="expiresIn">Expires In</label>
<select id="expiresIn" style="width: 100%; padding: 0.5rem; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);"> <select id="expiresIn" class="admin-input">
<option value="">Never</option> <option value="">Never</option>
<option value="30">30 days</option> <option value="30">30 days</option>
<option value="90">90 days</option> <option value="90">90 days</option>
@@ -67,22 +66,22 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
<!-- New Key Display (hidden by default) --> <!-- New Key Display (hidden by default) -->
<div id="newKeyDisplay" class="ascii-frame-inner key-generated-alert" style="display: none; margin-bottom: 1.5rem;"> <div id="newKeyDisplay" class="ascii-frame-inner key-generated-alert" style="display: none;">
<h3 style="color: var(--terminal-amber); margin-bottom: 0.5rem;">New API Key Generated</h3> <h3 class="admin-section-title">New API Key Generated</h3>
<p style="color: var(--priority-1); margin-bottom: 1rem; font-size: 0.9rem;"> <p class="text-danger text-sm mb-1">
Copy this key now. You won't be able to see it again! Copy this key now. You won't be able to see it again!
</p> </p>
<div style="display: flex; gap: 0.5rem; align-items: center;"> <div class="admin-form-row">
<input type="text" id="newKeyValue" readonly <input type="text" id="newKeyValue" readonly class="admin-input">
style="flex: 1; padding: 0.75rem; font-family: var(--font-mono); font-size: 0.85rem; background: var(--bg-primary); border: 2px solid var(--terminal-green); color: var(--terminal-green);">
<button data-action="copy-api-key" class="btn" title="Copy to clipboard">COPY</button> <button data-action="copy-api-key" class="btn" title="Copy to clipboard">COPY</button>
</div> </div>
</div> </div>
<!-- Existing Keys Table --> <!-- Existing Keys Table -->
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">Existing API Keys</h3> <h3 class="admin-section-title">Existing API Keys</h3>
<table style="width: 100%; font-size: 0.9rem;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
@@ -98,50 +97,45 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody> <tbody>
<?php if (empty($apiKeys)): ?> <?php if (empty($apiKeys)): ?>
<tr> <tr>
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="8" class="empty-state">No API keys found. Generate one above.</td>
No API keys found. Generate one above.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($apiKeys as $key): ?> <?php foreach ($apiKeys as $key): ?>
<tr id="key-row-<?php echo $key['api_key_id']; ?>"> <tr id="key-row-<?php echo $key['api_key_id']; ?>">
<td><?php echo htmlspecialchars($key['key_name']); ?></td> <td><?php echo htmlspecialchars($key['key_name']); ?></td>
<td style="font-family: var(--font-mono);"> <td class="mono">
<code><?php echo htmlspecialchars($key['key_prefix']); ?>...</code> <code><?php echo htmlspecialchars($key['key_prefix']); ?>...</code>
</td> </td>
<td><?php echo htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown'); ?></td> <td><?php echo htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown'); ?></td>
<td style="white-space: nowrap;"><?php echo date('Y-m-d H:i', strtotime($key['created_at'])); ?></td> <td class="nowrap"><?php echo date('Y-m-d H:i', strtotime($key['created_at'])); ?></td>
<td style="white-space: nowrap;"> <td class="nowrap">
<?php if ($key['expires_at']): ?> <?php if ($key['expires_at']): ?>
<?php <?php $expired = strtotime($key['expires_at']) < time(); ?>
$expired = strtotime($key['expires_at']) < time(); <span class="<?php echo $expired ? 'text-danger' : ''; ?>">
$color = $expired ? 'var(--priority-1)' : 'var(--terminal-green)';
?>
<span style="color: <?php echo $color; ?>;">
<?php echo date('Y-m-d', strtotime($key['expires_at'])); ?> <?php echo date('Y-m-d', strtotime($key['expires_at'])); ?>
<?php if ($expired): ?> (Expired)<?php endif; ?> <?php if ($expired): ?> (Expired)<?php endif; ?>
</span> </span>
<?php else: ?> <?php else: ?>
<span style="color: var(--terminal-cyan);">Never</span> <span class="text-cyan">Never</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="white-space: nowrap;"> <td class="nowrap">
<?php echo $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never'; ?> <?php echo $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never'; ?>
</td> </td>
<td> <td>
<?php if ($key['is_active']): ?> <?php if ($key['is_active']): ?>
<span style="color: var(--status-open);">Active</span> <span class="text-open">Active</span>
<?php else: ?> <?php else: ?>
<span style="color: var(--status-closed);">Revoked</span> <span class="text-closed">Revoked</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<?php if ($key['is_active']): ?> <?php if ($key['is_active']): ?>
<button data-action="revoke-key" data-id="<?php echo $key['api_key_id']; ?>" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;"> <button data-action="revoke-key" data-id="<?php echo $key['api_key_id']; ?>" class="btn btn-secondary btn-small">
Revoke REVOKE
</button> </button>
<?php else: ?> <?php else: ?>
<span style="color: var(--text-muted);">-</span> <span class="text-muted">-</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
@@ -150,13 +144,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<!-- API Usage Info --> <!-- API Usage Info -->
<div class="ascii-frame-inner" style="margin-top: 1.5rem;"> <div class="ascii-frame-inner">
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">API Usage</h3> <h3 class="admin-section-title">API Usage</h3>
<p style="margin-bottom: 0.5rem;">Include the API key in your requests using the Authorization header:</p> <p>Include the API key in your requests using the Authorization header:</p>
<pre style="background: var(--bg-primary); padding: 1rem; border: 1px solid var(--terminal-green); overflow-x: auto;"><code>Authorization: Bearer YOUR_API_KEY</code></pre> <pre class="admin-code-block"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--text-muted);"> <p class="text-muted text-sm">
API keys provide programmatic access to create and manage tickets. Keep your keys secure and rotate them regularly. API keys provide programmatic access to create and manage tickets. Keep your keys secure and rotate them regularly.
</p> </p>
</div> </div>
@@ -192,20 +187,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
} }
try { try {
const response = await fetch('/api/generate_api_key.php', { const data = await lt.api.post('/api/generate_api_key.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
key_name: keyName, key_name: keyName,
expires_in_days: expiresIn || null expires_in_days: expiresIn || null
})
}); });
const data = await response.json();
if (data.success) { if (data.success) {
// Show the new key // Show the new key
document.getElementById('newKeyValue').value = data.api_key; document.getElementById('newKeyValue').value = data.api_key;
@@ -231,32 +217,21 @@ $nonce = SecurityHeadersMiddleware::getNonce();
lt.toast.success('API key copied to clipboard'); lt.toast.success('API key copied to clipboard');
} }
async function revokeKey(keyId) { function revokeKey(keyId) {
if (!confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) { showConfirmModal('Revoke API Key', 'Are you sure you want to revoke this API key? This action cannot be undone.', 'error', function() {
return; lt.api.post('/api/revoke_api_key.php', { key_id: keyId })
} .then(data => {
try {
const response = await fetch('/api/revoke_api_key.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ key_id: keyId })
});
const data = await response.json();
if (data.success) { if (data.success) {
lt.toast.success('API key revoked successfully'); lt.toast.success('API key revoked successfully');
location.reload(); location.reload();
} else { } else {
lt.toast.error(data.error || 'Failed to revoke API key'); lt.toast.error(data.error || 'Failed to revoke API key');
} }
} catch (error) { })
.catch(error => {
lt.toast.error('Error revoking API key: ' + error.message); lt.toast.error('Error revoking API key: ' + error.message);
} });
});
} }
</script> </script>
</body> </body>

View File

@@ -13,8 +13,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<title>Audit Log - Admin</title> <title>Audit Log - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.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"> <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="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
@@ -24,7 +24,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Audit Log</span> <span class="admin-page-title">Admin: Audit Log</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?php if (isset($GLOBALS['currentUser'])): ?>
@@ -34,7 +34,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="ascii-frame-outer" style="max-width: 1400px; margin: 2rem auto;"> <div class="ascii-frame-outer admin-container-wide">
<span class="bottom-left-corner">╚</span> <span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
@@ -42,10 +42,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<!-- Filters --> <!-- Filters -->
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap;"> <form method="GET" class="admin-form-row">
<div> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Action Type</label> <label class="admin-label" for="action_type">Action Type</label>
<select name="action_type" class="setting-select"> <select name="action_type" id="action_type" class="admin-input">
<option value="">All Actions</option> <option value="">All Actions</option>
<option value="create" <?php echo ($filters['action_type'] ?? '') === 'create' ? 'selected' : ''; ?>>Create</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="update" <?php echo ($filters['action_type'] ?? '') === 'update' ? 'selected' : ''; ?>>Update</option>
@@ -57,9 +57,9 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="security" <?php echo ($filters['action_type'] ?? '') === 'security' ? 'selected' : ''; ?>>Security</option> <option value="security" <?php echo ($filters['action_type'] ?? '') === 'security' ? 'selected' : ''; ?>>Security</option>
</select> </select>
</div> </div>
<div> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">User</label> <label class="admin-label" for="user_id">User</label>
<select name="user_id" class="setting-select"> <select name="user_id" id="user_id" class="admin-input">
<option value="">All Users</option> <option value="">All Users</option>
<?php if (isset($users)): foreach ($users as $user): ?> <?php if (isset($users)): foreach ($users as $user): ?>
<option value="<?php echo $user['user_id']; ?>" <?php echo ($filters['user_id'] ?? '') == $user['user_id'] ? 'selected' : ''; ?>> <option value="<?php echo $user['user_id']; ?>" <?php echo ($filters['user_id'] ?? '') == $user['user_id'] ? 'selected' : ''; ?>>
@@ -68,22 +68,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php endforeach; endif; ?> <?php endforeach; endif; ?>
</select> </select>
</div> </div>
<div> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label> <label class="admin-label" for="date_from">Date From</label>
<input type="date" name="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="setting-select"> <input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="admin-input">
</div> </div>
<div> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label> <label class="admin-label" for="date_to">Date To</label>
<input type="date" name="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="setting-select"> <input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="admin-input">
</div> </div>
<div style="display: flex; align-items: flex-end;"> <div class="admin-form-actions">
<button type="submit" class="btn">FILTER</button> <button type="submit" class="btn">FILTER</button>
<a href="?" class="btn btn-secondary" style="margin-left: 0.5rem;">RESET</a> <a href="?" class="btn btn-secondary">RESET</a>
</div> </div>
</form> </form>
<!-- Log Table --> <!-- Log Table -->
<table style="width: 100%; font-size: 0.9rem;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>Timestamp</th> <th>Timestamp</th>
@@ -98,34 +99,32 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody> <tbody>
<?php if (empty($auditLogs)): ?> <?php if (empty($auditLogs)): ?>
<tr> <tr>
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="7" class="empty-state">No audit log entries found.</td>
No audit log entries found.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($auditLogs as $log): ?> <?php foreach ($auditLogs as $log): ?>
<tr> <tr>
<td style="white-space: nowrap;"><?php echo date('Y-m-d H:i:s', strtotime($log['created_at'])); ?></td> <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><?php echo htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System'); ?></td>
<td> <td>
<span style="color: var(--terminal-amber);"><?php echo htmlspecialchars($log['action_type']); ?></span> <span class="text-amber"><?php echo htmlspecialchars($log['action_type']); ?></span>
</td> </td>
<td><?php echo htmlspecialchars($log['entity_type'] ?? '-'); ?></td> <td><?php echo htmlspecialchars($log['entity_type'] ?? '-'); ?></td>
<td> <td>
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?> <?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
<a href="/ticket/<?php echo htmlspecialchars($log['entity_id']); ?>" style="color: var(--terminal-green);"> <a href="/ticket/<?php echo htmlspecialchars($log['entity_id']); ?>" class="text-green">
<?php echo htmlspecialchars($log['entity_id']); ?> <?php echo htmlspecialchars($log['entity_id']); ?>
</a> </a>
<?php else: ?> <?php else: ?>
<?php echo htmlspecialchars($log['entity_id'] ?? '-'); ?> <?php echo htmlspecialchars($log['entity_id'] ?? '-'); ?>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;"> <td class="td-truncate">
<?php <?php
if ($log['details']) { if ($log['details']) {
$details = is_string($log['details']) ? json_decode($log['details'], true) : $log['details']; $details = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
if (is_array($details)) { if (is_array($details)) {
echo '<code style="font-size: 0.8rem;">' . htmlspecialchars(json_encode($details)) . '</code>'; echo '<code>' . htmlspecialchars(json_encode($details)) . '</code>';
} else { } else {
echo htmlspecialchars($log['details']); echo htmlspecialchars($log['details']);
} }
@@ -134,16 +133,17 @@ $nonce = SecurityHeadersMiddleware::getNonce();
} }
?> ?>
</td> </td>
<td style="white-space: nowrap; font-size: 0.85rem;"><?php echo htmlspecialchars($log['ip_address'] ?? '-'); ?></td> <td class="nowrap text-sm"><?php echo htmlspecialchars($log['ip_address'] ?? '-'); ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div>
<!-- Pagination --> <!-- Pagination -->
<?php if ($totalPages > 1): ?> <?php if ($totalPages > 1): ?>
<div class="pagination" style="margin-top: 1rem; text-align: center;"> <div class="pagination">
<?php <?php
$params = $_GET; $params = $_GET;
for ($i = 1; $i <= min($totalPages, 10); $i++) { for ($i = 1; $i <= min($totalPages, 10); $i++) {
@@ -161,6 +161,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
</div> </div>
<script>if (window.lt) lt.keys.initDefaults();</script> <script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body> </body>
</html> </html>

View File

@@ -13,9 +13,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<title>Custom Fields - Admin</title> <title>Custom Fields - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.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"> <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="/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; ?>"> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script> </script>
@@ -24,7 +25,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Custom Fields</span> <span class="admin-page-title">Admin: Custom Fields</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?php if (isset($GLOBALS['currentUser'])): ?>
@@ -34,19 +35,20 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;"> <div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span> <span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Custom Fields Management</div> <div class="ascii-section-header">Custom Fields Management</div>
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> <div class="admin-header-row">
<h2 style="margin: 0;">Custom Field Definitions</h2> <h2>Custom Field Definitions</h2>
<button data-action="show-create-modal" class="btn">+ NEW FIELD</button> <button data-action="show-create-modal" class="btn">+ NEW FIELD</button>
</div> </div>
<table style="width: 100%;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>Order</th> <th>Order</th>
@@ -62,9 +64,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody> <tbody>
<?php if (empty($customFields)): ?> <?php if (empty($customFields)): ?>
<tr> <tr>
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="8" class="empty-state">No custom fields defined.</td>
No custom fields defined.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($customFields as $field): ?> <?php foreach ($customFields as $field): ?>
@@ -76,7 +76,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<td><?php echo htmlspecialchars($field['category'] ?? 'All'); ?></td> <td><?php echo htmlspecialchars($field['category'] ?? 'All'); ?></td>
<td><?php echo $field['is_required'] ? 'Yes' : 'No'; ?></td> <td><?php echo $field['is_required'] ? 'Yes' : 'No'; ?></td>
<td> <td>
<span style="color: <?php echo $field['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;"> <span class="<?php echo $field['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $field['is_active'] ? 'Active' : 'Inactive'; ?> <?php echo $field['is_active'] ? 'Active' : 'Inactive'; ?>
</span> </span>
</td> </td>
@@ -92,13 +92,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="fieldModal" aria-hidden="true"> <div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal" style="max-width: 500px;"> <div class="lt-modal lt-modal-sm">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Custom Field</span> <span class="lt-modal-title" id="modalTitle">Create Custom Field</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<form id="fieldForm"> <form id="fieldForm">
<input type="hidden" id="field_id" name="field_id"> <input type="hidden" id="field_id" name="field_id">
@@ -156,7 +157,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
function showCreateModal() { function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Custom Field'; document.getElementById('modalTitle').textContent = 'Create Custom Field';
@@ -230,30 +230,19 @@ $nonce = SecurityHeadersMiddleware::getNonce();
data.field_options = { options: options }; data.field_options = { options: options };
} }
const method = data.field_id ? 'PUT' : 'POST';
const url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : ''); 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);
fetch(url, { apiCall.then(result => {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
if (result.success) { if (result.success) {
window.location.reload(); window.location.reload();
} else { } else {
lt.toast.error(result.error || 'Failed to save'); lt.toast.error(result.error || 'Failed to save');
} }
}); }).catch(err => lt.toast.error('Failed to save'));
} }
function editField(id) { function editField(id) {
fetch('/api/custom_fields.php?id=' + id) lt.api.get('/api/custom_fields.php?id=' + id)
.then(r => r.json())
.then(data => { .then(data => {
if (data.success && data.field) { if (data.success && data.field) {
const f = data.field; const f = data.field;
@@ -276,14 +265,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
} }
function deleteField(id) { function deleteField(id) {
if (!confirm('Delete this custom field? All values will be lost.')) return; showConfirmModal('Delete Custom Field', 'Delete this custom field? All values will be lost.', 'error', function() {
fetch('/api/custom_fields.php?id=' + id, { lt.api.delete('/api/custom_fields.php?id=' + id)
method: 'DELETE',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
.then(data => { .then(data => {
if (data.success) window.location.reload(); if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
}); });
} }
</script> </script>

View File

@@ -13,9 +13,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<title>Recurring Tickets - Admin</title> <title>Recurring Tickets - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.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"> <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="/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; ?>"> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script> </script>
@@ -24,7 +25,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Recurring Tickets</span> <span class="admin-page-title">Admin: Recurring Tickets</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?php if (isset($GLOBALS['currentUser'])): ?>
@@ -34,19 +35,20 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;"> <div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span> <span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Recurring Tickets Management</div> <div class="ascii-section-header">Recurring Tickets Management</div>
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> <div class="admin-header-row">
<h2 style="margin: 0;">Scheduled Tickets</h2> <h2>Scheduled Tickets</h2>
<button data-action="show-create-modal" class="btn">+ NEW RECURRING TICKET</button> <button data-action="show-create-modal" class="btn">+ NEW RECURRING TICKET</button>
</div> </div>
<table style="width: 100%;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@@ -62,9 +64,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody> <tbody>
<?php if (empty($recurringTickets)): ?> <?php if (empty($recurringTickets)): ?>
<tr> <tr>
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="8" class="empty-state">No recurring tickets configured.</td>
No recurring tickets configured.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($recurringTickets as $rt): ?> <?php foreach ($recurringTickets as $rt): ?>
@@ -86,16 +86,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</td> </td>
<td><?php echo htmlspecialchars($rt['category']); ?></td> <td><?php echo htmlspecialchars($rt['category']); ?></td>
<td><?php echo htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned'); ?></td> <td><?php echo htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned'); ?></td>
<td><?php echo date('M d, Y H:i', strtotime($rt['next_run_at'])); ?></td> <td class="nowrap"><?php echo date('M d, Y H:i', strtotime($rt['next_run_at'])); ?></td>
<td> <td>
<span style="color: <?php echo $rt['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;"> <span class="<?php echo $rt['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $rt['is_active'] ? 'Active' : 'Inactive'; ?> <?php echo $rt['is_active'] ? 'Active' : 'Inactive'; ?>
</span> </span>
</td> </td>
<td> <td>
<button data-action="edit-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">EDIT</button> <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"> <button data-action="toggle-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">
<?php echo $rt['is_active'] ? 'Disable' : 'Enable'; ?> <?php echo $rt['is_active'] ? 'DISABLE' : 'ENABLE'; ?>
</button> </button>
<button data-action="delete-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small btn-danger">DELETE</button> <button data-action="delete-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td> </td>
@@ -107,24 +107,25 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="recurringModal" aria-hidden="true"> <div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal" style="max-width: 800px; width: 90%;"> <div class="lt-modal lt-modal-lg">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Recurring Ticket</span> <span class="lt-modal-title" id="modalTitle">Create Recurring Ticket</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<form id="recurringForm"> <form id="recurringForm">
<input type="hidden" id="recurring_id" name="recurring_id"> <input type="hidden" id="recurring_id" name="recurring_id">
<div class="lt-modal-body"> <div class="lt-modal-body">
<div class="setting-row"> <div class="setting-row">
<label for="title_template">Title Template *</label> <label for="title_template">Title Template *</label>
<input type="text" id="title_template" name="title_template" required style="width: 100%;" placeholder="Use {{date}}, {{month}}, etc."> <input type="text" id="title_template" name="title_template" required placeholder="Use {{date}}, {{month}}, etc.">
</div> </div>
<div class="setting-row"> <div class="setting-row">
<label for="description_template">Description Template</label> <label for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" rows="8" style="width: 100%; min-height: 150px;"></textarea> <textarea id="description_template" name="description_template" rows="8"></textarea>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<label for="schedule_type">Schedule Type *</label> <label for="schedule_type">Schedule Type *</label>
@@ -142,7 +143,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label for="schedule_time">Schedule Time *</label> <label for="schedule_time">Schedule Time *</label>
<input type="time" id="schedule_time" name="schedule_time" value="09:00" required> <input type="time" id="schedule_time" name="schedule_time" value="09:00" required>
</div> </div>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;"> <div class="setting-grid-2">
<div class="setting-row setting-row-compact"> <div class="setting-row setting-row-compact">
<label for="category">Category</label> <label for="category">Category</label>
<select id="category" name="category"> <select id="category" name="category">
@@ -191,7 +192,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
function showCreateModal() { function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket'; document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
@@ -271,53 +271,37 @@ $nonce = SecurityHeadersMiddleware::getNonce();
const form = new FormData(document.getElementById('recurringForm')); const form = new FormData(document.getElementById('recurringForm'));
const data = Object.fromEntries(form); const data = Object.fromEntries(form);
const method = data.recurring_id ? 'PUT' : 'POST';
const url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : ''); 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);
fetch(url, { apiCall.then(result => {
method: method, if (result.success) {
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.reload(); window.location.reload();
} else { } else {
lt.toast.error(data.error || 'Failed to save'); lt.toast.error(result.error || 'Failed to save');
} }
}); }).catch(err => lt.toast.error('Failed to save'));
} }
function toggleRecurring(id) { function toggleRecurring(id) {
fetch('/api/manage_recurring.php?action=toggle&id=' + id, { lt.api.post('/api/manage_recurring.php?action=toggle&id=' + id)
method: 'POST',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
.then(data => { .then(data => {
if (data.success) window.location.reload(); if (data.success) window.location.reload();
}); else lt.toast.error(data.error || 'Failed to toggle');
}).catch(err => lt.toast.error('Failed to toggle'));
} }
function deleteRecurring(id) { function deleteRecurring(id) {
if (!confirm('Delete this recurring ticket schedule?')) return; showConfirmModal('Delete Schedule', 'Delete this recurring ticket schedule?', 'error', function() {
fetch('/api/manage_recurring.php?id=' + id, { lt.api.delete('/api/manage_recurring.php?id=' + id)
method: 'DELETE',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
.then(data => { .then(data => {
if (data.success) window.location.reload(); if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
}); });
} }
function editRecurring(id) { function editRecurring(id) {
fetch('/api/manage_recurring.php?id=' + id) lt.api.get('/api/manage_recurring.php?id=' + id)
.then(r => r.json())
.then(data => { .then(data => {
if (data.success && data.recurring) { if (data.success && data.recurring) {
const rt = data.recurring; const rt = data.recurring;
@@ -340,8 +324,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
// Load users for assignee dropdown // Load users for assignee dropdown
function loadUsers() { function loadUsers() {
fetch('/api/get_users.php') lt.api.get('/api/get_users.php')
.then(r => r.json())
.then(data => { .then(data => {
if (data.success && data.users) { if (data.success && data.users) {
const select = document.getElementById('assigned_to'); const select = document.getElementById('assigned_to');

View File

@@ -13,9 +13,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<title>Template Management - Admin</title> <title>Template Management - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.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"> <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="/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; ?>"> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script> </script>
@@ -24,7 +25,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Templates</span> <span class="admin-page-title">Admin: Templates</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?php if (isset($GLOBALS['currentUser'])): ?>
@@ -34,23 +35,24 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;"> <div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span> <span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Ticket Template Management</div> <div class="ascii-section-header">Ticket Template Management</div>
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> <div class="admin-header-row">
<h2 style="margin: 0;">Ticket Templates</h2> <h2>Ticket Templates</h2>
<button data-action="show-create-modal" class="btn">+ NEW TEMPLATE</button> <button data-action="show-create-modal" class="btn">+ NEW TEMPLATE</button>
</div> </div>
<p style="color: var(--terminal-green-dim); margin-bottom: 1rem;"> <p class="text-muted-green mb-1">
Templates pre-fill ticket creation forms with standard content for common ticket types. Templates pre-fill ticket creation forms with standard content for common ticket types.
</p> </p>
<table style="width: 100%;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>Template Name</th> <th>Template Name</th>
@@ -64,9 +66,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody> <tbody>
<?php if (empty($templates)): ?> <?php if (empty($templates)): ?>
<tr> <tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="6" class="empty-state">No templates defined. Create templates to speed up ticket creation.</td>
No templates defined. Create templates to speed up ticket creation.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($templates as $tpl): ?> <?php foreach ($templates as $tpl): ?>
@@ -76,7 +76,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<td><?php echo htmlspecialchars($tpl['type'] ?? 'Any'); ?></td> <td><?php echo htmlspecialchars($tpl['type'] ?? 'Any'); ?></td>
<td>P<?php echo $tpl['default_priority'] ?? '4'; ?></td> <td>P<?php echo $tpl['default_priority'] ?? '4'; ?></td>
<td> <td>
<span style="color: <?php echo ($tpl['is_active'] ?? 1) ? 'var(--status-open)' : 'var(--status-closed)'; ?>;"> <span class="<?php echo ($tpl['is_active'] ?? 1) ? 'text-open' : 'text-closed'; ?>">
<?php echo ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive'; ?> <?php echo ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive'; ?>
</span> </span>
</td> </td>
@@ -92,30 +92,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="templateModal" aria-hidden="true"> <div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal" style="max-width: 800px; width: 90%;"> <div class="lt-modal lt-modal-lg">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Template</span> <span class="lt-modal-title" id="modalTitle">Create Template</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<form id="templateForm"> <form id="templateForm">
<input type="hidden" id="template_id" name="template_id"> <input type="hidden" id="template_id" name="template_id">
<div class="lt-modal-body"> <div class="lt-modal-body">
<div class="setting-row"> <div class="setting-row">
<label for="template_name">Template Name *</label> <label for="template_name">Template Name *</label>
<input type="text" id="template_name" name="template_name" required style="width: 100%;"> <input type="text" id="template_name" name="template_name" required>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<label for="title_template">Title Template</label> <label for="title_template">Title Template</label>
<input type="text" id="title_template" name="title_template" style="width: 100%;" placeholder="Pre-filled title text"> <input type="text" id="title_template" name="title_template" placeholder="Pre-filled title text">
</div> </div>
<div class="setting-row"> <div class="setting-row">
<label for="description_template">Description Template</label> <label for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" rows="10" style="width: 100%; min-height: 200px;" placeholder="Pre-filled description content"></textarea> <textarea id="description_template" name="description_template" rows="10" placeholder="Pre-filled description content"></textarea>
</div> </div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;"> <div class="setting-grid-3">
<div class="setting-row setting-row-compact"> <div class="setting-row setting-row-compact">
<label for="category">Category</label> <label for="category">Category</label>
<select id="category" name="category"> <select id="category" name="category">
@@ -162,7 +163,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
const templates = <?php echo json_encode($templates ?? []); ?>; const templates = <?php echo json_encode($templates ?? []); ?>;
@@ -217,25 +217,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
is_active: document.getElementById('is_active').checked ? 1 : 0 is_active: document.getElementById('is_active').checked ? 1 : 0
}; };
const method = data.template_id ? 'PUT' : 'POST';
const url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : ''); const url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
const apiCall = data.template_id ? lt.api.put(url, data) : lt.api.post(url, data);
fetch(url, { apiCall.then(result => {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
if (result.success) { if (result.success) {
window.location.reload(); window.location.reload();
} else { } else {
lt.toast.error(result.error || 'Failed to save'); lt.toast.error(result.error || 'Failed to save');
} }
}); }).catch(err => lt.toast.error('Failed to save'));
} }
function editTemplate(id) { function editTemplate(id) {
@@ -255,14 +245,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
} }
function deleteTemplate(id) { function deleteTemplate(id) {
if (!confirm('Delete this template?')) return; showConfirmModal('Delete Template', 'Delete this template?', 'error', function() {
fetch('/api/manage_templates.php?id=' + id, { lt.api.delete('/api/manage_templates.php?id=' + id)
method: 'DELETE',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
.then(data => { .then(data => {
if (data.success) window.location.reload(); if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
}); });
} }
</script> </script>

View File

@@ -13,8 +13,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<title>User Activity - Admin</title> <title>User Activity - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.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"> <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="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
@@ -24,7 +24,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: User Activity</span> <span class="admin-page-title">Admin: User Activity</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?php if (isset($GLOBALS['currentUser'])): ?>
@@ -34,7 +34,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;"> <div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span> <span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
@@ -42,37 +42,38 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<!-- Date Range Filter --> <!-- Date Range Filter -->
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: flex-end;"> <form method="GET" class="admin-form-row">
<div> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label> <label class="admin-label" for="date_from">Date From</label>
<input type="date" name="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="setting-select"> <input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="admin-input">
</div> </div>
<div> <div class="admin-form-field">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label> <label class="admin-label" for="date_to">Date To</label>
<input type="date" name="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="setting-select"> <input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="admin-input">
</div> </div>
<div class="admin-form-actions">
<button type="submit" class="btn">APPLY</button> <button type="submit" class="btn">APPLY</button>
<a href="?" class="btn btn-secondary">RESET</a> <a href="?" class="btn btn-secondary">RESET</a>
</div>
</form> </form>
<!-- User Activity Table --> <!-- User Activity Table -->
<table style="width: 100%;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>User</th> <th>User</th>
<th style="text-align: center;">Tickets Created</th> <th class="text-center">Tickets Created</th>
<th style="text-align: center;">Tickets Resolved</th> <th class="text-center">Tickets Resolved</th>
<th style="text-align: center;">Comments Added</th> <th class="text-center">Comments Added</th>
<th style="text-align: center;">Tickets Assigned</th> <th class="text-center">Tickets Assigned</th>
<th style="text-align: center;">Last Activity</th> <th class="text-center">Last Activity</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if (empty($userStats)): ?> <?php if (empty($userStats)): ?>
<tr> <tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="6" class="empty-state">No user activity data available.</td>
No user activity data available.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($userStats as $user): ?> <?php foreach ($userStats as $user): ?>
@@ -80,22 +81,22 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<td> <td>
<strong><?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?></strong> <strong><?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?></strong>
<?php if ($user['is_admin']): ?> <?php if ($user['is_admin']): ?>
<span class="admin-badge" style="font-size: 0.7rem;">[ ADMIN ]</span> <span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="text-align: center;"> <td class="text-center">
<span style="color: var(--terminal-green); font-weight: bold;"><?php echo $user['tickets_created'] ?? 0; ?></span> <span class="text-green fw-bold"><?php echo $user['tickets_created'] ?? 0; ?></span>
</td> </td>
<td style="text-align: center;"> <td class="text-center">
<span style="color: var(--status-open); font-weight: bold;"><?php echo $user['tickets_resolved'] ?? 0; ?></span> <span class="text-open fw-bold"><?php echo $user['tickets_resolved'] ?? 0; ?></span>
</td> </td>
<td style="text-align: center;"> <td class="text-center">
<span style="color: var(--terminal-cyan); font-weight: bold;"><?php echo $user['comments_added'] ?? 0; ?></span> <span class="text-cyan fw-bold"><?php echo $user['comments_added'] ?? 0; ?></span>
</td> </td>
<td style="text-align: center;"> <td class="text-center">
<span style="color: var(--terminal-amber); font-weight: bold;"><?php echo $user['tickets_assigned'] ?? 0; ?></span> <span class="text-amber fw-bold"><?php echo $user['tickets_assigned'] ?? 0; ?></span>
</td> </td>
<td style="text-align: center; font-size: 0.9rem;"> <td class="text-center text-sm">
<?php echo $user['last_activity'] ? date('M d, Y H:i', strtotime($user['last_activity'])) : 'Never'; ?> <?php echo $user['last_activity'] ? date('M d, Y H:i', strtotime($user['last_activity'])) : 'Never'; ?>
</td> </td>
</tr> </tr>
@@ -103,42 +104,32 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div>
<!-- Summary Stats --> <!-- Summary Stats -->
<?php if (!empty($userStats)): ?> <?php if (!empty($userStats)): ?>
<div style="margin-top: 2rem; padding: 1rem; border: 1px solid var(--terminal-green);"> <div class="admin-stats-grid">
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Summary</h4>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; text-align: center;">
<div> <div>
<div style="font-size: 1.5rem; color: var(--terminal-green); font-weight: bold;"> <div class="admin-stat-value text-green"><?php echo array_sum(array_column($userStats, 'tickets_created')); ?></div>
<?php echo array_sum(array_column($userStats, 'tickets_created')); ?> <div class="admin-stat-label">Total Created</div>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Created</div>
</div> </div>
<div> <div>
<div style="font-size: 1.5rem; color: var(--status-open); font-weight: bold;"> <div class="admin-stat-value text-open"><?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?></div>
<?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?> <div class="admin-stat-label">Total Resolved</div>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Resolved</div>
</div> </div>
<div> <div>
<div style="font-size: 1.5rem; color: var(--terminal-cyan); font-weight: bold;"> <div class="admin-stat-value text-cyan"><?php echo array_sum(array_column($userStats, 'comments_added')); ?></div>
<?php echo array_sum(array_column($userStats, 'comments_added')); ?> <div class="admin-stat-label">Total Comments</div>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Comments</div>
</div> </div>
<div> <div>
<div style="font-size: 1.5rem; color: var(--terminal-amber); font-weight: bold;"> <div class="admin-stat-value text-amber"><?php echo count($userStats); ?></div>
<?php echo count($userStats); ?> <div class="admin-stat-label">Active Users</div>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Active Users</div>
</div>
</div> </div>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
</div> </div>
<script>if (window.lt) lt.keys.initDefaults();</script> <script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body> </body>
</html> </html>

View File

@@ -13,9 +13,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<title>Workflow Designer - Admin</title> <title>Workflow Designer - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <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="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.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"> <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="/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; ?>"> <script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script> </script>
@@ -24,7 +25,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="user-header"> <div class="user-header">
<div class="user-header-left"> <div class="user-header-left">
<a href="/" class="back-link">[ ← DASHBOARD ]</a> <a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Workflow Designer</span> <span class="admin-page-title">Admin: Workflow Designer</span>
</div> </div>
<div class="user-header-right"> <div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?> <?php if (isset($GLOBALS['currentUser'])): ?>
@@ -34,36 +35,36 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;"> <div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span> <span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span> <span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Status Workflow Designer</div> <div class="ascii-section-header">Status Workflow Designer</div>
<div class="ascii-content"> <div class="ascii-content">
<div class="ascii-frame-inner"> <div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> <div class="admin-header-row">
<h2 style="margin: 0;">Status Transitions</h2> <h2>Status Transitions</h2>
<button data-action="show-create-modal" class="btn">+ NEW TRANSITION</button> <button data-action="show-create-modal" class="btn">+ NEW TRANSITION</button>
</div> </div>
<p style="color: var(--terminal-green-dim); margin-bottom: 1rem;"> <p class="text-muted-green mb-1">
Define which status transitions are allowed. This controls what options appear in the status dropdown. Define which status transitions are allowed. This controls what options appear in the status dropdown.
</p> </p>
<!-- Visual Workflow Diagram --> <!-- Visual Workflow Diagram -->
<div style="margin-bottom: 2rem; padding: 1rem; border: 1px solid var(--terminal-green); background: var(--bg-secondary);"> <div class="workflow-diagram">
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Workflow Diagram</h4> <h4 class="admin-section-title">Workflow Diagram</h4>
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap;"> <div class="workflow-diagram-nodes">
<?php <?php
$statuses = ['Open', 'Pending', 'In Progress', 'Closed']; $statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
foreach ($statuses as $status): foreach ($statuses as $status):
$statusClass = 'status-' . str_replace(' ', '-', strtolower($status)); $statusClass = 'status-' . str_replace(' ', '-', strtolower($status));
?> ?>
<div style="text-align: center;"> <div class="workflow-diagram-node">
<div class="<?php echo $statusClass; ?>" style="padding: 0.5rem 1rem; display: inline-block;"> <div class="<?php echo $statusClass; ?>">
<?php echo $status; ?> <?php echo $status; ?>
</div> </div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim); margin-top: 0.5rem;"> <div class="text-muted-green workflow-diagram-node-label">
<?php <?php
$toCount = 0; $toCount = 0;
if (isset($workflows)) { if (isset($workflows)) {
@@ -80,7 +81,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
<!-- Transitions Table --> <!-- Transitions Table -->
<table style="width: 100%;"> <div class="table-wrapper">
<table>
<thead> <thead>
<tr> <tr>
<th>From Status</th> <th>From Status</th>
@@ -95,9 +97,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody> <tbody>
<?php if (empty($workflows)): ?> <?php if (empty($workflows)): ?>
<tr> <tr>
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);"> <td colspan="7" class="empty-state">No transitions defined. Add transitions to enable status changes.</td>
No transitions defined. Add transitions to enable status changes.
</td>
</tr> </tr>
<?php else: ?> <?php else: ?>
<?php foreach ($workflows as $wf): ?> <?php foreach ($workflows as $wf): ?>
@@ -107,16 +107,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php echo htmlspecialchars($wf['from_status']); ?> <?php echo htmlspecialchars($wf['from_status']); ?>
</span> </span>
</td> </td>
<td style="text-align: center; color: var(--terminal-amber);">→</td> <td class="text-amber text-center">→</td>
<td> <td>
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['to_status'])); ?>"> <span class="status-<?php echo str_replace(' ', '-', strtolower($wf['to_status'])); ?>">
<?php echo htmlspecialchars($wf['to_status']); ?> <?php echo htmlspecialchars($wf['to_status']); ?>
</span> </span>
</td> </td>
<td style="text-align: center;"><?php echo $wf['requires_comment'] ? '✓' : ''; ?></td> <td class="text-center"><?php echo $wf['requires_comment'] ? '✓' : ''; ?></td>
<td style="text-align: center;"><?php echo $wf['requires_admin'] ? '✓' : ''; ?></td> <td class="text-center"><?php echo $wf['requires_admin'] ? '✓' : ''; ?></td>
<td style="text-align: center;"> <td class="text-center">
<span style="color: <?php echo $wf['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;"> <span class="<?php echo $wf['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $wf['is_active'] ? '✓' : '✗'; ?> <?php echo $wf['is_active'] ? '✓' : '✗'; ?>
</span> </span>
</td> </td>
@@ -132,13 +132,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Create/Edit Modal --> <!-- Create/Edit Modal -->
<div class="lt-modal-overlay" id="workflowModal" aria-hidden="true"> <div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal" style="max-width: 450px;"> <div class="lt-modal lt-modal-sm">
<div class="lt-modal-header"> <div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Transition</span> <span class="lt-modal-title" id="modalTitle">Create Transition</span>
<button class="lt-modal-close" data-modal-close>✕</button> <button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div> </div>
<form id="workflowForm"> <form id="workflowForm">
<input type="hidden" id="transition_id" name="transition_id"> <input type="hidden" id="transition_id" name="transition_id">
@@ -179,7 +180,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div> </div>
</div> </div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
const workflows = <?php echo json_encode($workflows ?? []); ?>; const workflows = <?php echo json_encode($workflows ?? []); ?>;
@@ -232,25 +232,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
is_active: document.getElementById('is_active').checked ? 1 : 0 is_active: document.getElementById('is_active').checked ? 1 : 0
}; };
const method = data.transition_id ? 'PUT' : 'POST';
const url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : ''); const url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
const apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
fetch(url, { apiCall.then(result => {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
if (result.success) { if (result.success) {
window.location.reload(); window.location.reload();
} else { } else {
lt.toast.error(result.error || 'Failed to save'); lt.toast.error(result.error || 'Failed to save');
} }
}); }).catch(err => lt.toast.error('Failed to save'));
} }
function editTransition(id) { function editTransition(id) {
@@ -268,14 +258,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
} }
function deleteTransition(id) { function deleteTransition(id) {
if (!confirm('Delete this status transition?')) return; showConfirmModal('Delete Transition', 'Delete this status transition?', 'error', function() {
fetch('/api/manage_workflows.php?id=' + id, { lt.api.delete('/api/manage_workflows.php?id=' + id)
method: 'DELETE',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
.then(data => { .then(data => {
if (data.success) window.location.reload(); if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
}); });
} }
</script> </script>