Compare commits

...

2 Commits

Author SHA1 Message Date
d86a60c609 feat: Enhance toast system with queuing and manual dismiss
Improved toast notification system with queue management:

**Features Added**:
1. **Toast Queuing**:
   - Multiple toasts no longer replace each other
   - Toasts are queued and displayed sequentially
   - Smooth transitions between queued messages
   - Prevents message loss during rapid operations

2. **Manual Dismissal**:
   - Click [×] button to dismiss toast immediately
   - Useful for long-duration error messages
   - Clears auto-dismiss timeout on manual close
   - Next queued toast appears immediately after dismiss

3. **Queue Management**:
   - Internal toastQueue[] array tracks pending messages
   - currentToast reference prevents overlapping displays
   - dismissToast() handles both auto and manual dismissal
   - Automatic dequeue when toast closes

**Implementation**:
- displayToast() separated from showToast() for queue handling
- timeoutId stored on toast element for cleanup
- Close button styled with terminal aesthetic ([×])
- 300ms fade-out animation preserved

**Benefits**:
✓ No lost messages during bulk operations
✓ Better UX - users can dismiss errors immediately
✓ Clean queue management prevents memory leaks
✓ Maintains terminal aesthetic with minimal close button

Example: Bulk assign 10 tickets with 2 failures now shows:
1. "Bulk assign: 8 succeeded, 2 failed" (toast 1)
2. Next operation's message queued (toast 2)
3. User can dismiss or wait for auto-dismiss

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 17:00:35 -05:00
998b85e907 feat: Replace browser alerts with terminal-aesthetic notifications
Replaced all native browser dialogs with custom terminal-style UI:

**Utility Functions** (dashboard.js):
- showConfirmModal() - Reusable confirmation modal with type-based colors
- showInputModal() - Text input modal for user prompts
- Both support keyboard shortcuts (ESC to cancel, Enter to submit)

**Alert Replacements** (22 instances):
- Validation warnings → toast.warning() (amber, 2s)
- Error messages → toast.error() (red, 5s)
- Success messages → toast.success() or toast.warning() with details
- Example: "Bulk close: 5 succeeded, 2 failed" vs simple "Operation complete"

**Confirm Replacements** (3 instances):
- dashboard.js:509 - Bulk close confirmation → showConfirmModal()
- ticket.js:417 - Status change warning → showConfirmModal()
- advanced-search.js:321 - Delete filter → showConfirmModal('error' type)

**Prompt Replacement** (1 instance):
- advanced-search.js:151 - Save filter name → showInputModal()

**Benefits**:
✓ Visual consistency - matches terminal CRT aesthetic
✓ Non-blocking - toasts don't interrupt workflow
✓ Better UX - different colors for different message types
✓ Keyboard friendly - ESC/Enter support in modals
✓ Reusable - modal functions available for future use

All dialogs maintain retro aesthetic with:
- ASCII borders (╚ ╝)
- Terminal green glow
- Monospace fonts
- Color-coded by type (amber warning, red error, cyan info)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 16:54:02 -05:00
4 changed files with 363 additions and 112 deletions

View File

