Compare commits
41 Commits
d204756cfe
...
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 | |||
| e756f8e0bb | |||
| fea7575ac8 | |||
| 6fbba3939f | |||
| f3c15e2582 | |||
| 51fa5a8a3c | |||
| 4a838b68ca | |||
| 5545328e53 | |||
| 8bb43c14db | |||
| 92544d60ce | |||
| 89a685a502 |
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
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
|
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'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
@@ -60,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'],
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ if (!$attachmentId || !is_numeric($attachmentId)) {
|
|||||||
$attachmentId = (int)$attachmentId;
|
$attachmentId = (int)$attachmentId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$attachmentModel = new AttachmentModel();
|
$attachmentModel = new AttachmentModel(Database::getConnection());
|
||||||
|
|
||||||
// Get attachment details
|
// Get attachment details
|
||||||
$attachment = $attachmentModel->getAttachment($attachmentId);
|
$attachment = $attachmentModel->getAttachment($attachmentId);
|
||||||
|
|||||||
@@ -21,8 +21,19 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
// Only allow POST or DELETE — reject GET to prevent CSRF bypass
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
if ($method !== 'POST' && $method !== 'DELETE') {
|
||||||
|
http_response_code(405);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
|
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'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
@@ -48,9 +59,9 @@ try {
|
|||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
if (!$data || !isset($data['comment_id'])) {
|
if (!$data || !isset($data['comment_id'])) {
|
||||||
// Try query params
|
// Also check POST params (no GET fallback — prevents CSRF bypass via URL)
|
||||||
if (isset($_GET['comment_id'])) {
|
if (isset($_POST['comment_id'])) {
|
||||||
$data = ['comment_id' => $_GET['comment_id']];
|
$data = ['comment_id' => $_POST['comment_id']];
|
||||||
} else {
|
} else {
|
||||||
throw new Exception("Missing required field: comment_id");
|
throw new Exception("Missing required field: comment_id");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ if (!$attachmentId || !is_numeric($attachmentId)) {
|
|||||||
$attachmentId = (int)$attachmentId;
|
$attachmentId = (int)$attachmentId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$attachmentModel = new AttachmentModel();
|
$attachmentModel = new AttachmentModel(Database::getConnection());
|
||||||
|
|
||||||
// Get attachment details
|
// Get attachment details
|
||||||
$attachment = $attachmentModel->getAttachment($attachmentId);
|
$attachment = $attachmentModel->getAttachment($attachmentId);
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ try {
|
|||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
|
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'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ try {
|
|||||||
require_once $workflowModelPath;
|
require_once $workflowModelPath;
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
|
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'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
@@ -77,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,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$attachmentModel = new AttachmentModel();
|
$attachmentModel = new AttachmentModel(Database::getConnection());
|
||||||
$attachments = $attachmentModel->getAttachments($ticketId);
|
$attachments = $attachmentModel->getAttachments($ticketId);
|
||||||
|
|
||||||
// Add formatted file size and icon to each attachment
|
// Add formatted file size and icon to each attachment
|
||||||
@@ -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,
|
||||||
|
|||||||
1701
assets/css/base.css
Normal file
1701
assets/css/base.css
Normal file
File diff suppressed because it is too large
Load Diff
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
function openAdvancedSearch() {
|
function openAdvancedSearch() {
|
||||||
const modal = document.getElementById('advancedSearchModal');
|
const modal = document.getElementById('advancedSearchModal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.style.display = 'flex';
|
lt.modal.open('advancedSearchModal');
|
||||||
document.body.classList.add('modal-open');
|
|
||||||
loadUsersForSearch();
|
loadUsersForSearch();
|
||||||
populateCurrentFilters();
|
populateCurrentFilters();
|
||||||
loadSavedFilters();
|
loadSavedFilters();
|
||||||
@@ -17,28 +16,13 @@ function openAdvancedSearch() {
|
|||||||
|
|
||||||
// Close advanced search modal
|
// Close advanced search modal
|
||||||
function closeAdvancedSearch() {
|
function closeAdvancedSearch() {
|
||||||
const modal = document.getElementById('advancedSearchModal');
|
lt.modal.close('advancedSearchModal');
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
||||||
const response = await fetch('/api/get_users.php', {
|
const data = await lt.api.get('/api/get_users.php');
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.users) {
|
if (data.success && data.users) {
|
||||||
const createdBySelect = document.getElementById('adv-created-by');
|
const createdBySelect = document.getElementById('adv-created-by');
|
||||||
@@ -68,7 +52,7 @@ async function loadUsersForSearch() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading users:', error);
|
lt.toast.error('Error loading users');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,37 +140,21 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterCriteria = getCurrentFilterCriteria();
|
const filterCriteria = getCurrentFilterCriteria();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/saved_filters.php', {
|
await lt.api.post('/api/saved_filters.php', {
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
filter_name: filterName.trim(),
|
filter_name: filterName.trim(),
|
||||||
filter_criteria: filterCriteria
|
filter_criteria: filterCriteria
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
lt.toast.success(`Filter "${filterName}" saved successfully!`, 3000);
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(`Filter "${filterName}" saved successfully!`, 3000);
|
|
||||||
loadSavedFilters();
|
loadSavedFilters();
|
||||||
} else {
|
|
||||||
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'), 4000);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving filter:', error);
|
lt.toast.error('Error saving filter: ' + (error.message || 'Unknown error'), 4000);
|
||||||
toast.error('Error saving filter', 4000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -233,16 +201,12 @@ function getCurrentFilterCriteria() {
|
|||||||
// Load saved filters
|
// Load saved filters
|
||||||
async function loadSavedFilters() {
|
async function loadSavedFilters() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/saved_filters.php', {
|
const data = await lt.api.get('/api/saved_filters.php');
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.filters) {
|
if (data.success && data.filters) {
|
||||||
populateSavedFiltersDropdown(data.filters);
|
populateSavedFiltersDropdown(data.filters);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading saved filters:', error);
|
lt.toast.error('Error loading saved filters');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +241,7 @@ function loadSavedFilter() {
|
|||||||
const criteria = JSON.parse(selectedOption.dataset.criteria);
|
const criteria = JSON.parse(selectedOption.dataset.criteria);
|
||||||
applySavedFilterCriteria(criteria);
|
applySavedFilterCriteria(criteria);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading filter:', error);
|
lt.toast.error('Error loading filter');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,9 +278,7 @@ async function deleteSavedFilter() {
|
|||||||
const selectedOption = dropdown.options[dropdown.selectedIndex];
|
const selectedOption = dropdown.options[dropdown.selectedIndex];
|
||||||
|
|
||||||
if (!selectedOption || selectedOption.value === '') {
|
if (!selectedOption || selectedOption.value === '') {
|
||||||
if (typeof toast !== 'undefined') {
|
lt.toast.error('Please select a filter to delete');
|
||||||
toast.error('Please select a filter to delete');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,45 +291,21 @@ async function deleteSavedFilter() {
|
|||||||
'error',
|
'error',
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/saved_filters.php', {
|
await lt.api.delete('/api/saved_filters.php', { filter_id: filterId });
|
||||||
method: 'DELETE',
|
lt.toast.success('Filter deleted successfully', 3000);
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ filter_id: filterId })
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success('Filter deleted successfully', 3000);
|
|
||||||
loadSavedFilters();
|
loadSavedFilters();
|
||||||
resetAdvancedSearch();
|
resetAdvancedSearch();
|
||||||
} else {
|
|
||||||
toast.error('Failed to delete filter', 4000);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting filter:', error);
|
lt.toast.error('Error deleting filter', 4000);
|
||||||
toast.error('Error deleting filter', 4000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard shortcut (Ctrl+Shift+F)
|
// Keyboard shortcut (Ctrl+Shift+F) — ESC is handled globally by lt.keys.initDefaults()
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
|
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openAdvancedSearch();
|
openAdvancedSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ESC to close
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
const modal = document.getElementById('advancedSearchModal');
|
|
||||||
if (modal && modal.style.display === 'flex') {
|
|
||||||
closeAdvancedSearch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,26 +60,13 @@ function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = tru
|
|||||||
const container = document.querySelector(containerSelector);
|
const container = document.querySelector(containerSelector);
|
||||||
|
|
||||||
if (!container || !banner) {
|
if (!container || !banner) {
|
||||||
console.error('ASCII Banner: Container or banner not found', { bannerId, containerSelector });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
|
|
||||||
@@ -179,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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
793
assets/js/base.js
Normal file
793
assets/js/base.js
Normal file
@@ -0,0 +1,793 @@
|
|||||||
|
/**
|
||||||
|
* LOTUSGUILD TERMINAL DESIGN SYSTEM v1.0 — base.js
|
||||||
|
* Core JavaScript utilities shared across all LotusGuild applications
|
||||||
|
*
|
||||||
|
* Apps: Tinker Tickets (PHP), PULSE (Node.js), GANDALF (Flask)
|
||||||
|
* Namespace: window.lt
|
||||||
|
*
|
||||||
|
* CONTENTS
|
||||||
|
* 1. HTML Escape
|
||||||
|
* 2. Toast Notifications
|
||||||
|
* 3. Terminal Audio (beep)
|
||||||
|
* 4. Modal Management
|
||||||
|
* 5. Tab Management
|
||||||
|
* 6. Boot Sequence Animation
|
||||||
|
* 7. Keyboard Shortcuts
|
||||||
|
* 8. Sidebar Collapse
|
||||||
|
* 9. CSRF Token Helpers
|
||||||
|
* 10. Fetch Helpers (JSON API wrapper)
|
||||||
|
* 11. Time Formatting
|
||||||
|
* 12. Bytes Formatting
|
||||||
|
* 13. Table Keyboard Navigation
|
||||||
|
* 14. Sortable Table Headers
|
||||||
|
* 15. Stats Widget Filtering
|
||||||
|
* 16. Auto-refresh Manager
|
||||||
|
* 17. Initialisation
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
1. HTML ESCAPE
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
/**
|
||||||
|
* Escape a value for safe insertion into innerHTML.
|
||||||
|
* Always prefer textContent/innerText when possible, but use this
|
||||||
|
* when you must build HTML strings (e.g. template literals for lists).
|
||||||
|
*
|
||||||
|
* @param {*} str
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function escHtml(str) {
|
||||||
|
if (str === null || str === undefined) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
2. TOAST NOTIFICATIONS
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage:
|
||||||
|
lt.toast.success('Ticket saved');
|
||||||
|
lt.toast.error('Network error', 5000);
|
||||||
|
lt.toast.warning('Rate limit approaching');
|
||||||
|
lt.toast.info('Workflow started');
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
const _toastQueue = [];
|
||||||
|
let _toastActive = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} message
|
||||||
|
* @param {'success'|'error'|'warning'|'info'} type
|
||||||
|
* @param {number} [duration=3500] ms before auto-dismiss
|
||||||
|
*/
|
||||||
|
function showToast(message, type, duration) {
|
||||||
|
type = type || 'info';
|
||||||
|
duration = duration || 3500;
|
||||||
|
|
||||||
|
if (_toastActive) {
|
||||||
|
_toastQueue.push({ message, type, duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_displayToast(message, type, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _displayToast(message, type, duration) {
|
||||||
|
_toastActive = true;
|
||||||
|
|
||||||
|
let container = document.querySelector('.lt-toast-container');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'lt-toast-container';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = { success: '✓', error: '✗', warning: '!', info: 'i' };
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'lt-toast lt-toast-' + type;
|
||||||
|
toast.setAttribute('role', 'alert');
|
||||||
|
toast.setAttribute('aria-live', 'polite');
|
||||||
|
|
||||||
|
const iconEl = document.createElement('span');
|
||||||
|
iconEl.className = 'lt-toast-icon';
|
||||||
|
iconEl.textContent = '[' + (icons[type] || 'i') + ']';
|
||||||
|
|
||||||
|
const msgEl = document.createElement('span');
|
||||||
|
msgEl.className = 'lt-toast-msg';
|
||||||
|
msgEl.textContent = message;
|
||||||
|
|
||||||
|
const closeEl = document.createElement('button');
|
||||||
|
closeEl.className = 'lt-toast-close';
|
||||||
|
closeEl.textContent = '✕';
|
||||||
|
closeEl.setAttribute('aria-label', 'Dismiss');
|
||||||
|
closeEl.addEventListener('click', () => _dismissToast(toast));
|
||||||
|
|
||||||
|
toast.appendChild(iconEl);
|
||||||
|
toast.appendChild(msgEl);
|
||||||
|
toast.appendChild(closeEl);
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
/* Auto-dismiss */
|
||||||
|
const timer = setTimeout(() => _dismissToast(toast), duration);
|
||||||
|
toast._lt_timer = timer;
|
||||||
|
|
||||||
|
/* Optional audio feedback */
|
||||||
|
_beep(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _dismissToast(toast) {
|
||||||
|
if (!toast || !toast.parentNode) return;
|
||||||
|
clearTimeout(toast._lt_timer);
|
||||||
|
toast.classList.add('lt-toast--hiding');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (toast.parentNode) toast.parentNode.removeChild(toast);
|
||||||
|
_toastActive = false;
|
||||||
|
if (_toastQueue.length) {
|
||||||
|
const next = _toastQueue.shift();
|
||||||
|
_displayToast(next.message, next.type, next.duration);
|
||||||
|
}
|
||||||
|
}, 320);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = {
|
||||||
|
success: (msg, dur) => showToast(msg, 'success', dur),
|
||||||
|
error: (msg, dur) => showToast(msg, 'error', dur),
|
||||||
|
warning: (msg, dur) => showToast(msg, 'warning', dur),
|
||||||
|
info: (msg, dur) => showToast(msg, 'info', dur),
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
3. TERMINAL AUDIO
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage: lt.beep('success' | 'error' | 'info')
|
||||||
|
Silent-fails if Web Audio API is unavailable.
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function _beep(type) {
|
||||||
|
try {
|
||||||
|
const ctx = new (global.AudioContext || global.webkitAudioContext)();
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.frequency.value = type === 'success' ? 880
|
||||||
|
: type === 'error' ? 220
|
||||||
|
: 440;
|
||||||
|
osc.type = 'sine';
|
||||||
|
gain.gain.setValueAtTime(0.08, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.12);
|
||||||
|
osc.start(ctx.currentTime);
|
||||||
|
osc.stop(ctx.currentTime + 0.12);
|
||||||
|
} catch (_) { /* silently fail */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
4. MODAL MANAGEMENT
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage:
|
||||||
|
lt.modal.open('my-modal-id');
|
||||||
|
lt.modal.close('my-modal-id');
|
||||||
|
lt.modal.closeAll();
|
||||||
|
|
||||||
|
HTML contract:
|
||||||
|
<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-header">
|
||||||
|
<span class="lt-modal-title" id="myModalTitle">Title</span>
|
||||||
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-body">…</div>
|
||||||
|
<div class="lt-modal-footer">…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function openModal(id) {
|
||||||
|
const el = typeof id === 'string' ? document.getElementById(id) : id;
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add('show');
|
||||||
|
el.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
/* Focus first focusable element */
|
||||||
|
const first = el.querySelector('button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||||
|
if (first) setTimeout(() => first.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
const el = typeof id === 'string' ? document.getElementById(id) : id;
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove('show');
|
||||||
|
el.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllModals() {
|
||||||
|
document.querySelectorAll('.lt-modal-overlay.show').forEach(closeModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delegated close handlers */
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
/* Click on overlay backdrop (outside .lt-modal) */
|
||||||
|
if (e.target.classList.contains('lt-modal-overlay')) {
|
||||||
|
closeModal(e.target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* [data-modal-close] button */
|
||||||
|
const closeBtn = e.target.closest('[data-modal-close]');
|
||||||
|
if (closeBtn) {
|
||||||
|
const overlay = closeBtn.closest('.lt-modal-overlay');
|
||||||
|
if (overlay) closeModal(overlay);
|
||||||
|
}
|
||||||
|
/* [data-modal-open="id"] trigger */
|
||||||
|
const openBtn = e.target.closest('[data-modal-open]');
|
||||||
|
if (openBtn) openModal(openBtn.dataset.modalOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
const modal = { open: openModal, close: closeModal, closeAll: closeAllModals };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
5. TAB MANAGEMENT
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage:
|
||||||
|
lt.tabs.init(); // auto-wires all .lt-tab elements
|
||||||
|
lt.tabs.switch('tab-panel-id');
|
||||||
|
|
||||||
|
HTML contract:
|
||||||
|
<div class="lt-tabs">
|
||||||
|
<button class="lt-tab active" data-tab="panel-one">One</button>
|
||||||
|
<button class="lt-tab" data-tab="panel-two">Two</button>
|
||||||
|
</div>
|
||||||
|
<div id="panel-one" class="lt-tab-panel active">…</div>
|
||||||
|
<div id="panel-two" class="lt-tab-panel">…</div>
|
||||||
|
|
||||||
|
Persistence: localStorage key 'lt_activeTab_<page>'
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function switchTab(panelId) {
|
||||||
|
document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
|
||||||
|
const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]');
|
||||||
|
const panel = document.getElementById(panelId);
|
||||||
|
if (btn) btn.classList.add('active');
|
||||||
|
if (panel) panel.classList.add('active');
|
||||||
|
|
||||||
|
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTabs() {
|
||||||
|
/* Restore from localStorage */
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
|
||||||
|
if (saved && document.getElementById(saved)) { switchTab(saved); return; }
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
/* Wire click handlers */
|
||||||
|
document.querySelectorAll('.lt-tab[data-tab]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = { init: initTabs, switch: switchTab };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
6. BOOT SEQUENCE ANIMATION
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage:
|
||||||
|
lt.boot.run('APP NAME'); // shows once per session
|
||||||
|
lt.boot.run('APP NAME', true); // force show even if already seen
|
||||||
|
|
||||||
|
HTML contract (add to <body>, hidden by default):
|
||||||
|
<div id="lt-boot" class="lt-boot-overlay" style="display:none">
|
||||||
|
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||||
|
</div>
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function runBoot(appName, force) {
|
||||||
|
const storageKey = 'lt_booted_' + (appName || 'app');
|
||||||
|
if (!force && sessionStorage.getItem(storageKey)) return;
|
||||||
|
|
||||||
|
const overlay = document.getElementById('lt-boot');
|
||||||
|
const pre = document.getElementById('lt-boot-text');
|
||||||
|
if (!overlay || !pre) return;
|
||||||
|
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
|
||||||
|
const name = (appName || 'TERMINAL').toUpperCase();
|
||||||
|
const titleStr = name + ' v1.0';
|
||||||
|
const innerWidth = 43;
|
||||||
|
const leftPad = Math.max(0, Math.floor((innerWidth - titleStr.length) / 2));
|
||||||
|
const rightPad = Math.max(0, innerWidth - titleStr.length - leftPad);
|
||||||
|
const messages = [
|
||||||
|
'╔═══════════════════════════════════════════╗',
|
||||||
|
'║' + ' '.repeat(leftPad) + titleStr + ' '.repeat(rightPad) + '║',
|
||||||
|
'║ BOOTING SYSTEM... ║',
|
||||||
|
'╚═══════════════════════════════════════════╝',
|
||||||
|
'',
|
||||||
|
'[ OK ] Checking kernel modules...',
|
||||||
|
'[ OK ] Mounting filesystem...',
|
||||||
|
'[ OK ] Initializing database connection...',
|
||||||
|
'[ OK ] Loading user session...',
|
||||||
|
'[ OK ] Applying security headers...',
|
||||||
|
'[ OK ] Rendering terminal interface...',
|
||||||
|
'',
|
||||||
|
'> SYSTEM READY ✓',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
pre.textContent = '';
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (i < messages.length) {
|
||||||
|
pre.textContent += messages[i] + '\n';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
clearInterval(interval);
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
overlay.classList.remove('fade-out');
|
||||||
|
}, 520);
|
||||||
|
}, 400);
|
||||||
|
sessionStorage.setItem(storageKey, '1');
|
||||||
|
}
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
const boot = { run: runBoot };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
7. KEYBOARD SHORTCUTS
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Register handlers:
|
||||||
|
lt.keys.on('ctrl+k', () => searchBox.focus());
|
||||||
|
lt.keys.on('?', showHelpModal);
|
||||||
|
lt.keys.on('Escape', lt.modal.closeAll);
|
||||||
|
|
||||||
|
Built-in defaults (activate with lt.keys.initDefaults()):
|
||||||
|
ESC → close all modals
|
||||||
|
? → show #lt-keys-help modal if present
|
||||||
|
Ctrl/⌘+K → focus .lt-search-input
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
const _keyHandlers = {};
|
||||||
|
|
||||||
|
function normalizeKey(combo) {
|
||||||
|
return combo
|
||||||
|
.replace(/ctrl\+/i, 'ctrl+')
|
||||||
|
.replace(/cmd\+/i, 'ctrl+') /* treat Cmd as Ctrl */
|
||||||
|
.replace(/meta\+/i, 'ctrl+')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerKey(combo, handler) {
|
||||||
|
_keyHandlers[normalizeKey(combo)] = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterKey(combo) {
|
||||||
|
delete _keyHandlers[normalizeKey(combo)];
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
const inInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)
|
||||||
|
|| e.target.isContentEditable;
|
||||||
|
|
||||||
|
/* Build the combo string */
|
||||||
|
let combo = '';
|
||||||
|
if (e.ctrlKey || e.metaKey) combo += 'ctrl+';
|
||||||
|
if (e.altKey) combo += 'alt+';
|
||||||
|
if (e.shiftKey) combo += 'shift+';
|
||||||
|
combo += e.key.toLowerCase();
|
||||||
|
|
||||||
|
/* Always fire ESC, Ctrl combos regardless of input focus */
|
||||||
|
const alwaysFire = e.key === 'Escape' || e.ctrlKey || e.metaKey;
|
||||||
|
|
||||||
|
if (inInput && !alwaysFire) return;
|
||||||
|
|
||||||
|
const handler = _keyHandlers[combo];
|
||||||
|
if (handler) {
|
||||||
|
e.preventDefault();
|
||||||
|
handler(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function initDefaultKeys() {
|
||||||
|
registerKey('Escape', closeAllModals);
|
||||||
|
registerKey('?', () => {
|
||||||
|
const helpModal = document.getElementById('lt-keys-help');
|
||||||
|
if (helpModal) openModal(helpModal);
|
||||||
|
});
|
||||||
|
registerKey('ctrl+k', () => {
|
||||||
|
const search = document.querySelector('.lt-search-input');
|
||||||
|
if (search) { search.focus(); search.select(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = {
|
||||||
|
on: registerKey,
|
||||||
|
off: unregisterKey,
|
||||||
|
initDefaults: initDefaultKeys,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
8. SIDEBAR COLLAPSE
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage: lt.sidebar.init();
|
||||||
|
|
||||||
|
HTML contract:
|
||||||
|
<aside class="lt-sidebar" id="lt-sidebar">
|
||||||
|
<div class="lt-sidebar-header">
|
||||||
|
Filters
|
||||||
|
<button class="lt-sidebar-toggle" data-sidebar-toggle="lt-sidebar">◀</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-sidebar-body">…</div>
|
||||||
|
</aside>
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function initSidebar() {
|
||||||
|
document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => {
|
||||||
|
const sidebar = document.getElementById(btn.dataset.sidebarToggle);
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
/* Restore state */
|
||||||
|
const collapsed = sessionStorage.getItem('lt_sidebar_' + btn.dataset.sidebarToggle) === '1';
|
||||||
|
if (collapsed) {
|
||||||
|
sidebar.classList.add('collapsed');
|
||||||
|
btn.textContent = '▶';
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
sidebar.classList.toggle('collapsed');
|
||||||
|
const isCollapsed = sidebar.classList.contains('collapsed');
|
||||||
|
btn.textContent = isCollapsed ? '▶' : '◀';
|
||||||
|
try { sessionStorage.setItem('lt_sidebar_' + btn.dataset.sidebarToggle, isCollapsed ? '1' : '0'); } catch (_) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebar = { init: initSidebar };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
9. CSRF TOKEN HELPERS
|
||||||
|
----------------------------------------------------------------
|
||||||
|
PHP apps: window.CSRF_TOKEN is set by the view via:
|
||||||
|
<script nonce="...">window.CSRF_TOKEN = '<?= CsrfMiddleware::getToken() ?>';</script>
|
||||||
|
Node apps: set via: window.CSRF_TOKEN = '<%= csrfToken %>';
|
||||||
|
Flask: use Flask-WTF meta tag or inject via template.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
const headers = lt.csrf.headers();
|
||||||
|
fetch('/api/foo', { method: 'POST', headers: lt.csrf.headers(), body: … });
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function csrfHeaders() {
|
||||||
|
const token = global.CSRF_TOKEN || '';
|
||||||
|
return token ? { 'X-CSRF-Token': token } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrf = { headers: csrfHeaders };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
10. FETCH HELPERS
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage:
|
||||||
|
const data = await lt.api.get('/api/tickets');
|
||||||
|
const res = await lt.api.post('/api/update_ticket.php', { id: 1, status: 'Closed' });
|
||||||
|
const res = await lt.api.delete('/api/ticket_dependencies.php', { id: 5 });
|
||||||
|
|
||||||
|
All methods:
|
||||||
|
- Automatically set Content-Type: application/json
|
||||||
|
- Attach CSRF token header
|
||||||
|
- Parse JSON response
|
||||||
|
- On non-2xx: throw an Error with the server's error message
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
async function apiFetch(method, url, body) {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: Object.assign(
|
||||||
|
{ 'Content-Type': 'application/json' },
|
||||||
|
csrfHeaders()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch(url, opts);
|
||||||
|
} catch (networkErr) {
|
||||||
|
throw new Error('Network error: ' + networkErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await resp.json();
|
||||||
|
} catch (_) {
|
||||||
|
data = { success: resp.ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
get: (url) => apiFetch('GET', url),
|
||||||
|
post: (url, body) => apiFetch('POST', url, body),
|
||||||
|
put: (url, body) => apiFetch('PUT', url, body),
|
||||||
|
patch: (url, body) => apiFetch('PATCH', url, body),
|
||||||
|
delete: (url, body) => apiFetch('DELETE', url, body),
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
11. TIME FORMATTING
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
/**
|
||||||
|
* Returns a human-readable relative time string.
|
||||||
|
* @param {string|number|Date} value ISO string, Unix ms, or Date
|
||||||
|
* @returns {string} e.g. "5m ago", "2h ago", "3d ago"
|
||||||
|
*/
|
||||||
|
function timeAgo(value) {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (isNaN(date)) return '—';
|
||||||
|
const diff = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
|
if (diff < 60) return diff + 's ago';
|
||||||
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||||
|
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||||
|
return Math.floor(diff / 86400) + 'd ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format seconds → "1h 23m 45s" style.
|
||||||
|
* @param {number} secs
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatUptime(secs) {
|
||||||
|
secs = Math.floor(secs);
|
||||||
|
const d = Math.floor(secs / 86400);
|
||||||
|
const h = Math.floor((secs % 86400) / 3600);
|
||||||
|
const m = Math.floor((secs % 3600) / 60);
|
||||||
|
const s = secs % 60;
|
||||||
|
const parts = [];
|
||||||
|
if (d) parts.push(d + 'd');
|
||||||
|
if (h) parts.push(h + 'h');
|
||||||
|
if (m) parts.push(m + 'm');
|
||||||
|
if (!d) parts.push(s + 's');
|
||||||
|
return parts.join(' ') || '0s';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO datetime string for display.
|
||||||
|
* Uses the timezone configured in window.APP_TIMEZONE (PHP apps)
|
||||||
|
* or falls back to the browser locale.
|
||||||
|
*/
|
||||||
|
function formatDate(value) {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (isNaN(date)) return '—';
|
||||||
|
const tz = global.APP_TIMEZONE || undefined;
|
||||||
|
try {
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
timeZone: tz,
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = { ago: timeAgo, uptime: formatUptime, format: formatDate };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
12. BYTES FORMATTING
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
/**
|
||||||
|
* @param {number} bytes
|
||||||
|
* @returns {string} e.g. "1.23 GB"
|
||||||
|
*/
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === null || bytes === undefined) return '—';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let i = 0;
|
||||||
|
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
|
||||||
|
return bytes.toFixed(i === 0 ? 0 : 2) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
13. TABLE KEYBOARD NAVIGATION (vim-style j/k)
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage: lt.tableNav.init('my-table-id');
|
||||||
|
|
||||||
|
Keys registered:
|
||||||
|
j or ArrowDown → move selection down
|
||||||
|
k or ArrowUp → move selection up
|
||||||
|
Enter → follow first <a> in selected row
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function initTableNav(tableId) {
|
||||||
|
const table = tableId ? document.getElementById(tableId) : document.querySelector('.lt-table');
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
function rows() { return Array.from(table.querySelectorAll('tbody tr')); }
|
||||||
|
function selected() { return table.querySelector('tbody tr.lt-row-selected'); }
|
||||||
|
|
||||||
|
function move(dir) {
|
||||||
|
const all = rows();
|
||||||
|
if (!all.length) return;
|
||||||
|
const cur = selected();
|
||||||
|
const idx = cur ? all.indexOf(cur) : -1;
|
||||||
|
const next = dir === 'down'
|
||||||
|
? all[idx < all.length - 1 ? idx + 1 : 0]
|
||||||
|
: all[idx > 0 ? idx - 1 : all.length - 1];
|
||||||
|
if (cur) cur.classList.remove('lt-row-selected');
|
||||||
|
next.classList.add('lt-row-selected');
|
||||||
|
next.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
keys.on('j', () => move('down'));
|
||||||
|
keys.on('ArrowDown', () => move('down'));
|
||||||
|
keys.on('k', () => move('up'));
|
||||||
|
keys.on('ArrowUp', () => move('up'));
|
||||||
|
keys.on('Enter', () => {
|
||||||
|
const row = selected();
|
||||||
|
if (!row) return;
|
||||||
|
const link = row.querySelector('a[href]');
|
||||||
|
if (link) global.location.href = link.href;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableNav = { init: initTableNav };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
14. SORTABLE TABLE HEADERS
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage: lt.sortTable.init('my-table-id');
|
||||||
|
|
||||||
|
Markup: add data-sort-key="field" to <th> elements.
|
||||||
|
Sorts rows client-side by the text content of the matching column.
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function initSortTable(tableId) {
|
||||||
|
const table = document.getElementById(tableId);
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
|
||||||
|
ths.forEach((th, colIdx) => {
|
||||||
|
let dir = 'asc';
|
||||||
|
|
||||||
|
th.addEventListener('click', () => {
|
||||||
|
/* Reset all headers */
|
||||||
|
ths.forEach(h => h.removeAttribute('data-sort'));
|
||||||
|
th.setAttribute('data-sort', dir);
|
||||||
|
|
||||||
|
const tbody = table.querySelector('tbody');
|
||||||
|
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aText = (a.cells[colIdx] || {}).textContent || '';
|
||||||
|
const bText = (b.cells[colIdx] || {}).textContent || '';
|
||||||
|
const n = !isNaN(parseFloat(aText)) && !isNaN(parseFloat(bText));
|
||||||
|
const cmp = n
|
||||||
|
? parseFloat(aText) - parseFloat(bText)
|
||||||
|
: aText.localeCompare(bText);
|
||||||
|
return dir === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
rows.forEach(r => tbody.appendChild(r));
|
||||||
|
dir = dir === 'asc' ? 'desc' : 'asc';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortTable = { init: initSortTable };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
15. STATS WIDGET FILTERING
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage: lt.statsFilter.init();
|
||||||
|
|
||||||
|
HTML contract:
|
||||||
|
<div class="lt-stat-card" data-filter-key="status" data-filter-val="Open">…</div>
|
||||||
|
<!-- clicking the card adds ?filter=status:Open to the URL and
|
||||||
|
calls the optional window.lt_onStatFilter(key, val) hook -->
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function initStatsFilter() {
|
||||||
|
document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
const key = card.dataset.filterKey;
|
||||||
|
const val = card.dataset.filterVal;
|
||||||
|
|
||||||
|
/* Toggle active state */
|
||||||
|
const wasActive = card.classList.contains('active');
|
||||||
|
document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active'));
|
||||||
|
if (!wasActive) card.classList.add('active');
|
||||||
|
|
||||||
|
/* Call app-specific filter hook if defined */
|
||||||
|
if (typeof global.lt_onStatFilter === 'function') {
|
||||||
|
global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const statsFilter = { init: initStatsFilter };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
16. AUTO-REFRESH MANAGER
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage:
|
||||||
|
lt.autoRefresh.start(refreshFn, 30000); // every 30 s
|
||||||
|
lt.autoRefresh.stop();
|
||||||
|
lt.autoRefresh.now(); // trigger immediately + restart timer
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
let _arTimer = null;
|
||||||
|
let _arFn = null;
|
||||||
|
let _arInterval = 30000;
|
||||||
|
|
||||||
|
function arStart(fn, intervalMs) {
|
||||||
|
arStop();
|
||||||
|
_arFn = fn;
|
||||||
|
_arInterval = intervalMs || 30000;
|
||||||
|
_arTimer = setInterval(_arFn, _arInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
function arStop() {
|
||||||
|
if (_arTimer) { clearInterval(_arTimer); _arTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function arNow() {
|
||||||
|
arStop();
|
||||||
|
if (_arFn) {
|
||||||
|
_arFn();
|
||||||
|
_arTimer = setInterval(_arFn, _arInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoRefresh = { start: arStart, stop: arStop, now: arNow };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
17. INITIALISATION
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Called automatically on DOMContentLoaded.
|
||||||
|
Each sub-system can also be initialised manually after the DOM
|
||||||
|
has been updated with AJAX content.
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function init() {
|
||||||
|
initTabs();
|
||||||
|
initSidebar();
|
||||||
|
initDefaultKeys();
|
||||||
|
initStatsFilter();
|
||||||
|
|
||||||
|
/* Boot sequence: runs if #lt-boot element is present */
|
||||||
|
const bootEl = document.getElementById('lt-boot');
|
||||||
|
if (bootEl) {
|
||||||
|
const appName = bootEl.dataset.appName || document.title;
|
||||||
|
runBoot(appName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Public API
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
global.lt = {
|
||||||
|
escHtml,
|
||||||
|
toast,
|
||||||
|
beep: _beep,
|
||||||
|
modal,
|
||||||
|
tabs,
|
||||||
|
boot,
|
||||||
|
keys,
|
||||||
|
sidebar,
|
||||||
|
csrf,
|
||||||
|
api,
|
||||||
|
time,
|
||||||
|
bytes: { format: formatBytes },
|
||||||
|
tableNav,
|
||||||
|
sortTable,
|
||||||
|
statsFilter,
|
||||||
|
autoRefresh,
|
||||||
|
};
|
||||||
|
|
||||||
|
}(window));
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,173 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Keyboard shortcuts for power users
|
* Keyboard shortcuts for power users.
|
||||||
|
* App-specific shortcuts registered via lt.keys.on() from web_template/base.js.
|
||||||
|
* ESC, Ctrl+K, and ? are handled by lt.keys.initDefaults().
|
||||||
*/
|
*/
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
// ESC: Close modals, cancel edit mode, blur inputs
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
// Close any open modals first
|
|
||||||
const openModals = document.querySelectorAll('.modal-overlay');
|
|
||||||
let closedModal = false;
|
|
||||||
openModals.forEach(modal => {
|
|
||||||
if (modal.style.display !== 'none' && modal.offsetParent !== null) {
|
|
||||||
modal.remove();
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
closedModal = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close settings modal if open
|
|
||||||
const settingsModal = document.getElementById('settingsModal');
|
|
||||||
if (settingsModal && settingsModal.style.display !== 'none') {
|
|
||||||
settingsModal.style.display = 'none';
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
closedModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close advanced search modal if open
|
|
||||||
const searchModal = document.getElementById('advancedSearchModal');
|
|
||||||
if (searchModal && searchModal.style.display !== 'none') {
|
|
||||||
searchModal.style.display = 'none';
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
closedModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we closed a modal, stop here
|
|
||||||
if (closedModal) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blur any focused input
|
|
||||||
if (e.target.tagName === 'INPUT' ||
|
|
||||||
e.target.tagName === 'TEXTAREA' ||
|
|
||||||
e.target.isContentEditable) {
|
|
||||||
e.target.blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel edit mode on ticket pages
|
|
||||||
const editButton = document.getElementById('editButton');
|
|
||||||
if (editButton && editButton.classList.contains('active')) {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip other shortcuts if user is typing in an input/textarea
|
|
||||||
if (e.target.tagName === 'INPUT' ||
|
|
||||||
e.target.tagName === 'TEXTAREA' ||
|
|
||||||
e.target.isContentEditable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + E: Toggle edit mode (on ticket pages)
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
|
|
||||||
e.preventDefault();
|
|
||||||
const editButton = document.getElementById('editButton');
|
|
||||||
if (editButton) {
|
|
||||||
editButton.click();
|
|
||||||
toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + S: Save ticket (on ticket pages)
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
||||||
e.preventDefault();
|
|
||||||
const editButton = document.getElementById('editButton');
|
|
||||||
if (editButton && editButton.classList.contains('active')) {
|
|
||||||
editButton.click();
|
|
||||||
toast.success('Saving ticket...');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + K: Focus search (on dashboard)
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
||||||
e.preventDefault();
|
|
||||||
const searchBox = document.querySelector('.search-box');
|
|
||||||
if (searchBox) {
|
|
||||||
searchBox.focus();
|
|
||||||
searchBox.select();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ? : Show keyboard shortcuts help (requires Shift on most keyboards)
|
|
||||||
if (e.key === '?') {
|
|
||||||
e.preventDefault();
|
|
||||||
showKeyboardHelp();
|
|
||||||
}
|
|
||||||
|
|
||||||
// J: Move to next row in table (Gmail-style)
|
|
||||||
if (e.key === 'j') {
|
|
||||||
e.preventDefault();
|
|
||||||
navigateTableRow('next');
|
|
||||||
}
|
|
||||||
|
|
||||||
// K: Move to previous row in table (Gmail-style)
|
|
||||||
if (e.key === 'k') {
|
|
||||||
e.preventDefault();
|
|
||||||
navigateTableRow('prev');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter: Open selected ticket
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
|
|
||||||
if (selectedRow) {
|
|
||||||
e.preventDefault();
|
|
||||||
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
|
|
||||||
if (ticketLink) {
|
|
||||||
window.location.href = ticketLink.href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// N: Create new ticket (on dashboard)
|
|
||||||
if (e.key === 'n') {
|
|
||||||
e.preventDefault();
|
|
||||||
const newTicketBtn = document.querySelector('a[href*="/create"]');
|
|
||||||
if (newTicketBtn) {
|
|
||||||
window.location.href = newTicketBtn.href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// C: Focus comment textarea (on ticket page)
|
|
||||||
if (e.key === 'c') {
|
|
||||||
const commentBox = document.getElementById('newComment');
|
|
||||||
if (commentBox) {
|
|
||||||
e.preventDefault();
|
|
||||||
commentBox.focus();
|
|
||||||
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// G then D: Go to Dashboard (vim-style)
|
|
||||||
if (e.key === 'g') {
|
|
||||||
window._pendingG = true;
|
|
||||||
setTimeout(() => { window._pendingG = false; }, 1000);
|
|
||||||
}
|
|
||||||
if (e.key === 'd' && window._pendingG) {
|
|
||||||
e.preventDefault();
|
|
||||||
window._pendingG = false;
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1-4: Quick status change on ticket page
|
|
||||||
if (['1', '2', '3', '4'].includes(e.key)) {
|
|
||||||
const statusSelect = document.getElementById('statusSelect');
|
|
||||||
if (statusSelect && !document.querySelector('.modal-overlay')) {
|
|
||||||
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
|
|
||||||
const targetStatus = statusMap[e.key];
|
|
||||||
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
|
|
||||||
if (option && !option.disabled) {
|
|
||||||
e.preventDefault();
|
|
||||||
statusSelect.value = targetStatus;
|
|
||||||
statusSelect.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track currently selected row for J/K navigation
|
// Track currently selected row for J/K navigation
|
||||||
let currentSelectedRowIndex = -1;
|
let currentSelectedRowIndex = -1;
|
||||||
|
|
||||||
@@ -175,7 +11,6 @@ function navigateTableRow(direction) {
|
|||||||
const rows = document.querySelectorAll('tbody tr');
|
const rows = document.querySelectorAll('tbody tr');
|
||||||
if (rows.length === 0) return;
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
// Remove current selection
|
|
||||||
rows.forEach(row => row.classList.remove('keyboard-selected'));
|
rows.forEach(row => row.classList.remove('keyboard-selected'));
|
||||||
|
|
||||||
if (direction === 'next') {
|
if (direction === 'next') {
|
||||||
@@ -184,7 +19,6 @@ function navigateTableRow(direction) {
|
|||||||
currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0);
|
currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add selection to new row
|
|
||||||
const selectedRow = rows[currentSelectedRowIndex];
|
const selectedRow = rows[currentSelectedRowIndex];
|
||||||
if (selectedRow) {
|
if (selectedRow) {
|
||||||
selectedRow.classList.add('keyboard-selected');
|
selectedRow.classList.add('keyboard-selected');
|
||||||
@@ -193,59 +27,140 @@ function navigateTableRow(direction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showKeyboardHelp() {
|
function showKeyboardHelp() {
|
||||||
// Check if help is already showing
|
if (document.getElementById('keyboardHelpModal')) return;
|
||||||
if (document.getElementById('keyboardHelpModal')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.id = 'keyboardHelpModal';
|
modal.id = 'keyboardHelpModal';
|
||||||
modal.className = 'modal-overlay';
|
modal.className = 'lt-modal-overlay';
|
||||||
|
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="modal-content ascii-frame-outer" style="max-width: 500px;">
|
<div class="lt-modal lt-modal-sm">
|
||||||
<div class="ascii-frame">
|
<div class="lt-modal-header">
|
||||||
<div class="ascii-content">
|
<span class="lt-modal-title" id="keyboardHelpModalTitle">KEYBOARD SHORTCUTS</span>
|
||||||
<h3 style="margin: 0 0 1rem 0; color: var(--terminal-green);">KEYBOARD SHORTCUTS</h3>
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
<div class="modal-body" style="padding: 0;">
|
</div>
|
||||||
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Navigation</h4>
|
<div class="lt-modal-body">
|
||||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
|
<h4 class="kb-section-heading">Navigation</h4>
|
||||||
<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>
|
<table class="kb-shortcuts-table">
|
||||||
<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>J</kbd></td><td>Next 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>K</kbd></td><td>Previous ticket in list</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>Enter</kbd></td><td>Open selected ticket</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.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.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.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="modal-footer" style="margin-top: 1rem;">
|
<div class="lt-modal-footer">
|
||||||
<button class="btn btn-secondary" data-action="close-shortcuts-modal">Close</button>
|
<button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
|
lt.modal.open('keyboardHelpModal');
|
||||||
// Add event listener for the close button
|
|
||||||
modal.querySelector('[data-action="close-shortcuts-modal"]').addEventListener('click', function() {
|
|
||||||
modal.remove();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (!window.lt) return;
|
||||||
|
|
||||||
|
// Ctrl+E: Toggle edit mode (ticket pages)
|
||||||
|
lt.keys.on('ctrl+e', function() {
|
||||||
|
const editButton = document.getElementById('editButton');
|
||||||
|
if (editButton) {
|
||||||
|
editButton.click();
|
||||||
|
lt.toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ctrl+S: Save ticket (ticket pages)
|
||||||
|
lt.keys.on('ctrl+s', function() {
|
||||||
|
const editButton = document.getElementById('editButton');
|
||||||
|
if (editButton && editButton.classList.contains('active')) {
|
||||||
|
editButton.click();
|
||||||
|
lt.toast.success('Saving ticket...');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ?: Show keyboard shortcuts help (lt.keys.initDefaults also handles this, but we override to show our modal)
|
||||||
|
lt.keys.on('?', function() {
|
||||||
|
showKeyboardHelp();
|
||||||
|
});
|
||||||
|
|
||||||
|
// J: Next row
|
||||||
|
lt.keys.on('j', () => navigateTableRow('next'));
|
||||||
|
|
||||||
|
// K: Previous row
|
||||||
|
lt.keys.on('k', () => navigateTableRow('prev'));
|
||||||
|
|
||||||
|
// Enter: Open selected ticket
|
||||||
|
lt.keys.on('enter', function() {
|
||||||
|
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
|
||||||
|
if (selectedRow) {
|
||||||
|
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
|
||||||
|
if (ticketLink) window.location.href = ticketLink.href;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// N: New ticket
|
||||||
|
lt.keys.on('n', function() {
|
||||||
|
const newTicketBtn = document.querySelector('a[href*="/create"]');
|
||||||
|
if (newTicketBtn) window.location.href = newTicketBtn.href;
|
||||||
|
});
|
||||||
|
|
||||||
|
// C: Focus comment box
|
||||||
|
lt.keys.on('c', function() {
|
||||||
|
const commentBox = document.getElementById('newComment');
|
||||||
|
if (commentBox) {
|
||||||
|
commentBox.focus();
|
||||||
|
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// G then D: Go to Dashboard (vim-style)
|
||||||
|
lt.keys.on('g', function() {
|
||||||
|
window._pendingG = true;
|
||||||
|
setTimeout(() => { window._pendingG = false; }, 1000);
|
||||||
|
});
|
||||||
|
lt.keys.on('d', function() {
|
||||||
|
if (window._pendingG) {
|
||||||
|
window._pendingG = false;
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1-4: Quick status change on ticket page
|
||||||
|
['1', '2', '3', '4'].forEach(key => {
|
||||||
|
lt.keys.on(key, function() {
|
||||||
|
const statusSelect = document.getElementById('statusSelect');
|
||||||
|
if (statusSelect && !document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) {
|
||||||
|
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
|
||||||
|
const targetStatus = statusMap[key];
|
||||||
|
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
|
||||||
|
if (option && !option.disabled) {
|
||||||
|
statusSelect.value = targetStatus;
|
||||||
|
statusSelect.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -8,16 +8,13 @@ let userPreferences = {};
|
|||||||
// Load preferences on page load
|
// Load preferences on page load
|
||||||
async function loadUserPreferences() {
|
async function loadUserPreferences() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user_preferences.php', {
|
const data = await lt.api.get('/api/user_preferences.php');
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
userPreferences = data.preferences;
|
userPreferences = data.preferences;
|
||||||
applyPreferences();
|
applyPreferences();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading preferences:', error);
|
lt.toast.error('Error loading preferences');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,34 +91,12 @@ async function saveSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Batch save all preferences in one request
|
await lt.api.post('/api/user_preferences.php', { preferences: prefs });
|
||||||
const response = await fetch('/api/user_preferences.php', {
|
lt.toast.success('Preferences saved successfully!');
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ preferences: prefs })
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error('Failed to save preferences');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.success('Preferences saved successfully!');
|
|
||||||
}
|
|
||||||
closeSettingsModal();
|
closeSettingsModal();
|
||||||
|
|
||||||
// Reload page to apply new preferences
|
|
||||||
setTimeout(() => window.location.reload(), 1000);
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof toast !== 'undefined') {
|
lt.toast.error('Error saving preferences');
|
||||||
toast.error('Error saving preferences');
|
|
||||||
}
|
|
||||||
console.error('Error saving preferences:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,24 +104,18 @@ async function saveSettings() {
|
|||||||
function openSettingsModal() {
|
function openSettingsModal() {
|
||||||
const modal = document.getElementById('settingsModal');
|
const modal = document.getElementById('settingsModal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.style.display = 'flex';
|
lt.modal.open('settingsModal');
|
||||||
document.body.classList.add('modal-open');
|
|
||||||
loadUserPreferences();
|
loadUserPreferences();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSettingsModal() {
|
function closeSettingsModal() {
|
||||||
const modal = document.getElementById('settingsModal');
|
lt.modal.close('settingsModal');
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal when clicking on backdrop (outside the settings content)
|
// Close modal when clicking on backdrop (outside the settings content)
|
||||||
function closeOnBackdropClick(event) {
|
function closeOnBackdropClick(event) {
|
||||||
const modal = document.getElementById('settingsModal');
|
const modal = document.getElementById('settingsModal');
|
||||||
// Only close if clicking directly on the modal backdrop, not on content
|
|
||||||
if (event.target === modal) {
|
if (event.target === modal) {
|
||||||
closeSettingsModal();
|
closeSettingsModal();
|
||||||
}
|
}
|
||||||
@@ -158,15 +127,10 @@ document.addEventListener('keydown', (e) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openSettingsModal();
|
openSettingsModal();
|
||||||
}
|
}
|
||||||
|
// ESC is handled globally by lt.keys.initDefaults()
|
||||||
// ESC to close modal
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
const modal = document.getElementById('settingsModal');
|
|
||||||
if (modal && modal.style.display !== 'none' && modal.style.display !== '') {
|
|
||||||
closeSettingsModal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', loadUserPreferences);
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (window.lt) loadUserPreferences();
|
||||||
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,94 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Terminal-style toast notification system with queuing
|
* Deprecated: use lt.toast.* directly (from web_template/base.js).
|
||||||
|
* This shim maintains backwards compatibility while callers are migrated.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Toast queue management
|
// showToast() shim — used by inline view scripts
|
||||||
let toastQueue = [];
|
function showToast(message, type = 'info', duration = 3500) {
|
||||||
let currentToast = null;
|
switch (type) {
|
||||||
|
case 'success': lt.toast.success(message, duration); break;
|
||||||
function showToast(message, type = 'info', duration = 3000) {
|
case 'error': lt.toast.error(message, duration); break;
|
||||||
// Queue if a toast is already showing
|
case 'warning': lt.toast.warning(message, duration); break;
|
||||||
if (currentToast) {
|
default: lt.toast.info(message, duration); break;
|
||||||
toastQueue.push({ message, type, duration });
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
displayToast(message, type, duration);
|
// window.toast.* shim — used by JS files
|
||||||
}
|
|
||||||
|
|
||||||
function displayToast(message, type, duration) {
|
|
||||||
// Create toast element
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `terminal-toast toast-${type}`;
|
|
||||||
currentToast = toast;
|
|
||||||
|
|
||||||
// Icon based on type
|
|
||||||
const icons = {
|
|
||||||
success: '✓',
|
|
||||||
error: '✗',
|
|
||||||
info: 'ℹ',
|
|
||||||
warning: '⚠'
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconSpan = document.createElement('span');
|
|
||||||
iconSpan.className = 'toast-icon';
|
|
||||||
iconSpan.textContent = `[${icons[type] || 'ℹ'}]`;
|
|
||||||
|
|
||||||
const msgSpan = document.createElement('span');
|
|
||||||
msgSpan.className = 'toast-message';
|
|
||||||
msgSpan.textContent = message;
|
|
||||||
|
|
||||||
const closeSpan = document.createElement('span');
|
|
||||||
closeSpan.className = 'toast-close';
|
|
||||||
closeSpan.style.cssText = 'margin-left: auto; cursor: pointer; opacity: 0.7; padding-left: 1rem;';
|
|
||||||
closeSpan.textContent = '[×]';
|
|
||||||
|
|
||||||
toast.appendChild(iconSpan);
|
|
||||||
toast.appendChild(msgSpan);
|
|
||||||
toast.appendChild(closeSpan);
|
|
||||||
|
|
||||||
// Add to document
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
// Trigger animation
|
|
||||||
setTimeout(() => toast.classList.add('show'), 10);
|
|
||||||
|
|
||||||
// Manual dismiss handler
|
|
||||||
const closeBtn = toast.querySelector('.toast-close');
|
|
||||||
closeBtn.addEventListener('click', () => dismissToast(toast));
|
|
||||||
|
|
||||||
// Auto-remove after duration
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
dismissToast(toast);
|
|
||||||
}, duration);
|
|
||||||
|
|
||||||
// Store timeout ID for manual dismiss
|
|
||||||
toast.timeoutId = timeoutId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissToast(toast) {
|
|
||||||
// Clear auto-dismiss timeout
|
|
||||||
if (toast.timeoutId) {
|
|
||||||
clearTimeout(toast.timeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.classList.remove('show');
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.remove();
|
|
||||||
currentToast = null;
|
|
||||||
|
|
||||||
// Show next toast in queue
|
|
||||||
if (toastQueue.length > 0) {
|
|
||||||
const next = toastQueue.shift();
|
|
||||||
displayToast(next.message, next.type, next.duration);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convenience functions
|
|
||||||
window.toast = {
|
window.toast = {
|
||||||
success: (msg, duration) => showToast(msg, 'success', duration),
|
success: (msg, dur) => lt.toast.success(msg, dur),
|
||||||
error: (msg, duration) => showToast(msg, 'error', duration),
|
error: (msg, dur) => lt.toast.error(msg, dur),
|
||||||
info: (msg, duration) => showToast(msg, 'info', duration),
|
warning: (msg, dur) => lt.toast.warning(msg, dur),
|
||||||
warning: (msg, duration) => showToast(msg, 'warning', duration)
|
info: (msg, dur) => lt.toast.info(msg, dur),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// XSS prevention helper
|
// XSS prevention helper — delegates to lt.escHtml() from web_template/base.js
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
return lt.escHtml(text);
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats)
|
// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats)
|
||||||
@@ -12,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));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
15
deploy.sh
15
deploy.sh
@@ -1,15 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "Deploying tinker_tickets to web server..."
|
|
||||||
|
|
||||||
# Deploy to web server
|
|
||||||
echo "Syncing to web server (10.10.10.45)..."
|
|
||||||
rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' ./ root@10.10.10.45:/var/www/html/tinkertickets/
|
|
||||||
|
|
||||||
# Set proper permissions on the web server
|
|
||||||
echo "Setting proper file permissions..."
|
|
||||||
ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/tinkertickets && find /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/tinkertickets -type d -exec chmod 755 {} \;"
|
|
||||||
|
|
||||||
echo "Deployment to web server complete!"
|
|
||||||
echo "Don't forget to commit and push your changes via VS Code when ready."
|
|
||||||
@@ -162,13 +162,5 @@ class Database {
|
|||||||
return self::getConnection()->insert_id;
|
return self::getConnection()->insert_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// escape() removed — use prepared statements with bind_param() instead
|
||||||
* Escape a string for use in queries (prefer prepared statements)
|
|
||||||
*
|
|
||||||
* @param string $string String to escape
|
|
||||||
* @return string Escaped string
|
|
||||||
*/
|
|
||||||
public static function escape(string $string): string {
|
|
||||||
return self::getConnection()->real_escape_string($string);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ if (!str_starts_with($requestPath, '/api/')) {
|
|||||||
require_once 'models/UserPreferencesModel.php';
|
require_once 'models/UserPreferencesModel.php';
|
||||||
$prefsModel = new UserPreferencesModel($conn);
|
$prefsModel = new UserPreferencesModel($conn);
|
||||||
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
|
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
|
||||||
if ($userTimezone) {
|
if ($userTimezone && in_array($userTimezone, DateTimeZone::listIdentifiers())) {
|
||||||
// Override system timezone with user preference
|
// Override system timezone with user preference (validated against known identifiers)
|
||||||
date_default_timezone_set($userTimezone);
|
date_default_timezone_set($userTimezone);
|
||||||
$GLOBALS['config']['TIMEZONE'] = $userTimezone;
|
$GLOBALS['config']['TIMEZONE'] = $userTimezone;
|
||||||
$now = new DateTime('now', new DateTimeZone($userTimezone));
|
$now = new DateTime('now', new DateTimeZone($userTimezone));
|
||||||
|
|||||||
@@ -3,22 +3,11 @@
|
|||||||
* AttachmentModel - Handles ticket file attachments
|
* AttachmentModel - Handles ticket file attachments
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/../config/config.php';
|
|
||||||
|
|
||||||
class AttachmentModel {
|
class AttachmentModel {
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct($conn) {
|
||||||
$this->conn = new mysqli(
|
$this->conn = $conn;
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($this->conn->connect_error) {
|
|
||||||
throw new Exception('Database connection failed: ' . $this->conn->connect_error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,9 +193,4 @@ class AttachmentModel {
|
|||||||
return in_array($mimeType, $allowedTypes);
|
return in_array($mimeType, $allowedTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __destruct() {
|
|
||||||
if ($this->conn) {
|
|
||||||
$this->conn->close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ class BulkOperationsModel {
|
|||||||
* @return int|false Operation ID or false on failure
|
* @return int|false Operation ID or false on failure
|
||||||
*/
|
*/
|
||||||
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
|
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
|
||||||
|
// Validate ticket IDs to prevent injection via implode
|
||||||
|
$ticketIds = array_values(array_filter(
|
||||||
|
array_map('strval', $ticketIds),
|
||||||
|
fn($id) => preg_match('/^[0-9]+$/', $id)
|
||||||
|
));
|
||||||
|
if (empty($ticketIds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$ticketIdsStr = implode(',', $ticketIds);
|
$ticketIdsStr = implode(',', $ticketIds);
|
||||||
$totalTickets = count($ticketIds);
|
$totalTickets = count($ticketIds);
|
||||||
$parametersJson = $parameters ? json_encode($parameters) : null;
|
$parametersJson = $parameters ? json_encode($parameters) : null;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class CommentModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("s", $ticketId);
|
$stmt->bind_param("i", $ticketId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
@@ -126,7 +126,8 @@ class CommentModel {
|
|||||||
private function buildCommentThread($comment, &$allComments) {
|
private function buildCommentThread($comment, &$allComments) {
|
||||||
$comment['replies'] = [];
|
$comment['replies'] = [];
|
||||||
foreach ($allComments as $c) {
|
foreach ($allComments as $c) {
|
||||||
if ($c['parent_comment_id'] == $comment['comment_id']) {
|
if ($c['parent_comment_id'] == $comment['comment_id']
|
||||||
|
&& isset($allComments[$c['comment_id']])) {
|
||||||
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
|
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ class SavedFiltersModel {
|
|||||||
* Save a new filter
|
* Save a new filter
|
||||||
*/
|
*/
|
||||||
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) {
|
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) {
|
||||||
|
$this->conn->begin_transaction();
|
||||||
|
try {
|
||||||
// If this is set as default, unset all other defaults for this user
|
// If this is set as default, unset all other defaults for this user
|
||||||
if ($isDefault) {
|
if ($isDefault) {
|
||||||
$this->clearDefaultFilters($userId);
|
$this->clearDefaultFilters($userId);
|
||||||
@@ -71,12 +73,17 @@ class SavedFiltersModel {
|
|||||||
$stmt->bind_param("issi", $userId, $filterName, $criteriaJson, $isDefault);
|
$stmt->bind_param("issi", $userId, $filterName, $criteriaJson, $isDefault);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
return [
|
$filterId = $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName);
|
||||||
'success' => true,
|
$this->conn->commit();
|
||||||
'filter_id' => $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName)
|
return ['success' => true, 'filter_id' => $filterId];
|
||||||
];
|
}
|
||||||
|
$error = $this->conn->error;
|
||||||
|
$this->conn->rollback();
|
||||||
|
return ['success' => false, 'error' => $error];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->conn->rollback();
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
return ['success' => false, 'error' => $this->conn->error];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,18 +133,25 @@ class SavedFiltersModel {
|
|||||||
* Set a filter as default
|
* Set a filter as default
|
||||||
*/
|
*/
|
||||||
public function setDefaultFilter($filterId, $userId) {
|
public function setDefaultFilter($filterId, $userId) {
|
||||||
// First, clear all defaults
|
$this->conn->begin_transaction();
|
||||||
|
try {
|
||||||
$this->clearDefaultFilters($userId);
|
$this->clearDefaultFilters($userId);
|
||||||
|
|
||||||
// Then set this one as default
|
|
||||||
$sql = "UPDATE saved_filters SET is_default = 1 WHERE filter_id = ? AND user_id = ?";
|
$sql = "UPDATE saved_filters SET is_default = 1 WHERE filter_id = ? AND user_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("ii", $filterId, $userId);
|
$stmt->bind_param("ii", $filterId, $userId);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
|
$this->conn->commit();
|
||||||
return ['success' => true];
|
return ['success' => true];
|
||||||
}
|
}
|
||||||
return ['success' => false, 'error' => $this->conn->error];
|
$error = $this->conn->error;
|
||||||
|
$this->conn->rollback();
|
||||||
|
return ['success' => false, 'error' => $error];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->conn->rollback();
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class StatsModel {
|
|||||||
u.username,
|
u.username,
|
||||||
COUNT(t.ticket_id) as ticket_count
|
COUNT(t.ticket_id) as ticket_count
|
||||||
FROM tickets t
|
FROM tickets t
|
||||||
JOIN users u ON t.assigned_to = u.user_id
|
LEFT JOIN users u ON t.assigned_to = u.user_id
|
||||||
WHERE t.status != 'Closed'
|
WHERE t.status != 'Closed'
|
||||||
GROUP BY t.assigned_to
|
GROUP BY t.assigned_to
|
||||||
ORDER BY ticket_count DESC
|
ORDER BY ticket_count DESC
|
||||||
|
|||||||
@@ -422,6 +422,34 @@ class TicketModel {
|
|||||||
'ticket_id' => $ticket_id
|
'ticket_id' => $ticket_id
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
|
// Handle duplicate key (errno 1062) caused by race condition between
|
||||||
|
// the uniqueness SELECT above and this INSERT — regenerate and retry once
|
||||||
|
if ($this->conn->errno === 1062) {
|
||||||
|
$stmt->close();
|
||||||
|
try {
|
||||||
|
$ticket_id = sprintf('%09d', random_int(100000000, 999999999));
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$ticket_id = sprintf('%09d', mt_rand(100000000, 999999999));
|
||||||
|
}
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param(
|
||||||
|
"sssssssiiss",
|
||||||
|
$ticket_id,
|
||||||
|
$ticketData['title'],
|
||||||
|
$ticketData['description'],
|
||||||
|
$status,
|
||||||
|
$priority,
|
||||||
|
$category,
|
||||||
|
$type,
|
||||||
|
$createdBy,
|
||||||
|
$assignedTo,
|
||||||
|
$visibility,
|
||||||
|
$visibilityGroups
|
||||||
|
);
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
return ['success' => true, 'ticket_id' => $ticket_id];
|
||||||
|
}
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $this->conn->error
|
'error' => $this->conn->error
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class WorkflowModel {
|
|||||||
WHERE is_active = TRUE";
|
WHERE is_active = TRUE";
|
||||||
$result = $this->conn->query($sql);
|
$result = $this->conn->query($sql);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$transitions = [];
|
$transitions = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
$from = $row['from_status'];
|
$from = $row['from_status'];
|
||||||
@@ -102,6 +106,10 @@ class WorkflowModel {
|
|||||||
ORDER BY status";
|
ORDER BY status";
|
||||||
$result = $this->conn->query($sql);
|
$result = $this->conn->query($sql);
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$statuses = [];
|
$statuses = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
$statuses[] = $row['status'];
|
$statuses[] = $row['status'];
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<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="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c">
|
<link rel="stylesheet" href="/assets/css/base.css">
|
||||||
<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/dashboard.css?v=20260320">
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></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; ?>" 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(); ?>';
|
||||||
@@ -23,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>
|
||||||
@@ -46,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>
|
||||||
@@ -60,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 $error; ?>
|
<strong>[ ! ] ERROR:</strong> <?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,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>
|
||||||
@@ -107,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>
|
||||||
@@ -183,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>
|
||||||
@@ -204,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';
|
||||||
@@ -218,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>
|
||||||
@@ -256,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>
|
||||||
@@ -275,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,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 => {
|
||||||
@@ -321,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,6 +351,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
toggleVisibilityGroups();
|
toggleVisibilityGroups();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (window.lt) lt.keys.initDefaults();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,60 +12,64 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<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="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e">
|
<link rel="stylesheet" href="/assets/css/base.css">
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script>
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.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/ascii-banner.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/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/markdown.js?v=20260320"></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(); ?>';
|
||||||
// Timezone configuration (from server)
|
// Timezone configuration (from server)
|
||||||
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
|
window.APP_TIMEZONE = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
|
||||||
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
|
window.APP_TIMEZONE_OFFSET = <?php echo (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0); ?>; // minutes from UTC
|
||||||
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>';
|
window.APP_TIMEZONE_ABBREV = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body data-categories='<?php echo json_encode($categories); ?>' data-types='<?php echo json_encode($types); ?>'>
|
<body data-categories='<?php echo htmlspecialchars(json_encode($categories), ENT_QUOTES, 'UTF-8'); ?>' data-types='<?php echo htmlspecialchars(json_encode($types), ENT_QUOTES, 'UTF-8'); ?>'>
|
||||||
|
|
||||||
<!-- 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)
|
||||||
@@ -78,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 -->
|
||||||
@@ -161,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>
|
||||||
@@ -178,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>
|
||||||
@@ -201,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>
|
||||||
@@ -250,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'])): ?>
|
||||||
@@ -271,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>
|
||||||
@@ -285,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>
|
||||||
@@ -308,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>
|
||||||
@@ -350,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; ?>
|
||||||
|
|
||||||
@@ -392,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; ?>
|
||||||
|
|
||||||
@@ -406,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',
|
||||||
@@ -428,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>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -450,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";
|
||||||
@@ -507,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
|
||||||
@@ -539,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">
|
||||||
@@ -573,17 +558,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Settings Modal -->
|
<!-- Settings Modal -->
|
||||||
<div class="settings-modal" id="settingsModal" style="display: none;" data-action="close-settings-backdrop" 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="settings-content">
|
<div class="lt-modal">
|
||||||
<span class="bottom-left-corner">╚</span>
|
<div class="lt-modal-header">
|
||||||
<span class="bottom-right-corner">╝</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>
|
||||||
<div class="settings-header">
|
|
||||||
<h3 id="settingsModalTitle">⚙ System Preferences</h3>
|
|
||||||
<button class="close-settings" data-action="close-settings" aria-label="Close settings">✗</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-body">
|
<div class="lt-modal-body">
|
||||||
<!-- Display Preferences -->
|
<!-- Display Preferences -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h4>╔══ Display Preferences ══╗</h4>
|
<h4>╔══ Display Preferences ══╗</h4>
|
||||||
@@ -724,23 +706,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-footer">
|
<div class="lt-modal-footer">
|
||||||
<button class="btn 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="btn btn-secondary" 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>
|
||||||
|
|
||||||
<!-- Advanced Search Modal -->
|
<!-- Advanced Search Modal -->
|
||||||
<div class="settings-modal" id="advancedSearchModal" style="display: none;" data-action="close-advanced-search-backdrop" 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="settings-content">
|
<div class="lt-modal">
|
||||||
<div class="settings-header">
|
<div class="lt-modal-header">
|
||||||
<h3 id="advancedSearchModalTitle">🔍 Advanced Search</h3>
|
<span class="lt-modal-title" id="advancedSearchModalTitle">[ FILTER ] ADVANCED SEARCH</span>
|
||||||
<button class="close-settings" data-action="close-advanced-search" aria-label="Close advanced search">✗</button>
|
<button class="lt-modal-close" data-modal-close aria-label="Close advanced search">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="advancedSearchForm">
|
<form id="advancedSearchForm">
|
||||||
<div class="settings-body">
|
<div class="lt-modal-body">
|
||||||
<!-- Saved Filters -->
|
<!-- Saved Filters -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h4>╔══ Saved Filters ══╗</h4>
|
<h4>╔══ Saved Filters ══╗</h4>
|
||||||
@@ -751,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,19 +823,21 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-footer">
|
<div class="lt-modal-footer">
|
||||||
<button type="submit" class="btn btn-primary">Search</button>
|
<button type="submit" class="lt-btn lt-btn-primary">SEARCH</button>
|
||||||
<button type="button" class="btn btn-secondary" 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="btn btn-secondary" 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)
|
||||||
|
if (window.lt) lt.keys.initDefaults();
|
||||||
// Event delegation for all data-action handlers
|
// Event delegation for all data-action handlers
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
const target = event.target.closest('[data-action]');
|
const target = event.target.closest('[data-action]');
|
||||||
@@ -878,21 +862,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
openSettingsModal();
|
openSettingsModal();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'close-settings':
|
case 'manual-refresh':
|
||||||
closeSettingsModal();
|
lt.autoRefresh.now();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'close-settings-backdrop':
|
case 'close-settings':
|
||||||
if (event.target === target) closeSettingsModal();
|
closeSettingsModal();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'save-settings':
|
case 'save-settings':
|
||||||
saveSettings();
|
saveSettings();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'toggle-banner':
|
|
||||||
toggleBanner();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'toggle-sidebar':
|
case 'toggle-sidebar':
|
||||||
toggleSidebar();
|
toggleSidebar();
|
||||||
@@ -906,10 +887,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
closeAdvancedSearch();
|
closeAdvancedSearch();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'close-advanced-search-backdrop':
|
|
||||||
if (event.target === target) closeAdvancedSearch();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'reset-advanced-search':
|
case 'reset-advanced-search':
|
||||||
resetAdvancedSearch();
|
resetAdvancedSearch();
|
||||||
break;
|
break;
|
||||||
@@ -1015,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) {
|
||||||
@@ -50,20 +50,21 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<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="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e">
|
<link rel="stylesheet" href="/assets/css/base.css">
|
||||||
<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/dashboard.css?v=20260320">
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.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/markdown.js?v=20260131e"></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/markdown.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; ?>" 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/ticket.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(); ?>';
|
||||||
// Timezone configuration (from server)
|
// Timezone configuration (from server)
|
||||||
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
|
window.APP_TIMEZONE = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
|
||||||
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
|
window.APP_TIMEZONE_OFFSET = <?php echo (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0); ?>; // minutes from UTC
|
||||||
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>';
|
window.APP_TIMEZONE_ABBREV = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
|
||||||
</script>
|
</script>
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
// Store ticket data in a global variable (using json_encode for XSS safety)
|
// Store ticket data in a global variable (using json_encode for XSS safety)
|
||||||
@@ -80,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>
|
||||||
@@ -132,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">
|
||||||
@@ -140,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>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -219,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):
|
||||||
@@ -242,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>
|
||||||
@@ -272,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>
|
||||||
|
|
||||||
@@ -292,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>
|
||||||
|
|
||||||
@@ -321,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>
|
||||||
|
|
||||||
@@ -360,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>";
|
||||||
|
|
||||||
@@ -420,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>
|
||||||
@@ -461,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>
|
||||||
|
|
||||||
@@ -496,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">
|
||||||
@@ -556,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';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,15 +654,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings modal backdrop click
|
// Settings modal backdrop click (lt-modal-overlay handles this via data-modal-close)
|
||||||
var settingsModal = document.getElementById('settingsModal');
|
|
||||||
if (settingsModal) {
|
|
||||||
settingsModal.addEventListener('click', function(e) {
|
|
||||||
if (e.target.classList.contains('settings-modal')) {
|
|
||||||
if (typeof closeSettingsModal === 'function') closeSettingsModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle change events for data-action
|
// Handle change events for data-action
|
||||||
document.addEventListener('change', function(e) {
|
document.addEventListener('change', function(e) {
|
||||||
@@ -688,17 +681,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Settings Modal (same as dashboard) -->
|
<!-- Settings Modal (same as dashboard) -->
|
||||||
<div class="settings-modal" id="settingsModal" style="display: none;" 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="settings-content">
|
<div class="lt-modal">
|
||||||
<span class="bottom-left-corner">╚</span>
|
<div class="lt-modal-header">
|
||||||
<span class="bottom-right-corner">╝</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>
|
||||||
<div class="settings-header">
|
|
||||||
<h3 id="ticketSettingsTitle">⚙ System Preferences</h3>
|
|
||||||
<button class="close-settings" id="closeSettingsBtn" aria-label="Close settings">✗</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-body">
|
<div class="lt-modal-body">
|
||||||
<!-- Display Preferences -->
|
<!-- Display Preferences -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h4>╔══ Display Preferences ══╗</h4>
|
<h4>╔══ Display Preferences ══╗</h4>
|
||||||
@@ -816,13 +806,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-footer">
|
<div class="lt-modal-footer">
|
||||||
<button class="btn btn-primary" id="saveSettingsBtn">Save Preferences</button>
|
<button class="lt-btn lt-btn-primary" id="saveSettingsBtn">SAVE PREFERENCES</button>
|
||||||
<button class="btn btn-secondary" 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/settings.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?v=20260320"></script>
|
||||||
|
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -12,9 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<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="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
<link rel="stylesheet" href="/assets/css/base.css">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||||
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
</script>
|
</script>
|
||||||
@@ -22,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>
|
||||||
@@ -59,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>
|
||||||
@@ -96,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>
|
||||||
@@ -148,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>
|
||||||
@@ -185,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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,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">
|
||||||
@@ -9,24 +12,29 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<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="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
<link rel="stylesheet" href="/assets/css/base.css">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||||
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||||
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
|
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>
|
||||||
|
|
||||||
@@ -34,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>
|
||||||
@@ -49,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' : ''; ?>>
|
||||||
@@ -60,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>
|
||||||
@@ -90,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']);
|
||||||
}
|
}
|
||||||
@@ -126,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) {
|
||||||
@@ -153,5 +161,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<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="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
<link rel="stylesheet" href="/assets/css/base.css">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||||
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
</script>
|
</script>
|
||||||
@@ -21,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>
|
||||||
@@ -60,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): ?>
|
||||||
@@ -74,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; ?>
|
||||||
@@ -90,17 +92,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create/Edit Modal -->
|
<!-- Create/Edit Modal -->
|
||||||
<div class="settings-modal" id="fieldModal" style="display: none;" data-action="close-modal-backdrop">
|
<div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||||
<div class="settings-content" style="max-width: 500px;">
|
<div class="lt-modal lt-modal-sm">
|
||||||
<div class="settings-header">
|
<div class="lt-modal-header">
|
||||||
<h3 id="modalTitle">Create Custom Field</h3>
|
<span class="lt-modal-title" id="modalTitle">Create Custom Field</span>
|
||||||
<button class="close-settings" data-action="close-modal">×</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">
|
||||||
<div class="settings-body">
|
<div class="lt-modal-body">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<label for="field_name">Field Name * (internal)</label>
|
<label for="field_name">Field Name * (internal)</label>
|
||||||
<input type="text" id="field_name" name="field_name" required pattern="[a-z_]+" placeholder="e.g., server_name">
|
<input type="text" id="field_name" name="field_name" required pattern="[a-z_]+" placeholder="e.g., server_name">
|
||||||
@@ -120,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>
|
||||||
@@ -146,15 +149,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-footer">
|
<div class="lt-modal-footer">
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||||
<button type="button" class="btn btn-secondary" data-action="close-modal">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';
|
||||||
@@ -162,11 +164,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
document.getElementById('field_id').value = '';
|
document.getElementById('field_id').value = '';
|
||||||
document.getElementById('is_active').checked = true;
|
document.getElementById('is_active').checked = true;
|
||||||
toggleOptionsField();
|
toggleOptionsField();
|
||||||
document.getElementById('fieldModal').style.display = 'flex';
|
lt.modal.open('fieldModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
document.getElementById('fieldModal').style.display = 'none';
|
lt.modal.close('fieldModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event delegation for data-action handlers
|
// Event delegation for data-action handlers
|
||||||
@@ -179,12 +181,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
case 'show-create-modal':
|
case 'show-create-modal':
|
||||||
showCreateModal();
|
showCreateModal();
|
||||||
break;
|
break;
|
||||||
case 'close-modal':
|
|
||||||
closeModal();
|
|
||||||
break;
|
|
||||||
case 'close-modal-backdrop':
|
|
||||||
if (event.target === target) closeModal();
|
|
||||||
break;
|
|
||||||
case 'edit-field':
|
case 'edit-field':
|
||||||
editField(target.dataset.id);
|
editField(target.dataset.id);
|
||||||
break;
|
break;
|
||||||
@@ -208,16 +204,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
saveField(e);
|
saveField(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close modal on ESC key
|
if (window.lt) lt.keys.initDefaults();
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
||||||
@@ -239,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 {
|
||||||
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;
|
||||||
@@ -279,20 +259,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
document.getElementById('field_options').value = f.field_options.options.join('\n');
|
document.getElementById('field_options').value = f.field_options.options.join('\n');
|
||||||
}
|
}
|
||||||
document.getElementById('modalTitle').textContent = 'Edit Custom Field';
|
document.getElementById('modalTitle').textContent = 'Edit Custom Field';
|
||||||
document.getElementById('fieldModal').style.display = 'flex';
|
lt.modal.open('fieldModal');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<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="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
<link rel="stylesheet" href="/assets/css/base.css">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||||
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
</script>
|
</script>
|
||||||
@@ -21,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>
|
||||||
@@ -60,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): ?>
|
||||||
@@ -79,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; ?>
|
||||||
@@ -105,24 +107,25 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create/Edit Modal -->
|
<!-- Create/Edit Modal -->
|
||||||
<div class="settings-modal" id="recurringModal" style="display: none;" data-action="close-modal-backdrop">
|
<div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||||
<div class="settings-content" style="max-width: 800px; width: 90%;">
|
<div class="lt-modal lt-modal-lg">
|
||||||
<div class="settings-header">
|
<div class="lt-modal-header">
|
||||||
<h3 id="modalTitle">Create Recurring Ticket</h3>
|
<span class="lt-modal-title" id="modalTitle">Create Recurring Ticket</span>
|
||||||
<button class="close-settings" data-action="close-modal">×</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="settings-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>
|
||||||
@@ -132,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>
|
||||||
@@ -140,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">
|
||||||
@@ -181,26 +184,25 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-footer">
|
<div class="lt-modal-footer">
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||||
<button type="button" class="btn btn-secondary" data-action="close-modal">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';
|
||||||
document.getElementById('recurringForm').reset();
|
document.getElementById('recurringForm').reset();
|
||||||
document.getElementById('recurring_id').value = '';
|
document.getElementById('recurring_id').value = '';
|
||||||
updateScheduleOptions();
|
updateScheduleOptions();
|
||||||
document.getElementById('recurringModal').style.display = 'flex';
|
lt.modal.open('recurringModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
document.getElementById('recurringModal').style.display = 'none';
|
lt.modal.close('recurringModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event delegation for data-action handlers
|
// Event delegation for data-action handlers
|
||||||
@@ -213,12 +215,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
case 'show-create-modal':
|
case 'show-create-modal':
|
||||||
showCreateModal();
|
showCreateModal();
|
||||||
break;
|
break;
|
||||||
case 'close-modal':
|
|
||||||
closeModal();
|
|
||||||
break;
|
|
||||||
case 'close-modal-backdrop':
|
|
||||||
if (event.target === target) closeModal();
|
|
||||||
break;
|
|
||||||
case 'edit-recurring':
|
case 'edit-recurring':
|
||||||
editRecurring(target.dataset.id);
|
editRecurring(target.dataset.id);
|
||||||
break;
|
break;
|
||||||
@@ -245,12 +241,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
saveRecurring(e);
|
saveRecurring(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close modal on ESC key
|
if (window.lt) lt.keys.initDefaults();
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateScheduleOptions() {
|
function updateScheduleOptions() {
|
||||||
const type = document.getElementById('schedule_type').value;
|
const type = document.getElementById('schedule_type').value;
|
||||||
@@ -260,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>`;
|
||||||
}
|
}
|
||||||
@@ -280,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 {
|
||||||
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;
|
||||||
@@ -342,15 +317,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
document.getElementById('priority').value = rt.priority || 4;
|
document.getElementById('priority').value = rt.priority || 4;
|
||||||
document.getElementById('assigned_to').value = rt.assigned_to || '';
|
document.getElementById('assigned_to').value = rt.assigned_to || '';
|
||||||
document.getElementById('modalTitle').textContent = 'Edit Recurring Ticket';
|
document.getElementById('modalTitle').textContent = 'Edit Recurring Ticket';
|
||||||
document.getElementById('recurringModal').style.display = 'flex';
|
lt.modal.open('recurringModal');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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');
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<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="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
<link rel="stylesheet" href="/assets/css/base.css">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||||
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
</script>
|
</script>
|
||||||
@@ -21,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>
|
||||||
@@ -62,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): ?>
|
||||||
@@ -74,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; ?>
|
||||||
@@ -90,30 +92,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create/Edit Modal -->
|
<!-- Create/Edit Modal -->
|
||||||
<div class="settings-modal" id="templateModal" style="display: none;" data-action="close-modal-backdrop">
|
<div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||||
<div class="settings-content" style="max-width: 800px; width: 90%;">
|
<div class="lt-modal lt-modal-lg">
|
||||||
<div class="settings-header">
|
<div class="lt-modal-header">
|
||||||
<h3 id="modalTitle">Create Template</h3>
|
<span class="lt-modal-title" id="modalTitle">Create Template</span>
|
||||||
<button class="close-settings" data-action="close-modal">×</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="settings-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">
|
||||||
@@ -152,15 +155,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-footer">
|
<div class="lt-modal-footer">
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||||
<button type="button" class="btn btn-secondary" data-action="close-modal">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 ?? []); ?>;
|
||||||
|
|
||||||
@@ -169,11 +171,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
document.getElementById('templateForm').reset();
|
document.getElementById('templateForm').reset();
|
||||||
document.getElementById('template_id').value = '';
|
document.getElementById('template_id').value = '';
|
||||||
document.getElementById('is_active').checked = true;
|
document.getElementById('is_active').checked = true;
|
||||||
document.getElementById('templateModal').style.display = 'flex';
|
lt.modal.open('templateModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
document.getElementById('templateModal').style.display = 'none';
|
lt.modal.close('templateModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event delegation for data-action handlers
|
// Event delegation for data-action handlers
|
||||||
@@ -186,12 +188,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
case 'show-create-modal':
|
case 'show-create-modal':
|
||||||
showCreateModal();
|
showCreateModal();
|
||||||
break;
|
break;
|
||||||
case 'close-modal':
|
|
||||||
closeModal();
|
|
||||||
break;
|
|
||||||
case 'close-modal-backdrop':
|
|
||||||
if (event.target === target) closeModal();
|
|
||||||
break;
|
|
||||||
case 'edit-template':
|
case 'edit-template':
|
||||||
editTemplate(target.dataset.id);
|
editTemplate(target.dataset.id);
|
||||||
break;
|
break;
|
||||||
@@ -206,12 +202,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
saveTemplate(e);
|
saveTemplate(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close modal on ESC key
|
if (window.lt) lt.keys.initDefaults();
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function saveTemplate(e) {
|
function saveTemplate(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -226,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 {
|
||||||
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) {
|
||||||
@@ -260,18 +241,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
document.getElementById('priority').value = tpl.default_priority || 4;
|
document.getElementById('priority').value = tpl.default_priority || 4;
|
||||||
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
|
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
|
||||||
document.getElementById('modalTitle').textContent = 'Edit Template';
|
document.getElementById('modalTitle').textContent = 'Edit Template';
|
||||||
document.getElementById('templateModal').style.display = 'flex';
|
lt.modal.open('templateModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
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">
|
||||||
@@ -9,24 +12,29 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<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="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
<link rel="stylesheet" href="/assets/css/base.css">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||||
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||||
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
|
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>
|
||||||
|
|
||||||
@@ -34,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): ?>
|
||||||
@@ -72,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>
|
||||||
@@ -95,41 +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 nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<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="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
|
<link rel="stylesheet" href="/assets/css/base.css">
|
||||||
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
|
||||||
|
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
|
||||||
<script nonce="<?php echo $nonce; ?>">
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
</script>
|
</script>
|
||||||
@@ -21,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)) {
|
||||||
@@ -78,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>
|
||||||
@@ -93,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): ?>
|
||||||
@@ -105,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; ?>
|
||||||
@@ -130,17 +132,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create/Edit Modal -->
|
<!-- Create/Edit Modal -->
|
||||||
<div class="settings-modal" id="workflowModal" style="display: none;" data-action="close-modal-backdrop">
|
<div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||||
<div class="settings-content" style="max-width: 450px;">
|
<div class="lt-modal lt-modal-sm">
|
||||||
<div class="settings-header">
|
<div class="lt-modal-header">
|
||||||
<h3 id="modalTitle">Create Transition</h3>
|
<span class="lt-modal-title" id="modalTitle">Create Transition</span>
|
||||||
<button class="close-settings" data-action="close-modal">×</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">
|
||||||
<div class="settings-body">
|
<div class="lt-modal-body">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<label for="from_status">From Status *</label>
|
<label for="from_status">From Status *</label>
|
||||||
<select id="from_status" name="from_status" required>
|
<select id="from_status" name="from_status" required>
|
||||||
@@ -169,15 +172,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-footer">
|
<div class="lt-modal-footer">
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
|
||||||
<button type="button" class="btn btn-secondary" data-action="close-modal">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 ?? []); ?>;
|
||||||
|
|
||||||
@@ -186,11 +188,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
document.getElementById('workflowForm').reset();
|
document.getElementById('workflowForm').reset();
|
||||||
document.getElementById('transition_id').value = '';
|
document.getElementById('transition_id').value = '';
|
||||||
document.getElementById('is_active').checked = true;
|
document.getElementById('is_active').checked = true;
|
||||||
document.getElementById('workflowModal').style.display = 'flex';
|
lt.modal.open('workflowModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
document.getElementById('workflowModal').style.display = 'none';
|
lt.modal.close('workflowModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event delegation for data-action handlers
|
// Event delegation for data-action handlers
|
||||||
@@ -203,12 +205,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
case 'show-create-modal':
|
case 'show-create-modal':
|
||||||
showCreateModal();
|
showCreateModal();
|
||||||
break;
|
break;
|
||||||
case 'close-modal':
|
|
||||||
closeModal();
|
|
||||||
break;
|
|
||||||
case 'close-modal-backdrop':
|
|
||||||
if (event.target === target) closeModal();
|
|
||||||
break;
|
|
||||||
case 'edit-transition':
|
case 'edit-transition':
|
||||||
editTransition(target.dataset.id);
|
editTransition(target.dataset.id);
|
||||||
break;
|
break;
|
||||||
@@ -223,12 +219,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
saveTransition(e);
|
saveTransition(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close modal on ESC key
|
if (window.lt) lt.keys.initDefaults();
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function saveTransition(e) {
|
function saveTransition(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -241,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 {
|
||||||
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) {
|
||||||
@@ -273,18 +254,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
|
|||||||
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
|
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
|
||||||
document.getElementById('is_active').checked = wf.is_active == 1;
|
document.getElementById('is_active').checked = wf.is_active == 1;
|
||||||
document.getElementById('modalTitle').textContent = 'Edit Transition';
|
document.getElementById('modalTitle').textContent = 'Edit Transition';
|
||||||
document.getElementById('workflowModal').style.display = 'flex';
|
lt.modal.open('workflowModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
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