From 538baadd57408551802ba23cf330d0c5239e7047 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 5 Apr 2026 18:09:53 -0400 Subject: [PATCH] Add comment skeleton loaders, workflow validation, monthly schedule fix - TicketView.php: Show 3 lt-skeleton-card placeholders in the comment list while "Load more" fetches; skeletons are removed on resolve or error - ticket.css: Add .comment-skeleton margin spacing - WorkflowDesignerView.php + manage_workflows.php: Prevent creating/editing status transitions where from_status === to_status (client + server check) - RecurringTicketsView.php: Expand monthly day picker from 28 to 31 days (days 29-31 labelled "last day in short months") - RecurringTicketModel.php: Clamp monthly schedule day to last day of target month using format('t') instead of hard-capping at 28 Co-Authored-By: Claude Sonnet 4.6 --- api/manage_workflows.php | 12 ++++++++++++ assets/css/ticket.css | 4 ++++ models/RecurringTicketModel.php | 7 +++++-- views/TicketView.php | 26 ++++++++++++++++++++++++-- views/admin/RecurringTicketsView.php | 4 ++-- views/admin/WorkflowDesignerView.php | 4 ++++ 6 files changed, 51 insertions(+), 6 deletions(-) diff --git a/api/manage_workflows.php b/api/manage_workflows.php index 628b5ab..de27db5 100644 --- a/api/manage_workflows.php +++ b/api/manage_workflows.php @@ -79,6 +79,12 @@ try { case 'POST': $data = json_decode(file_get_contents('php://input'), true); + if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']); + exit; + } + $stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active) VALUES (?, ?, ?, ?, ?)"); $wf_from = $data['from_status']; @@ -116,6 +122,12 @@ try { $data = json_decode(file_get_contents('php://input'), true); + if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']); + exit; + } + $stmt = $conn->prepare("UPDATE status_transitions SET from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ? WHERE transition_id = ?"); diff --git a/assets/css/ticket.css b/assets/css/ticket.css index 82b67d0..b34b055 100644 --- a/assets/css/ticket.css +++ b/assets/css/ticket.css @@ -365,3 +365,7 @@ kbd { /* Metadata selects use .lt-display-field (base.css) in read mode instead of disabled — full opacity, non-interactive, no fading. */ + + +/* Skeleton placeholder for comment lazy-load */ +.comment-skeleton { margin-bottom: 0.75rem; } diff --git a/models/RecurringTicketModel.php b/models/RecurringTicketModel.php index 3d2b77d..8d0f3f8 100644 --- a/models/RecurringTicketModel.php +++ b/models/RecurringTicketModel.php @@ -181,10 +181,13 @@ class RecurringTicketModel { break; case 'monthly': - $day = max(1, min(28, $scheduleDay)); // Limit to 28 for safety + $day = max(1, min(31, $scheduleDay)); $next = new DateTime(); $next->modify('first day of next month'); - $next->setDate($next->format('Y'), $next->format('m'), $day); + // Clamp to the last day of the target month (handles Feb, 30-day months, etc.) + $daysInMonth = (int)$next->format('t'); + $day = min($day, $daysInMonth); + $next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day); $next->setTime($time->format('H'), $time->format('i'), 0); break; diff --git a/views/TicketView.php b/views/TicketView.php index 21cba8e..37589bf 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -1141,10 +1141,33 @@ document.addEventListener('DOMContentLoaded', function () { loadMoreBtn.disabled = true; loadMoreBtn.textContent = 'Loading\u2026'; + // Insert skeleton placeholders while fetching + var list = document.getElementById('commentsList'); + var wrap = document.getElementById('loadMoreComments'); + var skeletons = []; + for (var s = 0; s < 3; s++) { + var sk = document.createElement('div'); + sk.className = 'lt-skeleton-card comment-skeleton'; + sk.setAttribute('aria-hidden', 'true'); + sk.innerHTML = + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + list.insertBefore(sk, wrap); + skeletons.push(sk); + } + var url = '/api/get_comments.php?ticket_id=' + td.ticket_id + '&offset=' + td.commentOffset + '&limit=' + td.commentPageSize; lt.api.get(url).then(function (data) { + // Remove skeleton placeholders + skeletons.forEach(function (sk) { if (sk.parentNode) sk.parentNode.removeChild(sk); }); + if (!data.success) { lt.toast.error('Failed to load comments: ' + (data.error || 'Unknown error')); loadMoreBtn.disabled = false; @@ -1152,8 +1175,6 @@ document.addEventListener('DOMContentLoaded', function () { return; } - var list = document.getElementById('commentsList'); - var wrap = document.getElementById('loadMoreComments'); data.comments.forEach(function (c) { list.insertBefore(buildCommentEl(c, td.currentUserId, td.isAdmin), wrap); }); @@ -1178,6 +1199,7 @@ document.addEventListener('DOMContentLoaded', function () { }); } }).catch(function (err) { + skeletons.forEach(function (sk) { if (sk.parentNode) sk.parentNode.removeChild(sk); }); lt.toast.error('Failed to load comments'); loadMoreBtn.disabled = false; loadMoreBtn.innerHTML = 'Load more comments'; diff --git a/views/admin/RecurringTicketsView.php b/views/admin/RecurringTicketsView.php index 9d45722..32fe05b 100644 --- a/views/admin/RecurringTicketsView.php +++ b/views/admin/RecurringTicketsView.php @@ -212,10 +212,10 @@ function updateScheduleOptions() { }); } else if (type === 'monthly') { dayRow.classList.remove('is-hidden'); - for (var i = 1; i <= 28; i++) { + for (var i = 1; i <= 31; i++) { var opt = document.createElement('option'); opt.value = String(i); - opt.textContent = 'Day ' + i; + opt.textContent = 'Day ' + i + (i > 28 ? ' (last day in short months)' : ''); daySelect.appendChild(opt); } } diff --git a/views/admin/WorkflowDesignerView.php b/views/admin/WorkflowDesignerView.php index 171182a..9923539 100644 --- a/views/admin/WorkflowDesignerView.php +++ b/views/admin/WorkflowDesignerView.php @@ -216,6 +216,10 @@ function saveTransition(e) { requires_admin: document.getElementById('requires_admin').checked ? 1 : 0, is_active: document.getElementById('wf_is_active').checked ? 1 : 0, }; + if (data.from_status === data.to_status) { + lt.toast.error('From Status and To Status cannot be the same'); + return; + } var url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : ''); var apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data); apiCall.then(function (result) {