Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0863449f | ||
|
|
1fb984e352 | ||
|
|
ce0ea66994 | ||
|
|
4fd2c7ce7d | ||
|
|
2ff7345a73 | ||
|
|
1de04d4908 | ||
|
|
153f9a7cef | ||
|
|
0a7201d754 | ||
|
|
12ffd217bb | ||
|
|
a5b0655623 | ||
|
|
fa5f347c08 | ||
|
|
1d03800ab2 | ||
|
|
9d982ab73f |
@@ -99,6 +99,11 @@ try {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the trimmed text (not the raw client value) — matches update_comment.php
|
||||||
|
// and keeps stored comment_text free of leading whitespace that could shift a
|
||||||
|
// markdown-enabled comment's first line out of column 0 on reload.
|
||||||
|
$data['comment_text'] = $commentTextRaw;
|
||||||
|
|
||||||
// Never trust a client-supplied display name — always attribute the comment to
|
// Never trust a client-supplied display name — always attribute the comment to
|
||||||
// the authenticated session user.
|
// the authenticated session user.
|
||||||
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
|
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
|
||||||
|
|||||||
@@ -107,10 +107,17 @@ $result = $bulkOpsModel->processBulkOperation($operationId);
|
|||||||
|
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
$conn->close();
|
$conn->close();
|
||||||
echo json_encode([
|
$response = [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $result['error']
|
'error' => $result['error']
|
||||||
]);
|
];
|
||||||
|
// Let the client know it should collect a comment and retry, rather than
|
||||||
|
// showing the failure as a dead end.
|
||||||
|
if (!empty($result['requires_comment'])) {
|
||||||
|
$response['requires_comment'] = true;
|
||||||
|
http_response_code(400);
|
||||||
|
}
|
||||||
|
echo json_encode($response);
|
||||||
} else {
|
} else {
|
||||||
// Invalidate stats cache so dashboard tiles reflect changes immediately
|
// Invalidate stats cache so dashboard tiles reflect changes immediately
|
||||||
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
--accent-green-bright: #33FFAA;
|
--accent-green-bright: #33FFAA;
|
||||||
--accent-green-dim: rgba(0,255,136,0.10);
|
--accent-green-dim: rgba(0,255,136,0.10);
|
||||||
--accent-green-border: rgba(0,255,136,0.22);
|
--accent-green-border: rgba(0,255,136,0.22);
|
||||||
|
--shadow-color: rgba(0,0,0,0.5);
|
||||||
|
|
||||||
/* --- Error / Critical --- */
|
/* --- Error / Critical --- */
|
||||||
--accent-red: #FF2D55;
|
--accent-red: #FF2D55;
|
||||||
@@ -3640,6 +3641,8 @@ html[data-theme="light"] {
|
|||||||
--accent-red-dim: rgba(181,0,31,0.10);
|
--accent-red-dim: rgba(181,0,31,0.10);
|
||||||
--accent-amber-dim: rgba(138,90,0,0.10);
|
--accent-amber-dim: rgba(138,90,0,0.10);
|
||||||
--accent-cyan-border: rgba(0,98,184,0.28);
|
--accent-cyan-border: rgba(0,98,184,0.28);
|
||||||
|
--accent-green-border: rgba(0,109,53,0.28);
|
||||||
|
--shadow-color: rgba(50,80,130,0.18);
|
||||||
|
|
||||||
/* — Glows become subtle drop shadows in light mode — */
|
/* — Glows become subtle drop shadows in light mode — */
|
||||||
--glow-orange: 0 0 0 1px rgba(196,78,0,0.25), 0 1px 6px rgba(196,78,0,0.18);
|
--glow-orange: 0 0 0 1px rgba(196,78,0,0.25), 0 1px 6px rgba(196,78,0,0.18);
|
||||||
@@ -3737,6 +3740,25 @@ html[data-theme="light"] .lt-textarea:focus-visible {
|
|||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
box-shadow: var(--box-glow-cyan);
|
box-shadow: var(--box-glow-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Native <select> popup in light mode.
|
||||||
|
`.lt-select` sets `color-scheme: dark` on the element itself, which beats the
|
||||||
|
`color-scheme: light` declared on <html>, so the browser drew the dropdown with
|
||||||
|
dark chrome even in light mode. Reset it per element, and re-tint the option
|
||||||
|
list, which is otherwise hardcoded to #0d1117 for the dark theme. */
|
||||||
|
html[data-theme="light"] .lt-select { color-scheme: light; }
|
||||||
|
html[data-theme="light"] .lt-select option,
|
||||||
|
html[data-theme="light"] select option {
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .lt-select option:hover,
|
||||||
|
html[data-theme="light"] .lt-select option:focus,
|
||||||
|
html[data-theme="light"] .lt-select option:checked,
|
||||||
|
html[data-theme="light"] select option:checked {
|
||||||
|
background: var(--accent-orange-dim);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
html[data-theme="light"] .lt-label { color: var(--text-muted); }
|
html[data-theme="light"] .lt-label { color: var(--text-muted); }
|
||||||
|
|
||||||
/* — Buttons — */
|
/* — Buttons — */
|
||||||
|
|||||||
@@ -338,17 +338,22 @@ kbd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Ticket preview popup ────────────────────────────────────── */
|
/* ── Ticket preview popup ────────────────────────────────────── */
|
||||||
|
/* --lt-surface is not defined anywhere, so the background always fell through to
|
||||||
|
the hardcoded #0a0e14 — a near-black panel in light mode, with no colour set at
|
||||||
|
all, so the inherited near-black body text was invisible on it. These tokens
|
||||||
|
are redefined for light mode in base.css. */
|
||||||
.ticket-preview-popup {
|
.ticket-preview-popup {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
background: var(--lt-surface, #0a0e14);
|
background: var(--bg-card);
|
||||||
border: 1px solid rgba(0, 255, 65, 0.4);
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--accent-green-border);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
box-shadow: 0 4px 20px var(--shadow-color);
|
||||||
}
|
}
|
||||||
.ticket-preview-popup .preview-header {
|
.ticket-preview-popup .preview-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -356,7 +361,7 @@ kbd {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
.ticket-preview-popup .preview-id { color: var(--lt-cyan, #00ffff); font-weight: 700; }
|
.ticket-preview-popup .preview-id { color: var(--accent-cyan); font-weight: 700; }
|
||||||
.ticket-preview-popup .preview-title { font-weight: 600; margin-bottom: 0.4rem; }
|
.ticket-preview-popup .preview-title { font-weight: 600; margin-bottom: 0.4rem; }
|
||||||
.ticket-preview-popup .preview-meta { opacity: 0.7; display: flex; flex-direction: column; gap: 0.1rem; }
|
.ticket-preview-popup .preview-meta { opacity: 0.7; display: flex; flex-direction: column; gap: 0.1rem; }
|
||||||
.ticket-preview-popup .preview-footer { margin-top: 0.4rem; opacity: 0.5; font-size: 0.65rem; }
|
.ticket-preview-popup .preview-footer { margin-top: 0.4rem; opacity: 0.5; font-size: 0.65rem; }
|
||||||
|
|||||||
+12
-4
@@ -54,10 +54,18 @@ body.edit-mode .editable-metadata {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
.lt-status-select.lt-status-open { color: var(--lt-success, #00ff41); border-color: var(--lt-success, #00ff41); }
|
/* --lt-success / --lt-amber / --lt-cyan / --lt-danger are not defined anywhere,
|
||||||
.lt-status-select.lt-status-pending { color: var(--lt-amber, #ffb000); border-color: var(--lt-amber, #ffb000); }
|
so these always fell through to the hardcoded neon fallbacks — unreadable on
|
||||||
.lt-status-select.lt-status-in-progress { color: var(--lt-cyan, #00ffff); border-color: var(--lt-cyan, #00ffff); }
|
the light theme's white input background. The --accent-* tokens carry the same
|
||||||
.lt-status-select.lt-status-closed { color: var(--lt-danger, #ff4d4d); border-color: var(--lt-danger, #ff4d4d); }
|
hues and are redefined for light mode in base.css.
|
||||||
|
|
||||||
|
The leading .lt-select is needed for specificity: base.css's
|
||||||
|
`html[data-theme="light"] .lt-select` (0,2,1) would otherwise outrank a plain
|
||||||
|
two-class selector and repaint every status the same near-black. */
|
||||||
|
.lt-select.lt-status-select.lt-status-open { color: var(--accent-green); border-color: var(--accent-green); }
|
||||||
|
.lt-select.lt-status-select.lt-status-pending { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||||
|
.lt-select.lt-status-select.lt-status-in-progress { color: var(--accent-cyan); border-color: var(--accent-cyan); }
|
||||||
|
.lt-select.lt-status-select.lt-status-closed { color: var(--accent-red); border-color: var(--accent-red); }
|
||||||
|
|
||||||
/* ── Ticket meta KV grid ─────────────────────────────────────── */
|
/* ── Ticket meta KV grid ─────────────────────────────────────── */
|
||||||
.ticket-meta-grid {
|
.ticket-meta-grid {
|
||||||
|
|||||||
@@ -241,6 +241,11 @@
|
|||||||
trigger.focus();
|
trigger.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Announce the close so whoever opened the modal can undo optimistic UI or
|
||||||
|
// clean up a dynamically-inserted overlay. A modal can be dismissed four
|
||||||
|
// ways — the ✕ button, a Cancel button, a backdrop click, and Escape — and
|
||||||
|
// the last two are handled globally here, so button-only listeners miss them.
|
||||||
|
el.dispatchEvent(new CustomEvent('lt:modalclose', { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAllModals() {
|
function closeAllModals() {
|
||||||
@@ -2774,6 +2779,9 @@
|
|||||||
setTimeout(() => { if (modalEl && modalEl.parentNode) modalEl.remove(); }, 300);
|
setTimeout(() => { if (modalEl && modalEl.parentNode) modalEl.remove(); }, 300);
|
||||||
resolve(value);
|
resolve(value);
|
||||||
};
|
};
|
||||||
|
// Any dismissal counts as "no comment given", including a backdrop click or
|
||||||
|
// Escape, which close the overlay through the global handlers above.
|
||||||
|
modalEl.addEventListener('lt:modalclose', () => finish(null));
|
||||||
modalEl.querySelector('[data-modal-close]').addEventListener('click', () => finish(null));
|
modalEl.querySelector('[data-modal-close]').addEventListener('click', () => finish(null));
|
||||||
document.getElementById(modalId + '_cancel').addEventListener('click', () => finish(null));
|
document.getElementById(modalId + '_cancel').addEventListener('click', () => finish(null));
|
||||||
document.getElementById(modalId + '_confirm').addEventListener('click', () => {
|
document.getElementById(modalId + '_confirm').addEventListener('click', () => {
|
||||||
|
|||||||
+115
-16
@@ -157,6 +157,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
case 'close-bulk-status-modal':
|
case 'close-bulk-status-modal':
|
||||||
closeBulkStatusModal();
|
closeBulkStatusModal();
|
||||||
break;
|
break;
|
||||||
|
case 'perform-bulk-close':
|
||||||
|
performBulkCloseAction();
|
||||||
|
break;
|
||||||
|
case 'close-bulk-close-modal':
|
||||||
|
closeBulkCloseModal();
|
||||||
|
break;
|
||||||
case 'perform-bulk-delete':
|
case 'perform-bulk-delete':
|
||||||
performBulkDelete();
|
performBulkDelete();
|
||||||
break;
|
break;
|
||||||
@@ -515,24 +521,59 @@ function bulkClose() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showConfirmModal(
|
// Closing needs a reason: the default workflow marks every → Closed transition
|
||||||
`Close ${ticketIds.length} Ticket(s)?`,
|
// requires_comment, so collect it here instead of failing server-side.
|
||||||
'Are you sure you want to close these tickets?',
|
const modalHtml = `
|
||||||
'warning',
|
<div class="lt-modal-overlay" id="bulkCloseModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkCloseModalTitle">
|
||||||
() => performBulkCloseAction(ticketIds)
|
<div class="lt-modal">
|
||||||
);
|
<div class="lt-modal-header" style="color:var(--terminal-amber)">
|
||||||
|
<span class="lt-modal-title" id="bulkCloseModalTitle">[ ! ] Close ${ticketIds.length} Ticket(s)</span>
|
||||||
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-body">
|
||||||
|
<label for="bulkCloseComment">Close Reason:</label>
|
||||||
|
<textarea id="bulkCloseComment" class="lt-input lt-w-full" rows="3"
|
||||||
|
placeholder="Why are these tickets being closed?…"
|
||||||
|
style="resize:vertical;font-family:inherit;font-size:0.8rem"
|
||||||
|
aria-label="Reason for closing the tickets"></textarea>
|
||||||
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.35rem">
|
||||||
|
Posted as a comment on every ticket closed. Tickets whose workflow
|
||||||
|
forbids closing from their current status are skipped.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button data-action="perform-bulk-close" class="lt-btn lt-btn-primary">CLOSE TICKETS</button>
|
||||||
|
<button data-action="close-bulk-close-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
openModalWithDismiss('bulkCloseModal', closeBulkCloseModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBulkCloseModal() {
|
||||||
|
lt.modal.close('bulkCloseModal');
|
||||||
|
const modal = document.getElementById('bulkCloseModal');
|
||||||
|
if (modal) setTimeout(() => modal.remove(), 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
function performBulkCloseAction(ticketIds) {
|
function performBulkCloseAction(ticketIds) {
|
||||||
|
ticketIds = ticketIds || getSelectedTicketIds();
|
||||||
|
const commentEl = document.getElementById('bulkCloseComment');
|
||||||
|
const comment = commentEl ? commentEl.value.trim() : '';
|
||||||
|
|
||||||
lt.api.post('/api/bulk_operation.php', {
|
lt.api.post('/api/bulk_operation.php', {
|
||||||
operation_type: 'bulk_close',
|
operation_type: 'bulk_close',
|
||||||
ticket_ids: ticketIds
|
ticket_ids: ticketIds,
|
||||||
|
parameters: { comment: comment }
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
closeBulkCloseModal();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
if (data.failed > 0) {
|
if (data.failed > 0) {
|
||||||
lt.toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
lt.toast.warning(bulkResultMessage('Bulk close', data), 6000);
|
||||||
} else {
|
} else {
|
||||||
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
|
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
|
||||||
}
|
}
|
||||||
@@ -542,6 +583,14 @@ function performBulkCloseAction(ticketIds) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
// Missing required comment — keep the modal open so it can be entered.
|
||||||
|
if (error && error.data && error.data.requires_comment) {
|
||||||
|
lt.toast.warning(error.data.error || 'A close reason is required', 6000);
|
||||||
|
const ta = document.getElementById('bulkCloseComment');
|
||||||
|
if (ta) ta.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeBulkCloseModal();
|
||||||
lt.toast.error('Bulk close failed: ' + error.message, 5000);
|
lt.toast.error('Bulk close failed: ' + error.message, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -584,7 +633,7 @@ function showBulkAssignModal() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
lt.modal.open('bulkAssignModal');
|
openModalWithDismiss('bulkAssignModal', closeBulkAssignModal);
|
||||||
setTimeout(() => { const inp = document.getElementById('bulkAssignUserInput'); if (inp) inp.focus(); }, 120);
|
setTimeout(() => { const inp = document.getElementById('bulkAssignUserInput'); if (inp) inp.focus(); }, 120);
|
||||||
|
|
||||||
lt.api.get('/api/get_users.php')
|
lt.api.get('/api/get_users.php')
|
||||||
@@ -682,7 +731,7 @@ function showBulkPriorityModal() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
lt.modal.open('bulkPriorityModal');
|
openModalWithDismiss('bulkPriorityModal', closeBulkPriorityModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeBulkPriorityModal() {
|
function closeBulkPriorityModal() {
|
||||||
@@ -777,6 +826,15 @@ function showBulkStatusModal() {
|
|||||||
<option value="">Select Status...</option>
|
<option value="">Select Status...</option>
|
||||||
${(window.TICKET_STATUSES || ['Open','Pending','In Progress','Closed']).map(s => `<option value="${s}">${s}</option>`).join('')}
|
${(window.TICKET_STATUSES || ['Open','Pending','In Progress','Closed']).map(s => `<option value="${s}">${s}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
|
<label for="bulkStatusComment" style="margin-top:0.75rem">Reason / Comment:</label>
|
||||||
|
<textarea id="bulkStatusComment" class="lt-input lt-w-full" rows="3"
|
||||||
|
placeholder="Reason for the status change…"
|
||||||
|
style="resize:vertical;font-family:inherit;font-size:0.8rem"
|
||||||
|
aria-label="Reason for the bulk status change"></textarea>
|
||||||
|
<p class="lt-text-xs lt-text-muted" style="margin-top:0.35rem">
|
||||||
|
Required for transitions the Workflow Designer marks as needing a comment
|
||||||
|
(e.g. closing a ticket). Posted as a comment on every ticket changed.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-modal-footer">
|
<div class="lt-modal-footer">
|
||||||
<button data-action="perform-bulk-status" class="lt-btn lt-btn-primary">UPDATE</button>
|
<button data-action="perform-bulk-status" class="lt-btn lt-btn-primary">UPDATE</button>
|
||||||
@@ -787,7 +845,7 @@ function showBulkStatusModal() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
lt.modal.open('bulkStatusModal');
|
openModalWithDismiss('bulkStatusModal', closeBulkStatusModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeBulkStatusModal() {
|
function closeBulkStatusModal() {
|
||||||
@@ -807,16 +865,19 @@ function performBulkStatusChange() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commentEl = document.getElementById('bulkStatusComment');
|
||||||
|
const comment = commentEl ? commentEl.value.trim() : '';
|
||||||
|
|
||||||
lt.api.post('/api/bulk_operation.php', {
|
lt.api.post('/api/bulk_operation.php', {
|
||||||
operation_type: 'bulk_status',
|
operation_type: 'bulk_status',
|
||||||
ticket_ids: ticketIds,
|
ticket_ids: ticketIds,
|
||||||
parameters: { status: status }
|
parameters: { status: status, comment: comment }
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
closeBulkStatusModal();
|
closeBulkStatusModal();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
if (data.failed > 0) {
|
if (data.failed > 0) {
|
||||||
lt.toast.warning(`Status update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
lt.toast.warning(bulkResultMessage('Status update', data), 6000);
|
||||||
} else {
|
} else {
|
||||||
lt.toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
|
lt.toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
|
||||||
}
|
}
|
||||||
@@ -826,10 +887,32 @@ function performBulkStatusChange() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
// Workflow needs a comment for at least one selected ticket — keep the
|
||||||
|
// modal open so the reason can be typed in without re-selecting.
|
||||||
|
if (error && error.data && error.data.requires_comment) {
|
||||||
|
lt.toast.warning(error.data.error || 'A comment is required for this status change', 6000);
|
||||||
|
const ta = document.getElementById('bulkStatusComment');
|
||||||
|
if (ta) ta.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeBulkStatusModal();
|
||||||
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
|
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a result message for a partially-successful bulk operation, surfacing the
|
||||||
|
* per-ticket reasons (e.g. "transition not allowed") instead of a bare count.
|
||||||
|
*/
|
||||||
|
function bulkResultMessage(label, data) {
|
||||||
|
let msg = `${label}: ${data.processed} succeeded, ${data.failed} failed`;
|
||||||
|
if (Array.isArray(data.errors) && data.errors.length) {
|
||||||
|
msg += ' — ' + data.errors.slice(0, 3).join('; ');
|
||||||
|
if (data.errors.length > 3) msg += ` (+${data.errors.length - 3} more)`;
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
// Bulk Delete
|
// Bulk Delete
|
||||||
function showBulkDeleteModal() {
|
function showBulkDeleteModal() {
|
||||||
const ticketIds = getSelectedTicketIds();
|
const ticketIds = getSelectedTicketIds();
|
||||||
@@ -859,7 +942,7 @@ function showBulkDeleteModal() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
lt.modal.open('bulkDeleteModal');
|
openModalWithDismiss('bulkDeleteModal', closeBulkDeleteModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeBulkDeleteModal() {
|
function closeBulkDeleteModal() {
|
||||||
@@ -949,6 +1032,22 @@ function showInputModal(title, label, placeholder = '', onSubmit, onCancel = nul
|
|||||||
input.addEventListener('keypress', (e) => { if (e.key === 'Enter') handleSubmit(); });
|
input.addEventListener('keypress', (e) => { if (e.key === 'Enter') handleSubmit(); });
|
||||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
||||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
||||||
|
// Backdrop click / Escape close the overlay via base.js's global handlers.
|
||||||
|
modal.addEventListener('lt:modalclose', () => cleanup(onCancel));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a dynamically-inserted modal and make sure it tears itself down however it
|
||||||
|
* is dismissed. base.js handles backdrop clicks and Escape globally, so wiring
|
||||||
|
* only the ✕/Cancel buttons leaves the overlay in the DOM — and the next open
|
||||||
|
* inserts a second element with the same id, which then shadows the live one.
|
||||||
|
*/
|
||||||
|
function openModalWithDismiss(modalId, onDismiss) {
|
||||||
|
lt.modal.open(modalId);
|
||||||
|
const el = document.getElementById(modalId);
|
||||||
|
// lt.modal.close() early-returns once .is-open is gone, so the close call
|
||||||
|
// inside onDismiss cannot re-enter this listener.
|
||||||
|
if (el) el.addEventListener('lt:modalclose', onDismiss);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -986,7 +1085,7 @@ function quickStatusChange(ticketId, currentStatus) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
lt.modal.open('quickStatusModal');
|
openModalWithDismiss('quickStatusModal', closeQuickStatusModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeQuickStatusModal() {
|
function closeQuickStatusModal() {
|
||||||
@@ -1053,7 +1152,7 @@ function quickAssign(ticketId) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
lt.modal.open('quickAssignModal');
|
openModalWithDismiss('quickAssignModal', closeQuickAssignModal);
|
||||||
|
|
||||||
lt.api.get('/api/get_users.php')
|
lt.api.get('/api/get_users.php')
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
|||||||
+22
-2
@@ -284,7 +284,15 @@ function addComment() {
|
|||||||
// Clear the comment box
|
// Clear the comment box
|
||||||
const nc = document.getElementById('newComment');
|
const nc = document.getElementById('newComment');
|
||||||
if (nc) nc.value = '';
|
if (nc) nc.value = '';
|
||||||
|
|
||||||
|
// Clear the live preview — clearing the textarea programmatically
|
||||||
|
// does not fire 'input', so updatePreview() never runs
|
||||||
|
const previewDiv = document.getElementById('markdownPreview');
|
||||||
|
if (previewDiv) {
|
||||||
|
previewDiv.innerHTML = '';
|
||||||
|
previewDiv.classList.add('is-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Format the comment text for display
|
// Format the comment text for display
|
||||||
let displayText;
|
let displayText;
|
||||||
if (isMarkdownEnabled) {
|
if (isMarkdownEnabled) {
|
||||||
@@ -521,7 +529,19 @@ function updateTicketStatus() {
|
|||||||
`);
|
`);
|
||||||
const modal = document.getElementById(modalId);
|
const modal = document.getElementById(modalId);
|
||||||
lt.modal.open(modalId);
|
lt.modal.open(modalId);
|
||||||
const cleanup = (ok) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (!ok) statusSelect.selectedIndex = 0; };
|
let settled = false;
|
||||||
|
const cleanup = (ok) => {
|
||||||
|
if (settled) return; // lt.modal.close() below re-enters via lt:modalclose
|
||||||
|
settled = true;
|
||||||
|
lt.modal.close(modalId);
|
||||||
|
setTimeout(() => modal.remove(), 300);
|
||||||
|
if (!ok) statusSelect.selectedIndex = 0;
|
||||||
|
};
|
||||||
|
// Backdrop click and Escape close the overlay through base.js's global
|
||||||
|
// handlers. Without this the dropdown kept displaying the new status
|
||||||
|
// while the server was never called, so the ticket looked closed until
|
||||||
|
// a reload revealed it was still open.
|
||||||
|
modal.addEventListener('lt:modalclose', () => cleanup(false));
|
||||||
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(false));
|
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(false));
|
||||||
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(false));
|
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(false));
|
||||||
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => {
|
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => {
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ CREATE TABLE IF NOT EXISTS `bulk_operations` (
|
|||||||
`ticket_ids` text NOT NULL,
|
`ticket_ids` text NOT NULL,
|
||||||
`performed_by` int(11) NOT NULL,
|
`performed_by` int(11) NOT NULL,
|
||||||
`parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`parameters`)),
|
`parameters` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`parameters`)),
|
||||||
`status` varchar(20) DEFAULT 'pending',
|
-- 32, not 20: 'completed_with_errors' is 21 chars (see 001_widen_bulk_operations_status.sql)
|
||||||
|
`status` varchar(32) DEFAULT 'pending',
|
||||||
`total_tickets` int(11) DEFAULT NULL,
|
`total_tickets` int(11) DEFAULT NULL,
|
||||||
`processed_tickets` int(11) DEFAULT 0,
|
`processed_tickets` int(11) DEFAULT 0,
|
||||||
`failed_tickets` int(11) DEFAULT 0,
|
`failed_tickets` int(11) DEFAULT 0,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Widen bulk_operations.status
|
||||||
|
--
|
||||||
|
-- The code writes 'completed_with_errors' (21 chars) when a bulk operation
|
||||||
|
-- finishes with per-ticket failures, but the column was varchar(20), so the
|
||||||
|
-- write failed with "Data too long for column 'status'". This was unreachable
|
||||||
|
-- while bulk status changes forced every transition through; now that they
|
||||||
|
-- honour the Workflow Designer, partial failures are a normal outcome.
|
||||||
|
--
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
ALTER TABLE `bulk_operations`
|
||||||
|
MODIFY COLUMN `status` varchar(32) DEFAULT 'pending';
|
||||||
+139
-13
@@ -7,11 +7,47 @@ class BulkOperationsModel
|
|||||||
{
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
|
/** @var WorkflowModel|null Lazily created; only needed by status-changing operations */
|
||||||
|
private $workflowModel = null;
|
||||||
|
|
||||||
|
/** @var CommentModel|null Lazily created; only needed when a status change carries a comment */
|
||||||
|
private $commentModel = null;
|
||||||
|
|
||||||
|
/** @var array<int,string> user_id → display name, resolved once per request */
|
||||||
|
private $userNames = [];
|
||||||
|
|
||||||
public function __construct($conn)
|
public function __construct($conn)
|
||||||
{
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workflow model, created on first use.
|
||||||
|
*/
|
||||||
|
private function workflow(): WorkflowModel
|
||||||
|
{
|
||||||
|
if ($this->workflowModel === null) {
|
||||||
|
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||||
|
$this->workflowModel = new WorkflowModel($this->conn);
|
||||||
|
}
|
||||||
|
return $this->workflowModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status a bulk operation is trying to move tickets into, or null for
|
||||||
|
* operations that don't change status.
|
||||||
|
*/
|
||||||
|
private function targetStatusFor(string $operationType, array $parameters): ?string
|
||||||
|
{
|
||||||
|
if ($operationType === 'bulk_close') {
|
||||||
|
return 'Closed';
|
||||||
|
}
|
||||||
|
if ($operationType === 'bulk_status') {
|
||||||
|
return isset($parameters['status']) ? (string)$parameters['status'] : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new bulk operation record
|
* Create a new bulk operation record
|
||||||
*
|
*
|
||||||
@@ -100,6 +136,30 @@ class BulkOperationsModel
|
|||||||
// Batch load all tickets in one query to eliminate N+1 problem
|
// Batch load all tickets in one query to eliminate N+1 problem
|
||||||
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
||||||
|
|
||||||
|
// Status-changing operations honour the Workflow Designer. If any ticket in
|
||||||
|
// the selection needs a comment for its transition, reject the whole batch
|
||||||
|
// before mutating anything so the client can collect one — a partially
|
||||||
|
// applied batch is worse than none.
|
||||||
|
$targetStatus = $this->targetStatusFor($operation['operation_type'], is_array($parameters) ? $parameters : []);
|
||||||
|
$bulkComment = trim((string)($parameters['comment'] ?? ''));
|
||||||
|
if ($targetStatus !== null && $bulkComment === '') {
|
||||||
|
foreach ($ticketIds as $tid) {
|
||||||
|
$t = $ticketsById[trim($tid)] ?? null;
|
||||||
|
if (!$t || $t['status'] === $targetStatus) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($this->workflow()->transitionRequiresComment($t['status'], $targetStatus)) {
|
||||||
|
return [
|
||||||
|
'processed' => 0,
|
||||||
|
'failed' => count($ticketIds),
|
||||||
|
'error' => 'A comment is required to change status from '
|
||||||
|
. $t['status'] . ' → ' . $targetStatus,
|
||||||
|
'requires_comment' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start transaction for data consistency
|
// Start transaction for data consistency
|
||||||
$this->conn->begin_transaction();
|
$this->conn->begin_transaction();
|
||||||
|
|
||||||
@@ -113,11 +173,32 @@ class BulkOperationsModel
|
|||||||
$success = false;
|
$success = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// NOTE: bulk_status / bulk_close intentionally do NOT run
|
// bulk_status / bulk_close enforce the same Workflow Designer
|
||||||
// WorkflowModel::isTransitionAllowed(). Bulk operations are an
|
// rules as the single-ticket path: a transition the designer
|
||||||
// admin-only escape hatch for forcing ticket states (e.g. mass
|
// doesn't define is refused, and requires_comment is honoured
|
||||||
// re-opening), so they bypass the workflow transition rules that
|
// (checked up front, above). requires_admin is satisfied because
|
||||||
// the single-ticket update path enforces. This is by design.
|
// api/bulk_operation.php already gates the endpoint on admin.
|
||||||
|
if ($targetStatus !== null) {
|
||||||
|
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||||
|
if ($currentTicket && $currentTicket['status'] === $targetStatus) {
|
||||||
|
// Already in the requested state — nothing to do, and
|
||||||
|
// reporting a no-op as a failure would just confuse.
|
||||||
|
$processed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$allowed = $currentTicket === null || $this->workflow()->isTransitionAllowed(
|
||||||
|
$currentTicket['status'],
|
||||||
|
$targetStatus,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
if (!$allowed) {
|
||||||
|
$failed++;
|
||||||
|
$errors[] = "Ticket $ticketId: transition not allowed ("
|
||||||
|
. $currentTicket['status'] . ' → ' . $targetStatus . ')';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch ($operation['operation_type']) {
|
switch ($operation['operation_type']) {
|
||||||
case 'bulk_close':
|
case 'bulk_close':
|
||||||
// Get current ticket from pre-loaded batch
|
// Get current ticket from pre-loaded batch
|
||||||
@@ -232,6 +313,12 @@ class BulkOperationsModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
|
// Persist the status-change reason as a real comment, so a
|
||||||
|
// bulk close is as auditable on the ticket as a single close
|
||||||
|
// (where the client posts the comment before updating).
|
||||||
|
if ($targetStatus !== null && $bulkComment !== '') {
|
||||||
|
$this->postBulkComment($ticketId, (int)$operation['performed_by'], $bulkComment);
|
||||||
|
}
|
||||||
$processed++;
|
$processed++;
|
||||||
} else {
|
} else {
|
||||||
$failed++;
|
$failed++;
|
||||||
@@ -290,14 +377,22 @@ class BulkOperationsModel
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update operation status
|
// Update operation status. This is bookkeeping only and runs after the
|
||||||
$status = $failed > 0 ? 'completed_with_errors' : 'completed';
|
// ticket changes are committed, so a failure here (e.g. the status column
|
||||||
$sql = "UPDATE bulk_operations SET status = ?, processed_tickets = ?, failed_tickets = ?,
|
// not yet widened by 001_widen_bulk_operations_status.sql on an instance
|
||||||
completed_at = NOW() WHERE operation_id = ?";
|
// deployed ahead of its migrations) must not turn a completed operation
|
||||||
$stmt = $this->conn->prepare($sql);
|
// into an error response.
|
||||||
$stmt->bind_param("siii", $status, $processed, $failed, $operationId);
|
try {
|
||||||
$stmt->execute();
|
$status = $failed > 0 ? 'completed_with_errors' : 'completed';
|
||||||
$stmt->close();
|
$sql = "UPDATE bulk_operations SET status = ?, processed_tickets = ?, failed_tickets = ?,
|
||||||
|
completed_at = NOW() WHERE operation_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("siii", $status, $processed, $failed, $operationId);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log("Bulk operation $operationId completed but status bookkeeping failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
$result = ['processed' => $processed, 'failed' => $failed];
|
$result = ['processed' => $processed, 'failed' => $failed];
|
||||||
if (!empty($errors)) {
|
if (!empty($errors)) {
|
||||||
@@ -350,6 +445,37 @@ class BulkOperationsModel
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post the bulk status-change reason as a comment on one ticket.
|
||||||
|
*
|
||||||
|
* Runs inside the caller's transaction, so a rollback drops the comment along
|
||||||
|
* with the status change.
|
||||||
|
*/
|
||||||
|
private function postBulkComment(string $ticketId, int $userId, string $text): void
|
||||||
|
{
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
|
||||||
|
if ($this->commentModel === null) {
|
||||||
|
$this->commentModel = new CommentModel($this->conn);
|
||||||
|
}
|
||||||
|
if (!isset($this->userNames[$userId])) {
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT COALESCE(NULLIF(display_name, ''), username) AS name FROM users WHERE user_id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("i", $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->get_result()->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
$this->userNames[$userId] = $row['name'] ?? 'User';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->commentModel->addComment($ticketId, [
|
||||||
|
'user_name' => $this->userNames[$userId],
|
||||||
|
'comment_text' => $text,
|
||||||
|
'markdown_enabled' => 0,
|
||||||
|
], $userId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether a user ID exists.
|
* Check whether a user ID exists.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+65
-2
@@ -277,9 +277,64 @@ include __DIR__ . '/layout_header.php';
|
|||||||
array_values($stats['by_category'] ?? [])
|
array_values($stats['by_category'] ?? [])
|
||||||
))) ?>;
|
))) ?>;
|
||||||
|
|
||||||
|
// ── Click-to-filter ────────────────────────────────────────────────────────
|
||||||
|
// Charts navigate to the same URL filters the stat cards use.
|
||||||
|
//
|
||||||
|
// The status the click filters on has to be explicit rather than left to the
|
||||||
|
// default: with no `status` param the controller falls back to the viewer's
|
||||||
|
// default_status_filters preference, which can be anything, so the resulting
|
||||||
|
// list would not necessarily match what the chart counted. StatsModel builds
|
||||||
|
// by_priority and by_category with `status != 'Closed'`, while by_status spans
|
||||||
|
// every status — so only the priority and category charts pin the open set.
|
||||||
|
function openStatuses() {
|
||||||
|
var all = window.TICKET_STATUSES || ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
|
return all.filter(function(s) { return s !== 'Closed'; }).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function gotoFilter(params) {
|
||||||
|
var qs = new URLSearchParams();
|
||||||
|
Object.keys(params).forEach(function(k) {
|
||||||
|
if (params[k] !== null && params[k] !== undefined && params[k] !== '') qs.set(k, params[k]);
|
||||||
|
});
|
||||||
|
window.location.href = '/?' + qs.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each chart maps a clicked label to a filter. Returns null when the label
|
||||||
|
// can't be mapped, so the click is simply ignored.
|
||||||
|
var CHART_FILTERS = {
|
||||||
|
chartPriority: function(label) {
|
||||||
|
var m = /^P(\d+)$/.exec(label);
|
||||||
|
return m ? { priority: m[1], status: openStatuses() } : null;
|
||||||
|
},
|
||||||
|
chartStatus: function(label) {
|
||||||
|
return label ? { status: label } : null;
|
||||||
|
},
|
||||||
|
chartCategory: function(label) {
|
||||||
|
return label ? { category: label, status: openStatuses() } : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function filterOnClick(canvasId) {
|
||||||
|
return function(evt, elements, chart) {
|
||||||
|
if (!elements || !elements.length) return;
|
||||||
|
var label = chart.data.labels[elements[0].index];
|
||||||
|
var mapper = CHART_FILTERS[canvasId];
|
||||||
|
var params = mapper && mapper(label);
|
||||||
|
if (params) gotoFilter(params);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer cursor over clickable segments so the affordance is visible.
|
||||||
|
function filterOnHover(evt, elements) {
|
||||||
|
if (evt && evt.native && evt.native.target) {
|
||||||
|
evt.native.target.style.cursor = (elements && elements.length) ? 'pointer' : 'default';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function makeDonut(canvasId, data, colorMap) {
|
function makeDonut(canvasId, data, colorMap) {
|
||||||
var ctx = document.getElementById(canvasId);
|
var ctx = document.getElementById(canvasId);
|
||||||
if (!ctx || !data.length) return;
|
if (!ctx || !data.length) return;
|
||||||
|
ctx.title = 'Click a segment to filter the ticket list';
|
||||||
return new Chart(ctx, {
|
return new Chart(ctx, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: {
|
data: {
|
||||||
@@ -295,12 +350,14 @@ include __DIR__ . '/layout_header.php';
|
|||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true, maintainAspectRatio: false,
|
responsive: true, maintainAspectRatio: false,
|
||||||
|
onClick: filterOnClick(canvasId),
|
||||||
|
onHover: filterOnHover,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
labels: { color: '#8fa3b1', font: { family: 'monospace', size: 10 }, padding: 8, boxWidth: 10 }
|
labels: { color: '#8fa3b1', font: { family: 'monospace', size: 10 }, padding: 8, boxWidth: 10 }
|
||||||
},
|
},
|
||||||
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed; } } }
|
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.parsed + ' — click to filter'; } } }
|
||||||
},
|
},
|
||||||
cutout: '68%'
|
cutout: '68%'
|
||||||
}
|
}
|
||||||
@@ -310,6 +367,7 @@ include __DIR__ . '/layout_header.php';
|
|||||||
function makeBar(canvasId, data) {
|
function makeBar(canvasId, data) {
|
||||||
var ctx = document.getElementById(canvasId);
|
var ctx = document.getElementById(canvasId);
|
||||||
if (!ctx || !data.length) return;
|
if (!ctx || !data.length) return;
|
||||||
|
ctx.title = 'Click a bar to filter the ticket list';
|
||||||
return new Chart(ctx, {
|
return new Chart(ctx, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
@@ -323,7 +381,12 @@ include __DIR__ . '/layout_header.php';
|
|||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
||||||
plugins: { legend: { display: false } },
|
onClick: filterOnClick(canvasId),
|
||||||
|
onHover: filterOnHover,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: { callbacks: { label: function(ctx) { return ' ' + ctx.parsed.x + ' — click to filter'; } } }
|
||||||
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { ticks: { color: '#8fa3b1', font: { size: 10 } }, grid: { color: 'rgba(0,255,65,0.06)' } },
|
x: { ticks: { color: '#8fa3b1', font: { size: 10 } }, grid: { color: 'rgba(0,255,65,0.06)' } },
|
||||||
y: { ticks: { color: '#8fa3b1', font: { family: 'monospace', size: 10 } }, grid: { display: false } }
|
y: { ticks: { color: '#8fa3b1', font: { family: 'monospace', size: 10 } }, grid: { display: false } }
|
||||||
|
|||||||
@@ -1219,7 +1219,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (typeof parseMarkdown === 'function') {
|
if (typeof parseMarkdown === 'function') {
|
||||||
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
|
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
|
||||||
if (!el.dataset.rendered) {
|
if (!el.dataset.rendered) {
|
||||||
el.innerHTML = parseMarkdown(el.textContent);
|
el.innerHTML = parseMarkdown(el.textContent.trim());
|
||||||
el.dataset.rendered = '1';
|
el.dataset.rendered = '1';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user