@@ -148,8 +148,15 @@ function resetAdvancedSearch() {
// Save current search as a filter
async function saveCurrentFilter() {
const filterName = prompt('Enter a name for this filter:');
if (!filterName || filterName.trim() === '') return;
showInputModal(
'Save Search Filter',
'Enter a name for this filter:',
'My Filter',
async (filterName) => {
if (!filterName || filterName.trim() === '') {
toast.warning('Filter name cannot be empty', 2000);
return;
}
const filterCriteria = getCurrentFilterCriteria();
@@ -169,21 +176,17 @@ async function saveCurrentFilter() {
const result = await response.json();
if (result.success) {
if (typeof toast !== 'undefined') {
toast.success(`Filter "${filterName}" saved successfully!`);
}
toast.success(`Filter "${filterName}" saved successfully!`, 3000);
loadSavedFilters();
} else {
if (typeof toast !== 'undefined') {
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'));
}
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'), 4000);
}
} catch (error) {
console.error('Error saving filter:', error);
if (typeof toast !== 'undefined') {
toast.error('Error saving filter');
toast.error('Error saving filter', 4000);
}
}
);
}
// Get current filter criteria from form
@@ -315,10 +318,11 @@ async function deleteSavedFilter() {
const filterId = selectedOption.value;
const filterName = selectedOption.textContent;
if (!confirm(`Are you sure you want to delete the filter "${filterName}"?`)) {
return;
}
showConfirmModal(
`Delete Filter "${filterName}"?`,
'This action cannot be undone.',
'error',
async () => {
try {
const response = await fetch('/api/saved_filters.php', {
method: 'DELETE',
@@ -332,22 +336,18 @@ async function deleteSavedFilter() {
const result = await response.json();
if (result.success) {
if (typeof toast !== 'undefined') {
toast.success('Filter deleted successfully');
}
toast.success('Filter deleted successfully', 3000);
loadSavedFilters();
resetAdvancedSearch();
} else {
if (typeof toast !== 'undefined') {
toast.error('Failed to delete filter');
}
toast.error('Failed to delete filter', 4000);
}
} catch (error) {
console.error('Error deleting filter:', error);
if (typeof toast !== 'undefined') {
toast.error('Error deleting filter');
toast.error('Error deleting filter', 4000);
}
}
);
}
// Keyboard shortcut (Ctrl+Shift+F)

View File

@@ -342,12 +342,12 @@ function quickSave() {
} else {
console.error('Error updating ticket:', result.error || 'Unknown error');
alert('Error updating ticket: ' + (result.error || 'Unknown error'));
toast.error('Error updating ticket: ' + (result.error || 'Unknown error'), 5000);
}
})
.catch(error => {
console.error('Error updating ticket:', error);
alert('Error updating ticket: ' + error.message);
toast.error('Error updating ticket: ' + error.message, 5000);
});
}
@@ -446,12 +446,12 @@ function loadTemplate() {
console.log('Template loaded:', template.template_name);
} else {
console.error('Failed to load template:', data.error);
alert('Failed to load template: ' + (data.error || 'Unknown error'));
toast.error('Failed to load template: ' + (data.error || 'Unknown error'), 4000);
}
})
.catch(error => {
console.error('Error loading template:', error);
alert('Error loading template: ' + error.message);
toast.error('Error loading template: ' + error.message, 4000);
});
}
@@ -502,14 +502,20 @@ function bulkClose() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
alert('No tickets selected');
toast.warning('No tickets selected', 2000);
return;
}
if (!confirm(`Close ${ticketIds.length} ticket(s)?`)) {
return;
showConfirmModal(
`Close ${ticketIds.length} Ticket(s)?`,
'Are you sure you want to close these tickets?',
'warning',
() => performBulkCloseAction(ticketIds)
);
}
function performBulkCloseAction(ticketIds) {
fetch('/api/bulk_operation.php', {
method: 'POST',
headers: {
@@ -524,15 +530,19 @@ function bulkClose() {
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Bulk Close Complete:\n${data.processed} succeeded\n${data.failed} failed`);
window.location.reload();
if (data.failed > 0) {
toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
alert('Error: ' + (data.error || 'Unknown error'));
toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
console.error('Error performing bulk close:', error);
alert('Error performing bulk close: ' + error.message);
toast.error('Bulk close failed: ' + error.message, 5000);
});
}
@@ -540,7 +550,7 @@ function showBulkAssignModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
alert('No tickets selected');
toast.warning('No tickets selected', 2000);
return;
}
@@ -610,7 +620,7 @@ function performBulkAssign() {
const ticketIds = getSelectedTicketIds();
if (!userId) {
alert('Please select a user');
toast.warning('Please select a user', 2000);
return;
}
@@ -629,16 +639,20 @@ function performBulkAssign() {
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Bulk Assign Complete:\n${data.processed} succeeded\n${data.failed} failed`);
closeBulkAssignModal();
window.location.reload();
if (data.failed > 0) {
toast.warning(`Bulk assign: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
alert('Error: ' + (data.error || 'Unknown error'));
toast.success(`Successfully assigned ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
console.error('Error performing bulk assign:', error);
alert('Error performing bulk assign: ' + error.message);
toast.error('Bulk assign failed: ' + error.message, 5000);
});
}
@@ -646,7 +660,7 @@ function showBulkPriorityModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
alert('No tickets selected');
toast.warning('No tickets selected', 2000);
return;
}
@@ -701,7 +715,7 @@ function performBulkPriority() {
const ticketIds = getSelectedTicketIds();
if (!priority) {
alert('Please select a priority');
toast.warning('Please select a priority', 2000);
return;
}
@@ -720,16 +734,20 @@ function performBulkPriority() {
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Bulk Priority Update Complete:\n${data.processed} succeeded\n${data.failed} failed`);
closeBulkPriorityModal();
window.location.reload();
if (data.failed > 0) {
toast.warning(`Priority update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
alert('Error: ' + (data.error || 'Unknown error'));
toast.success(`Successfully updated priority for ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
console.error('Error performing bulk priority update:', error);
alert('Error performing bulk priority update: ' + error.message);
toast.error('Bulk priority update failed: ' + error.message, 5000);
});
}
@@ -777,7 +795,7 @@ function showBulkStatusModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
alert('No tickets selected');
toast.warning('No tickets selected', 2000);
return;
}
@@ -831,7 +849,7 @@ function performBulkStatusChange() {
const ticketIds = getSelectedTicketIds();
if (!status) {
alert('Please select a status');
toast.warning('Please select a status', 2000);
return;
}
@@ -851,14 +869,19 @@ function performBulkStatusChange() {
.then(data => {
closeBulkStatusModal();
if (data.success) {
window.location.reload();
if (data.failed > 0) {
toast.warning(`Status update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
} else {
alert('Error: ' + (data.error || 'Unknown error'));
toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
}
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
console.error('Error performing bulk status change:', error);
alert('Error performing bulk status change: ' + error.message);
toast.error('Bulk status change failed: ' + error.message, 5000);
});
}
@@ -867,7 +890,7 @@ function showBulkDeleteModal() {
const ticketIds = getSelectedTicketIds();
if (ticketIds.length === 0) {
alert('No tickets selected');
toast.warning('No tickets selected', 2000);
return;
}
@@ -933,16 +956,196 @@ function performBulkDelete() {
.then(data => {
closeBulkDeleteModal();
if (data.success) {
if (typeof toast !== 'undefined') {
toast.success(`Successfully deleted ${ticketIds.length} ticket(s)`);
}
setTimeout(() => window.location.reload(), 1000);
toast.success(`Successfully deleted ${ticketIds.length} ticket(s)`, 4000);
setTimeout(() => window.location.reload(), 1500);
} else {
alert('Error: ' + (data.error || 'Unknown error'));
toast.error('Error: ' + (data.error || 'Unknown error'), 5000);
}
})
.catch(error => {
console.error('Error performing bulk delete:', error);
alert('Error performing bulk delete: ' + error.message);
toast.error('Bulk delete failed: ' + error.message, 5000);
});
}
// ============================================
// TERMINAL-STYLE MODAL UTILITIES
// ============================================
/**
* Show a terminal-style confirmation modal
* @param {string} title - Modal title
* @param {string} message - Message body
* @param {string} type - 'warning', 'error', 'info' (affects color)
* @param {function} onConfirm - Callback when user confirms
* @param {function} onCancel - Optional callback when user cancels
*/
function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
const modalId = 'confirmModal' + Date.now();
// Color scheme based on type
const colors = {
warning: 'var(--terminal-amber)',
error: 'var(--status-closed)',
info: 'var(--terminal-cyan)'
};
const color = colors[type] || colors.warning;
// Icon based on type
const icons = {
warning: '⚠',
error: '✗',
info: ''
};
const icon = icons[type] || icons.warning;
const modalHtml = `
<div class="modal-overlay" id="${modalId}">
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header" style="color: ${color};">
${icon} ${title}
</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="modal-body" style="padding: 1.5rem; text-align: center;">
<p style="color: var(--terminal-green); white-space: pre-line;">
${message}
</p>
</div>
</div>
</div>
<div class="ascii-divider"></div>
<div class="ascii-content">
<div class="modal-footer">
<button class="btn btn-primary" id="${modalId}_confirm">Confirm</button>
<button class="btn btn-secondary" id="${modalId}_cancel">Cancel</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById(modalId);
const confirmBtn = document.getElementById(`${modalId}_confirm`);
const cancelBtn = document.getElementById(`${modalId}_cancel`);
confirmBtn.addEventListener('click', () => {
modal.remove();
if (onConfirm) onConfirm();
});
cancelBtn.addEventListener('click', () => {
modal.remove();
if (onCancel) onCancel();
});
// ESC key to cancel
const escHandler = (e) => {
if (e.key === 'Escape') {
modal.remove();
if (onCancel) onCancel();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
}
/**
* Show a terminal-style input modal
* @param {string} title - Modal title
* @param {string} label - Input field label
* @param {string} placeholder - Input placeholder text
* @param {function} onSubmit - Callback with input value when submitted
* @param {function} onCancel - Optional callback when cancelled
*/
function showInputModal(title, label, placeholder = '', onSubmit, onCancel = null) {
const modalId = 'inputModal' + Date.now();
const inputId = modalId + '_input';
const modalHtml = `
<div class="modal-overlay" id="${modalId}">
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">
${title}
</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="modal-body" style="padding: 1.5rem;">
<label for="${inputId}" style="display: block; margin-bottom: 0.5rem; color: var(--terminal-green);">
${label}
</label>
<input
type="text"
id="${inputId}"
class="terminal-input"
placeholder="${placeholder}"
style="width: 100%; padding: 0.5rem; background: var(--bg-primary); border: 1px solid var(--terminal-green); color: var(--terminal-green); font-family: var(--font-mono);"
/>
</div>
</div>
</div>
<div class="ascii-divider"></div>
<div class="ascii-content">
<div class="modal-footer">
<button class="btn btn-primary" id="${modalId}_submit">Save</button>
<button class="btn btn-secondary" id="${modalId}_cancel">Cancel</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = document.getElementById(modalId);
const input = document.getElementById(inputId);
const submitBtn = document.getElementById(`${modalId}_submit`);
const cancelBtn = document.getElementById(`${modalId}_cancel`);
// Focus input
setTimeout(() => input.focus(), 100);
const handleSubmit = () => {
const value = input.value.trim();
modal.remove();
if (onSubmit) onSubmit(value);
};
submitBtn.addEventListener('click', handleSubmit);
// Enter key to submit
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleSubmit();
}
});
cancelBtn.addEventListener('click', () => {
modal.remove();
if (onCancel) onCancel();
});
// ESC key to cancel
const escHandler = (e) => {
if (e.key === 'Escape') {
modal.remove();
if (onCancel) onCancel();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
}

View File

@@ -414,14 +414,27 @@ function updateTicketStatus() {
// Warn if comment is required
if (requiresComment) {
const proceed = confirm(`This status change requires a comment. Please add a comment explaining the reason for this transition.\n\nProceed with status change to "${newStatus}"?`);
if (!proceed) {
// Reset to current status
showConfirmModal(
'Status Change Requires Comment',
`This transition to "${newStatus}" requires a comment explaining the reason.\n\nPlease add a comment before changing the status.`,
'warning',
() => {
// User confirmed, proceed with status change
performStatusChange(statusSelect, newStatus);
},
() => {
// User cancelled, reset to current status
statusSelect.selectedIndex = 0;
}
);
return;
}
}
performStatusChange(statusSelect, newStatus);
});
// Extract status change logic into reusable function
function performStatusChange(statusSelect, newStatus) {
// Extract ticket ID
let ticketId;
if (window.location.href.includes('?id=')) {

View File

@@ -1,17 +1,26 @@
/**
* Terminal-style toast notification system
* Terminal-style toast notification system with queuing
*/
// Toast queue management
let toastQueue = [];
let currentToast = null;
function showToast(message, type = 'info', duration = 3000) {
// Remove any existing toasts
const existingToast = document.querySelector('.terminal-toast');
if (existingToast) {
existingToast.remove();
// Queue if a toast is already showing
if (currentToast) {
toastQueue.push({ message, type, duration });
return;
}
displayToast(message, type, duration);
}
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 = {
@@ -24,6 +33,7 @@ function showToast(message, type = 'info', duration = 3000) {
toast.innerHTML = `
<span class="toast-icon">[${icons[type] || ''}]</span>
<span class="toast-message">${message}</span>
<span class="toast-close" style="margin-left: auto; cursor: pointer; opacity: 0.7; padding-left: 1rem;">[×]</span>
`;
// Add to document
@@ -32,11 +42,36 @@ function showToast(message, type = 'info', duration = 3000) {
// 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
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
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