diff --git a/.env.example b/.env.example index cbce12a..4b76f6f 100644 --- a/.env.example +++ b/.env.example @@ -1,60 +1,79 @@ -# Tinker Tickets Environment Configuration -# Copy this file to .env and fill in your values -# -# NOTE: This file is parsed with parse_ini_file(). Any value containing special -# characters (#, ;, =, quotes, spaces, etc.) MUST be wrapped in double quotes, -# e.g. DB_PASS="p@ss;word#1". The application now fails loudly (dies with a clear -# error) if the .env file cannot be parsed, so an unquoted special character will -# take the whole app down rather than silently using a wrong value. +; Tinker Tickets Environment Configuration +; Copy this file to .env and fill in your values +; +; NOTE: This file is parsed with PHP's parse_ini_file. Any value containing +; special characters -- #, ;, =, quotes, spaces, etc. -- MUST be wrapped in +; double quotes, e.g. DB_PASS="p@ss;word#1". The application now fails loudly +; -- dies with a clear error -- if the .env file cannot be parsed, so an +; unquoted special character will take the whole app down rather than +; silently using a wrong value. +; +; Comments in this file use ";" rather than "#": PHP's ini parser treats "#" +; comments as fragile -- punctuation like parentheses or quotes inside a "#" +; comment can produce a syntax error even though the line is meant to be +; inert, silently breaking every value below it. ";" comments don't have this +; problem, so keep using ";" for any comment added to this file. -# Database Configuration +; Database Configuration DB_HOST=10.10.10.50 DB_USER=tinkertickets DB_PASS=your_password_here DB_NAME=ticketing_system -# Matrix Webhook (optional - for notifications via matrix-hookshot) -# Set to your hookshot generic webhook URL, e.g.: -# https://matrix.lotusguild.org/webhook/ +; Matrix Webhook (optional - for notifications via matrix-hookshot) +; Set to your hookshot generic webhook URL, e.g.: +; https://matrix.lotusguild.org/webhook/uuid-goes-here MATRIX_WEBHOOK_URL= -# Matrix users to @mention on every new ticket (comma-separated Matrix user IDs) -# e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org +; Matrix users to @mention on every new ticket (comma-separated Matrix user IDs) +; e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org MATRIX_NOTIFY_USERS= -# Application Domain (required for Matrix webhook ticket links) -# Set this to your public domain (e.g., t.lotusguild.org) +; Matrix homeserver domain (used to build Matrix user IDs from LLDAP usernames) +MATRIX_DOMAIN= + +; Synapse internal URL and admin token (used to resolve usernames -> Matrix IDs +; for watcher DMs) +SYNAPSE_ADMIN_URL= +SYNAPSE_ADMIN_TOKEN= + +; Optional: send a Matrix notification on comments and/or assignments (0/1) +MATRIX_NOTIFY_COMMENTS=0 +MATRIX_NOTIFY_ASSIGNMENTS=0 + +; Application Domain (required for Matrix webhook ticket links) +; Set this to your public domain, e.g. t.lotusguild.org APP_DOMAIN= -# Allowed Hosts for HTTP_HOST validation (comma-separated) -# Include all domains that can access this application +; Allowed Hosts for HTTP_HOST validation (comma-separated) +; Include all domains that can access this application ALLOWED_HOSTS=localhost,127.0.0.1 -# Trusted reverse proxy IP(s), comma-separated (e.g. the Authelia/nginx proxy). -# Set this to the IP address(es) of your reverse proxy. Authelia forward-auth -# headers (Remote-User / Remote-Groups) and forwarded client IPs are only -# trusted when REMOTE_ADDR is in this list. -# -# Leaving this EMPTY disables reverse-proxy verification entirely: the app then -# trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if -# the PHP backend is reachable directly (bypassing the proxy), because a client -# can then spoof those headers and log in as an admin. Only leave it empty when -# network topology guarantees PHP is reachable solely via the trusted proxy. -# -# Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27 -# Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28 +; Trusted reverse proxy IPs, comma-separated -- e.g. the Authelia/nginx proxy. +; Set this to the IP address(es) of your reverse proxy. Authelia forward-auth +; headers (Remote-User / Remote-Groups) and forwarded client IPs are only +; trusted when REMOTE_ADDR is in this list. +; +; Leaving this EMPTY disables reverse-proxy verification entirely: the app then +; trusts Remote-User / Remote-Groups headers from ANY source. That is unsafe if +; the PHP backend is reachable directly (bypassing the proxy), because a client +; can then spoof those headers and log in as an admin. Only leave it empty when +; network topology guarantees PHP is reachable solely via the trusted proxy. +; +; Exact IP match only (no CIDR). Example (single proxy): TRUSTED_PROXIES=10.10.10.27 +; Example (multiple): TRUSTED_PROXIES=10.10.10.27,10.10.10.28 TRUSTED_PROXIES= -# Timezone (default: America/New_York) +; Timezone (default: America/New_York) TIMEZONE=America/New_York -# LDAP / lldap (for user avatar lookups) +; LDAP / lldap (for user avatar lookups) LDAP_ENABLED=true LDAP_HOST=10.10.10.39 LDAP_PORT=3890 -LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com +LDAP_BIND_DN="uid=tinker-tickets,ou=people,dc=example,dc=com" LDAP_BIND_PW= -LDAP_BASE_DN=dc=example,dc=com -LDAP_USER_BASE=ou=people,dc=example,dc=com -# How long to cache avatar images locally (seconds, default 3600) +LDAP_BASE_DN="dc=example,dc=com" +LDAP_USER_BASE="ou=people,dc=example,dc=com" +; How long to cache avatar images locally (seconds, default 3600) AVATAR_CACHE_TTL=3600 diff --git a/README.md b/README.md index 3f0ce79..ac755b4 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,6 @@ tinker_tickets/ │ ├── Database.php # Centralized mysqli connection │ ├── ErrorHandler.php # Global error/exception handler │ ├── NotificationHelper.php # Matrix hookshot webhook events -│ ├── OutputHelper.php # Safe HTML output helpers │ ├── ResponseHelper.php # JSON API response helpers │ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API │ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN @@ -556,7 +555,7 @@ Key conventions and gotchas for working with this codebase: 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. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, and `clone_ticket`. +24. **Stats cache**: `StatsModel` caches stats for 60 s. Any path that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after the change. Callers: `TicketController::create` (manual create), `create_ticket_api.php` (external API create/escalate/reopen), `cron/create_recurring_tickets.php`, `bulk_operation`, `assign_ticket`, `update_ticket`, `clone_ticket`, and `ticket_status_api.php` (Bearer API status-change endpoint). 25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic (SHA-256 hash, no time window) that updates/escalates an existing open duplicate or reopens a closed one rather than creating a new ticket. ## File Reference diff --git a/api/get_users.php b/api/get_users.php index 77e0afd..5971ee3 100644 --- a/api/get_users.php +++ b/api/get_users.php @@ -8,8 +8,10 @@ require_once __DIR__ . '/bootstrap.php'; try { - // Get all users for mentions/assignment - $result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username"); + // Get all users for mentions/assignment. Capped as defense-in-depth against + // a single call scraping an unbounded user list — every caller only needs + // this for typeahead/dropdown filtering, never a literal full roster. + $result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username LIMIT 500"); if (!$result) { throw new Exception("Failed to query users"); diff --git a/api/health.php b/api/health.php index 908eb31..8be48e4 100644 --- a/api/health.php +++ b/api/health.php @@ -129,6 +129,39 @@ if (version_compare(PHP_VERSION, $requirements['min_php_version'], '>=')) { $healthy = false; } +// Check 7: memory_limit / max_execution_time sanity (warnings, not fatal — a +// low default doesn't fail requests until something large actually runs, so +// surface it here rather than waiting for a mysterious failure under load). +$memLimitIni = ini_get('memory_limit'); +$memLimitUnit = strtolower(substr(trim($memLimitIni), -1)); +$memLimitBytes = $memLimitIni === '-1' + ? -1 + : (int)$memLimitIni * match ($memLimitUnit) { + 'g' => 1024 * 1024 * 1024, + 'm' => 1024 * 1024, + 'k' => 1024, + default => 1, + }; +$minMemBytes = $requirements['min_memory_limit_mb'] * 1024 * 1024; +if ($memLimitBytes === -1 || $memLimitBytes >= $minMemBytes) { + $checks['memory_limit'] = ['status' => 'ok', 'message' => $memLimitIni]; +} else { + $checks['memory_limit'] = [ + 'status' => 'warning', + 'message' => sprintf('%s is below the recommended minimum %dM', $memLimitIni, $requirements['min_memory_limit_mb']) + ]; +} + +$maxExecTime = (int)ini_get('max_execution_time'); +if ($maxExecTime === 0 || $maxExecTime >= $requirements['min_max_execution_time']) { + $checks['max_execution_time'] = ['status' => 'ok', 'message' => (string)$maxExecTime]; +} else { + $checks['max_execution_time'] = [ + 'status' => 'warning', + 'message' => sprintf('%ds is below the recommended minimum %ds', $maxExecTime, $requirements['min_max_execution_time']) + ]; +} + // Calculate response time $responseTime = round((microtime(true) - $startTime) * 1000, 2); diff --git a/api/notifications.php b/api/notifications.php index c3d6449..e5c0586 100644 --- a/api/notifications.php +++ b/api/notifications.php @@ -225,10 +225,21 @@ foreach ($all as $row) { 'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}", 'mention' => "{$row['actor_name']} mentioned you on ticket #{$ticketId}", 'update' => (function () use ($row, $details, $ticketId) { - // logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}} - $from = $details['status']['from'] ?? ($details['old_value'] ?? '?'); - $to = $details['status']['to'] ?? ($details['new_value'] ?? '?'); - return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}"; + // Visibility changes log a flat {field, from, to} shape (api/update_ticket.php). + if (isset($details['field'], $details['from'], $details['to'])) { + return "{$row['actor_name']} changed {$details['field']} on #{$ticketId}: {$details['from']} → {$details['to']}"; + } + + // Single/bulk field updates log a per-field delta, e.g. + // {"status": {"from": "Open", "to": "In Progress"}}. Only one field + // changed at a time is reported, in priority order below. + foreach (['status', 'priority', 'title', 'category', 'type', 'description'] as $field) { + if (isset($details[$field]['from'], $details[$field]['to'])) { + return "{$row['actor_name']} changed {$field} on #{$ticketId}: {$details[$field]['from']} → {$details[$field]['to']}"; + } + } + + return "{$row['actor_name']} updated ticket #{$ticketId}"; })(), default => "{$row['actor_name']} updated ticket #{$ticketId}", }; diff --git a/api/upload_attachment.php b/api/upload_attachment.php index 93edf0d..481d99f 100644 --- a/api/upload_attachment.php +++ b/api/upload_attachment.php @@ -29,6 +29,73 @@ require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php'; header('Content-Type: application/json'); +/** + * Strip EXIF/metadata (including GPS) from an image file in place by + * decoding and re-encoding it via GD, which drops metadata chunks that + * aren't part of the pixel data. Best-effort: leaves the file untouched on + * any failure (corrupt image, unsupported format, GD unavailable) rather + * than blocking the upload — original bytes are what would have been stored + * anyway before this existed. + * + * download_attachment.php streams attachments back byte-for-byte to any user + * with ticket visibility, so an unstripped phone photo's embedded GPS data + * would otherwise leak a data center/office's physical location even on a + * Confidential-visibility ticket. + */ +function stripImageMetadata(string $path, string $mimeType): void +{ + if (!extension_loaded('gd')) { + return; + } + + // Guard against a decompression-bomb-style crafted image (small file, + // huge decoded pixel buffer) exhausting memory during decode. + $dims = @getimagesize($path); + if ($dims === false) { + return; + } + [$width, $height] = $dims; + if ($width * $height > 40_000_000) { // ~40 MP cap + return; + } + + $loaders = [ + 'image/jpeg' => 'imagecreatefromjpeg', + 'image/png' => 'imagecreatefrompng', + 'image/gif' => 'imagecreatefromgif', + 'image/webp' => 'imagecreatefromwebp', + ]; + $loader = $loaders[$mimeType] ?? null; + if ($loader === null || !function_exists($loader)) { + return; + } + + $image = @$loader($path); + if ($image === false) { + return; + } + + // Preserve transparency for formats that support it. + imagesavealpha($image, true); + imagealphablending($image, false); + + $tmpPath = $path . '.tmp'; + $saved = match ($mimeType) { + 'image/jpeg' => imagejpeg($image, $tmpPath, 90), + 'image/png' => imagepng($image, $tmpPath, 6), + 'image/gif' => imagegif($image, $tmpPath), + 'image/webp' => imagewebp($image, $tmpPath, 90), + default => false, + }; + imagedestroy($image); + + if ($saved && file_exists($tmpPath)) { + rename($tmpPath, $path); + } elseif (file_exists($tmpPath)) { + unlink($tmpPath); + } +} + // Check authentication if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) { ResponseHelper::unauthorized(); @@ -127,6 +194,23 @@ if ($file['size'] > $maxSize) { ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')'); } +// Check per-ticket attachment count/storage quota — bounds an authenticated +// low-privilege user slowly filling the uploads/ disk across many tickets, +// which was previously bounded only by the request-rate limiter, not volume. +$attachmentModel = new AttachmentModel($conn); +$maxAttachments = $GLOBALS['config']['MAX_ATTACHMENTS_PER_TICKET'] ?? 50; +if ($attachmentModel->getAttachmentCount($ticketId) >= $maxAttachments) { + ResponseHelper::error("This ticket already has the maximum of {$maxAttachments} attachments"); +} + +$maxTotalSize = $GLOBALS['config']['MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET'] ?? 104857600; +if ($attachmentModel->getTotalSizeForTicket($ticketId) + $file['size'] > $maxTotalSize) { + ResponseHelper::error( + 'This upload would exceed the ticket\'s total attachment size limit of ' + . AttachmentModel::formatFileSize($maxTotalSize) + ); +} + // Get MIME type $finfo = new finfo(FILEINFO_MIME_TYPE); $mimeType = $finfo->file($file['tmp_name']); @@ -184,6 +268,11 @@ if (!move_uploaded_file($file['tmp_name'], $targetPath)) { ResponseHelper::serverError('Failed to move uploaded file'); } +// Strip EXIF/GPS metadata from image uploads before it's ever served back +if (str_starts_with($mimeType, 'image/')) { + stripImageMetadata($targetPath, $mimeType); +} + // Sanitize original filename $originalFilename = basename($file['name']); $originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename); @@ -193,7 +282,6 @@ if (empty($originalFilename)) { // Save to database try { - $attachmentModel = new AttachmentModel($conn); $attachmentId = $attachmentModel->addAttachment( $ticketId, $uniqueFilename, diff --git a/assets/js/advanced-search.js b/assets/js/advanced-search.js index 7155fc5..09c42f9 100644 --- a/assets/js/advanced-search.js +++ b/assets/js/advanced-search.js @@ -87,11 +87,17 @@ function performAdvancedSearch(event) { params.set('search', searchText); } - // Date ranges - const createdFrom = document.getElementById('adv-created-from').value; - const createdTo = document.getElementById('adv-created-to').value; - const updatedFrom = document.getElementById('adv-updated-from').value; - const updatedTo = document.getElementById('adv-updated-to').value; + // Date ranges — swap if the user entered an end date before the start date + let createdFrom = document.getElementById('adv-created-from').value; + let createdTo = document.getElementById('adv-created-to').value; + if (createdFrom && createdTo && createdFrom > createdTo) { + [createdFrom, createdTo] = [createdTo, createdFrom]; + } + let updatedFrom = document.getElementById('adv-updated-from').value; + let updatedTo = document.getElementById('adv-updated-to').value; + if (updatedFrom && updatedTo && updatedFrom > updatedTo) { + [updatedFrom, updatedTo] = [updatedTo, updatedFrom]; + } if (createdFrom) params.set('created_from', createdFrom); if (createdTo) params.set('created_to', createdTo); @@ -105,9 +111,12 @@ function performAdvancedSearch(event) { params.set('status', selectedStatuses.join(',')); } - // Priority range - const priorityMin = document.getElementById('adv-priority-min').value; - const priorityMax = document.getElementById('adv-priority-max').value; + // Priority range — swap if min > max so the range is always satisfiable + let priorityMin = document.getElementById('adv-priority-min').value; + let priorityMax = document.getElementById('adv-priority-max').value; + if (priorityMin && priorityMax && Number(priorityMin) > Number(priorityMax)) { + [priorityMin, priorityMax] = [priorityMax, priorityMin]; + } if (priorityMin) params.set('priority_min', priorityMin); if (priorityMax) params.set('priority_max', priorityMax); diff --git a/assets/js/base.js b/assets/js/base.js index 8925325..3547921 100644 --- a/assets/js/base.js +++ b/assets/js/base.js @@ -2475,6 +2475,101 @@ list.addEventListener('drop', e => { e.preventDefault(); }); + // Touch fallback — iOS Safari doesn't implement HTML5 drag-and-drop on + // arbitrary elements at all, and mobile Chrome's support is poor, so + // kanban drag was effectively unusable via touch without this. Touch + // events for a given touch point are always dispatched to the element + // touchstart fired on (per spec), so per-list local state here is safe; + // cross-list moves are resolved via elementFromPoint against the live + // finger position, same as dragover does via e.target above. + const DRAG_THRESHOLD = 8; // px of movement before a touch starts a drag + let _touchItem = null, _touchDragging = false; + let _touchStartX = 0, _touchStartY = 0, _touchOffsetX = 0, _touchOffsetY = 0; + + function _touchTargetList(x, y) { + const el = document.elementFromPoint(x, y); + const found = el ? el.closest('[data-sortable-group]') : null; + return found && (found === list || _sameGroup(found)) ? found : null; + } + + list.addEventListener('touchstart', e => { + const item = e.target.closest('[data-sortable-item]'); + if (!item || !list.contains(item)) return; + if (handle && !e.target.closest(handle)) return; + const t = e.touches[0]; + _touchItem = item; + _touchDragging = false; + _touchStartX = t.clientX; + _touchStartY = t.clientY; + }, { passive: true }); + + // touchmove/touchend/touchcancel are registered on document, not list: + // once the dragged item is reparented to document.body below, it's no + // longer a descendant of list, so events targeting it (touch events + // keep targeting their touchstart element for the whole gesture) would + // stop bubbling to a listener on list. + document.addEventListener('touchmove', e => { + if (!_touchItem) return; + const t = e.touches[0]; + + if (!_touchDragging) { + if (Math.abs(t.clientX - _touchStartX) < DRAG_THRESHOLD && Math.abs(t.clientY - _touchStartY) < DRAG_THRESHOLD) return; + // Drag intent confirmed — take over from here, blocking page scroll. + _touchDragging = true; + _srtDragging = _touchItem; + _srtSrcList = list; + _srtPlaceholder = _makePlaceholder(_touchItem); + _touchItem.classList.add('is-dragging'); + const rect = _touchItem.getBoundingClientRect(); + _touchOffsetX = _touchStartX - rect.left; + _touchOffsetY = _touchStartY - rect.top; + _touchItem.parentNode.insertBefore(_srtPlaceholder, _touchItem); + _touchItem.style.position = 'fixed'; + _touchItem.style.zIndex = '1000'; + _touchItem.style.width = rect.width + 'px'; + _touchItem.style.pointerEvents = 'none'; + document.body.appendChild(_touchItem); // avoid clipping by an overflow:hidden ancestor + } + + e.preventDefault(); + _touchItem.style.left = (t.clientX - _touchOffsetX) + 'px'; + _touchItem.style.top = (t.clientY - _touchOffsetY) + 'px'; + + const targetList = _touchTargetList(t.clientX, t.clientY); + if (!targetList) return; + const overEl = document.elementFromPoint(t.clientX, t.clientY); + const over = overEl ? overEl.closest('[data-sortable-item]') : null; + if (over && over !== _srtDragging && targetList.contains(over)) { + const rect = over.getBoundingClientRect(); + targetList.insertBefore(_srtPlaceholder, t.clientY < rect.top + rect.height / 2 ? over : over.nextSibling); + } else if (!targetList.contains(_srtPlaceholder)) { + targetList.appendChild(_srtPlaceholder); + } + }, { passive: false }); + + function _touchEnd() { + if (_touchDragging && _srtDragging) { + _srtDragging.classList.remove('is-dragging'); + _srtDragging.style.position = ''; + _srtDragging.style.zIndex = ''; + _srtDragging.style.width = ''; + _srtDragging.style.pointerEvents = ''; + _srtDragging.style.left = ''; + _srtDragging.style.top = ''; + if (_srtPlaceholder && _srtPlaceholder.parentNode) { + _srtPlaceholder.parentNode.insertBefore(_srtDragging, _srtPlaceholder); + _srtPlaceholder.remove(); + } + if (onSort) onSort(_getItems(), _srtDragging); + bus.emit('sortable:change', { list, items: _getItems(), moved: _srtDragging }); + } + _touchItem = null; _touchDragging = false; + _srtDragging = null; _srtPlaceholder = null; _srtSrcList = null; + } + + document.addEventListener('touchend', _touchEnd); + document.addEventListener('touchcancel', _touchEnd); + return { refresh() { Array.from(list.children).forEach(child => { if (!child.hasAttribute('data-sortable-item')) _mark(child); }); }, getOrder: () => _getItems().map(el => el.dataset.id || el.textContent.trim()), diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index 6d38c2c..9b431c7 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -297,8 +297,12 @@ function clearAllFilters() { params.delete('type'); params.delete('assigned_to'); params.delete('search'); - params.delete('date_from'); - params.delete('date_to'); + params.delete('created_from'); + params.delete('created_to'); + params.delete('updated_from'); + params.delete('updated_to'); + params.delete('closed_from'); + params.delete('closed_to'); params.delete('page'); // Keep sort parameters @@ -381,73 +385,6 @@ function initSettingsModal() { } } -function sortTable(table, column) { - const headers = table.querySelectorAll('th'); - headers.forEach(header => { - header.classList.remove('sort-asc', 'sort-desc'); - }); - - const rows = Array.from(table.querySelectorAll('tbody tr')); - const currentDirection = table.dataset.sortColumn == column - ? (table.dataset.sortDirection === 'asc' ? 'desc' : 'asc') - : 'asc'; - - table.dataset.sortColumn = column; - table.dataset.sortDirection = currentDirection; - - rows.sort((a, b) => { - const aValue = a.children[column].textContent.trim(); - const bValue = b.children[column].textContent.trim(); - - // Check if this is a date column — prefer data-ts attribute over text (which may be relative) - const headerText = headers[column].textContent.toLowerCase(); - if (headerText === 'created' || headerText === 'updated') { - const cellA = a.children[column]; - const cellB = b.children[column]; - const dateA = new Date(cellA.dataset.ts || aValue); - const dateB = new Date(cellB.dataset.ts || bValue); - return currentDirection === 'asc' ? dateA - dateB : dateB - dateA; - } - - // Special handling for "Assigned To" column - if (headerText === 'assigned to') { - const aUnassigned = aValue === 'Unassigned'; - const bUnassigned = bValue === 'Unassigned'; - - // Both unassigned - equal - if (aUnassigned && bUnassigned) return 0; - - // Put unassigned at the end regardless of sort direction - if (aUnassigned) return 1; - if (bUnassigned) return -1; - - // Otherwise sort names normally - return currentDirection === 'asc' - ? aValue.localeCompare(bValue) - : bValue.localeCompare(aValue); - } - - // Numeric comparison - const numA = parseFloat(aValue); - const numB = parseFloat(bValue); - - if (!isNaN(numA) && !isNaN(numB)) { - return currentDirection === 'asc' ? numA - numB : numB - numA; - } - - // String comparison - return currentDirection === 'asc' - ? aValue.localeCompare(bValue) - : bValue.localeCompare(aValue); - }); - - const currentHeader = headers[column]; - currentHeader.classList.add(currentDirection === 'asc' ? 'sort-asc' : 'sort-desc'); - - const tbody = table.querySelector('tbody'); - rows.forEach(row => tbody.appendChild(row)); -} - // Old settings modal functions removed - now using settings.js with new settings modal @@ -1135,12 +1072,11 @@ function quickAssign(ticketId) {

Ticket #${lt.escHtml(String(ticketId))}

-
-
- -
- +
+ +