Compare commits
31 Commits
e756f8e0bb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ce95e555d5 | |||
| f45ec9b0f7 | |||
| 5a41ebf180 | |||
| e35401d54e | |||
| 913e294f9d | |||
| 28aa9e33ea | |||
| 31aa7d1b81 | |||
| 7695c6134c | |||
| 11f75fd823 | |||
| e179709fc3 | |||
| b03a9cfc8c | |||
| d44a530018 | |||
| 3c3b9d0a61 | |||
| 1046537429 | |||
| d8220da1e0 | |||
| 021c01b3d4 | |||
| 22cab10d5d | |||
| f0d7b9aa61 | |||
| 3493ed78f8 | |||
| 90c5b3ff71 | |||
| 84bea80abd | |||
| 2f9af856dc | |||
| 27075a62ee | |||
| dd8833ee2f | |||
| ab3e77a9ba | |||
| 68ff89b48c | |||
| 328c103460 | |||
| 21ef9154e9 | |||
| 4ecd72bc04 | |||
| 368ad9b48e | |||
| 3497c4cb47 |
22
README.md
22
README.md
@@ -126,6 +126,11 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
|
|||||||
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) |
|
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) |
|
||||||
| `Ctrl/Cmd + S` | Save changes (ticket page) |
|
| `Ctrl/Cmd + S` | Save changes (ticket page) |
|
||||||
| `Ctrl/Cmd + K` | Focus search box (dashboard) |
|
| `Ctrl/Cmd + K` | Focus search box (dashboard) |
|
||||||
|
| `N` | New ticket (dashboard) |
|
||||||
|
| `J` / `K` | Next / previous row (dashboard table) |
|
||||||
|
| `Enter` | Open selected ticket (dashboard) |
|
||||||
|
| `G` then `D` | Go to dashboard |
|
||||||
|
| `1`–`4` | Quick status change (ticket page) |
|
||||||
| `ESC` | Cancel edit / close modal |
|
| `ESC` | Cancel edit / close modal |
|
||||||
| `?` | Show keyboard shortcuts help |
|
| `?` | Show keyboard shortcuts help |
|
||||||
|
|
||||||
@@ -242,17 +247,20 @@ tinker_tickets/
|
|||||||
│ └── upload_attachment.php # GET/POST: List or upload attachments
|
│ └── upload_attachment.php # GET/POST: List or upload attachments
|
||||||
├── assets/
|
├── assets/
|
||||||
│ ├── css/
|
│ ├── css/
|
||||||
|
│ │ ├── base.css # LotusGuild Terminal Design System (symlinked from web_template)
|
||||||
│ │ ├── dashboard.css # Dashboard + terminal styling
|
│ │ ├── dashboard.css # Dashboard + terminal styling
|
||||||
│ │ └── ticket.css # Ticket view styling
|
│ │ └── ticket.css # Ticket view styling
|
||||||
│ ├── js/
|
│ ├── js/
|
||||||
│ │ ├── advanced-search.js # Advanced search modal
|
│ │ ├── advanced-search.js # Advanced search modal
|
||||||
│ │ ├── ascii-banner.js # ASCII art banner
|
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
|
||||||
|
│ │ ├── base.js # LotusGuild JS utilities — window.lt (symlinked from web_template)
|
||||||
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
|
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
|
||||||
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts
|
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
|
||||||
│ │ ├── 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 # Toast notifications
|
│ │ ├── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
|
||||||
|
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
|
||||||
│ └── images/
|
│ └── images/
|
||||||
│ └── favicon.png
|
│ └── favicon.png
|
||||||
├── config/
|
├── config/
|
||||||
@@ -387,6 +395,14 @@ Key conventions and gotchas for working with this codebase:
|
|||||||
15. **CSP nonces**: All inline `<script>` tags require `nonce="<?php echo $nonce; ?>"`
|
15. **CSP nonces**: All inline `<script>` tags require `nonce="<?php echo $nonce; ?>"`
|
||||||
16. **Visibility validation**: Internal visibility requires at least one group — validated server-side
|
16. **Visibility validation**: Internal visibility requires at least one group — validated server-side
|
||||||
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
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ try {
|
|||||||
throw new Exception("Invalid JSON data received");
|
throw new Exception("Invalid JSON data received");
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketId = $data['ticket_id'];
|
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0;
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize models
|
// Initialize models
|
||||||
$commentModel = new CommentModel($conn);
|
$commentModel = new CommentModel($conn);
|
||||||
|
|||||||
@@ -6,10 +6,17 @@ require_once dirname(__DIR__) . '/models/UserModel.php';
|
|||||||
|
|
||||||
// Get request data
|
// Get request data
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
$ticketId = $data['ticket_id'] ?? null;
|
if (!is_array($data)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : null;
|
||||||
$assignedTo = $data['assigned_to'] ?? null;
|
$assignedTo = $data['assigned_to'] ?? null;
|
||||||
|
|
||||||
if (!$ticketId) {
|
if (!$ticketId) {
|
||||||
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
|
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -18,6 +25,21 @@ $ticketModel = new TicketModel($conn);
|
|||||||
$auditLogModel = new AuditLogModel($conn);
|
$auditLogModel = new AuditLogModel($conn);
|
||||||
$userModel = new UserModel($conn);
|
$userModel = new UserModel($conn);
|
||||||
|
|
||||||
|
// Verify ticket exists
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization: only admins or the ticket creator/assignee can reassign
|
||||||
|
if (!$isAdmin && $ticket['created_by'] !== $userId && $ticket['assigned_to'] !== $userId) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if ($assignedTo === null || $assignedTo === '') {
|
if ($assignedTo === null || $assignedTo === '') {
|
||||||
// Unassign ticket
|
// Unassign ticket
|
||||||
$success = $ticketModel->unassignTicket($ticketId, $userId);
|
$success = $ticketModel->unassignTicket($ticketId, $userId);
|
||||||
@@ -40,4 +62,9 @@ if ($assignedTo === null || $assignedTo === '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(['success' => $success]);
|
if (!$success) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to update ticket assignment']);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ header('Content-Type: application/json');
|
|||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// Check admin status - bulk operations are admin-only
|
// Check admin status - bulk operations are admin-only
|
||||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||||
if (!$isAdmin) {
|
if (!$isAdmin) {
|
||||||
|
http_response_code(403);
|
||||||
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
@@ -50,8 +52,14 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$sourceTicketId = $data['ticket_id'];
|
$sourceTicketId = (int)$data['ticket_id'];
|
||||||
|
if ($sourceTicketId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
$userId = $_SESSION['user']['user_id'];
|
$userId = $_SESSION['user']['user_id'];
|
||||||
|
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||||
|
|
||||||
// Get database connection
|
// Get database connection
|
||||||
$conn = Database::getConnection();
|
$conn = Database::getConnection();
|
||||||
@@ -66,6 +74,15 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authorization: non-admins cannot clone internal tickets unless they created/are assigned
|
||||||
|
if (!$isAdmin && ($sourceTicket['visibility'] ?? 'public') === 'internal') {
|
||||||
|
if ($sourceTicket['created_by'] != $userId && $sourceTicket['assigned_to'] != $userId) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare cloned ticket data
|
// Prepare cloned ticket data
|
||||||
$clonedTicketData = [
|
$clonedTicketData = [
|
||||||
'title' => '[CLONE] ' . $sourceTicket['title'],
|
'title' => '[CLONE] ' . $sourceTicket['title'],
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ try {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authorization: admins can edit any ticket; others only their own or assigned
|
||||||
|
if (!$this->isAdmin
|
||||||
|
&& $currentTicket['created_by'] != $this->userId
|
||||||
|
&& $currentTicket['assigned_to'] != $this->userId
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Permission denied'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Merge current data with updates, keeping existing values for missing fields
|
// Merge current data with updates, keeping existing values for missing fields
|
||||||
$updateData = [
|
$updateData = [
|
||||||
'ticket_id' => $id,
|
'ticket_id' => $id,
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ if (empty($originalFilename)) {
|
|||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
try {
|
try {
|
||||||
$attachmentModel = new AttachmentModel();
|
$attachmentModel = new AttachmentModel($conn);
|
||||||
$attachmentId = $attachmentModel->addAttachment(
|
$attachmentId = $attachmentModel->addAttachment(
|
||||||
$ticketId,
|
$ticketId,
|
||||||
$uniqueFilename,
|
$uniqueFilename,
|
||||||
|
|||||||
@@ -128,9 +128,9 @@
|
|||||||
--header-height: 58px;
|
--header-height: 58px;
|
||||||
--container-max: 1600px;
|
--container-max: 1600px;
|
||||||
|
|
||||||
/* --- Transitions --- */
|
/* --- Transitions — scoped to GPU-safe properties (no box-shadow/filter) --- */
|
||||||
--transition-fast: all 0.15s ease;
|
--transition-fast: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
||||||
--transition-default: all 0.3s ease;
|
--transition-default: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
/* --- Z-index ladder --- */
|
/* --- Z-index ladder --- */
|
||||||
--z-base: 1;
|
--z-base: 1;
|
||||||
@@ -171,11 +171,10 @@ body {
|
|||||||
a {
|
a {
|
||||||
color: var(--terminal-green);
|
color: var(--terminal-green);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: var(--transition-fast);
|
transition: color 0.15s ease;
|
||||||
}
|
}
|
||||||
a:hover {
|
a:hover {
|
||||||
color: var(--terminal-amber);
|
color: var(--terminal-amber);
|
||||||
text-shadow: var(--glow-amber);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ul, ol { list-style: none; }
|
ul, ol { list-style: none; }
|
||||||
@@ -186,37 +185,21 @@ img, svg { display: block; max-width: 100%; }
|
|||||||
03. CRT & TERMINAL EFFECTS
|
03. CRT & TERMINAL EFFECTS
|
||||||
---------------------------------------------------------------- */
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
/* Horizontal scanline overlay — fixed over the entire viewport */
|
/* Scanlines baked into body background — position:fixed overlay removed because
|
||||||
body::before {
|
Chrome promotes all position:fixed elements to GPU compositing layers, causing
|
||||||
content: '';
|
a compositor re-blend blink on every CPU repaint triggered by hover states. */
|
||||||
position: fixed;
|
body {
|
||||||
inset: 0;
|
background-image: repeating-linear-gradient(
|
||||||
background: repeating-linear-gradient(
|
|
||||||
0deg,
|
0deg,
|
||||||
rgba(0, 0, 0, 0.15) 0px,
|
rgba(0, 0, 0, 0.15) 0px,
|
||||||
rgba(0, 0, 0, 0.15) 1px,
|
rgba(0, 0, 0, 0.15) 1px,
|
||||||
transparent 1px,
|
transparent 1px,
|
||||||
transparent 2px
|
transparent 2px
|
||||||
);
|
);
|
||||||
pointer-events: none;
|
|
||||||
z-index: var(--z-overlay);
|
|
||||||
animation: scanline 8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Binary data-stream watermark — bottom-right corner */
|
|
||||||
body::after {
|
|
||||||
content: '10101010';
|
|
||||||
position: fixed;
|
|
||||||
bottom: 10px;
|
|
||||||
right: 14px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.55rem;
|
|
||||||
color: var(--terminal-green);
|
|
||||||
opacity: 0.07;
|
|
||||||
pointer-events: none;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
animation: data-stream 3s steps(1) infinite;
|
|
||||||
}
|
}
|
||||||
|
body::before { display: none; }
|
||||||
|
/* body::after binary watermark also suppressed — was position:fixed (GPU layer) */
|
||||||
|
body::after { display: none; }
|
||||||
|
|
||||||
/* ----------------------------------------------------------------
|
/* ----------------------------------------------------------------
|
||||||
04. TYPOGRAPHY
|
04. TYPOGRAPHY
|
||||||
@@ -650,7 +633,6 @@ pre {
|
|||||||
}
|
}
|
||||||
.lt-card:hover {
|
.lt-card:hover {
|
||||||
border-color: var(--terminal-green);
|
border-color: var(--terminal-green);
|
||||||
box-shadow: var(--box-glow-green);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lt-card-title {
|
.lt-card-title {
|
||||||
@@ -701,13 +683,9 @@ pre {
|
|||||||
background: var(--terminal-green-dim);
|
background: var(--terminal-green-dim);
|
||||||
color: var(--terminal-amber);
|
color: var(--terminal-amber);
|
||||||
border-color: var(--terminal-amber);
|
border-color: var(--terminal-amber);
|
||||||
text-shadow: var(--glow-amber-intense);
|
|
||||||
box-shadow: var(--box-glow-amber);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
.lt-btn:active {
|
.lt-btn:active {
|
||||||
transform: translateY(0);
|
opacity: 0.85;
|
||||||
box-shadow: var(--box-glow-green);
|
|
||||||
}
|
}
|
||||||
.lt-btn:disabled {
|
.lt-btn:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
@@ -725,7 +703,6 @@ pre {
|
|||||||
.lt-btn-primary::before { content: '> '; }
|
.lt-btn-primary::before { content: '> '; }
|
||||||
.lt-btn-primary:hover {
|
.lt-btn-primary:hover {
|
||||||
background: var(--terminal-amber-dim);
|
background: var(--terminal-amber-dim);
|
||||||
box-shadow: var(--box-glow-amber);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Red (destructive / danger) */
|
/* Red (destructive / danger) */
|
||||||
@@ -737,8 +714,6 @@ pre {
|
|||||||
background: var(--terminal-red-dim);
|
background: var(--terminal-red-dim);
|
||||||
color: var(--terminal-red);
|
color: var(--terminal-red);
|
||||||
border-color: var(--terminal-red);
|
border-color: var(--terminal-red);
|
||||||
text-shadow: var(--glow-red);
|
|
||||||
box-shadow: var(--box-glow-red);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small variant */
|
/* Small variant */
|
||||||
@@ -899,7 +874,6 @@ pre {
|
|||||||
}
|
}
|
||||||
.lt-table tbody tr:hover {
|
.lt-table tbody tr:hover {
|
||||||
background: rgba(0, 255, 65, 0.06);
|
background: rgba(0, 255, 65, 0.06);
|
||||||
box-shadow: inset 0 0 20px rgba(0, 255, 65, 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Data table — compact, row-only separators, good for dense data */
|
/* Data table — compact, row-only separators, good for dense data */
|
||||||
@@ -948,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); }
|
||||||
@@ -1203,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
|
||||||
@@ -1357,8 +1333,6 @@ pre {
|
|||||||
}
|
}
|
||||||
.lt-stat-card:hover {
|
.lt-stat-card:hover {
|
||||||
border-color: var(--terminal-amber);
|
border-color: var(--terminal-amber);
|
||||||
box-shadow: var(--box-glow-amber);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
.lt-stat-card.active {
|
.lt-stat-card.active {
|
||||||
background: var(--terminal-amber-dim);
|
background: var(--terminal-amber-dim);
|
||||||
@@ -1560,23 +1534,23 @@ pre {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes corner-pulse {
|
@keyframes corner-pulse {
|
||||||
0%, 100% { text-shadow: var(--glow-green); }
|
0%, 100% { opacity: 0.7; }
|
||||||
50% { text-shadow: var(--glow-green-intense); }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes subtle-pulse {
|
@keyframes subtle-pulse {
|
||||||
0%, 100% { text-shadow: var(--glow-amber); }
|
0%, 100% { opacity: 0.75; }
|
||||||
50% { text-shadow: var(--glow-amber-intense); }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-glow {
|
@keyframes pulse-glow {
|
||||||
0%, 100% { text-shadow: 0 0 5px currentColor, 0 0 10px currentColor; }
|
0%, 100% { opacity: 0.7; }
|
||||||
50% { text-shadow: 0 0 10px currentColor, 0 0 20px currentColor, 0 0 30px currentColor; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-red {
|
@keyframes pulse-red {
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 68, 68, 0.5); }
|
0%, 100% { opacity: 0.6; }
|
||||||
50% { box-shadow: 0 0 6px 3px rgba(255, 68, 68, 0.2); }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes focus-pulse {
|
@keyframes focus-pulse {
|
||||||
@@ -1615,8 +1589,8 @@ pre {
|
|||||||
|
|
||||||
/* Item pulse for actively running tasks */
|
/* Item pulse for actively running tasks */
|
||||||
@keyframes exec-running-pulse {
|
@keyframes exec-running-pulse {
|
||||||
0%, 100% { border-color: var(--terminal-green); }
|
0%, 100% { border-color: var(--terminal-green); opacity: 0.7; }
|
||||||
50% { border-color: var(--status-running); box-shadow: 0 0 8px rgba(255, 193, 7, 0.35); }
|
50% { border-color: var(--status-running); opacity: 1; }
|
||||||
}
|
}
|
||||||
.lt-item-running { animation: exec-running-pulse 2s ease-in-out infinite; }
|
.lt-item-running { animation: exec-running-pulse 2s ease-in-out infinite; }
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -262,12 +262,11 @@
|
|||||||
color: var(--terminal-green);
|
color: var(--terminal-green);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-select:hover {
|
.metadata-select:hover {
|
||||||
border-color: var(--terminal-amber);
|
border-color: var(--terminal-amber);
|
||||||
box-shadow: var(--glow-amber);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-select:focus {
|
.metadata-select:focus {
|
||||||
@@ -346,24 +345,28 @@ textarea[data-field="description"]:not(:disabled)::after {
|
|||||||
color: var(--terminal-amber);
|
color: var(--terminal-amber);
|
||||||
border-color: var(--terminal-amber);
|
border-color: var(--terminal-amber);
|
||||||
background: rgba(255, 176, 0, 0.1);
|
background: rgba(255, 176, 0, 0.1);
|
||||||
|
box-shadow: 0 0 6px rgba(255, 176, 0, 0.4);
|
||||||
animation: pulse-warning 2s ease-in-out infinite;
|
animation: pulse-warning 2s ease-in-out infinite;
|
||||||
|
will-change: opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticket-age.age-critical {
|
.ticket-age.age-critical {
|
||||||
color: var(--priority-1);
|
color: var(--priority-1);
|
||||||
border-color: var(--priority-1);
|
border-color: var(--priority-1);
|
||||||
background: rgba(255, 77, 77, 0.15);
|
background: rgba(255, 77, 77, 0.15);
|
||||||
|
box-shadow: 0 0 8px rgba(255, 77, 77, 0.5);
|
||||||
animation: pulse-critical 1s ease-in-out infinite;
|
animation: pulse-critical 1s ease-in-out infinite;
|
||||||
|
will-change: opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-warning {
|
@keyframes pulse-warning {
|
||||||
0%, 100% { box-shadow: 0 0 5px rgba(255, 176, 0, 0.3); }
|
0%, 100% { opacity: 0.75; }
|
||||||
50% { box-shadow: 0 0 15px rgba(255, 176, 0, 0.6); }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-critical {
|
@keyframes pulse-critical {
|
||||||
0%, 100% { box-shadow: 0 0 5px rgba(255, 77, 77, 0.3); }
|
0%, 100% { opacity: 0.7; }
|
||||||
50% { box-shadow: 0 0 20px rgba(255, 77, 77, 0.8); }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab transition animations */
|
/* Tab transition animations */
|
||||||
@@ -463,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;
|
||||||
@@ -508,7 +555,7 @@ textarea[data-field="description"]:not(:disabled)::after {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: all 0.3s ease;
|
transition: border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
input.editable {
|
input.editable {
|
||||||
@@ -537,6 +584,8 @@ textarea.editable {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button Styles */
|
/* Button Styles */
|
||||||
@@ -548,7 +597,7 @@ textarea.editable {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
transition: all 0.3s ease;
|
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.primary {
|
.btn.primary {
|
||||||
@@ -564,8 +613,6 @@ textarea.editable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Comments Section - TERMINAL STYLE */
|
/* Comments Section - TERMINAL STYLE */
|
||||||
@@ -629,7 +676,7 @@ textarea.editable {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transition: all 0.3s ease;
|
transition: border-color 0.2s ease;
|
||||||
animation: comment-appear 0.4s ease-out;
|
animation: comment-appear 0.4s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,8 +693,6 @@ textarea.editable {
|
|||||||
|
|
||||||
.comment:hover {
|
.comment:hover {
|
||||||
border-color: var(--terminal-amber);
|
border-color: var(--terminal-amber);
|
||||||
background: linear-gradient(135deg, var(--bg-primary) 0%, rgba(255, 176, 0, 0.03) 100%);
|
|
||||||
box-shadow: 0 0 15px rgba(0, 255, 65, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment:hover::before,
|
.comment:hover::before,
|
||||||
@@ -764,13 +809,16 @@ textarea.editable {
|
|||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-action-btn:hover {
|
.comment-action-btn:hover,
|
||||||
|
.comment-action-btn:focus-visible {
|
||||||
background: rgba(0, 255, 65, 0.1);
|
background: rgba(0, 255, 65, 0.1);
|
||||||
|
outline: 2px solid var(--terminal-amber);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-action-btn.edit-btn:hover {
|
.comment-action-btn.edit-btn:hover {
|
||||||
@@ -942,6 +990,11 @@ textarea.editable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.animate-fadein { animation: fadeIn 0.3s ease; }
|
||||||
|
.is-hidden { display: none !important; }
|
||||||
|
.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;
|
||||||
@@ -1059,7 +1112,7 @@ textarea.editable {
|
|||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--terminal-green);
|
color: var(--terminal-green);
|
||||||
transition: all 0.3s ease;
|
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-right: -2px;
|
margin-right: -2px;
|
||||||
}
|
}
|
||||||
@@ -1074,9 +1127,12 @@ textarea.editable {
|
|||||||
color: var(--terminal-green);
|
color: var(--terminal-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn:hover {
|
.tab-btn:hover,
|
||||||
|
.tab-btn:focus-visible {
|
||||||
background: rgba(0, 255, 65, 0.05);
|
background: rgba(0, 255, 65, 0.05);
|
||||||
color: var(--terminal-amber);
|
color: var(--terminal-amber);
|
||||||
|
outline: 2px solid var(--terminal-amber);
|
||||||
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn.active {
|
.tab-btn.active {
|
||||||
@@ -1159,7 +1215,7 @@ textarea.editable {
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
transition: .4s;
|
transition: background-color 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider:before {
|
.slider:before {
|
||||||
@@ -1169,8 +1225,8 @@ textarea.editable {
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
left: 4px;
|
left: 4px;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
background-color: white;
|
background-color: var(--bg-primary);
|
||||||
transition: .4s;
|
transition: transform 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider.round {
|
.slider.round {
|
||||||
@@ -1328,24 +1384,24 @@ input:checked + .slider:before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .timeline-content {
|
body.dark-mode .timeline-content {
|
||||||
--card-bg: #2d3748;
|
--card-bg: var(--bg-tertiary);
|
||||||
--border-color: #444;
|
--border-color: var(--border-color);
|
||||||
--text-muted: #a0aec0;
|
--text-muted: var(--text-muted);
|
||||||
--text-secondary: #cbd5e0;
|
--text-secondary: var(--text-secondary);
|
||||||
background: #2d3748;
|
background: var(--bg-tertiary);
|
||||||
color: #f7fafc;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .timeline-header strong {
|
body.dark-mode .timeline-header strong {
|
||||||
color: #f7fafc;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .timeline-action {
|
body.dark-mode .timeline-action {
|
||||||
color: #a0aec0;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .timeline-date {
|
body.dark-mode .timeline-date {
|
||||||
color: #718096;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
/* Status select dropdown */
|
/* Status select dropdown */
|
||||||
.status-select {
|
.status-select {
|
||||||
@@ -1357,38 +1413,38 @@ body.dark-mode .timeline-date {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: opacity 0.15s ease, border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-select:hover {
|
.status-select:hover {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
border-color: rgba(0, 255, 65, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-select:focus {
|
.status-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: rgba(255, 255, 255, 0.5);
|
border-color: var(--terminal-amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status colors for dropdown */
|
/* Status colors for dropdown */
|
||||||
.status-select.status-open {
|
.status-select.status-open {
|
||||||
background-color: var(--status-open) !important;
|
background-color: var(--status-open) !important;
|
||||||
color: white !important;
|
color: var(--bg-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-select.status-in-progress {
|
.status-select.status-in-progress {
|
||||||
background-color: var(--status-in-progress) !important;
|
background-color: var(--status-in-progress) !important;
|
||||||
color: #212529 !important;
|
color: var(--bg-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-select.status-closed {
|
.status-select.status-closed {
|
||||||
background-color: var(--status-closed) !important;
|
background-color: var(--status-closed) !important;
|
||||||
color: white !important;
|
color: var(--bg-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-select.status-resolved {
|
.status-select.status-resolved {
|
||||||
background-color: #28a745 !important;
|
background-color: var(--status-open) !important;
|
||||||
color: white !important;
|
color: var(--bg-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dropdown options inherit colors */
|
/* Dropdown options inherit colors */
|
||||||
@@ -1399,66 +1455,56 @@ body.dark-mode .timeline-date {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .status-select option {
|
body.dark-mode .status-select option {
|
||||||
background-color: #2d3748;
|
background-color: var(--bg-tertiary);
|
||||||
color: #f7fafc;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode for Activity tab and general improvements */
|
/* Dark mode for Activity tab and general improvements */
|
||||||
body.dark-mode .tab-content {
|
body.dark-mode .tab-content {
|
||||||
color: var(--text-primary, #f7fafc);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode #activity-tab {
|
body.dark-mode #activity-tab {
|
||||||
background: var(--bg-secondary, #1a202c);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-primary, #f7fafc);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode #activity-tab p {
|
body.dark-mode #activity-tab p {
|
||||||
color: var(--text-primary, #f7fafc);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Comprehensive Dark Mode Fix - Ensure no white on white */
|
/* Comprehensive Dark Mode Fix - terminal CSS variables apply throughout */
|
||||||
body.dark-mode {
|
|
||||||
--bg-primary: #1a202c;
|
|
||||||
--bg-secondary: #2d3748;
|
|
||||||
--bg-tertiary: #4a5568;
|
|
||||||
--text-primary: #e2e8f0;
|
|
||||||
--text-secondary: #cbd5e0;
|
|
||||||
--text-muted: #a0aec0;
|
|
||||||
--border-color: #4a5568;
|
|
||||||
--card-bg: #2d3748;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure ticket container has dark background */
|
/* Ensure ticket container has dark background */
|
||||||
body.dark-mode .ticket-container {
|
body.dark-mode .ticket-container {
|
||||||
background: #1a202c !important;
|
background: var(--bg-secondary) !important;
|
||||||
color: #e2e8f0 !important;
|
color: var(--text-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure all ticket details sections are dark */
|
/* Ensure all ticket details sections are dark */
|
||||||
body.dark-mode .ticket-details {
|
body.dark-mode .ticket-details {
|
||||||
background: #1a202c !important;
|
background: var(--bg-secondary) !important;
|
||||||
color: #e2e8f0 !important;
|
color: var(--text-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure detail groups are dark */
|
/* Ensure detail groups are dark */
|
||||||
body.dark-mode .detail-group {
|
body.dark-mode .detail-group {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
color: #e2e8f0 !important;
|
color: var(--text-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure labels are visible */
|
/* Ensure labels are visible */
|
||||||
body.dark-mode .detail-group label,
|
body.dark-mode .detail-group label,
|
||||||
body.dark-mode label {
|
body.dark-mode label {
|
||||||
color: #cbd5e0 !important;
|
color: var(--text-secondary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix textarea and input fields */
|
/* Fix textarea and input fields */
|
||||||
body.dark-mode textarea,
|
body.dark-mode textarea,
|
||||||
body.dark-mode input[type="text"] {
|
body.dark-mode input[type="text"] {
|
||||||
background: #2d3748 !important;
|
background: var(--bg-tertiary) !important;
|
||||||
color: #e2e8f0 !important;
|
color: var(--text-primary) !important;
|
||||||
border-color: #4a5568 !important;
|
border-color: var(--border-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure timeline event backgrounds are dark */
|
/* Ensure timeline event backgrounds are dark */
|
||||||
@@ -1468,30 +1514,38 @@ body.dark-mode .timeline-event {
|
|||||||
|
|
||||||
/* Fix any remaining white text issues */
|
/* Fix any remaining white text issues */
|
||||||
body.dark-mode .timeline-details {
|
body.dark-mode .timeline-details {
|
||||||
color: #cbd5e0 !important;
|
color: var(--text-secondary) !important;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix comment sections */
|
/* Fix comment sections */
|
||||||
body.dark-mode .comment {
|
body.dark-mode .comment {
|
||||||
background: #2d3748 !important;
|
background: var(--bg-tertiary) !important;
|
||||||
color: #e2e8f0 !important;
|
color: var(--text-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .comment-text {
|
body.dark-mode .comment-text {
|
||||||
color: #e2e8f0 !important;
|
color: var(--text-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .comment-header {
|
body.dark-mode .comment-header {
|
||||||
color: #cbd5e0 !important;
|
color: var(--text-secondary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix any form elements */
|
/* Fix any form elements */
|
||||||
body.dark-mode select,
|
body.dark-mode select,
|
||||||
body.dark-mode .editable {
|
body.dark-mode .editable {
|
||||||
background: #2d3748 !important;
|
background: var(--bg-tertiary) !important;
|
||||||
color: #e2e8f0 !important;
|
color: var(--text-primary) !important;
|
||||||
border-color: #4a5568 !important;
|
border-color: var(--border-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== RELATIVE TIMESTAMP CELLS ===== */
|
||||||
|
|
||||||
|
span.ts-cell {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dotted var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== RESPONSIVE DESIGN - TERMINAL EDITION ===== */
|
/* ===== RESPONSIVE DESIGN - TERMINAL EDITION ===== */
|
||||||
@@ -1608,7 +1662,7 @@ body.dark-mode .editable {
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1695,7 +1749,7 @@ body.dark-mode .editable {
|
|||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border: 1px solid var(--terminal-green);
|
border: 1px solid var(--terminal-green);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
transition: all 0.2s ease;
|
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-item:hover {
|
.attachment-item:hover {
|
||||||
@@ -1756,7 +1810,7 @@ body.dark-mode .editable {
|
|||||||
|
|
||||||
.btn-danger:hover {
|
.btn-danger:hover {
|
||||||
background: var(--priority-1);
|
background: var(--priority-1);
|
||||||
color: white;
|
color: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile responsiveness for attachments */
|
/* Mobile responsiveness for attachments */
|
||||||
@@ -1786,12 +1840,11 @@ body.dark-mode .editable {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: background-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mention:hover {
|
.mention:hover {
|
||||||
background: rgba(0, 255, 255, 0.2);
|
background: rgba(0, 255, 255, 0.2);
|
||||||
text-shadow: 0 0 5px var(--terminal-cyan);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mention::before {
|
.mention::before {
|
||||||
@@ -1821,7 +1874,7 @@ body.dark-mode .editable {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--terminal-green);
|
color: var(--terminal-green);
|
||||||
transition: all 0.2s ease;
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -1862,7 +1915,7 @@ body.dark-mode .editable {
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
min-width: 32px;
|
min-width: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1915,7 +1968,7 @@ body.dark-mode .editable {
|
|||||||
color: var(--terminal-green);
|
color: var(--terminal-green);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
transition: all 0.2s ease;
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-dropdown-content a:hover {
|
.export-dropdown-content a:hover {
|
||||||
@@ -2137,6 +2190,38 @@ body.dark-mode .editable {
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dependency list items */
|
||||||
|
.dependency-group h4 {
|
||||||
|
color: var(--terminal-amber);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px dashed var(--terminal-green-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-item a {
|
||||||
|
color: var(--terminal-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-item .dependency-title {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-item .status-badge {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-item .btn-small {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Upload progress */
|
/* Upload progress */
|
||||||
.upload-progress {
|
.upload-progress {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -2645,7 +2730,7 @@ body.dark-mode .editable {
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: rgba(0, 255, 65, 0.05);
|
background: rgba(0, 255, 65, 0.05);
|
||||||
border-radius: 4px;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -148,7 +140,7 @@ async function saveCurrentFilter() {
|
|||||||
'My Filter',
|
'My Filter',
|
||||||
async (filterName) => {
|
async (filterName) => {
|
||||||
if (!filterName || filterName.trim() === '') {
|
if (!filterName || filterName.trim() === '') {
|
||||||
toast.warning('Filter name cannot be empty', 2000);
|
lt.toast.warning('Filter name cannot be empty', 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function initMobileSidebar() {
|
|||||||
const toggleBtn = document.createElement('button');
|
const toggleBtn = document.createElement('button');
|
||||||
toggleBtn.id = 'mobileFilterToggle';
|
toggleBtn.id = 'mobileFilterToggle';
|
||||||
toggleBtn.className = 'mobile-filter-toggle';
|
toggleBtn.className = 'mobile-filter-toggle';
|
||||||
toggleBtn.innerHTML = '☰ Filters & Search Options';
|
toggleBtn.innerHTML = '[ = ] Filters & Search Options';
|
||||||
toggleBtn.onclick = openMobileSidebar;
|
toggleBtn.onclick = openMobileSidebar;
|
||||||
dashboardMain.insertBefore(toggleBtn, dashboardMain.firstChild);
|
dashboardMain.insertBefore(toggleBtn, dashboardMain.firstChild);
|
||||||
}
|
}
|
||||||
@@ -79,20 +79,20 @@ function initMobileSidebar() {
|
|||||||
nav.className = 'mobile-bottom-nav';
|
nav.className = 'mobile-bottom-nav';
|
||||||
nav.innerHTML = `
|
nav.innerHTML = `
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<span class="nav-icon">🏠</span>
|
<span class="nav-icon">[ ~ ]</span>
|
||||||
<span class="nav-label">Home</span>
|
<span class="nav-label">HOME</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" data-action="open-mobile-sidebar">
|
<button type="button" data-action="open-mobile-sidebar">
|
||||||
<span class="nav-icon">🔍</span>
|
<span class="nav-icon">[ / ]</span>
|
||||||
<span class="nav-label">Filter</span>
|
<span class="nav-label">FILTER</span>
|
||||||
</button>
|
</button>
|
||||||
<a href="/ticket/create">
|
<a href="/ticket/create">
|
||||||
<span class="nav-icon">➕</span>
|
<span class="nav-icon">[ + ]</span>
|
||||||
<span class="nav-label">New</span>
|
<span class="nav-label">NEW</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" data-action="open-settings-modal">
|
<button type="button" data-action="open-settings-modal">
|
||||||
<span class="nav-icon">⚙</span>
|
<span class="nav-icon">[ * ]</span>
|
||||||
<span class="nav-label">Settings</span>
|
<span class="nav-label">CFG</span>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(nav);
|
document.body.appendChild(nav);
|
||||||
@@ -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);
|
||||||
@@ -366,11 +365,13 @@ function sortTable(table, column) {
|
|||||||
const aValue = a.children[column].textContent.trim();
|
const aValue = a.children[column].textContent.trim();
|
||||||
const bValue = b.children[column].textContent.trim();
|
const bValue = b.children[column].textContent.trim();
|
||||||
|
|
||||||
// Check if this is a date column
|
// Check if this is a date column — prefer data-ts attribute over text (which may be relative)
|
||||||
const headerText = headers[column].textContent.toLowerCase();
|
const headerText = headers[column].textContent.toLowerCase();
|
||||||
if (headerText === 'created' || headerText === 'updated') {
|
if (headerText === 'created' || headerText === 'updated') {
|
||||||
const dateA = new Date(aValue);
|
const cellA = a.children[column];
|
||||||
const dateB = new Date(bValue);
|
const cellB = b.children[column];
|
||||||
|
const dateA = new Date(cellA.dataset.ts || aValue);
|
||||||
|
const dateB = new Date(cellB.dataset.ts || bValue);
|
||||||
return currentDirection === 'asc' ? dateA - dateB : dateB - dateA;
|
return currentDirection === 'asc' ? dateA - dateB : dateB - dateA;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,24 +521,7 @@ function quickSave() {
|
|||||||
priority: parseInt(prioritySelect.value)
|
priority: parseInt(prioritySelect.value)
|
||||||
};
|
};
|
||||||
|
|
||||||
fetch('/api/update_ticket.php', {
|
lt.api.post('/api/update_ticket.php', data)
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
return response.text().then(text => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error('Invalid JSON response: ' + text);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Update the hamburger menu display
|
// Update the hamburger menu display
|
||||||
@@ -566,11 +550,11 @@ function quickSave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
toast.error('Error updating ticket: ' + (result.error || 'Unknown error'), 5000);
|
lt.toast.error('Error updating ticket: ' + (result.error || 'Unknown error'), 5000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error('Error updating ticket: ' + error.message, 5000);
|
lt.toast.error('Error updating ticket: ' + error.message, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,19 +571,7 @@ function saveTicket() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch('/api/update_ticket.php', {
|
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data })
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
ticket_id: ticketId,
|
|
||||||
...data
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if(data.success) {
|
if(data.success) {
|
||||||
const statusDisplay = document.getElementById('statusDisplay');
|
const statusDisplay = document.getElementById('statusDisplay');
|
||||||
@@ -620,6 +592,7 @@ function saveTicket() {
|
|||||||
*/
|
*/
|
||||||
function loadTemplate() {
|
function loadTemplate() {
|
||||||
const templateSelect = document.getElementById('templateSelect');
|
const templateSelect = document.getElementById('templateSelect');
|
||||||
|
if (!templateSelect) return;
|
||||||
const templateId = templateSelect.value;
|
const templateId = templateSelect.value;
|
||||||
|
|
||||||
if (!templateId) {
|
if (!templateId) {
|
||||||
@@ -637,15 +610,7 @@ function loadTemplate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch template data
|
// Fetch template data
|
||||||
fetch(`/api/get_template.php?template_id=${templateId}`, {
|
lt.api.get(`/api/get_template.php?template_id=${templateId}`)
|
||||||
credentials: 'same-origin'
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch template');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.template) {
|
if (data.success && data.template) {
|
||||||
const template = data.template;
|
const template = data.template;
|
||||||
@@ -671,11 +636,11 @@ function loadTemplate() {
|
|||||||
document.getElementById('priority').value = template.default_priority;
|
document.getElementById('priority').value = template.default_priority;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error('Failed to load template: ' + (data.error || 'Unknown error'), 4000);
|
lt.toast.error('Failed to load template: ' + (data.error || 'Unknown error'), 4000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error('Error loading template: ' + error.message, 4000);
|
lt.toast.error('Error loading template: ' + error.message, 4000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,6 +650,7 @@ function loadTemplate() {
|
|||||||
|
|
||||||
function toggleSelectAll() {
|
function toggleSelectAll() {
|
||||||
const selectAll = document.getElementById('selectAllCheckbox');
|
const selectAll = document.getElementById('selectAllCheckbox');
|
||||||
|
if (!selectAll) return;
|
||||||
const checkboxes = document.querySelectorAll('.ticket-checkbox');
|
const checkboxes = document.querySelectorAll('.ticket-checkbox');
|
||||||
|
|
||||||
checkboxes.forEach(checkbox => {
|
checkboxes.forEach(checkbox => {
|
||||||
@@ -717,22 +683,14 @@ function updateSelectionCount() {
|
|||||||
const exportCount = document.getElementById('exportCount');
|
const exportCount = document.getElementById('exportCount');
|
||||||
|
|
||||||
if (toolbar && countDisplay) {
|
if (toolbar && countDisplay) {
|
||||||
if (count > 0) {
|
toolbar.classList.toggle('is-visible', count > 0);
|
||||||
toolbar.style.display = 'flex';
|
if (count > 0) countDisplay.textContent = count;
|
||||||
countDisplay.textContent = count;
|
|
||||||
} else {
|
|
||||||
toolbar.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide export dropdown based on selection
|
// Show/hide export dropdown based on selection
|
||||||
if (exportDropdown) {
|
if (exportDropdown) {
|
||||||
if (count > 0) {
|
exportDropdown.classList.toggle('is-visible', count > 0);
|
||||||
exportDropdown.style.display = '';
|
if (count > 0 && exportCount) exportCount.textContent = count;
|
||||||
if (exportCount) exportCount.textContent = count;
|
|
||||||
} else {
|
|
||||||
exportDropdown.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,7 +710,7 @@ function bulkClose() {
|
|||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
|
|
||||||
if (ticketIds.length === 0) {
|
if (ticketIds.length === 0) {
|
||||||
toast.warning('No tickets selected', 2000);
|
lt.toast.warning('No tickets selected', 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,33 +724,24 @@ function bulkClose() {
|
|||||||
|
|
||||||
function performBulkCloseAction(ticketIds) {
|
function performBulkCloseAction(ticketIds) {
|
||||||
|
|
||||||
fetch('/api/bulk_operation.php', {
|
lt.api.post('/api/bulk_operation.php', {
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
operation_type: 'bulk_close',
|
operation_type: 'bulk_close',
|
||||||
ticket_ids: ticketIds
|
ticket_ids: ticketIds
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
if (data.failed > 0) {
|
if (data.failed > 0) {
|
||||||
toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
lt.toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
||||||
} else {
|
} else {
|
||||||
toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
|
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
|
||||||
}
|
}
|
||||||
setTimeout(() => window.location.reload(), 1500);
|
setTimeout(() => window.location.reload(), 1500);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error('Bulk close failed: ' + error.message, 5000);
|
lt.toast.error('Bulk close failed: ' + error.message, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,27 +749,27 @@ function showBulkAssignModal() {
|
|||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
|
|
||||||
if (ticketIds.length === 0) {
|
if (ticketIds.length === 0) {
|
||||||
toast.warning('No tickets selected', 2000);
|
lt.toast.warning('No tickets selected', 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button data-action="perform-bulk-assign" class="lt-btn lt-btn-primary">Assign</button>
|
<button data-action="perform-bulk-assign" class="lt-btn lt-btn-primary">ASSIGN</button>
|
||||||
<button data-action="close-bulk-assign-modal" class="lt-btn lt-btn-ghost">Cancel</button>
|
<button data-action="close-bulk-assign-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -834,6 +783,7 @@ function showBulkAssignModal() {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success && data.users) {
|
if (data.success && data.users) {
|
||||||
const select = document.getElementById('bulkAssignUser');
|
const select = document.getElementById('bulkAssignUser');
|
||||||
|
if (select) {
|
||||||
data.users.forEach(user => {
|
data.users.forEach(user => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = user.user_id;
|
option.value = user.user_id;
|
||||||
@@ -841,6 +791,7 @@ function showBulkAssignModal() {
|
|||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => lt.toast.error('Error loading users'));
|
.catch(() => lt.toast.error('Error loading users'));
|
||||||
}
|
}
|
||||||
@@ -856,39 +807,30 @@ function performBulkAssign() {
|
|||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
toast.warning('Please select a user', 2000);
|
lt.toast.warning('Please select a user', 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch('/api/bulk_operation.php', {
|
lt.api.post('/api/bulk_operation.php', {
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
operation_type: 'bulk_assign',
|
operation_type: 'bulk_assign',
|
||||||
ticket_ids: ticketIds,
|
ticket_ids: ticketIds,
|
||||||
parameters: { assigned_to: parseInt(userId) }
|
parameters: { assigned_to: parseInt(userId) }
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
closeBulkAssignModal();
|
closeBulkAssignModal();
|
||||||
if (data.failed > 0) {
|
if (data.failed > 0) {
|
||||||
toast.warning(`Bulk assign: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
lt.toast.warning(`Bulk assign: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
||||||
} else {
|
} else {
|
||||||
toast.success(`Successfully assigned ${data.processed} ticket(s)`, 4000);
|
lt.toast.success(`Successfully assigned ${data.processed} ticket(s)`, 4000);
|
||||||
}
|
}
|
||||||
setTimeout(() => window.location.reload(), 1500);
|
setTimeout(() => window.location.reload(), 1500);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error('Bulk assign failed: ' + error.message, 5000);
|
lt.toast.error('Bulk assign failed: ' + error.message, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,20 +838,20 @@ function showBulkPriorityModal() {
|
|||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
|
|
||||||
if (ticketIds.length === 0) {
|
if (ticketIds.length === 0) {
|
||||||
toast.warning('No tickets selected', 2000);
|
lt.toast.warning('No tickets selected', 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
@@ -919,8 +861,8 @@ function showBulkPriorityModal() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button data-action="perform-bulk-priority" class="lt-btn lt-btn-primary">Update</button>
|
<button data-action="perform-bulk-priority" class="lt-btn lt-btn-primary">UPDATE</button>
|
||||||
<button data-action="close-bulk-priority-modal" class="lt-btn lt-btn-ghost">Cancel</button>
|
<button data-action="close-bulk-priority-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -941,39 +883,30 @@ function performBulkPriority() {
|
|||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
|
|
||||||
if (!priority) {
|
if (!priority) {
|
||||||
toast.warning('Please select a priority', 2000);
|
lt.toast.warning('Please select a priority', 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch('/api/bulk_operation.php', {
|
lt.api.post('/api/bulk_operation.php', {
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
operation_type: 'bulk_priority',
|
operation_type: 'bulk_priority',
|
||||||
ticket_ids: ticketIds,
|
ticket_ids: ticketIds,
|
||||||
parameters: { priority: parseInt(priority) }
|
parameters: { priority: parseInt(priority) }
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
closeBulkPriorityModal();
|
closeBulkPriorityModal();
|
||||||
if (data.failed > 0) {
|
if (data.failed > 0) {
|
||||||
toast.warning(`Priority update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
lt.toast.warning(`Priority update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
||||||
} else {
|
} else {
|
||||||
toast.success(`Successfully updated priority for ${data.processed} ticket(s)`, 4000);
|
lt.toast.success(`Successfully updated priority for ${data.processed} ticket(s)`, 4000);
|
||||||
}
|
}
|
||||||
setTimeout(() => window.location.reload(), 1500);
|
setTimeout(() => window.location.reload(), 1500);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error('Bulk priority update failed: ' + error.message, 5000);
|
lt.toast.error('Bulk priority update failed: ' + error.message, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -985,7 +918,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
|
||||||
@@ -1013,20 +945,20 @@ function showBulkStatusModal() {
|
|||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
|
|
||||||
if (ticketIds.length === 0) {
|
if (ticketIds.length === 0) {
|
||||||
toast.warning('No tickets selected', 2000);
|
lt.toast.warning('No tickets selected', 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
@@ -1035,8 +967,8 @@ function showBulkStatusModal() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button data-action="perform-bulk-status" class="lt-btn lt-btn-primary">Update</button>
|
<button data-action="perform-bulk-status" class="lt-btn lt-btn-primary">UPDATE</button>
|
||||||
<button data-action="close-bulk-status-modal" class="lt-btn lt-btn-ghost">Cancel</button>
|
<button data-action="close-bulk-status-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1057,39 +989,30 @@ function performBulkStatusChange() {
|
|||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
toast.warning('Please select a status', 2000);
|
lt.toast.warning('Please select a status', 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch('/api/bulk_operation.php', {
|
lt.api.post('/api/bulk_operation.php', {
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
operation_type: 'bulk_status',
|
operation_type: 'bulk_status',
|
||||||
ticket_ids: ticketIds,
|
ticket_ids: ticketIds,
|
||||||
parameters: { status: status }
|
parameters: { status: status }
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
closeBulkStatusModal();
|
closeBulkStatusModal();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
if (data.failed > 0) {
|
if (data.failed > 0) {
|
||||||
toast.warning(`Status update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
lt.toast.warning(`Status update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
||||||
} else {
|
} else {
|
||||||
toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
|
lt.toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
|
||||||
}
|
}
|
||||||
setTimeout(() => window.location.reload(), 1500);
|
setTimeout(() => window.location.reload(), 1500);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error('Bulk status change failed: ' + error.message, 5000);
|
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1098,24 +1021,24 @@ function showBulkDeleteModal() {
|
|||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
|
|
||||||
if (ticketIds.length === 0) {
|
if (ticketIds.length === 0) {
|
||||||
toast.warning('No tickets selected', 2000);
|
lt.toast.warning('No tickets selected', 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -1134,30 +1057,21 @@ function closeBulkDeleteModal() {
|
|||||||
function performBulkDelete() {
|
function performBulkDelete() {
|
||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
|
|
||||||
fetch('/api/bulk_operation.php', {
|
lt.api.post('/api/bulk_operation.php', {
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
operation_type: 'bulk_delete',
|
operation_type: 'bulk_delete',
|
||||||
ticket_ids: ticketIds
|
ticket_ids: ticketIds
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
closeBulkDeleteModal();
|
closeBulkDeleteModal();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success(`Successfully deleted ${ticketIds.length} ticket(s)`, 4000);
|
lt.toast.success(`Successfully deleted ${ticketIds.length} ticket(s)`, 4000);
|
||||||
setTimeout(() => window.location.reload(), 1500);
|
setTimeout(() => window.location.reload(), 1500);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error('Bulk delete failed: ' + error.message, 5000);
|
lt.toast.error('Bulk delete failed: ' + error.message, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1186,29 +1100,29 @@ function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel
|
|||||||
|
|
||||||
// Icon based on type
|
// Icon based on type
|
||||||
const icons = {
|
const icons = {
|
||||||
warning: '⚠',
|
warning: '[ ! ]',
|
||||||
error: '✗',
|
error: '[ X ]',
|
||||||
info: 'ℹ'
|
info: '[ i ]',
|
||||||
};
|
};
|
||||||
const icon = icons[type] || icons.warning;
|
const icon = icons[type] || icons.warning;
|
||||||
|
|
||||||
// Escape user-provided content to prevent XSS
|
// Escape user-provided content to prevent XSS
|
||||||
const safeTitle = escapeHtml(title);
|
const safeTitle = lt.escHtml(title);
|
||||||
const safeMessage = escapeHtml(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>
|
||||||
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">Cancel</button>
|
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1243,24 +1157,24 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
|
|||||||
const inputId = modalId + '_input';
|
const inputId = modalId + '_input';
|
||||||
|
|
||||||
// Escape user-provided content to prevent XSS
|
// Escape user-provided content to prevent XSS
|
||||||
const safeTitle = escapeHtml(title);
|
const safeTitle = lt.escHtml(title);
|
||||||
const safeLabel = escapeHtml(label);
|
const safeLabel = lt.escHtml(label);
|
||||||
const safePlaceholder = escapeHtml(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>
|
||||||
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">Cancel</button>
|
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1300,23 +1214,23 @@ 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 #${escapeHtml(ticketId)}</p>
|
<p class="mb-half">Ticket #${lt.escHtml(ticketId)}</p>
|
||||||
<p style="margin-bottom:0.5rem;color:var(--terminal-amber);">Current: ${escapeHtml(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>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button data-action="perform-quick-status" data-ticket-id="${ticketId}" class="lt-btn lt-btn-primary">Update</button>
|
<button data-action="perform-quick-status" data-ticket-id="${ticketId}" class="lt-btn lt-btn-primary">UPDATE</button>
|
||||||
<button data-action="close-quick-status-modal" class="lt-btn lt-btn-ghost">Cancel</button>
|
<button data-action="close-quick-status-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1335,31 +1249,19 @@ function closeQuickStatusModal() {
|
|||||||
function performQuickStatusChange(ticketId) {
|
function performQuickStatusChange(ticketId) {
|
||||||
const newStatus = document.getElementById('quickStatusSelect').value;
|
const newStatus = document.getElementById('quickStatusSelect').value;
|
||||||
|
|
||||||
fetch('/api/update_ticket.php', {
|
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
ticket_id: ticketId,
|
|
||||||
status: newStatus
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
closeQuickStatusModal();
|
closeQuickStatusModal();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success(`Status updated to ${newStatus}`, 3000);
|
lt.toast.success(`Status updated to ${newStatus}`, 3000);
|
||||||
setTimeout(() => window.location.reload(), 1000);
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
closeQuickStatusModal();
|
closeQuickStatusModal();
|
||||||
toast.error('Error updating status', 4000);
|
lt.toast.error('Error updating status', 4000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1368,22 +1270,22 @@ 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 #${escapeHtml(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>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button data-action="perform-quick-assign" data-ticket-id="${ticketId}" class="lt-btn lt-btn-primary">Assign</button>
|
<button data-action="perform-quick-assign" data-ticket-id="${ticketId}" class="lt-btn lt-btn-primary">ASSIGN</button>
|
||||||
<button data-action="close-quick-assign-modal" class="lt-btn lt-btn-ghost">Cancel</button>
|
<button data-action="close-quick-assign-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1417,31 +1319,19 @@ function closeQuickAssignModal() {
|
|||||||
function performQuickAssign(ticketId) {
|
function performQuickAssign(ticketId) {
|
||||||
const assignedTo = document.getElementById('quickAssignSelect').value || null;
|
const assignedTo = document.getElementById('quickAssignSelect').value || null;
|
||||||
|
|
||||||
fetch('/api/assign_ticket.php', {
|
lt.api.post('/api/assign_ticket.php', { ticket_id: ticketId, assigned_to: assignedTo })
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
ticket_id: ticketId,
|
|
||||||
assigned_to: assignedTo
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
closeQuickAssignModal();
|
closeQuickAssignModal();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success('Assignment updated', 3000);
|
lt.toast.success('Assignment updated', 3000);
|
||||||
setTimeout(() => window.location.reload(), 1000);
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
closeQuickAssignModal();
|
closeQuickAssignModal();
|
||||||
toast.error('Error updating assignment', 4000);
|
lt.toast.error('Error updating assignment', 4000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1461,14 +1351,14 @@ function setViewMode(mode) {
|
|||||||
if (!tableView || !cardView) return;
|
if (!tableView || !cardView) return;
|
||||||
|
|
||||||
if (mode === 'card') {
|
if (mode === 'card') {
|
||||||
tableView.style.display = 'none';
|
tableView.classList.add('is-hidden');
|
||||||
cardView.style.display = 'block';
|
cardView.classList.remove('is-hidden');
|
||||||
tableBtn.classList.remove('active');
|
tableBtn.classList.remove('active');
|
||||||
cardBtn.classList.add('active');
|
cardBtn.classList.add('active');
|
||||||
populateKanbanCards();
|
populateKanbanCards();
|
||||||
} else {
|
} else {
|
||||||
tableView.style.display = 'block';
|
tableView.classList.remove('is-hidden');
|
||||||
cardView.style.display = 'none';
|
cardView.classList.add('is-hidden');
|
||||||
tableBtn.classList.add('active');
|
tableBtn.classList.add('active');
|
||||||
cardBtn.classList.remove('active');
|
cardBtn.classList.remove('active');
|
||||||
}
|
}
|
||||||
@@ -1522,13 +1412,13 @@ function populateKanbanCards() {
|
|||||||
card.onclick = () => window.location.href = `/ticket/${ticketId}`;
|
card.onclick = () => window.location.href = `/ticket/${ticketId}`;
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-id">#${escapeHtml(ticketId)}</span>
|
<span class="card-id">#${lt.escHtml(ticketId)}</span>
|
||||||
<span class="card-priority p${priority}">P${priority}</span>
|
<span class="card-priority p${priority}">P${priority}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-title">${escapeHtml(title)}</div>
|
<div class="card-title">${lt.escHtml(title)}</div>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<span class="card-category">${escapeHtml(category)}</span>
|
<span class="card-category">${lt.escHtml(category)}</span>
|
||||||
<span class="card-assignee" title="${escapeHtml(assignedTo)}">${escapeHtml(initials)}</span>
|
<span class="card-assignee" title="${lt.escHtml(assignedTo)}">${lt.escHtml(initials)}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
column.appendChild(card);
|
column.appendChild(card);
|
||||||
@@ -1546,8 +1436,7 @@ function populateKanbanCards() {
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const savedMode = localStorage.getItem('ticketViewMode');
|
const savedMode = localStorage.getItem('ticketViewMode');
|
||||||
if (savedMode === 'card') {
|
if (savedMode === 'card') {
|
||||||
// Delay to ensure DOM is ready
|
setViewMode('card');
|
||||||
setTimeout(() => setViewMode('card'), 100);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1562,8 +1451,7 @@ function initTicketPreview() {
|
|||||||
// Create preview element
|
// Create preview element
|
||||||
const preview = document.createElement('div');
|
const preview = document.createElement('div');
|
||||||
preview.id = 'ticketPreview';
|
preview.id = 'ticketPreview';
|
||||||
preview.className = 'ticket-preview-popup';
|
preview.className = 'ticket-preview-popup is-hidden';
|
||||||
preview.style.display = 'none';
|
|
||||||
document.body.appendChild(preview);
|
document.body.appendChild(preview);
|
||||||
currentPreview = preview;
|
currentPreview = preview;
|
||||||
|
|
||||||
@@ -1615,17 +1503,17 @@ function showTicketPreview(event) {
|
|||||||
// Build preview content
|
// Build preview content
|
||||||
currentPreview.innerHTML = `
|
currentPreview.innerHTML = `
|
||||||
<div class="preview-header">
|
<div class="preview-header">
|
||||||
<span class="preview-id">#${escapeHtml(ticketId)}</span>
|
<span class="preview-id">#${lt.escHtml(ticketId)}</span>
|
||||||
<span class="preview-status status-${status.replace(/\s+/g, '-')}">${escapeHtml(status)}</span>
|
<span class="preview-status status-${status.replace(/\s+/g, '-')}">${lt.escHtml(status)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-title">${escapeHtml(title)}</div>
|
<div class="preview-title">${lt.escHtml(title)}</div>
|
||||||
<div class="preview-meta">
|
<div class="preview-meta">
|
||||||
<div><strong>Priority:</strong> P${escapeHtml(priority)}</div>
|
<div><strong>Priority:</strong> P${lt.escHtml(priority)}</div>
|
||||||
<div><strong>Category:</strong> ${escapeHtml(category)}</div>
|
<div><strong>Category:</strong> ${lt.escHtml(category)}</div>
|
||||||
<div><strong>Type:</strong> ${escapeHtml(type)}</div>
|
<div><strong>Type:</strong> ${lt.escHtml(type)}</div>
|
||||||
<div><strong>Assigned:</strong> ${escapeHtml(assignedTo)}</div>
|
<div><strong>Assigned:</strong> ${lt.escHtml(assignedTo)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-footer">Created by ${escapeHtml(createdBy)}</div>
|
<div class="preview-footer">Created by ${lt.escHtml(createdBy)}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Position the preview
|
// Position the preview
|
||||||
@@ -1646,7 +1534,7 @@ function showTicketPreview(event) {
|
|||||||
|
|
||||||
currentPreview.style.left = left + 'px';
|
currentPreview.style.left = left + 'px';
|
||||||
currentPreview.style.top = top + 'px';
|
currentPreview.style.top = top + 'px';
|
||||||
currentPreview.style.display = 'block';
|
currentPreview.classList.remove('is-hidden');
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,7 +1544,7 @@ function hideTicketPreview() {
|
|||||||
}
|
}
|
||||||
previewTimeout = setTimeout(() => {
|
previewTimeout = setTimeout(() => {
|
||||||
if (currentPreview) {
|
if (currentPreview) {
|
||||||
currentPreview.style.display = 'none';
|
currentPreview.classList.add('is-hidden');
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
@@ -1697,7 +1585,7 @@ function exportSelectedTickets(format) {
|
|||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
|
|
||||||
if (ticketIds.length === 0) {
|
if (ticketIds.length === 0) {
|
||||||
toast.warning('No tickets selected', 2000);
|
lt.toast.warning('No tickets selected', 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1797,7 +1685,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1807,12 +1695,57 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// AUTO-REFRESH (lt.autoRefresh integration)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload the dashboard, but skip if a modal is open or user is typing.
|
||||||
|
* Registered with lt.autoRefresh so it runs every 5 minutes automatically.
|
||||||
|
*/
|
||||||
|
function dashboardAutoRefresh() {
|
||||||
|
// Don't interrupt the user if a modal is open
|
||||||
|
if (document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) return;
|
||||||
|
// Don't interrupt if focus is in a text input
|
||||||
|
const tag = document.activeElement?.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Only run auto-refresh on the dashboard, not on ticket pages
|
||||||
|
if (!window.location.pathname.includes('/ticket/')) {
|
||||||
|
lt.autoRefresh.start(dashboardAutoRefresh, 5 * 60 * 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// RELATIVE TIMESTAMPS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert all .ts-cell[data-ts] elements to relative time using lt.time.ago().
|
||||||
|
* Runs once on DOMContentLoaded and refreshes every 60s so "2m ago" stays current.
|
||||||
|
* The original full timestamp is preserved in the title attribute for hover.
|
||||||
|
*/
|
||||||
|
function initRelativeTimes() {
|
||||||
|
document.querySelectorAll('.ts-cell[data-ts]').forEach(el => {
|
||||||
|
el.textContent = lt.time.ago(el.dataset.ts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initRelativeTimes);
|
||||||
|
setInterval(initRelativeTimes, 60000);
|
||||||
|
|
||||||
|
|
||||||
// Export for use in other scripts
|
// Export for use in other scripts
|
||||||
window.generateSkeletonRows = generateSkeletonRows;
|
window.generateSkeletonRows = generateSkeletonRows;
|
||||||
window.generateSkeletonComments = generateSkeletonComments;
|
window.generateSkeletonComments = generateSkeletonComments;
|
||||||
|
|||||||
@@ -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>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ function createEditorToolbar(textareaId, containerId) {
|
|||||||
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
|
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
|
||||||
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
|
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
|
||||||
<span class="toolbar-separator"></span>
|
<span class="toolbar-separator"></span>
|
||||||
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">🔗</button>
|
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Add event delegation for toolbar buttons
|
// Add event delegation for toolbar buttons
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ function toggleVisibilityGroupsEdit() {
|
|||||||
const visibility = document.getElementById('visibilitySelect')?.value;
|
const visibility = document.getElementById('visibilitySelect')?.value;
|
||||||
const groupsField = document.getElementById('visibilityGroupsField');
|
const groupsField = document.getElementById('visibilityGroupsField');
|
||||||
if (groupsField) {
|
if (groupsField) {
|
||||||
groupsField.style.display = visibility === 'internal' ? 'block' : 'none';
|
groupsField.classList.toggle('is-hidden', visibility !== 'internal');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,40 +48,21 @@ function saveTicket() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use the correct API path
|
// Use the correct API path
|
||||||
const apiUrl = '/api/update_ticket.php';
|
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, ...data })
|
||||||
|
|
||||||
fetch(apiUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
ticket_id: ticketId,
|
|
||||||
...data
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
return response.text().then(text => {
|
|
||||||
throw new Error('Network response was not ok');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if(data.success) {
|
if (data.success) {
|
||||||
const statusDisplay = document.getElementById('statusDisplay');
|
const statusDisplay = document.getElementById('statusDisplay');
|
||||||
if (statusDisplay) {
|
if (statusDisplay) {
|
||||||
statusDisplay.className = `status-${data.status}`;
|
statusDisplay.className = `status-${data.status}`;
|
||||||
statusDisplay.textContent = data.status;
|
statusDisplay.textContent = data.status;
|
||||||
}
|
}
|
||||||
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +86,8 @@ function toggleEditMode() {
|
|||||||
// Enable description (textarea)
|
// Enable description (textarea)
|
||||||
if (descriptionField) {
|
if (descriptionField) {
|
||||||
descriptionField.disabled = false;
|
descriptionField.disabled = false;
|
||||||
|
descriptionField.style.height = 'auto';
|
||||||
|
descriptionField.style.height = descriptionField.scrollHeight + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable metadata fields (priority, category, type)
|
// Enable metadata fields (priority, category, type)
|
||||||
@@ -134,7 +117,9 @@ function toggleEditMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addComment() {
|
function addComment() {
|
||||||
const commentText = document.getElementById('newComment').value;
|
const newComment = document.getElementById('newComment');
|
||||||
|
if (!newComment) return;
|
||||||
|
const commentText = newComment.value;
|
||||||
if (!commentText.trim()) {
|
if (!commentText.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -145,33 +130,19 @@ function addComment() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMarkdownEnabled = document.getElementById('markdownMaster').checked;
|
const markdownMaster = document.getElementById('markdownMaster');
|
||||||
|
const isMarkdownEnabled = markdownMaster ? markdownMaster.checked : false;
|
||||||
|
|
||||||
fetch('/api/add_comment.php', {
|
lt.api.post('/api/add_comment.php', {
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
ticket_id: ticketId,
|
ticket_id: ticketId,
|
||||||
comment_text: commentText,
|
comment_text: commentText,
|
||||||
markdown_enabled: isMarkdownEnabled
|
markdown_enabled: isMarkdownEnabled
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
return response.text().then(text => {
|
|
||||||
throw new Error('Network response was not ok');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if(data.success) {
|
if(data.success) {
|
||||||
// Clear the comment box
|
// Clear the comment box
|
||||||
document.getElementById('newComment').value = '';
|
const nc = document.getElementById('newComment');
|
||||||
|
if (nc) nc.value = '';
|
||||||
|
|
||||||
// Format the comment text for display
|
// Format the comment text for display
|
||||||
let displayText;
|
let displayText;
|
||||||
@@ -226,9 +197,11 @@ function addComment() {
|
|||||||
function togglePreview() {
|
function togglePreview() {
|
||||||
const preview = document.getElementById('markdownPreview');
|
const preview = document.getElementById('markdownPreview');
|
||||||
const textarea = document.getElementById('newComment');
|
const textarea = document.getElementById('newComment');
|
||||||
const isPreviewEnabled = document.getElementById('markdownToggle').checked;
|
const toggleEl = document.getElementById('markdownToggle');
|
||||||
|
if (!preview || !textarea || !toggleEl) return;
|
||||||
|
|
||||||
preview.style.display = isPreviewEnabled ? 'block' : 'none';
|
const isPreviewEnabled = toggleEl.checked;
|
||||||
|
preview.classList.toggle('is-hidden', !isPreviewEnabled);
|
||||||
|
|
||||||
if (isPreviewEnabled) {
|
if (isPreviewEnabled) {
|
||||||
preview.innerHTML = parseMarkdown(textarea.value);
|
preview.innerHTML = parseMarkdown(textarea.value);
|
||||||
@@ -239,27 +212,33 @@ function togglePreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
const commentText = document.getElementById('newComment').value;
|
const textarea = document.getElementById('newComment');
|
||||||
const previewDiv = document.getElementById('markdownPreview');
|
const previewDiv = document.getElementById('markdownPreview');
|
||||||
const isMarkdownEnabled = document.getElementById('markdownMaster').checked;
|
const masterEl = document.getElementById('markdownMaster');
|
||||||
|
if (!textarea || !previewDiv || !masterEl) return;
|
||||||
|
|
||||||
|
const commentText = textarea.value;
|
||||||
|
const isMarkdownEnabled = masterEl.checked;
|
||||||
|
|
||||||
if (isMarkdownEnabled && commentText.trim()) {
|
if (isMarkdownEnabled && commentText.trim()) {
|
||||||
// For markdown preview, use parseMarkdown which handles line breaks correctly
|
|
||||||
previewDiv.innerHTML = parseMarkdown(commentText);
|
previewDiv.innerHTML = parseMarkdown(commentText);
|
||||||
previewDiv.style.display = 'block';
|
previewDiv.classList.remove('is-hidden');
|
||||||
} else {
|
} else {
|
||||||
previewDiv.style.display = 'none';
|
previewDiv.classList.add('is-hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMarkdownMode() {
|
function toggleMarkdownMode() {
|
||||||
const previewToggle = document.getElementById('markdownToggle');
|
const previewToggle = document.getElementById('markdownToggle');
|
||||||
const isMasterEnabled = document.getElementById('markdownMaster').checked;
|
const masterEl = document.getElementById('markdownMaster');
|
||||||
|
if (!previewToggle || !masterEl) return;
|
||||||
|
|
||||||
|
const isMasterEnabled = masterEl.checked;
|
||||||
previewToggle.disabled = !isMasterEnabled;
|
previewToggle.disabled = !isMasterEnabled;
|
||||||
if (!isMasterEnabled) {
|
if (!isMasterEnabled) {
|
||||||
previewToggle.checked = false;
|
previewToggle.checked = false;
|
||||||
document.getElementById('markdownPreview').style.display = 'none';
|
const preview = document.getElementById('markdownPreview');
|
||||||
|
if (preview) preview.classList.add('is-hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,23 +284,14 @@ function handleAssignmentChange() {
|
|||||||
const ticketId = window.ticketData.id;
|
const ticketId = window.ticketData.id;
|
||||||
const assignedTo = this.value || null;
|
const assignedTo = this.value || null;
|
||||||
|
|
||||||
fetch('/api/assign_ticket.php', {
|
lt.api.post('/api/assign_ticket.php', { ticket_id: ticketId, assigned_to: assignedTo })
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ ticket_id: ticketId, assigned_to: assignedTo })
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
toast.error('Error updating assignment');
|
lt.toast.error('Error updating assignment');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error('Error updating assignment: ' + error.message);
|
lt.toast.error('Error updating assignment: ' + error.message);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -338,22 +308,13 @@ function handleMetadataChanges() {
|
|||||||
function updateTicketField(fieldName, newValue) {
|
function updateTicketField(fieldName, newValue) {
|
||||||
const ticketId = window.ticketData.id;
|
const ticketId = window.ticketData.id;
|
||||||
|
|
||||||
fetch('/api/update_ticket.php', {
|
lt.api.post('/api/update_ticket.php', {
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
ticket_id: ticketId,
|
ticket_id: ticketId,
|
||||||
[fieldName]: fieldName === 'priority' ? parseInt(newValue) : newValue
|
[fieldName]: fieldName === 'priority' ? parseInt(newValue) : newValue
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
toast.error(`Error updating ${fieldName}`);
|
lt.toast.error(`Error updating ${fieldName}`);
|
||||||
} else {
|
} else {
|
||||||
// Update window.ticketData
|
// Update window.ticketData
|
||||||
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
|
window.ticketData[fieldName] = fieldName === 'priority' ? parseInt(newValue) : newValue;
|
||||||
@@ -375,7 +336,7 @@ function handleMetadataChanges() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error(`Error updating ${fieldName}: ` + error.message);
|
lt.toast.error(`Error updating ${fieldName}: ` + error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,35 +405,7 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update status via API
|
// Update status via API
|
||||||
fetch('/api/update_ticket.php', {
|
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
ticket_id: ticketId,
|
|
||||||
status: newStatus
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(async response => {
|
|
||||||
const text = await response.text();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(text);
|
|
||||||
throw new Error(data.error || 'Server returned an error');
|
|
||||||
} catch (parseError) {
|
|
||||||
throw new Error(text || 'Network response was not ok');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch (parseError) {
|
|
||||||
throw new Error('Invalid JSON response from server');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Update the dropdown to show new status as current
|
// Update the dropdown to show new status as current
|
||||||
@@ -492,13 +425,13 @@ function performStatusChange(statusSelect, selectedOption, newStatus) {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, 500);
|
}, 500);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Error updating status: ' + (data.error || 'Unknown error'));
|
lt.toast.error('Error updating status: ' + (data.error || 'Unknown error'));
|
||||||
// Reset to current status
|
// Reset to current status
|
||||||
statusSelect.selectedIndex = 0;
|
statusSelect.selectedIndex = 0;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error('Error updating status: ' + error.message);
|
lt.toast.error('Error updating status: ' + error.message);
|
||||||
// Reset to current status
|
// Reset to current status
|
||||||
statusSelect.selectedIndex = 0;
|
statusSelect.selectedIndex = 0;
|
||||||
});
|
});
|
||||||
@@ -517,17 +450,7 @@ function showTab(tabName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hide all tabs
|
// Hide all tabs
|
||||||
descriptionTab.style.display = 'none';
|
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.remove('active'));
|
||||||
commentsTab.style.display = 'none';
|
|
||||||
if (attachmentsTab) {
|
|
||||||
attachmentsTab.style.display = 'none';
|
|
||||||
}
|
|
||||||
if (dependenciesTab) {
|
|
||||||
dependenciesTab.style.display = 'none';
|
|
||||||
}
|
|
||||||
if (activityTab) {
|
|
||||||
activityTab.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove active class and aria-selected from all buttons
|
// Remove active class and aria-selected from all buttons
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
@@ -536,10 +459,13 @@ function showTab(tabName) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Show selected tab and activate its button
|
// Show selected tab and activate its button
|
||||||
document.getElementById(`${tabName}-tab`).style.display = 'block';
|
const tabEl = document.getElementById(`${tabName}-tab`);
|
||||||
|
if (tabEl) tabEl.classList.add('active');
|
||||||
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
|
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
|
||||||
|
if (activeBtn) {
|
||||||
activeBtn.classList.add('active');
|
activeBtn.classList.add('active');
|
||||||
activeBtn.setAttribute('aria-selected', 'true');
|
activeBtn.setAttribute('aria-selected', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
// Load attachments when tab is shown
|
// Load attachments when tab is shown
|
||||||
if (tabName === 'attachments') {
|
if (tabName === 'attachments') {
|
||||||
@@ -560,15 +486,7 @@ function showTab(tabName) {
|
|||||||
function loadDependencies() {
|
function loadDependencies() {
|
||||||
const ticketId = window.ticketData.id;
|
const ticketId = window.ticketData.id;
|
||||||
|
|
||||||
fetch(`/api/ticket_dependencies.php?ticket_id=${ticketId}`, {
|
lt.api.get(`/api/ticket_dependencies.php?ticket_id=${ticketId}`)
|
||||||
credentials: 'same-origin'
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
renderDependencies(data.dependencies);
|
renderDependencies(data.dependencies);
|
||||||
@@ -587,10 +505,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);">${escapeHtml(message)}</p>`;
|
dependenciesList.innerHTML = `<p class="text-amber">${lt.escHtml(message)}</p>`;
|
||||||
}
|
}
|
||||||
if (dependentsList) {
|
if (dependentsList) {
|
||||||
dependentsList.innerHTML = `<p style="color: var(--terminal-amber);">${escapeHtml(message)}</p>`;
|
dependentsList.innerHTML = `<p class="text-amber">${lt.escHtml(message)}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,19 +530,19 @@ function renderDependencies(dependencies) {
|
|||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
hasAny = true;
|
hasAny = true;
|
||||||
html += `<div class="dependency-group">
|
html += `<div class="dependency-group">
|
||||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0;">${typeLabels[type]}</h4>`;
|
<h4>${typeLabels[type]}</h4>`;
|
||||||
|
|
||||||
items.forEach(dep => {
|
items.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/${escapeHtml(dep.depends_on_id)}" style="color: var(--terminal-green);">
|
<a href="/ticket/${lt.escHtml(dep.depends_on_id)}">
|
||||||
#${escapeHtml(dep.depends_on_id)}
|
#${lt.escHtml(dep.depends_on_id)}
|
||||||
</a>
|
</a>
|
||||||
<span style="margin-left: 0.5rem;">${escapeHtml(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;">${escapeHtml(dep.status)}</span>
|
<span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
<button data-action="remove-dependency" data-dependency-id="${dep.dependency_id}" class="btn btn-small" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;">Remove</button>
|
<button data-action="remove-dependency" data-dependency-id="${dep.dependency_id}" class="btn btn-small">REMOVE</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -633,7 +551,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;
|
||||||
@@ -644,21 +562,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/${escapeHtml(dep.ticket_id)}" style="color: var(--terminal-green);">
|
<a href="/ticket/${lt.escHtml(dep.ticket_id)}">
|
||||||
#${escapeHtml(dep.ticket_id)}
|
#${lt.escHtml(dep.ticket_id)}
|
||||||
</a>
|
</a>
|
||||||
<span style="margin-left: 0.5rem;">${escapeHtml(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;">${escapeHtml(dep.status)}</span>
|
<span class="status-badge ${statusClass}">${lt.escHtml(dep.status)}</span>
|
||||||
<span style="margin-left: 0.5rem; color: var(--terminal-amber);">(${escapeHtml(dep.dependency_type)})</span>
|
<span class="dependency-title text-amber">(${lt.escHtml(dep.dependency_type)})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
@@ -668,70 +586,56 @@ function renderDependents(dependents) {
|
|||||||
|
|
||||||
function addDependency() {
|
function addDependency() {
|
||||||
const ticketId = window.ticketData.id;
|
const ticketId = window.ticketData.id;
|
||||||
const dependsOnId = document.getElementById('dependencyTicketId').value.trim();
|
const depIdEl = document.getElementById('dependencyTicketId');
|
||||||
const dependencyType = document.getElementById('dependencyType').value;
|
const depTypeEl = document.getElementById('dependencyType');
|
||||||
|
if (!depIdEl || !depTypeEl) return;
|
||||||
|
const dependsOnId = depIdEl.value.trim();
|
||||||
|
const dependencyType = depTypeEl.value;
|
||||||
|
|
||||||
if (!dependsOnId) {
|
if (!dependsOnId) {
|
||||||
toast.warning('Please enter a ticket ID', 3000);
|
lt.toast.warning('Please enter a ticket ID', 3000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch('/api/ticket_dependencies.php', {
|
lt.api.post('/api/ticket_dependencies.php', {
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
ticket_id: ticketId,
|
ticket_id: ticketId,
|
||||||
depends_on_id: dependsOnId,
|
depends_on_id: dependsOnId,
|
||||||
dependency_type: dependencyType
|
dependency_type: dependencyType
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success('Dependency added', 3000);
|
lt.toast.success('Dependency added', 3000);
|
||||||
document.getElementById('dependencyTicketId').value = '';
|
if (depIdEl) depIdEl.value = '';
|
||||||
loadDependencies();
|
loadDependencies();
|
||||||
} else {
|
} else {
|
||||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error('Error adding dependency', 4000);
|
lt.toast.error('Error adding dependency', 4000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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',
|
||||||
fetch('/api/ticket_dependencies.php', {
|
function() {
|
||||||
method: 'DELETE',
|
lt.api.delete('/api/ticket_dependencies.php', { dependency_id: dependencyId })
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
dependency_id: dependencyId
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success('Dependency removed', 3000);
|
lt.toast.success('Dependency removed', 3000);
|
||||||
loadDependencies();
|
loadDependencies();
|
||||||
} else {
|
} else {
|
||||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error('Error removing dependency', 4000);
|
lt.toast.error('Error removing dependency', 4000);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -794,11 +698,12 @@ function handleFileUpload(files) {
|
|||||||
const progressDiv = document.getElementById('uploadProgress');
|
const progressDiv = document.getElementById('uploadProgress');
|
||||||
const progressFill = document.getElementById('progressFill');
|
const progressFill = document.getElementById('progressFill');
|
||||||
const statusText = document.getElementById('uploadStatus');
|
const statusText = document.getElementById('uploadStatus');
|
||||||
|
if (!progressDiv || !progressFill || !statusText) return;
|
||||||
|
|
||||||
let uploadedCount = 0;
|
let uploadedCount = 0;
|
||||||
const totalFiles = files.length;
|
const totalFiles = files.length;
|
||||||
|
|
||||||
progressDiv.style.display = 'block';
|
progressDiv.classList.remove('is-hidden');
|
||||||
statusText.textContent = `Uploading 0 of ${totalFiles} files...`;
|
statusText.textContent = `Uploading 0 of ${totalFiles} files...`;
|
||||||
progressFill.style.width = '0%';
|
progressFill.style.width = '0%';
|
||||||
|
|
||||||
@@ -828,18 +733,18 @@ function handleFileUpload(files) {
|
|||||||
const response = JSON.parse(xhr.responseText);
|
const response = JSON.parse(xhr.responseText);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
if (uploadedCount === totalFiles) {
|
if (uploadedCount === totalFiles) {
|
||||||
toast.success(`${totalFiles} file(s) uploaded successfully`, 3000);
|
lt.toast.success(`${totalFiles} file(s) uploaded successfully`, 3000);
|
||||||
loadAttachments();
|
loadAttachments();
|
||||||
resetUploadUI();
|
resetUploadUI();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(`Error uploading ${file.name}: ${response.error}`, 4000);
|
lt.toast.error(`Error uploading ${file.name}: ${response.error}`, 4000);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(`Error parsing response for ${file.name}`, 4000);
|
lt.toast.error(`Error parsing response for ${file.name}`, 4000);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(`Error uploading ${file.name}: Server error`, 4000);
|
lt.toast.error(`Error uploading ${file.name}: Server error`, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uploadedCount === totalFiles) {
|
if (uploadedCount === totalFiles) {
|
||||||
@@ -849,7 +754,7 @@ function handleFileUpload(files) {
|
|||||||
|
|
||||||
xhr.addEventListener('error', () => {
|
xhr.addEventListener('error', () => {
|
||||||
uploadedCount++;
|
uploadedCount++;
|
||||||
toast.error(`Error uploading ${file.name}: Network error`, 4000);
|
lt.toast.error(`Error uploading ${file.name}: Network error`, 4000);
|
||||||
if (uploadedCount === totalFiles) {
|
if (uploadedCount === totalFiles) {
|
||||||
setTimeout(resetUploadUI, 2000);
|
setTimeout(resetUploadUI, 2000);
|
||||||
}
|
}
|
||||||
@@ -864,7 +769,7 @@ function resetUploadUI() {
|
|||||||
const progressDiv = document.getElementById('uploadProgress');
|
const progressDiv = document.getElementById('uploadProgress');
|
||||||
const fileInput = document.getElementById('fileInput');
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
|
||||||
progressDiv.style.display = 'none';
|
progressDiv.classList.add('is-hidden');
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
}
|
}
|
||||||
@@ -876,19 +781,16 @@ function loadAttachments() {
|
|||||||
|
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
fetch(`/api/upload_attachment.php?ticket_id=${ticketId}`, {
|
lt.api.get(`/api/upload_attachment.php?ticket_id=${ticketId}`)
|
||||||
credentials: 'same-origin'
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
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>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,7 +799,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -905,24 +807,25 @@ function renderAttachments(attachments) {
|
|||||||
|
|
||||||
attachments.forEach(att => {
|
attachments.forEach(att => {
|
||||||
const uploaderName = att.display_name || att.username || 'Unknown';
|
const uploaderName = att.display_name || att.username || 'Unknown';
|
||||||
const uploadDate = new Date(att.uploaded_at).toLocaleDateString('en-US', {
|
const uploadDateFormatted = new Date(att.uploaded_at).toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
|
const uploadDate = `<span class="ts-cell" data-ts="${lt.escHtml(att.uploaded_at)}" title="${lt.escHtml(uploadDateFormatted)}">${lt.time.ago(att.uploaded_at)}</span>`;
|
||||||
|
|
||||||
html += `<div class="attachment-item" data-id="${att.attachment_id}">
|
html += `<div class="attachment-item" data-id="${att.attachment_id}">
|
||||||
<div class="attachment-icon">${escapeHtml(att.icon || '📎')}</div>
|
<div class="attachment-icon">${lt.escHtml(att.icon || '[ f ]')}</div>
|
||||||
<div class="attachment-info">
|
<div class="attachment-info">
|
||||||
<div class="attachment-name" title="${escapeHtml(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">
|
||||||
${escapeHtml(att.original_filename)}
|
${lt.escHtml(att.original_filename)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="attachment-meta">
|
<div class="attachment-meta">
|
||||||
${escapeHtml(att.file_size_formatted || formatFileSize(att.file_size))} • ${escapeHtml(uploaderName)} • ${escapeHtml(uploadDate)}
|
${lt.escHtml(att.file_size_formatted || formatFileSize(att.file_size))} • ${lt.escHtml(uploaderName)} • ${uploadDate}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="attachment-actions">
|
<div class="attachment-actions">
|
||||||
@@ -949,34 +852,25 @@ 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',
|
||||||
fetch('/api/delete_attachment.php', {
|
function() {
|
||||||
method: 'POST',
|
lt.api.post('/api/delete_attachment.php', { attachment_id: attachmentId })
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
attachment_id: attachmentId,
|
|
||||||
csrf_token: window.CSRF_TOKEN
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success('Attachment deleted', 3000);
|
lt.toast.success('Attachment deleted', 3000);
|
||||||
loadAttachments();
|
loadAttachments();
|
||||||
} else {
|
} else {
|
||||||
toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
lt.toast.error('Error: ' + (data.error || 'Unknown error'), 4000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
toast.error('Error deleting attachment', 4000);
|
lt.toast.error('Error deleting attachment', 4000);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -999,7 +893,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
|
||||||
@@ -1096,7 +995,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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1118,14 +1019,16 @@ 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="${escapeHtml(user.username)}" data-action="select-mention">
|
const ariaSelected = index === 0 ? 'true' : 'false';
|
||||||
<span class="mention-username">@${escapeHtml(user.username)}</span>
|
html += `<div class="mention-option ${isSelected}" role="option" aria-selected="${ariaSelected}" data-username="${lt.escHtml(user.username)}" data-action="select-mention">
|
||||||
${user.display_name ? `<span class="mention-displayname">${escapeHtml(user.display_name)}</span>` : ''}
|
<span class="mention-username">@${lt.escHtml(user.username)}</span>
|
||||||
|
${user.display_name ? `<span class="mention-displayname">${lt.escHtml(user.display_name)}</span>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
mentionAutocomplete.innerHTML = html;
|
mentionAutocomplete.innerHTML = html;
|
||||||
mentionAutocomplete.classList.add('active');
|
mentionAutocomplete.classList.add('active');
|
||||||
|
if (textarea) textarea.setAttribute('aria-expanded', 'true');
|
||||||
selectedMentionIndex = 0;
|
selectedMentionIndex = 0;
|
||||||
|
|
||||||
// Position dropdown below cursor
|
// Position dropdown below cursor
|
||||||
@@ -1140,6 +1043,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;
|
||||||
}
|
}
|
||||||
@@ -1252,21 +1157,21 @@ function editComment(commentId) {
|
|||||||
editForm.className = 'comment-edit-form';
|
editForm.className = 'comment-edit-form';
|
||||||
editForm.id = `comment-edit-form-${commentId}`;
|
editForm.id = `comment-edit-form-${commentId}`;
|
||||||
editForm.innerHTML = `
|
editForm.innerHTML = `
|
||||||
<textarea id="comment-edit-textarea-${commentId}" class="comment-edit-textarea">${escapeHtml(originalText)}</textarea>
|
<textarea id="comment-edit-textarea-${commentId}" class="comment-edit-textarea">${lt.escHtml(originalText)}</textarea>
|
||||||
<div class="comment-edit-controls">
|
<div class="comment-edit-controls">
|
||||||
<label class="markdown-toggle-small">
|
<label class="markdown-toggle-small">
|
||||||
<input type="checkbox" id="comment-edit-markdown-${commentId}" ${markdownEnabled ? 'checked' : ''}>
|
<input type="checkbox" id="comment-edit-markdown-${commentId}" ${markdownEnabled ? 'checked' : ''}>
|
||||||
Markdown
|
Markdown
|
||||||
</label>
|
</label>
|
||||||
<div class="comment-edit-buttons">
|
<div class="comment-edit-buttons">
|
||||||
<button type="button" class="btn btn-small" data-action="save-edit-comment" data-comment-id="${commentId}">Save</button>
|
<button type="button" class="btn btn-small" data-action="save-edit-comment" data-comment-id="${commentId}">SAVE</button>
|
||||||
<button type="button" class="btn btn-secondary btn-small" data-action="cancel-edit-comment" data-comment-id="${commentId}">Cancel</button>
|
<button type="button" class="btn btn-secondary btn-small" data-action="cancel-edit-comment" data-comment-id="${commentId}">CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Hide original text, show edit form
|
// Hide original text, show edit form
|
||||||
textDiv.style.display = 'none';
|
textDiv.classList.add('is-hidden');
|
||||||
textDiv.after(editForm);
|
textDiv.after(editForm);
|
||||||
commentDiv.classList.add('editing');
|
commentDiv.classList.add('editing');
|
||||||
|
|
||||||
@@ -1285,27 +1190,18 @@ function saveEditComment(commentId) {
|
|||||||
|
|
||||||
const newText = textarea.value.trim();
|
const newText = textarea.value.trim();
|
||||||
if (!newText) {
|
if (!newText) {
|
||||||
showToast('Comment cannot be empty', 'error');
|
lt.toast.error('Comment cannot be empty');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdownEnabled = markdownCheckbox ? markdownCheckbox.checked : false;
|
const markdownEnabled = markdownCheckbox ? markdownCheckbox.checked : false;
|
||||||
|
|
||||||
// Send update request
|
// Send update request
|
||||||
fetch('/api/update_comment.php', {
|
lt.api.post('/api/update_comment.php', {
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
comment_id: commentId,
|
comment_id: commentId,
|
||||||
comment_text: newText,
|
comment_text: newText,
|
||||||
markdown_enabled: markdownEnabled
|
markdown_enabled: markdownEnabled
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Update the comment display
|
// Update the comment display
|
||||||
@@ -1331,7 +1227,7 @@ function saveEditComment(commentId) {
|
|||||||
} else {
|
} else {
|
||||||
textDiv.removeAttribute('data-markdown');
|
textDiv.removeAttribute('data-markdown');
|
||||||
// Convert newlines to <br> and highlight mentions
|
// Convert newlines to <br> and highlight mentions
|
||||||
let displayText = escapeHtml(newText).replace(/\n/g, '<br>');
|
let displayText = lt.escHtml(newText).replace(/\n/g, '<br>');
|
||||||
displayText = highlightMentions(displayText);
|
displayText = highlightMentions(displayText);
|
||||||
// Auto-link URLs
|
// Auto-link URLs
|
||||||
if (typeof autoLinkUrls === 'function') {
|
if (typeof autoLinkUrls === 'function') {
|
||||||
@@ -1342,16 +1238,16 @@ function saveEditComment(commentId) {
|
|||||||
|
|
||||||
// Remove edit form and show text
|
// Remove edit form and show text
|
||||||
if (editForm) editForm.remove();
|
if (editForm) editForm.remove();
|
||||||
textDiv.style.display = '';
|
textDiv.classList.remove('is-hidden');
|
||||||
commentDiv.classList.remove('editing');
|
commentDiv.classList.remove('editing');
|
||||||
|
|
||||||
showToast('Comment updated successfully', 'success');
|
lt.toast.success('Comment updated successfully');
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to update comment', 'error');
|
lt.toast.error(data.error || 'Failed to update comment');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
showToast('Failed to update comment', 'error');
|
lt.toast.error('Failed to update comment');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1364,7 +1260,7 @@ function cancelEditComment(commentId) {
|
|||||||
const editForm = document.getElementById(`comment-edit-form-${commentId}`);
|
const editForm = document.getElementById(`comment-edit-form-${commentId}`);
|
||||||
|
|
||||||
if (editForm) editForm.remove();
|
if (editForm) editForm.remove();
|
||||||
if (textDiv) textDiv.style.display = '';
|
if (textDiv) textDiv.classList.remove('is-hidden');
|
||||||
if (commentDiv) commentDiv.classList.remove('editing');
|
if (commentDiv) commentDiv.classList.remove('editing');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1372,40 +1268,29 @@ 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',
|
||||||
fetch('/api/delete_comment.php', {
|
function() {
|
||||||
method: 'POST',
|
lt.api.post('/api/delete_comment.php', { comment_id: commentId })
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
comment_id: commentId
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.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);
|
||||||
}
|
}
|
||||||
showToast('Comment deleted successfully', 'success');
|
lt.toast.success('Comment deleted successfully');
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to delete comment', 'error');
|
lt.toast.error(data.error || 'Failed to delete comment');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
showToast('Failed to delete comment', 'error');
|
lt.toast.error('Failed to delete comment');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -1426,7 +1311,7 @@ function showReplyForm(commentId, userName) {
|
|||||||
<div class="reply-form-container" data-parent-id="${commentId}">
|
<div class="reply-form-container" data-parent-id="${commentId}">
|
||||||
<div class="reply-header">
|
<div class="reply-header">
|
||||||
<span>Replying to <span class="replying-to">@${userName}</span></span>
|
<span>Replying to <span class="replying-to">@${userName}</span></span>
|
||||||
<button type="button" class="close-reply-btn" data-action="close-reply">Cancel</button>
|
<button type="button" class="close-reply-btn" data-action="close-reply">CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="replyText" placeholder="Write your reply..."></textarea>
|
<textarea id="replyText" placeholder="Write your reply..."></textarea>
|
||||||
<div class="reply-actions">
|
<div class="reply-actions">
|
||||||
@@ -1435,7 +1320,7 @@ function showReplyForm(commentId, userName) {
|
|||||||
<span>Markdown</span>
|
<span>Markdown</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="reply-buttons">
|
<div class="reply-buttons">
|
||||||
<button type="button" class="btn btn-small" data-action="submit-reply" data-parent-id="${commentId}">Reply</button>
|
<button type="button" class="btn btn-small" data-action="submit-reply" data-parent-id="${commentId}">REPLY</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1457,7 +1342,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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1471,28 +1356,19 @@ function submitReply(parentCommentId) {
|
|||||||
const ticketId = window.ticketData.id;
|
const ticketId = window.ticketData.id;
|
||||||
|
|
||||||
if (!replyText || !replyText.value.trim()) {
|
if (!replyText || !replyText.value.trim()) {
|
||||||
showToast('Please enter a reply', 'warning');
|
lt.toast.warning('Please enter a reply');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const commentText = replyText.value.trim();
|
const commentText = replyText.value.trim();
|
||||||
const isMarkdownEnabled = replyMarkdown ? replyMarkdown.checked : false;
|
const isMarkdownEnabled = replyMarkdown ? replyMarkdown.checked : false;
|
||||||
|
|
||||||
fetch('/api/add_comment.php', {
|
lt.api.post('/api/add_comment.php', {
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
ticket_id: ticketId,
|
ticket_id: ticketId,
|
||||||
comment_text: commentText,
|
comment_text: commentText,
|
||||||
markdown_enabled: isMarkdownEnabled,
|
markdown_enabled: isMarkdownEnabled,
|
||||||
parent_comment_id: parentCommentId
|
parent_comment_id: parentCommentId
|
||||||
})
|
})
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Close the reply form
|
// Close the reply form
|
||||||
@@ -1543,8 +1419,8 @@ function submitReply(parentCommentId) {
|
|||||||
<span class="comment-date">${data.created_at}</span>
|
<span class="comment-date">${data.created_at}</span>
|
||||||
<div class="comment-actions">
|
<div class="comment-actions">
|
||||||
${newDepth < 3 ? `<button type="button" class="comment-action-btn reply-btn" data-action="reply-comment" data-comment-id="${data.comment_id}" data-user="${data.user_name}" title="Reply">↩</button>` : ''}
|
${newDepth < 3 ? `<button type="button" class="comment-action-btn reply-btn" data-action="reply-comment" data-comment-id="${data.comment_id}" data-user="${data.user_name}" title="Reply">↩</button>` : ''}
|
||||||
<button type="button" class="comment-action-btn edit-btn" data-action="edit-comment" data-comment-id="${data.comment_id}" title="Edit">✏️</button>
|
<button type="button" class="comment-action-btn edit-btn" data-action="edit-comment" data-comment-id="${data.comment_id}" title="Edit">[ EDIT ]</button>
|
||||||
<button type="button" class="comment-action-btn delete-btn" data-action="delete-comment" data-comment-id="${data.comment_id}" title="Delete">🗑️</button>
|
<button type="button" class="comment-action-btn delete-btn" data-action="delete-comment" data-comment-id="${data.comment_id}" title="Delete">[ DEL ]</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-text" id="comment-text-${data.comment_id}" ${isMarkdownEnabled ? 'data-markdown' : ''}>
|
<div class="comment-text" id="comment-text-${data.comment_id}" ${isMarkdownEnabled ? 'data-markdown' : ''}>
|
||||||
@@ -1555,17 +1431,17 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('Reply added successfully', 'success');
|
lt.toast.success('Reply added successfully');
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to add reply', 'error');
|
lt.toast.error(data.error || 'Failed to add reply');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
showToast('Failed to add reply', 'error');
|
lt.toast.error('Failed to add reply');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1579,6 +1455,19 @@ function toggleThreadCollapse(commentId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// RELATIVE TIMESTAMPS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function initRelativeTimes() {
|
||||||
|
document.querySelectorAll('.ts-cell[data-ts]').forEach(el => {
|
||||||
|
el.textContent = lt.time.ago(el.dataset.ts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initRelativeTimes);
|
||||||
|
setInterval(initRelativeTimes, 60000);
|
||||||
|
|
||||||
// Expose functions globally
|
// Expose functions globally
|
||||||
window.editComment = editComment;
|
window.editComment = editComment;
|
||||||
window.saveEditComment = saveEditComment;
|
window.saveEditComment = saveEditComment;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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=20260126c">
|
<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=20260124e">
|
<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(); ?>';
|
||||||
@@ -25,13 +25,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<body>
|
<body>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-header-right">
|
<div class="user-header-right">
|
||||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||||
<span class="user-name">👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||||
<span class="admin-badge">Admin</span>
|
<span class="admin-badge">[ ADMIN ]</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
@@ -62,8 +62,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<!-- ERROR SECTION -->
|
<!-- ERROR SECTION -->
|
||||||
<div class="ascii-content">
|
<div class="ascii-content">
|
||||||
<div class="ascii-frame-inner">
|
<div class="ascii-frame-inner">
|
||||||
<div class="error-message" style="color: var(--priority-1); border: 2px solid var(--priority-1); padding: 1rem; background: rgba(231, 76, 60, 0.1);">
|
<div class="error-message inline-error">
|
||||||
<strong>⚠ Error:</strong> <?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?>
|
<strong>[ ! ] ERROR:</strong> <?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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,11 +109,11 @@ $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" style="display: none; margin-top: 1rem; padding: 1rem; border: 2px solid var(--terminal-amber); background: rgba(241, 196, 15, 0.1);">
|
<div id="duplicateWarning" class="inline-warning is-hidden" role="alert" aria-live="polite" aria-atomic="true">
|
||||||
<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"></div>
|
<div id="duplicatesList" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 is-hidden">
|
||||||
<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>
|
||||||
@@ -258,8 +258,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<div class="ascii-content">
|
<div class="ascii-content">
|
||||||
<div class="ascii-frame-inner">
|
<div class="ascii-frame-inner">
|
||||||
<div class="ticket-footer">
|
<div class="ticket-footer">
|
||||||
<button type="submit" class="btn primary">Create Ticket</button>
|
<button type="submit" class="btn primary">CREATE TICKET</button>
|
||||||
<button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">Cancel</button>
|
<button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +277,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
const title = this.value.trim();
|
const title = this.value.trim();
|
||||||
|
|
||||||
if (title.length < 5) {
|
if (title.length < 5) {
|
||||||
document.getElementById('duplicateWarning').style.display = 'none';
|
document.getElementById('duplicateWarning').classList.add('is-hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,30 +288,29 @@ $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');
|
||||||
|
|
||||||
if (data.success && data.duplicates && data.duplicates.length > 0) {
|
if (data.success && data.duplicates && data.duplicates.length > 0) {
|
||||||
let html = '<ul style="margin: 0; padding-left: 1.5rem; color: var(--terminal-green);">';
|
let html = '<ul class="duplicate-list">';
|
||||||
data.duplicates.forEach(dup => {
|
data.duplicates.forEach(dup => {
|
||||||
html += `<li style="margin-bottom: 0.5rem;">
|
html += `<li>
|
||||||
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank" style="color: var(--terminal-green);">
|
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank">
|
||||||
#${escapeHtml(dup.ticket_id)}
|
#${escapeHtml(dup.ticket_id)}
|
||||||
</a>
|
</a>
|
||||||
- ${escapeHtml(dup.title)}
|
- ${escapeHtml(dup.title)}
|
||||||
<span style="color: var(--terminal-amber);">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span>
|
<span class="duplicate-meta">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span>
|
||||||
</li>`;
|
</li>`;
|
||||||
});
|
});
|
||||||
html += '</ul>';
|
html += '</ul>';
|
||||||
html += '<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--terminal-green-dim);">Consider checking these tickets before creating a new one.</p>';
|
html += '<p class="duplicate-hint">Consider checking these tickets before creating a new one.</p>';
|
||||||
|
|
||||||
listDiv.innerHTML = html;
|
listDiv.innerHTML = html;
|
||||||
warningDiv.style.display = 'block';
|
warningDiv.classList.remove('is-hidden');
|
||||||
} else {
|
} else {
|
||||||
warningDiv.style.display = 'none';
|
warningDiv.classList.add('is-hidden');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -323,9 +322,9 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
const visibility = document.getElementById('visibility').value;
|
const visibility = document.getElementById('visibility').value;
|
||||||
const groupsContainer = document.getElementById('visibilityGroupsContainer');
|
const groupsContainer = document.getElementById('visibilityGroupsContainer');
|
||||||
if (visibility === 'internal') {
|
if (visibility === 'internal') {
|
||||||
groupsContainer.style.display = 'block';
|
groupsContainer.classList.remove('is-hidden');
|
||||||
} else {
|
} else {
|
||||||
groupsContainer.style.display = 'none';
|
groupsContainer.classList.add('is-hidden');
|
||||||
document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false);
|
document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,6 +351,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
toggleVisibilityGroups();
|
toggleVisibilityGroups();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (window.lt) lt.keys.initDefaults();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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=20260131e">
|
<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,41 +32,44 @@ $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"></div>
|
||||||
<pre id="boot-text"></pre>
|
<pre id="boot-text"></pre>
|
||||||
</div>
|
</div>
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
function showBootSequence() {
|
function showBootSequence() {
|
||||||
const bootText = document.getElementById('boot-text');
|
const bootText = document.getElementById('boot-text');
|
||||||
const bootOverlay = document.getElementById('boot-sequence');
|
const bootOverlay = document.getElementById('boot-sequence');
|
||||||
|
|
||||||
|
// Render ASCII banner first, then start boot messages
|
||||||
|
renderResponsiveBanner('#boot-banner', 0);
|
||||||
|
|
||||||
const messages = [
|
const messages = [
|
||||||
'╔═══════════════════════════════════════╗',
|
|
||||||
'║ TINKER TICKETS TERMINAL v1.0 ║',
|
|
||||||
'║ BOOTING SYSTEM... ║',
|
|
||||||
'╚═══════════════════════════════════════╝',
|
|
||||||
'',
|
|
||||||
'[ OK ] Loading kernel modules...',
|
'[ OK ] Loading kernel modules...',
|
||||||
'[ OK ] Initializing ticket database...',
|
'[ OK ] Initializing ticket database...',
|
||||||
'[ OK ] Mounting user session...',
|
'[ OK ] Mounting user session...',
|
||||||
'[ OK ] Starting dashboard services...',
|
'[ OK ] Starting dashboard services...',
|
||||||
'[ OK ] Rendering ASCII frames...',
|
'[ OK ] Rendering ASCII frames...',
|
||||||
'',
|
'',
|
||||||
'> SYSTEM READY ✓',
|
'> SYSTEM READY [OK]',
|
||||||
''
|
''
|
||||||
];
|
];
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
// Brief pause after banner renders before boot text begins
|
||||||
|
setTimeout(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (i < messages.length) {
|
if (i < messages.length) {
|
||||||
bootText.textContent += messages[i] + '\n';
|
bootText.textContent += messages[i] + '\n';
|
||||||
i++;
|
i++;
|
||||||
} else {
|
} else {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
bootOverlay.style.opacity = '0';
|
bootOverlay.classList.add('boot-overlay--fade-out');
|
||||||
setTimeout(() => bootOverlay.remove(), 500);
|
setTimeout(() => bootOverlay.remove(), 500);
|
||||||
}, 500);
|
}, 500);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, 80);
|
}, 80);
|
||||||
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run on first visit only (per session)
|
// Run on first visit only (per session)
|
||||||
@@ -80,52 +82,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</script>
|
</script>
|
||||||
<header class="user-header" role="banner">
|
<header class="user-header" role="banner">
|
||||||
<div class="user-header-left">
|
<div class="user-header-left">
|
||||||
<a href="/" class="app-title">🎫 Tinker Tickets</a>
|
<a href="/" class="app-title">[ TINKER TICKETS ]</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-header-right">
|
<div class="user-header-right">
|
||||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||||
<span class="user-name">👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||||
<div class="admin-dropdown">
|
<div class="admin-dropdown">
|
||||||
<button class="admin-badge" data-action="toggle-admin-menu">Admin ▼</button>
|
<button class="admin-badge" data-action="toggle-admin-menu" aria-label="Admin menu" aria-haspopup="true" aria-expanded="false">ADMIN ▼</button>
|
||||||
<div class="admin-dropdown-content" id="adminDropdown">
|
<div class="admin-dropdown-content" id="adminDropdown">
|
||||||
<a href="/admin/templates">📋 Templates</a>
|
<a href="/admin/templates">TEMPLATES</a>
|
||||||
<a href="/admin/workflow">🔄 Workflow</a>
|
<a href="/admin/workflow">WORKFLOW</a>
|
||||||
<a href="/admin/recurring-tickets">🔁 Recurring Tickets</a>
|
<a href="/admin/recurring-tickets">RECURRING</a>
|
||||||
<a href="/admin/custom-fields">📝 Custom Fields</a>
|
<a href="/admin/custom-fields">CUSTOM FIELDS</a>
|
||||||
<a href="/admin/user-activity">👥 User Activity</a>
|
<a href="/admin/user-activity">USER ACTIVITY</a>
|
||||||
<a href="/admin/audit-log">📜 Audit Log</a>
|
<a href="/admin/audit-log">AUDIT LOG</a>
|
||||||
<a href="/admin/api-keys">🔑 API Keys</a>
|
<a href="/admin/api-keys">API KEYS</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<button class="settings-icon" title="Settings (Alt+S)" data-action="open-settings" aria-label="Settings">⚙</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>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Collapsible ASCII Banner -->
|
|
||||||
<div class="ascii-banner-wrapper collapsed">
|
|
||||||
<button class="banner-toggle" data-action="toggle-banner">
|
|
||||||
<span class="toggle-icon">▼</span> ASCII Banner
|
|
||||||
</button>
|
|
||||||
<div id="ascii-banner-container" class="banner-content"></div>
|
|
||||||
</div>
|
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
|
||||||
function toggleBanner() {
|
|
||||||
const wrapper = document.querySelector('.ascii-banner-wrapper');
|
|
||||||
const icon = document.querySelector('.toggle-icon');
|
|
||||||
wrapper.classList.toggle('collapsed');
|
|
||||||
icon.textContent = wrapper.classList.contains('collapsed') ? '▼' : '▲';
|
|
||||||
|
|
||||||
// Render banner on first expand (no animation for instant display)
|
|
||||||
if (!wrapper.classList.contains('collapsed') && !wrapper.dataset.rendered) {
|
|
||||||
renderResponsiveBanner('#ascii-banner-container', 0); // Speed 0 = no animation
|
|
||||||
wrapper.dataset.rendered = 'true';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Dashboard Layout with Sidebar -->
|
<!-- Dashboard Layout with Sidebar -->
|
||||||
<div class="dashboard-layout" id="dashboardLayout">
|
<div class="dashboard-layout" id="dashboardLayout">
|
||||||
<!-- Left Sidebar with Filters -->
|
<!-- Left Sidebar with Filters -->
|
||||||
@@ -163,7 +144,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<label>
|
<label>
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="category"
|
name="category"
|
||||||
value="<?php echo $cat; ?>"
|
value="<?php echo htmlspecialchars($cat); ?>"
|
||||||
<?php echo in_array($cat, $currentCategories) ? 'checked' : ''; ?>>
|
<?php echo in_array($cat, $currentCategories) ? 'checked' : ''; ?>>
|
||||||
<?php echo htmlspecialchars($cat); ?>
|
<?php echo htmlspecialchars($cat); ?>
|
||||||
</label>
|
</label>
|
||||||
@@ -180,15 +161,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<label>
|
<label>
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="type"
|
name="type"
|
||||||
value="<?php echo $type; ?>"
|
value="<?php echo htmlspecialchars($type); ?>"
|
||||||
<?php echo in_array($type, $currentTypes) ? 'checked' : ''; ?>>
|
<?php echo in_array($type, $currentTypes) ? 'checked' : ''; ?>>
|
||||||
<?php echo htmlspecialchars($type); ?>
|
<?php echo htmlspecialchars($type); ?>
|
||||||
</label>
|
</label>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="apply-filters-btn" class="btn">Apply Filters</button>
|
<button id="apply-filters-btn" class="btn">APPLY FILTERS</button>
|
||||||
<button id="clear-filters-btn" class="btn btn-secondary">Clear All</button>
|
<button id="clear-filters-btn" class="btn btn-secondary">CLEAR ALL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -203,42 +184,42 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<div class="stats-widgets">
|
<div class="stats-widgets">
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<div class="stat-card stat-open">
|
<div class="stat-card stat-open">
|
||||||
<div class="stat-icon">📂</div>
|
<div class="stat-icon">[ # ]</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value"><?php echo $stats['open_tickets']; ?></div>
|
<div class="stat-value"><?php echo $stats['open_tickets']; ?></div>
|
||||||
<div class="stat-label">Open Tickets</div>
|
<div class="stat-label">Open Tickets</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-critical">
|
<div class="stat-card stat-critical">
|
||||||
<div class="stat-icon">🔥</div>
|
<div class="stat-icon">[ ! ]</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value"><?php echo $stats['critical']; ?></div>
|
<div class="stat-value"><?php echo $stats['critical']; ?></div>
|
||||||
<div class="stat-label">Critical (P1)</div>
|
<div class="stat-label">Critical (P1)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-unassigned">
|
<div class="stat-card stat-unassigned">
|
||||||
<div class="stat-icon">👤</div>
|
<div class="stat-icon">[ @ ]</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value"><?php echo $stats['unassigned']; ?></div>
|
<div class="stat-value"><?php echo $stats['unassigned']; ?></div>
|
||||||
<div class="stat-label">Unassigned</div>
|
<div class="stat-label">Unassigned</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-today">
|
<div class="stat-card stat-today">
|
||||||
<div class="stat-icon">📅</div>
|
<div class="stat-icon">[ + ]</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value"><?php echo $stats['created_today']; ?></div>
|
<div class="stat-value"><?php echo $stats['created_today']; ?></div>
|
||||||
<div class="stat-label">Created Today</div>
|
<div class="stat-label">Created Today</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-resolved">
|
<div class="stat-card stat-resolved">
|
||||||
<div class="stat-icon">✓</div>
|
<div class="stat-icon">[ OK ]</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value"><?php echo $stats['closed_today']; ?></div>
|
<div class="stat-value"><?php echo $stats['closed_today']; ?></div>
|
||||||
<div class="stat-label">Closed Today</div>
|
<div class="stat-label">Closed Today</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-time">
|
<div class="stat-card stat-time">
|
||||||
<div class="stat-icon">⏱</div>
|
<div class="stat-icon">[ t ]</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value"><?php echo $stats['avg_resolution_hours']; ?>h</div>
|
<div class="stat-value"><?php echo $stats['avg_resolution_hours']; ?>h</div>
|
||||||
<div class="stat-label">Avg Resolution</div>
|
<div class="stat-label">Avg Resolution</div>
|
||||||
@@ -252,7 +233,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<div class="dashboard-toolbar">
|
<div class="dashboard-toolbar">
|
||||||
<!-- Left: Title + Search -->
|
<!-- Left: Title + Search -->
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<h1 class="dashboard-title">🎫 Tickets</h1>
|
<h1 class="dashboard-title">[ TICKETS ]</h1>
|
||||||
<form method="GET" action="" class="toolbar-search">
|
<form method="GET" action="" class="toolbar-search">
|
||||||
<!-- Preserve existing parameters -->
|
<!-- Preserve existing parameters -->
|
||||||
<?php if (isset($_GET['status'])): ?>
|
<?php if (isset($_GET['status'])): ?>
|
||||||
@@ -273,13 +254,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
|
|
||||||
<input type="text"
|
<input type="text"
|
||||||
name="search"
|
name="search"
|
||||||
placeholder="🔍 Search tickets..."
|
placeholder="> Search tickets..."
|
||||||
class="search-box"
|
class="search-box"
|
||||||
value="<?php echo isset($_GET['search']) ? htmlspecialchars($_GET['search']) : ''; ?>">
|
value="<?php echo isset($_GET['search']) ? htmlspecialchars($_GET['search']) : ''; ?>">
|
||||||
<button type="submit" class="btn search-btn">Search</button>
|
<button type="submit" class="btn search-btn">SEARCH</button>
|
||||||
<button type="button" class="btn btn-secondary" data-action="open-advanced-search" title="Advanced Search">⚙ Advanced</button>
|
<button type="button" class="btn btn-secondary" data-action="open-advanced-search" title="Advanced Search">FILTER</button>
|
||||||
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
|
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
|
||||||
<a href="?" class="clear-search-btn">✗</a>
|
<a href="?" class="clear-search-btn" aria-label="Clear search">[ X ]</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,12 +268,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<!-- Center: Actions + Count -->
|
<!-- Center: Actions + Count -->
|
||||||
<div class="toolbar-center">
|
<div class="toolbar-center">
|
||||||
<div class="view-toggle">
|
<div class="view-toggle">
|
||||||
<button id="tableViewBtn" class="view-btn active" data-action="set-view-mode" data-mode="table" title="Table View" aria-label="Table view" aria-pressed="true">≡</button>
|
<button id="tableViewBtn" class="view-btn active" data-action="set-view-mode" data-mode="table" title="Table View" aria-label="Table view" aria-pressed="true">[ = ]</button>
|
||||||
<button id="cardViewBtn" class="view-btn" data-action="set-view-mode" data-mode="card" title="Kanban View" aria-label="Kanban view" aria-pressed="false">▦</button>
|
<button id="cardViewBtn" class="view-btn" data-action="set-view-mode" data-mode="card" title="Kanban View" aria-label="Kanban view" aria-pressed="false">[ # ]</button>
|
||||||
</div>
|
</div>
|
||||||
<button data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="btn create-ticket">+ New Ticket</button>
|
<button data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="btn create-ticket">+ NEW TICKET</button>
|
||||||
<div class="export-dropdown" id="exportDropdown" style="display: none;">
|
<div class="export-dropdown" id="exportDropdown" style="display: none;">
|
||||||
<button class="btn" data-action="toggle-export-menu">↓ Export Selected (<span id="exportCount">0</span>)</button>
|
<button class="btn" data-action="toggle-export-menu" aria-label="Export selected tickets" aria-haspopup="true" aria-expanded="false">EXPORT SELECTED (<span id="exportCount">0</span>)</button>
|
||||||
<div class="export-dropdown-content" id="exportDropdownContent">
|
<div class="export-dropdown-content" id="exportDropdownContent">
|
||||||
<a href="#" data-action="export-tickets" data-format="csv">CSV</a>
|
<a href="#" data-action="export-tickets" data-format="csv">CSV</a>
|
||||||
<a href="#" data-action="export-tickets" data-format="json">JSON</a>
|
<a href="#" data-action="export-tickets" data-format="json">JSON</a>
|
||||||
@@ -310,23 +291,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
// Previous page button
|
// Previous page button
|
||||||
if ($page > 1) {
|
if ($page > 1) {
|
||||||
$currentParams['page'] = $page - 1;
|
$currentParams['page'] = $page - 1;
|
||||||
$prevUrl = '?' . http_build_query($currentParams);
|
$prevUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
||||||
echo "<button data-action='navigate' data-url='$prevUrl'>«</button>";
|
echo "<button data-action='navigate' data-url='$prevUrl' aria-label='Previous page'>[ « ]</button>";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page number buttons
|
// Page number buttons
|
||||||
for ($i = 1; $i <= $totalPages; $i++) {
|
for ($i = 1; $i <= $totalPages; $i++) {
|
||||||
$activeClass = ($i === $page) ? 'active' : '';
|
$activeClass = ($i === $page) ? 'active' : '';
|
||||||
$currentParams['page'] = $i;
|
$currentParams['page'] = $i;
|
||||||
$pageUrl = '?' . http_build_query($currentParams);
|
$pageUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
||||||
echo "<button class='$activeClass' data-action='navigate' data-url='$pageUrl'>$i</button>";
|
echo "<button class='$activeClass' data-action='navigate' data-url='$pageUrl'>$i</button>";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next page button
|
// Next page button
|
||||||
if ($page < $totalPages) {
|
if ($page < $totalPages) {
|
||||||
$currentParams['page'] = $page + 1;
|
$currentParams['page'] = $page + 1;
|
||||||
$nextUrl = '?' . http_build_query($currentParams);
|
$nextUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
|
||||||
echo "<button data-action='navigate' data-url='$nextUrl'>»</button>";
|
echo "<button data-action='navigate' data-url='$nextUrl' aria-label='Next page'>[ » ]</button>";
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
@@ -352,10 +333,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
|
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
|
||||||
<div class="bulk-actions-inline" style="display: none;">
|
<div class="bulk-actions-inline" style="display: none;">
|
||||||
<span id="selected-count">0</span> tickets selected
|
<span id="selected-count">0</span> tickets selected
|
||||||
<button data-action="bulk-status" class="btn btn-bulk">Change Status</button>
|
<button data-action="bulk-status" class="btn btn-bulk">CHANGE STATUS</button>
|
||||||
<button data-action="bulk-assign" class="btn btn-bulk">Assign</button>
|
<button data-action="bulk-assign" class="btn btn-bulk">ASSIGN</button>
|
||||||
<button data-action="bulk-priority" class="btn btn-bulk">Priority</button>
|
<button data-action="bulk-priority" class="btn btn-bulk">PRIORITY</button>
|
||||||
<button data-action="clear-selection" class="btn btn-secondary">Clear</button>
|
<button data-action="clear-selection" class="btn btn-secondary">CLEAR</button>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -394,11 +375,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<?php foreach ($activeFilters as $filter): ?>
|
<?php foreach ($activeFilters as $filter): ?>
|
||||||
<span class="filter-badge" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>">
|
<span class="filter-badge" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>">
|
||||||
<?php echo htmlspecialchars($filter['label']); ?>
|
<?php echo htmlspecialchars($filter['label']); ?>
|
||||||
<button type="button" class="filter-remove" data-action="remove-filter" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>" title="Remove filter">×</button>
|
<button type="button" class="filter-remove" data-action="remove-filter" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>" title="Remove filter" aria-label="Remove <?php echo htmlspecialchars($filter['label']); ?> filter">×</button>
|
||||||
</span>
|
</span>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-secondary btn-sm" data-action="clear-all-filters">Clear All</button>
|
<button type="button" class="btn btn-secondary btn-sm" data-action="clear-all-filters">CLEAR ALL</button>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -408,11 +389,11 @@ $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;"><input type="checkbox" id="selectAllCheckbox" data-action="toggle-select-all"></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';
|
||||||
$currentDir = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
|
$currentDir = (isset($_GET['dir']) && $_GET['dir'] === 'asc') ? 'asc' : 'desc';
|
||||||
|
|
||||||
$columns = [
|
$columns = [
|
||||||
'ticket_id' => 'Ticket ID',
|
'ticket_id' => 'Ticket ID',
|
||||||
@@ -430,13 +411,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
|
|
||||||
foreach($columns as $col => $label) {
|
foreach($columns as $col => $label) {
|
||||||
if ($col === '_actions') {
|
if ($col === '_actions') {
|
||||||
echo "<th 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" : '';
|
||||||
|
$ariaSort = ($currentSort === $col) ? "aria-sort='" . ($currentDir === 'asc' ? 'ascending' : 'descending') . "'" : '';
|
||||||
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]);
|
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]);
|
||||||
$sortUrl = '?' . http_build_query($sortParams);
|
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
|
||||||
echo "<th class='$sortClass' data-action='navigate' data-url='$sortUrl'>$label</th>";
|
echo "<th scope='col' class='$sortClass' data-action='navigate' data-url='$sortUrl' $ariaSort>$label</th>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -452,33 +434,34 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
|
|
||||||
// Add checkbox column for admins
|
// Add checkbox column for admins
|
||||||
if ($GLOBALS['currentUser']['is_admin'] ?? false) {
|
if ($GLOBALS['currentUser']['is_admin'] ?? false) {
|
||||||
echo "<td data-action='toggle-row-checkbox' class='checkbox-cell'><input type='checkbox' class='ticket-checkbox' value='{$row['ticket_id']}' data-action='update-selection'></td>";
|
echo "<td data-action='toggle-row-checkbox' class='checkbox-cell'><input type='checkbox' class='ticket-checkbox' value='" . $row['ticket_id'] . "' data-action='update-selection' aria-label='Select ticket " . $row['ticket_id'] . "'></td>";
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "<td><a href='/ticket/{$row['ticket_id']}' class='ticket-link'>{$row['ticket_id']}</a></td>";
|
echo "<td><a href='/ticket/{$row['ticket_id']}' class='ticket-link'>{$row['ticket_id']}</a></td>";
|
||||||
echo "<td><span>{$row['priority']}</span></td>";
|
echo "<td><span>{$row['priority']}</span></td>";
|
||||||
echo "<td>" . htmlspecialchars($row['title']) . "</td>";
|
echo "<td>" . htmlspecialchars($row['title']) . "</td>";
|
||||||
echo "<td>{$row['category']}</td>";
|
echo "<td>" . htmlspecialchars($row['category']) . "</td>";
|
||||||
echo "<td>{$row['type']}</td>";
|
echo "<td>" . htmlspecialchars($row['type']) . "</td>";
|
||||||
echo "<td><span class='status-" . str_replace(' ', '-', $row['status']) . "'>{$row['status']}</span></td>";
|
$statusSlug = htmlspecialchars(str_replace(' ', '-', $row['status']), ENT_QUOTES);
|
||||||
|
echo "<td><span class='status-" . $statusSlug . "'>" . htmlspecialchars($row['status']) . "</span></td>";
|
||||||
echo "<td>" . htmlspecialchars($creator) . "</td>";
|
echo "<td>" . htmlspecialchars($creator) . "</td>";
|
||||||
echo "<td>" . htmlspecialchars($assignedTo) . "</td>";
|
echo "<td>" . htmlspecialchars($assignedTo) . "</td>";
|
||||||
echo "<td>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>";
|
echo "<td class='ts-cell' data-ts='" . htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . date('Y-m-d H:i T', strtotime($row['created_at'])) . "'>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>";
|
||||||
echo "<td>" . date('Y-m-d H:i', strtotime($row['updated_at'])) . "</td>";
|
echo "<td class='ts-cell' data-ts='" . htmlspecialchars($row['updated_at'], ENT_QUOTES, 'UTF-8') . "' title='" . date('Y-m-d H:i T', strtotime($row['updated_at'])) . "'>" . date('Y-m-d H:i', strtotime($row['updated_at'])) . "</td>";
|
||||||
// Quick actions column
|
// Quick actions column
|
||||||
echo "<td class='quick-actions-cell'>";
|
echo "<td class='quick-actions-cell'>";
|
||||||
echo "<div class='quick-actions'>";
|
echo "<div class='quick-actions'>";
|
||||||
echo "<button data-action='view-ticket' data-ticket-id='{$row['ticket_id']}' class='quick-action-btn' title='View'>👁</button>";
|
echo "<button data-action='view-ticket' data-ticket-id='" . $row['ticket_id'] . "' class='quick-action-btn' title='View' aria-label='View ticket " . $row['ticket_id'] . "'>></button>";
|
||||||
echo "<button data-action='quick-status' data-ticket-id='{$row['ticket_id']}' data-status='{$row['status']}' class='quick-action-btn' title='Change Status'>🔄</button>";
|
echo "<button data-action='quick-status' data-ticket-id='" . (int)$row['ticket_id'] . "' data-status='" . htmlspecialchars($row['status'], ENT_QUOTES) . "' class='quick-action-btn' title='Change Status' aria-label='Change status for ticket " . (int)$row['ticket_id'] . "'>~</button>";
|
||||||
echo "<button data-action='quick-assign' data-ticket-id='{$row['ticket_id']}' class='quick-action-btn' title='Assign'>👤</button>";
|
echo "<button data-action='quick-assign' data-ticket-id='" . $row['ticket_id'] . "' class='quick-action-btn' title='Assign' aria-label='Assign ticket " . $row['ticket_id'] . "'>@</button>";
|
||||||
echo "</div>";
|
echo "</div>";
|
||||||
echo "</td>";
|
echo "</td>";
|
||||||
echo "</tr>";
|
echo "</tr>";
|
||||||
}
|
}
|
||||||
} 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";
|
||||||
@@ -509,17 +492,17 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<div class="ticket-card-main">
|
<div class="ticket-card-main">
|
||||||
<div class="ticket-card-title"><?php echo htmlspecialchars($row['title']); ?></div>
|
<div class="ticket-card-title"><?php echo htmlspecialchars($row['title']); ?></div>
|
||||||
<div class="ticket-card-meta">
|
<div class="ticket-card-meta">
|
||||||
<span>📁 <?php echo htmlspecialchars($row['category']); ?></span>
|
<span><?php echo htmlspecialchars($row['category']); ?></span>
|
||||||
<span>👤 <?php echo htmlspecialchars($assignedTo); ?></span>
|
<span>@ <?php echo htmlspecialchars($assignedTo); ?></span>
|
||||||
<span>📅 <?php echo date('M j', strtotime($row['updated_at'])); ?></span>
|
<span class="ts-cell" data-ts="<?php echo htmlspecialchars($row['updated_at'], ENT_QUOTES, 'UTF-8'); ?>" title="<?php echo date('Y-m-d H:i', strtotime($row['updated_at'])); ?>"><?php echo date('M j', strtotime($row['updated_at'])); ?></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ticket-card-status <?php echo $statusClass; ?>">
|
<div class="ticket-card-status <?php echo htmlspecialchars($statusClass); ?>">
|
||||||
<?php echo $row['status']; ?>
|
<?php echo htmlspecialchars($row['status']); ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="ticket-card-actions">
|
<div class="ticket-card-actions">
|
||||||
<button data-action="view-ticket" data-ticket-id="<?php echo $row['ticket_id']; ?>" title="View">👁</button>
|
<button data-action="view-ticket" data-ticket-id="<?php echo (int)$row['ticket_id']; ?>" title="View" aria-label="View ticket <?php echo (int)$row['ticket_id']; ?>">></button>
|
||||||
<button data-action="quick-status" data-ticket-id="<?php echo $row['ticket_id']; ?>" data-status="<?php echo $row['status']; ?>" title="Status">🔄</button>
|
<button data-action="quick-status" data-ticket-id="<?php echo (int)$row['ticket_id']; ?>" data-status="<?php echo htmlspecialchars($row['status'], ENT_QUOTES); ?>" title="Status" aria-label="Change status for ticket <?php echo (int)$row['ticket_id']; ?>">~</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
@@ -541,7 +524,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<!-- END OUTER FRAME -->
|
<!-- END OUTER FRAME -->
|
||||||
|
|
||||||
<!-- Kanban Card View -->
|
<!-- Kanban Card View -->
|
||||||
<section id="cardView" class="card-view-container" style="display: none;" aria-label="Kanban board view">
|
<section id="cardView" class="card-view-container is-hidden" aria-label="Kanban board view">
|
||||||
<div class="kanban-board">
|
<div class="kanban-board">
|
||||||
<div class="kanban-column" data-status="Open">
|
<div class="kanban-column" data-status="Open">
|
||||||
<div class="kanban-column-header status-Open">
|
<div class="kanban-column-header status-Open">
|
||||||
@@ -578,7 +561,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle">
|
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle">
|
||||||
<div class="lt-modal">
|
<div class="lt-modal">
|
||||||
<div class="lt-modal-header">
|
<div class="lt-modal-header">
|
||||||
<span class="lt-modal-title" id="settingsModalTitle">⚙ System Preferences</span>
|
<span class="lt-modal-title" id="settingsModalTitle">[ CFG ] SYSTEM PREFERENCES</span>
|
||||||
<button class="lt-modal-close" data-modal-close aria-label="Close settings">✕</button>
|
<button class="lt-modal-close" data-modal-close aria-label="Close settings">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -724,8 +707,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button class="lt-btn lt-btn-primary" data-action="save-settings">Save Preferences</button>
|
<button class="lt-btn lt-btn-primary" data-action="save-settings">SAVE PREFERENCES</button>
|
||||||
<button class="lt-btn lt-btn-ghost" data-action="close-settings">Cancel</button>
|
<button class="lt-btn lt-btn-ghost" data-action="close-settings">CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -734,7 +717,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<div class="lt-modal-overlay" id="advancedSearchModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="advancedSearchModalTitle">
|
<div class="lt-modal-overlay" id="advancedSearchModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="advancedSearchModalTitle">
|
||||||
<div class="lt-modal">
|
<div class="lt-modal">
|
||||||
<div class="lt-modal-header">
|
<div class="lt-modal-header">
|
||||||
<span class="lt-modal-title" id="advancedSearchModalTitle">🔍 Advanced Search</span>
|
<span class="lt-modal-title" id="advancedSearchModalTitle">[ FILTER ] ADVANCED SEARCH</span>
|
||||||
<button class="lt-modal-close" data-modal-close aria-label="Close advanced search">✕</button>
|
<button class="lt-modal-close" data-modal-close aria-label="Close advanced search">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -750,8 +733,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row setting-row-right">
|
<div class="setting-row setting-row-right">
|
||||||
<button type="button" class="btn btn-secondary btn-setting" data-action="save-filter">💾 Save Current</button>
|
<button type="button" class="btn btn-secondary btn-setting" data-action="save-filter">SAVE</button>
|
||||||
<button type="button" class="btn btn-secondary btn-setting" data-action="delete-filter">🗑 Delete Selected</button>
|
<button type="button" class="btn btn-secondary btn-setting" data-action="delete-filter">DELETE</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -841,17 +824,17 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button type="submit" class="lt-btn lt-btn-primary">Search</button>
|
<button type="submit" class="lt-btn lt-btn-primary">SEARCH</button>
|
||||||
<button type="button" class="lt-btn lt-btn-ghost" data-action="reset-advanced-search">Reset</button>
|
<button type="button" class="lt-btn lt-btn-ghost" data-action="reset-advanced-search">RESET</button>
|
||||||
<button type="button" class="lt-btn lt-btn-ghost" data-action="close-advanced-search">Cancel</button>
|
<button type="button" class="lt-btn lt-btn-ghost" data-action="close-advanced-search">CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</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();
|
||||||
@@ -879,6 +862,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
openSettingsModal();
|
openSettingsModal();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'manual-refresh':
|
||||||
|
lt.autoRefresh.now();
|
||||||
|
break;
|
||||||
|
|
||||||
case 'close-settings':
|
case 'close-settings':
|
||||||
closeSettingsModal();
|
closeSettingsModal();
|
||||||
break;
|
break;
|
||||||
@@ -887,9 +874,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
saveSettings();
|
saveSettings();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'toggle-banner':
|
|
||||||
toggleBanner();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'toggle-sidebar':
|
case 'toggle-sidebar':
|
||||||
toggleSidebar();
|
toggleSidebar();
|
||||||
@@ -1008,7 +992,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
|
|
||||||
// Stat card click handlers for filtering
|
// Stat card click handlers for filtering
|
||||||
document.querySelectorAll('.stat-card').forEach(card => {
|
document.querySelectorAll('.stat-card').forEach(card => {
|
||||||
card.style.cursor = 'pointer';
|
|
||||||
card.addEventListener('click', function() {
|
card.addEventListener('click', function() {
|
||||||
const classList = this.classList;
|
const classList = this.classList;
|
||||||
let url = '/?';
|
let url = '/?';
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
// Helper functions for timeline display
|
// Helper functions for timeline display
|
||||||
function getEventIcon($actionType) {
|
function getEventIcon($actionType) {
|
||||||
$icons = [
|
$icons = [
|
||||||
'create' => '✨',
|
'create' => '[ + ]',
|
||||||
'update' => '📝',
|
'update' => '[ ~ ]',
|
||||||
'comment' => '💬',
|
'comment' => '[ > ]',
|
||||||
'view' => '👁️',
|
'view' => '[ . ]',
|
||||||
'assign' => '👤',
|
'assign' => '[ @ ]',
|
||||||
'status_change' => '🔄'
|
'status_change' => '[ ! ]',
|
||||||
];
|
];
|
||||||
return $icons[$actionType] ?? '•';
|
return $icons[$actionType] ?? '[ * ]';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAction($event) {
|
function formatAction($event) {
|
||||||
@@ -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=20260131e">
|
<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=20260131e">
|
<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(); ?>';
|
||||||
@@ -82,15 +81,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<body>
|
<body>
|
||||||
<header class="user-header">
|
<header 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>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-header-right">
|
<div class="user-header-right">
|
||||||
<?php if (isset($GLOBALS['currentUser'])): ?>
|
<?php if (isset($GLOBALS['currentUser'])): ?>
|
||||||
<span class="user-name">👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||||
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
|
||||||
<span class="admin-badge">Admin</span>
|
<span class="admin-badge">[ ADMIN ]</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<button class="settings-icon" title="Settings (Alt+S)" id="settingsBtn" aria-label="Settings">⚙</button>
|
<button class="settings-icon" title="Settings (Alt+S)" id="settingsBtn" aria-label="Settings">[ CFG ]</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -134,7 +133,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<div class="ticket-age <?php echo $ageClass; ?>" title="Time since last update">
|
<div class="ticket-age <?php echo $ageClass; ?>" title="Time since last update">
|
||||||
<span class="age-icon"><?php echo $ageClass === 'age-critical' ? '⚠️' : ($ageClass === 'age-warning' ? '⏰' : '📅'); ?></span>
|
<span class="age-icon"><?php echo $ageClass === 'age-critical' ? '[ ! ]' : ($ageClass === 'age-warning' ? '[ ~ ]' : '[ t ]'); ?></span>
|
||||||
<span class="age-text">Last activity: <?php echo $ageStr; ?> ago</span>
|
<span class="age-text">Last activity: <?php echo $ageStr; ?> ago</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ticket-user-info">
|
<div class="ticket-user-info">
|
||||||
@@ -142,13 +141,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
$creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System';
|
$creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System';
|
||||||
echo "Created by: <strong>" . htmlspecialchars($creator) . "</strong>";
|
echo "Created by: <strong>" . htmlspecialchars($creator) . "</strong>";
|
||||||
if (!empty($ticket['created_at'])) {
|
if (!empty($ticket['created_at'])) {
|
||||||
echo " on " . date('M d, Y H:i', strtotime($ticket['created_at']));
|
$createdFmt = date('M d, Y H:i', strtotime($ticket['created_at']));
|
||||||
|
echo " on <span class='ts-cell' data-ts='" . htmlspecialchars($ticket['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $createdFmt . "'>" . $createdFmt . "</span>";
|
||||||
}
|
}
|
||||||
if (!empty($ticket['updater_display_name']) || !empty($ticket['updater_username'])) {
|
if (!empty($ticket['updater_display_name']) || !empty($ticket['updater_username'])) {
|
||||||
$updater = $ticket['updater_display_name'] ?? $ticket['updater_username'];
|
$updater = $ticket['updater_display_name'] ?? $ticket['updater_username'];
|
||||||
echo " • Last updated by: <strong>" . htmlspecialchars($updater) . "</strong>";
|
echo " • Last updated by: <strong>" . htmlspecialchars($updater) . "</strong>";
|
||||||
if (!empty($ticket['updated_at'])) {
|
if (!empty($ticket['updated_at'])) {
|
||||||
echo " on " . date('M d, Y H:i', strtotime($ticket['updated_at']));
|
$updatedFmt = date('M d, Y H:i', strtotime($ticket['updated_at']));
|
||||||
|
echo " on <span class='ts-cell' data-ts='" . htmlspecialchars($ticket['updated_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $updatedFmt . "'>" . $updatedFmt . "</span>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -221,7 +222,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<option value="confidential" <?php echo $currentVisibility == 'confidential' ? 'selected' : ''; ?>>Confidential</option>
|
<option value="confidential" <?php echo $currentVisibility == 'confidential' ? 'selected' : ''; ?>>Confidential</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata-field" id="visibilityGroupsField" <?php echo $currentVisibility !== 'internal' ? 'style="display: none;"' : ''; ?>>
|
<div class="metadata-field<?php echo $currentVisibility !== 'internal' ? ' is-hidden' : ''; ?>" id="visibilityGroupsField">
|
||||||
<label class="metadata-label metadata-label-cyan">Allowed Groups:</label>
|
<label class="metadata-label metadata-label-cyan">Allowed Groups:</label>
|
||||||
<div class="visibility-groups-edit">
|
<div class="visibility-groups-edit">
|
||||||
<?php foreach ($allAvailableGroups as $group):
|
<?php foreach ($allAvailableGroups as $group):
|
||||||
@@ -244,23 +245,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<div class="status-priority-group">
|
<div class="status-priority-group">
|
||||||
<select id="statusSelect" class="editable status-select status-<?php echo str_replace(' ', '-', strtolower($ticket["status"])); ?>" data-field="status" data-action="update-ticket-status">
|
<select id="statusSelect" class="editable status-select status-<?php echo str_replace(' ', '-', strtolower($ticket["status"])); ?>" data-field="status" data-action="update-ticket-status" aria-label="Change ticket status">
|
||||||
<option value="<?php echo $ticket['status']; ?>" selected>
|
<option value="<?php echo htmlspecialchars($ticket['status']); ?>" selected>
|
||||||
<?php echo $ticket['status']; ?> (current)
|
<?php echo htmlspecialchars($ticket['status']); ?> (current)
|
||||||
</option>
|
</option>
|
||||||
<?php foreach ($allowedTransitions as $transition): ?>
|
<?php foreach ($allowedTransitions as $transition): ?>
|
||||||
<option value="<?php echo $transition['to_status']; ?>"
|
<option value="<?php echo htmlspecialchars($transition['to_status']); ?>"
|
||||||
data-requires-comment="<?php echo $transition['requires_comment'] ? '1' : '0'; ?>"
|
data-requires-comment="<?php echo $transition['requires_comment'] ? '1' : '0'; ?>"
|
||||||
data-requires-admin="<?php echo $transition['requires_admin'] ? '1' : '0'; ?>">
|
data-requires-admin="<?php echo $transition['requires_admin'] ? '1' : '0'; ?>">
|
||||||
<?php echo $transition['to_status']; ?>
|
<?php echo htmlspecialchars($transition['to_status']); ?>
|
||||||
<?php if ($transition['requires_comment']): ?> *<?php endif; ?>
|
<?php if ($transition['requires_comment']): ?> *<?php endif; ?>
|
||||||
<?php if ($transition['requires_admin']): ?> (Admin)<?php endif; ?>
|
<?php if ($transition['requires_admin']): ?> (Admin)<?php endif; ?>
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button id="editButton" class="btn">Edit Ticket</button>
|
<button id="editButton" class="btn">EDIT TICKET</button>
|
||||||
<button id="cloneButton" class="btn btn-secondary" title="Create a copy of this ticket">Clone</button>
|
<button id="cloneButton" class="btn btn-secondary" title="Create a copy of this ticket">CLONE</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,11 +275,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<div class="ascii-section-header">Content Sections</div>
|
<div class="ascii-section-header">Content Sections</div>
|
||||||
<div class="ascii-content">
|
<div class="ascii-content">
|
||||||
<nav class="ticket-tabs" role="tablist" aria-label="Ticket content sections">
|
<nav class="ticket-tabs" role="tablist" aria-label="Ticket content sections">
|
||||||
<button class="tab-btn active" id="description-tab-btn" data-tab="description" role="tab" aria-selected="true" aria-controls="description-tab">Description</button>
|
<button class="tab-btn active" id="description-tab-btn" data-tab="description" role="tab" aria-selected="true" aria-controls="description-tab">DESCRIPTION</button>
|
||||||
<button class="tab-btn" id="comments-tab-btn" data-tab="comments" role="tab" aria-selected="false" aria-controls="comments-tab">Comments</button>
|
<button class="tab-btn" id="comments-tab-btn" data-tab="comments" role="tab" aria-selected="false" aria-controls="comments-tab">COMMENTS</button>
|
||||||
<button class="tab-btn" id="attachments-tab-btn" data-tab="attachments" role="tab" aria-selected="false" aria-controls="attachments-tab">Attachments</button>
|
<button class="tab-btn" id="attachments-tab-btn" data-tab="attachments" role="tab" aria-selected="false" aria-controls="attachments-tab">ATTACHMENTS</button>
|
||||||
<button class="tab-btn" id="dependencies-tab-btn" data-tab="dependencies" role="tab" aria-selected="false" aria-controls="dependencies-tab">Dependencies</button>
|
<button class="tab-btn" id="dependencies-tab-btn" data-tab="dependencies" role="tab" aria-selected="false" aria-controls="dependencies-tab">DEPENDENCIES</button>
|
||||||
<button class="tab-btn" id="activity-tab-btn" data-tab="activity" role="tab" aria-selected="false" aria-controls="activity-tab">Activity</button>
|
<button class="tab-btn" id="activity-tab-btn" data-tab="activity" role="tab" aria-selected="false" aria-controls="activity-tab">ACTIVITY</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -294,7 +295,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<div class="ascii-subsection-header">Description</div>
|
<div class="ascii-subsection-header">Description</div>
|
||||||
<div class="detail-group full-width">
|
<div class="detail-group full-width">
|
||||||
<label>Description</label>
|
<label>Description</label>
|
||||||
<textarea class="editable" data-field="description" disabled><?php echo $ticket["description"]; ?></textarea>
|
<textarea class="editable" data-field="description" disabled><?php echo htmlspecialchars($ticket["description"] ?? ''); ?></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -323,9 +324,9 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<span class="toggle-label">Preview Markdown</span>
|
<span class="toggle-label">Preview Markdown</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="addCommentBtn" class="btn">Add Comment</button>
|
<button id="addCommentBtn" class="btn">ADD COMMENT</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="markdownPreview" class="markdown-preview" style="display: none;"></div>
|
<div id="markdownPreview" class="markdown-preview is-hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -362,18 +363,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
|
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
|
||||||
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
|
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
|
||||||
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited">(edited)</span>' : '';
|
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited">(edited)</span>' : '';
|
||||||
echo "<span class='comment-date'>{$dateStr}{$editedIndicator}</span>";
|
echo "<span class='comment-date'><span class='ts-cell' data-ts='" . htmlspecialchars($comment['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $dateStr . "'>" . $dateStr . "</span>{$editedIndicator}</span>";
|
||||||
|
|
||||||
// Action buttons
|
// Action buttons
|
||||||
echo "<div class='comment-actions'>";
|
echo "<div class='comment-actions'>";
|
||||||
// Reply button (max depth of 3)
|
// Reply button (max depth of 3)
|
||||||
if ($threadDepth < 3) {
|
if ($threadDepth < 3) {
|
||||||
echo "<button type='button' class='comment-action-btn reply-btn' data-action='reply-comment' data-comment-id='{$commentId}' data-user=\"" . htmlspecialchars($displayName, ENT_QUOTES) . "\" title='Reply' aria-label='Reply to comment'>↩</button>";
|
echo "<button type='button' class='comment-action-btn reply-btn' data-action='reply-comment' data-comment-id='{$commentId}' data-user=\"" . htmlspecialchars($displayName, ENT_QUOTES) . "\" title='Reply' aria-label='Reply to comment'>[ << ]</button>";
|
||||||
}
|
}
|
||||||
// Edit/Delete buttons for owner or admin
|
// Edit/Delete buttons for owner or admin
|
||||||
if ($canModify) {
|
if ($canModify) {
|
||||||
echo "<button type='button' class='comment-action-btn edit-btn' data-action='edit-comment' data-comment-id='{$commentId}' title='Edit' aria-label='Edit comment'>✏️</button>";
|
echo "<button type='button' class='comment-action-btn edit-btn' data-action='edit-comment' data-comment-id='{$commentId}' title='Edit' aria-label='Edit comment'>[ EDIT ]</button>";
|
||||||
echo "<button type='button' class='comment-action-btn delete-btn' data-action='delete-comment' data-comment-id='{$commentId}' title='Delete' aria-label='Delete comment'>🗑️</button>";
|
echo "<button type='button' class='comment-action-btn delete-btn' data-action='delete-comment' data-comment-id='{$commentId}' title='Delete' aria-label='Delete comment'>[ DEL ]</button>";
|
||||||
}
|
}
|
||||||
echo "</div>";
|
echo "</div>";
|
||||||
|
|
||||||
@@ -422,14 +423,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<h3>Upload Files</h3>
|
<h3>Upload Files</h3>
|
||||||
<div class="upload-zone" id="uploadZone">
|
<div class="upload-zone" id="uploadZone">
|
||||||
<div class="upload-zone-content">
|
<div class="upload-zone-content">
|
||||||
<div class="upload-icon">📁</div>
|
<div class="upload-icon">[ + ]</div>
|
||||||
<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 is-hidden">
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-fill" id="progressFill"></div>
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -463,7 +464,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<option value="relates_to">Relates To</option>
|
<option value="relates_to">Relates To</option>
|
||||||
<option value="duplicates">Duplicates</option>
|
<option value="duplicates">Duplicates</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="addDependencyBtn" class="btn">Add</button>
|
<button id="addDependencyBtn" class="btn" aria-label="Add ticket dependency">ADD</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -498,7 +499,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<div class="timeline-header">
|
<div class="timeline-header">
|
||||||
<strong><?php echo htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System'); ?></strong>
|
<strong><?php echo htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System'); ?></strong>
|
||||||
<span class="timeline-action"><?php echo formatAction($event); ?></span>
|
<span class="timeline-action"><?php echo formatAction($event); ?></span>
|
||||||
<span class="timeline-date"><?php echo date('M d, Y H:i', strtotime($event['created_at'])); ?></span>
|
<?php $eventFmt = date('M d, Y H:i', strtotime($event['created_at'])); ?>
|
||||||
|
<span class="timeline-date ts-cell" data-ts="<?php echo htmlspecialchars($event['created_at'], ENT_QUOTES, 'UTF-8'); ?>" title="<?php echo $eventFmt; ?>"><?php echo $eventFmt; ?></span>
|
||||||
</div>
|
</div>
|
||||||
<?php if (!empty($event['details'])): ?>
|
<?php if (!empty($event['details'])): ?>
|
||||||
<div class="timeline-details">
|
<div class="timeline-details">
|
||||||
@@ -558,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';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,7 +684,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="ticketSettingsTitle">
|
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="ticketSettingsTitle">
|
||||||
<div class="lt-modal">
|
<div class="lt-modal">
|
||||||
<div class="lt-modal-header">
|
<div class="lt-modal-header">
|
||||||
<span class="lt-modal-title" id="ticketSettingsTitle">⚙ System Preferences</span>
|
<span class="lt-modal-title" id="ticketSettingsTitle">[ CFG ] SYSTEM PREFERENCES</span>
|
||||||
<button class="lt-modal-close" data-modal-close aria-label="Close settings">✕</button>
|
<button class="lt-modal-close" data-modal-close aria-label="Close settings">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -808,14 +807,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button class="lt-btn lt-btn-primary" id="saveSettingsBtn">Save Preferences</button>
|
<button class="lt-btn lt-btn-primary" id="saveSettingsBtn">SAVE PREFERENCES</button>
|
||||||
<button class="lt-btn lt-btn-ghost" id="cancelSettingsBtn">Cancel</button>
|
<button class="lt-btn lt-btn-ghost" id="cancelSettingsBtn">CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
@@ -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>
|
||||||
@@ -24,35 +24,34 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<body>
|
<body>
|
||||||
<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'])): ?>
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||||
<span class="admin-badge">Admin</span>
|
<span class="admin-badge">[ ADMIN ]</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</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>
|
||||||
@@ -61,28 +60,28 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" class="btn">Generate Key</button>
|
<button type="submit" class="btn">GENERATE KEY</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- New Key Display (hidden by default) -->
|
<!-- New Key Display (hidden by default) -->
|
||||||
<div id="newKeyDisplay" class="ascii-frame-inner" style="display: none; margin-bottom: 1.5rem; background: rgba(255, 176, 0, 0.1); border-color: var(--terminal-amber);">
|
<div id="newKeyDisplay" class="ascii-frame-inner key-generated-alert is-hidden">
|
||||||
<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>
|
||||||
@@ -187,40 +182,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
const expiresIn = document.getElementById('expiresIn').value;
|
const expiresIn = document.getElementById('expiresIn').value;
|
||||||
|
|
||||||
if (!keyName) {
|
if (!keyName) {
|
||||||
showToast('Please enter a key name', 'error');
|
lt.toast.error('Please enter a key name');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
document.getElementById('newKeyDisplay').style.display = 'block';
|
document.getElementById('newKeyDisplay').classList.remove('is-hidden');
|
||||||
document.getElementById('keyName').value = '';
|
document.getElementById('keyName').value = '';
|
||||||
|
|
||||||
showToast('API key generated successfully', 'success');
|
lt.toast.success('API key generated successfully');
|
||||||
|
|
||||||
// Reload page after 5 seconds to show new key in table
|
// Reload page after 5 seconds to show new key in table
|
||||||
setTimeout(() => location.reload(), 5000);
|
setTimeout(() => location.reload(), 5000);
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to generate API key', 'error');
|
lt.toast.error(data.error || 'Failed to generate API key');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('Error generating API key: ' + error.message, 'error');
|
lt.toast.error('Error generating API key: ' + error.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,35 +214,24 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
const keyInput = document.getElementById('newKeyValue');
|
const keyInput = document.getElementById('newKeyValue');
|
||||||
keyInput.select();
|
keyInput.select();
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
showToast('API key copied to clipboard', 'success');
|
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) {
|
||||||
showToast('API key revoked successfully', 'success');
|
lt.toast.success('API key revoked successfully');
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to revoke API key', 'error');
|
lt.toast.error(data.error || 'Failed to revoke API key');
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showToast('Error revoking API key: ' + error.message, 'error');
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
lt.toast.error('Error revoking API key: ' + error.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for browsing audit logs
|
// Admin view for browsing audit logs
|
||||||
// Receives $auditLogs, $totalPages, $page, $filters from controller
|
// Receives $auditLogs, $totalPages, $page, $filters from controller
|
||||||
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -10,25 +13,28 @@
|
|||||||
<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 src="/assets/js/base.js"></script>
|
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||||
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<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'])): ?>
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||||
<span class="admin-badge">Admin</span>
|
<span class="admin-badge">[ ADMIN ]</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</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>
|
||||||
|
|
||||||
@@ -36,10 +42,10 @@
|
|||||||
<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>
|
||||||
@@ -51,9 +57,9 @@
|
|||||||
<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' : ''; ?>>
|
||||||
@@ -62,22 +68,23 @@
|
|||||||
<?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>
|
||||||
@@ -92,34 +99,32 @@
|
|||||||
<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']);
|
||||||
}
|
}
|
||||||
@@ -128,22 +133,23 @@
|
|||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
</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++) {
|
||||||
$params['page'] = $i;
|
$params['page'] = $i;
|
||||||
$activeClass = ($i == $page) ? 'active' : '';
|
$activeClass = ($i == $page) ? 'active' : '';
|
||||||
$url = '?' . http_build_query($params);
|
$url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
|
||||||
echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> ";
|
echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> ";
|
||||||
}
|
}
|
||||||
if ($totalPages > 10) {
|
if ($totalPages > 10) {
|
||||||
@@ -155,6 +161,6 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -23,30 +24,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<body>
|
<body>
|
||||||
<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'])): ?>
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||||
<span class="admin-badge">Admin</span>
|
<span class="admin-badge">[ ADMIN ]</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</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,13 +76,13 @@ $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>
|
||||||
<td>
|
<td>
|
||||||
<button data-action="edit-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small">Edit</button>
|
<button data-action="edit-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small">EDIT</button>
|
||||||
<button data-action="delete-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small btn-danger">Delete</button>
|
<button data-action="delete-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -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">
|
||||||
@@ -122,7 +123,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<option value="number">Number</option>
|
<option value="number">Number</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row" id="options_row" style="display: none;">
|
<div class="setting-row is-hidden" id="options_row">
|
||||||
<label for="field_options">Options (one per line)</label>
|
<label for="field_options">Options (one per line)</label>
|
||||||
<textarea id="field_options" name="field_options" rows="4" placeholder="Option 1 Option 2 Option 3"></textarea>
|
<textarea id="field_options" name="field_options" rows="4" placeholder="Option 1 Option 2 Option 3"></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,14 +150,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button type="submit" class="lt-btn lt-btn-primary">Save</button>
|
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
|
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</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';
|
||||||
@@ -208,7 +208,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
|
|
||||||
function toggleOptionsField() {
|
function toggleOptionsField() {
|
||||||
const type = document.getElementById('field_type').value;
|
const type = document.getElementById('field_type').value;
|
||||||
document.getElementById('options_row').style.display = type === 'select' ? 'block' : 'none';
|
document.getElementById('options_row').classList.toggle('is-hidden', type !== 'select');
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveField(e) {
|
function saveField(e) {
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -23,30 +24,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<body>
|
<body>
|
||||||
<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'])): ?>
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||||
<span class="admin-badge">Admin</span>
|
<span class="admin-badge">[ ADMIN ]</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</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): ?>
|
||||||
@@ -81,23 +81,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
|
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
|
||||||
}
|
}
|
||||||
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
|
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
|
||||||
echo $schedule;
|
echo htmlspecialchars($schedule);
|
||||||
?>
|
?>
|
||||||
</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>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -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>
|
||||||
@@ -134,7 +135,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<option value="monthly">Monthly</option>
|
<option value="monthly">Monthly</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row" id="schedule_day_row" style="display: none;">
|
<div class="setting-row is-hidden" id="schedule_day_row">
|
||||||
<label for="schedule_day">Schedule Day</label>
|
<label for="schedule_day">Schedule Day</label>
|
||||||
<select id="schedule_day" name="schedule_day"></select>
|
<select id="schedule_day" name="schedule_day"></select>
|
||||||
</div>
|
</div>
|
||||||
@@ -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">
|
||||||
@@ -184,14 +185,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button type="submit" class="lt-btn lt-btn-primary">Save</button>
|
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
|
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</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';
|
||||||
@@ -251,15 +251,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
daySelect.innerHTML = '';
|
daySelect.innerHTML = '';
|
||||||
|
|
||||||
if (type === 'daily') {
|
if (type === 'daily') {
|
||||||
dayRow.style.display = 'none';
|
dayRow.classList.add('is-hidden');
|
||||||
} else if (type === 'weekly') {
|
} else if (type === 'weekly') {
|
||||||
dayRow.style.display = 'flex';
|
dayRow.classList.remove('is-hidden');
|
||||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||||
days.forEach((day, i) => {
|
days.forEach((day, i) => {
|
||||||
daySelect.innerHTML += `<option value="${i + 1}">${day}</option>`;
|
daySelect.innerHTML += `<option value="${i + 1}">${day}</option>`;
|
||||||
});
|
});
|
||||||
} else if (type === 'monthly') {
|
} else if (type === 'monthly') {
|
||||||
dayRow.style.display = 'flex';
|
dayRow.classList.remove('is-hidden');
|
||||||
for (let i = 1; i <= 28; i++) {
|
for (let i = 1; i <= 28; i++) {
|
||||||
daySelect.innerHTML += `<option value="${i}">Day ${i}</option>`;
|
daySelect.innerHTML += `<option value="${i}">Day ${i}</option>`;
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -23,34 +24,35 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<body>
|
<body>
|
||||||
<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'])): ?>
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||||
<span class="admin-badge">Admin</span>
|
<span class="admin-badge">[ ADMIN ]</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</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,13 +76,13 @@ $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>
|
||||||
<td>
|
<td>
|
||||||
<button data-action="edit-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small">Edit</button>
|
<button data-action="edit-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small">EDIT</button>
|
||||||
<button data-action="delete-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small btn-danger">Delete</button>
|
<button data-action="delete-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -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">
|
||||||
@@ -155,14 +156,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button type="submit" class="lt-btn lt-btn-primary">Save</button>
|
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
|
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</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>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
// Admin view for user activity reports
|
// Admin view for user activity reports
|
||||||
// Receives $userStats, $dateRange from controller
|
// Receives $userStats, $dateRange from controller
|
||||||
|
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
|
||||||
|
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
|
||||||
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -10,25 +13,28 @@
|
|||||||
<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 src="/assets/js/base.js"></script>
|
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||||
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<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'])): ?>
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||||
<span class="admin-badge">Admin</span>
|
<span class="admin-badge">[ ADMIN ]</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</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>
|
||||||
|
|
||||||
@@ -36,37 +42,38 @@
|
|||||||
<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 class="admin-form-actions">
|
||||||
|
<button type="submit" class="btn">APPLY</button>
|
||||||
|
<a href="?" class="btn btn-secondary">RESET</a>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn">Apply</button>
|
|
||||||
<a href="?" class="btn btn-secondary">Reset</a>
|
|
||||||
</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): ?>
|
||||||
@@ -74,22 +81,22 @@
|
|||||||
<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>
|
||||||
@@ -97,42 +104,32 @@
|
|||||||
<?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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -23,47 +24,47 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<body>
|
<body>
|
||||||
<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'])): ?>
|
||||||
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
|
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
|
||||||
<span class="admin-badge">Admin</span>
|
<span class="admin-badge">[ ADMIN ]</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</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,22 +107,22 @@ $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>
|
||||||
<td>
|
<td>
|
||||||
<button data-action="edit-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small">Edit</button>
|
<button data-action="edit-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small">EDIT</button>
|
||||||
<button data-action="delete-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small btn-danger">Delete</button>
|
<button data-action="delete-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -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">
|
||||||
@@ -172,14 +173,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button type="submit" class="lt-btn lt-btn-primary">Save</button>
|
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
|
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user