5 Commits

Author SHA1 Message Date
jared 54887ffa24 fix: kanban not loading on refresh + modal horizontal scroll + lt-kv-row CSS
Kanban restore bug:
- set-view-mode click handler called populateKanbanCards() directly but never
  called setViewMode(), so ticketViewMode was never saved to localStorage
- DOMContentLoaded restore checked ticketViewMode (never written) — it should
  check lt_activeTab_<path> which lt.tabs.init() actually saves
- Fix: delegate to setViewMode() from the click handler; DOMContentLoaded
  reads lt_activeTab_<path> and calls populateKanbanCards() when tab-kanban

Settings modal horizontal scroll:
- .lt-modal-body was missing overflow-x: hidden; content wider than the modal
  (e.g. kbd elements with white-space: nowrap) caused horizontal scrollbar
- Added overflow-x: hidden + min-width: 0 to .lt-modal-body

Missing lt-kv-row / lt-kv-label / lt-kv-value CSS:
- These classes were used in TicketView, DashboardView, admin views but had
  no primary CSS rules (only a light-theme color override existed)
- Without rules, lt-kv-row divs were block-level grid children consuming one
  grid cell each, making lt-kv-label/value stack inside wrong columns
- Added display:contents on lt-kv-row so children participate directly in
  the lt-kv-grid 2-column grid; lt-kv-label/value get padding, border, and
  min-width:0 + overflow-wrap:break-word to prevent grid column blowout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:45:43 -04:00
jared 613886068d fix: sanitize FULLTEXT boolean mode search to prevent MySQL parse errors
User input containing MySQL boolean operators (+, -, (, ), ~, *, ", @)
was passed directly to MATCH...AGAINST in BOOLEAN MODE, causing MySQL to
parse them as search operators rather than literals. Input like '(test)'
or '-keyword' would result in a MySQL syntax error / empty results.

Strip boolean mode special chars before building the FULLTEXT term;
the raw search string is still used unchanged for the LIKE fallback parts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:40:25 -04:00
jared 847d6b2656 fix: malformed img tag in header avatar + notif footer inline styles
- Avatar img tag was missing closing > — the endif fired before the tag
  closed, causing the initials span to be parsed as an attribute value;
  this would silently break the avatar fallback when image fails to load
- Replace style="width:100%;text-align:center" on notif footer link with
  lt-w-full lt-text-center utility classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:38:39 -04:00
jared c2cd923d32 fix: RecurringTicketModel INSERT bind_param type string mismatch
next_run_at was typed 'i' (int) but stores a datetime string → should be 's'.
is_active was typed 's' (string) but stores 0/1 boolean → should be 'i'.
Positions 10-11 were swapped: 'ssssiiisssis' → 'ssssiiisssii'.
The UPDATE method already had the correct types; only INSERT was affected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:37:22 -04:00
jared 67a7d769f0 fix: unassigned filter not working + null guards on modal selects
- DashboardController: handle assigned_to='unassigned' before validateUserId()
  which discarded the string, causing the filter to never reach TicketModel;
  model already correctly converts 'unassigned' to IS NULL in SQL
- dashboard.js: add null guards before .value access on dynamically-created
  modal selects (bulkPriority, bulkStatus, quickStatusSelect)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:35:04 -04:00
6 changed files with 59 additions and 18 deletions
+25
View File
@@ -1319,7 +1319,9 @@ select option:checked {
.lt-modal-body { .lt-modal-body {
padding: var(--space-lg); padding: var(--space-lg);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
flex: 1; flex: 1;
min-width: 0;
} }
.lt-modal-footer { .lt-modal-footer {
@@ -3187,6 +3189,29 @@ input[type="range"].lt-range::-moz-range-thumb {
.lt-kv-val--green { color: var(--accent-green); } .lt-kv-val--green { color: var(--accent-green); }
.lt-kv-val--red { color: var(--accent-red); } .lt-kv-val--red { color: var(--accent-red); }
/* lt-kv-row / lt-kv-label / lt-kv-value — alternate KV row pattern */
.lt-kv-row {
display: contents; /* children become direct grid items of lt-kv-grid */
}
.lt-kv-label {
padding: var(--space-xs) var(--space-md) var(--space-xs) 0;
color: var(--text-dim);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
border-bottom: 1px solid var(--border-dim);
align-self: center;
}
.lt-kv-value {
padding: var(--space-xs) 0 var(--space-xs) var(--space-md);
color: var(--text-primary);
border-bottom: 1px solid var(--border-dim);
min-width: 0;
overflow-wrap: break-word;
align-self: center;
}
/* ---------------------------------------------------------------- /* ----------------------------------------------------------------
43. HERO / BANNER SECTION 43. HERO / BANNER SECTION
+18 -11
View File
@@ -191,7 +191,7 @@ document.addEventListener('DOMContentLoaded', function() {
break; break;
// View mode toggle // View mode toggle
case 'set-view-mode': case 'set-view-mode':
if (target.dataset.mode === 'card') populateKanbanCards(); setViewMode(target.dataset.mode);
break; break;
// Settings // Settings
case 'open-settings': case 'open-settings':
@@ -686,7 +686,9 @@ function closeBulkPriorityModal() {
} }
function performBulkPriority() { function performBulkPriority() {
const priority = document.getElementById('bulkPriority').value; const priorityEl = document.getElementById('bulkPriority');
if (!priorityEl) return;
const priority = priorityEl.value;
const ticketIds = getSelectedTicketIds(); const ticketIds = getSelectedTicketIds();
if (!priority) { if (!priority) {
@@ -789,7 +791,9 @@ function closeBulkStatusModal() {
} }
function performBulkStatusChange() { function performBulkStatusChange() {
const status = document.getElementById('bulkStatus').value; const bulkStatusEl = document.getElementById('bulkStatus');
if (!bulkStatusEl) return;
const status = bulkStatusEl.value;
const ticketIds = getSelectedTicketIds(); const ticketIds = getSelectedTicketIds();
if (!status) { if (!status) {
@@ -986,7 +990,9 @@ function closeQuickStatusModal() {
} }
function performQuickStatusChange(ticketId) { function performQuickStatusChange(ticketId) {
const newStatus = document.getElementById('quickStatusSelect').value; const quickStatusEl = document.getElementById('quickStatusSelect');
if (!quickStatusEl) return;
const newStatus = quickStatusEl.value;
lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus }) lt.api.post('/api/update_ticket.php', { ticket_id: ticketId, status: newStatus })
.then(data => { .then(data => {
@@ -1239,14 +1245,15 @@ function populateKanbanCards() {
} }
} }
// Restore view mode on page load — click the kanban tab button to trigger lt.tabs // Restore view mode on page load — lt.tabs already restores the active panel visually
// via lt_activeTab_<path>; we just need to populate kanban cards if that panel is active
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem('ticketViewMode'); try {
if (savedMode === 'card') { const savedTab = localStorage.getItem('lt_activeTab_' + location.pathname);
const cardBtn = document.getElementById('cardViewBtn'); if (savedTab === 'tab-kanban') {
if (cardBtn) cardBtn.click(); populateKanbanCards();
else populateKanbanCards(); }
} } catch (_) {}
}); });
// ======================================== // ========================================
+9 -3
View File
@@ -136,10 +136,16 @@ class DashboardController {
// Validate user ID filters // Validate user ID filters
$createdBy = $this->validateUserId($_GET['created_by'] ?? null); $createdBy = $this->validateUserId($_GET['created_by'] ?? null);
$assignedTo = $this->validateUserId($_GET['assigned_to'] ?? null);
if ($createdBy !== null) $filters['created_by'] = $createdBy; if ($createdBy !== null) $filters['created_by'] = $createdBy;
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
// assigned_to accepts a numeric user ID or the special string 'unassigned'
$assignedToRaw = $_GET['assigned_to'] ?? null;
if ($assignedToRaw === 'unassigned') {
$filters['assigned_to'] = 'unassigned';
} else {
$assignedTo = $this->validateUserId($assignedToRaw);
if ($assignedTo !== null) $filters['assigned_to'] = $assignedTo;
}
// Get tickets with pagination, sorting, search, and advanced filters // Get tickets with pagination, sorting, search, and advanced filters
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters, $GLOBALS['currentUser'] ?? []); $result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters, $GLOBALS['currentUser'] ?? []);
+1 -1
View File
@@ -58,7 +58,7 @@ class RecurringTicketModel {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
$stmt->bind_param('ssssiiisssis', $stmt->bind_param('ssssiiisssii',
$data['title_template'], $data['title_template'],
$data['description_template'], $data['description_template'],
$data['category'], $data['category'],
+4 -1
View File
@@ -81,9 +81,12 @@ class TicketModel {
if ($search && !empty($search)) { if ($search && !empty($search)) {
if ($this->hasFulltextIndex()) { if ($this->hasFulltextIndex()) {
// MATCH...AGAINST for indexed full-text search (much faster at scale) // MATCH...AGAINST for indexed full-text search (much faster at scale)
// Strip MySQL boolean mode special chars to prevent parse errors on user input
$ftSearch = preg_replace('/[+\-><()\~*"@]+/', ' ', $search);
$ftSearch = trim(preg_replace('/\s+/', ' ', $ftSearch)) . '*';
$whereConditions[] = "(MATCH(t.title, t.description) AGAINST (? IN BOOLEAN MODE) OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)"; $whereConditions[] = "(MATCH(t.title, t.description) AGAINST (? IN BOOLEAN MODE) OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
$searchTerm = "%$search%"; $searchTerm = "%$search%";
$params = array_merge($params, [$search . '*', $searchTerm, $searchTerm, $searchTerm]); $params = array_merge($params, [$ftSearch, $searchTerm, $searchTerm, $searchTerm]);
$paramTypes .= 'ssss'; $paramTypes .= 'ssss';
} else { } else {
$whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)"; $whereConditions[] = "(t.title LIKE ? OR t.description LIKE ? OR t.ticket_id LIKE ? OR t.category LIKE ? OR t.type LIKE ?)";
+2 -2
View File
@@ -165,7 +165,7 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
<?php if ($_lt_userId > 0): ?> <?php if ($_lt_userId > 0): ?>
<img src="/api/user_avatar.php?user_id=<?= $_lt_userId ?>" <img src="/api/user_avatar.php?user_id=<?= $_lt_userId ?>"
alt="" alt=""
class="lt-avatar-img" class="lt-avatar-img">
<?php endif ?> <?php endif ?>
<span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span> <span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span>
</div> </div>
@@ -195,7 +195,7 @@ $_lt_assetVer = $GLOBALS['config']['ASSET_VERSION'] ?? '20260329';
<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading&hellip;</div> <div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading&hellip;</div>
</div> </div>
<div class="lt-notif-panel-footer"> <div class="lt-notif-panel-footer">
<a href="/admin/audit-log" class="lt-btn lt-btn-ghost lt-btn-sm" style="width:100%;text-align:center">View activity log</a> <a href="/admin/audit-log" class="lt-btn lt-btn-ghost lt-btn-sm lt-w-full lt-text-center">View activity log</a>
</div> </div>
</div> </div>
</div> </div>