feat: Major updates to backend structure and security enhancements
- Removed `COMMON_ERRORS.md` file to streamline documentation. - Added `Flask-Limiter` for rate limiting and `redis` for session management in `requirements.txt`. - Expanded `ROADMAP.md` to include completed security features and planned enhancements for version 2.2. - Enhanced `setup_myp.sh` for ultra-secure kiosk installation, including system hardening and security configurations. - Updated `app.py` to integrate CSRF protection and improved logging setup. - Refactored user model to include username and active status for better user management. - Improved job scheduler with uptime tracking and task management features. - Updated various templates for a more cohesive user interface and experience.
This commit is contained in:
584
backend/app/static/js/admin-dashboard.js
Normal file
584
backend/app/static/js/admin-dashboard.js
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* MYP Admin Dashboard
|
||||
* Core JavaScript für das Admin-Dashboard
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize navigation
|
||||
initNavigation();
|
||||
|
||||
// Initialize modal events
|
||||
initModals();
|
||||
|
||||
// Load initial data
|
||||
loadDashboardData();
|
||||
});
|
||||
|
||||
/**
|
||||
* Navigation Initialization
|
||||
*/
|
||||
function initNavigation() {
|
||||
// Desktop navigation
|
||||
const desktopNavItems = document.querySelectorAll('.admin-nav-item');
|
||||
desktopNavItems.forEach(item => {
|
||||
item.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const section = this.getAttribute('data-section');
|
||||
activateSection(section);
|
||||
updateActiveNavItem(this, desktopNavItems);
|
||||
});
|
||||
});
|
||||
|
||||
// Mobile navigation
|
||||
const mobileNavItems = document.querySelectorAll('.mobile-nav-item');
|
||||
mobileNavItems.forEach(item => {
|
||||
item.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const section = this.getAttribute('data-section');
|
||||
activateSection(section);
|
||||
updateActiveNavItem(this, mobileNavItems);
|
||||
closeMobileNav();
|
||||
});
|
||||
});
|
||||
|
||||
// Mobile menu toggle
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const closeMobileNavButton = document.getElementById('close-mobile-nav');
|
||||
|
||||
if (mobileMenuButton) {
|
||||
mobileMenuButton.addEventListener('click', openMobileNav);
|
||||
}
|
||||
|
||||
if (closeMobileNavButton) {
|
||||
closeMobileNavButton.addEventListener('click', closeMobileNav);
|
||||
}
|
||||
|
||||
// Setup hash navigation
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
if (window.location.hash) {
|
||||
handleHashChange();
|
||||
}
|
||||
}
|
||||
|
||||
function activateSection(section) {
|
||||
// Hide all sections
|
||||
document.querySelectorAll('.admin-section').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
el.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Show selected section
|
||||
const targetSection = document.getElementById(`${section}-section`);
|
||||
if (targetSection) {
|
||||
targetSection.classList.remove('hidden');
|
||||
targetSection.classList.add('active');
|
||||
|
||||
// Load section data if needed
|
||||
switch(section) {
|
||||
case 'dashboard':
|
||||
loadDashboardData();
|
||||
break;
|
||||
case 'users':
|
||||
loadUsers();
|
||||
break;
|
||||
case 'printers':
|
||||
loadPrinters();
|
||||
break;
|
||||
case 'scheduler':
|
||||
loadSchedulerStatus();
|
||||
break;
|
||||
case 'logs':
|
||||
loadLogs();
|
||||
break;
|
||||
}
|
||||
|
||||
// Update URL hash
|
||||
window.location.hash = section;
|
||||
}
|
||||
}
|
||||
|
||||
function updateActiveNavItem(activeItem, allItems) {
|
||||
// Remove active class from all items
|
||||
allItems.forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
|
||||
// Add active class to selected item
|
||||
activeItem.classList.add('active');
|
||||
}
|
||||
|
||||
function handleHashChange() {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash) {
|
||||
const navItem = document.querySelector(`.admin-nav-item[data-section="${hash}"]`);
|
||||
if (navItem) {
|
||||
activateSection(hash);
|
||||
updateActiveNavItem(navItem, document.querySelectorAll('.admin-nav-item'));
|
||||
|
||||
// Also update mobile nav
|
||||
const mobileNavItem = document.querySelector(`.mobile-nav-item[data-section="${hash}"]`);
|
||||
if (mobileNavItem) {
|
||||
updateActiveNavItem(mobileNavItem, document.querySelectorAll('.mobile-nav-item'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openMobileNav() {
|
||||
const mobileNav = document.getElementById('mobile-nav');
|
||||
if (mobileNav) {
|
||||
mobileNav.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function closeMobileNav() {
|
||||
const mobileNav = document.getElementById('mobile-nav');
|
||||
if (mobileNav) {
|
||||
mobileNav.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal Initialization
|
||||
*/
|
||||
function initModals() {
|
||||
// Delete modal
|
||||
const deleteModal = document.getElementById('delete-modal');
|
||||
const closeDeleteModalBtn = document.getElementById('close-delete-modal');
|
||||
const cancelDeleteBtn = document.getElementById('cancel-delete-btn');
|
||||
|
||||
if (closeDeleteModalBtn) {
|
||||
closeDeleteModalBtn.addEventListener('click', closeDeleteModal);
|
||||
}
|
||||
|
||||
if (cancelDeleteBtn) {
|
||||
cancelDeleteBtn.addEventListener('click', closeDeleteModal);
|
||||
}
|
||||
|
||||
// Toast notification
|
||||
const closeToastBtn = document.getElementById('close-toast');
|
||||
if (closeToastBtn) {
|
||||
closeToastBtn.addEventListener('click', closeToast);
|
||||
}
|
||||
|
||||
// Global refresh button
|
||||
const refreshAllBtn = document.getElementById('refresh-all-btn');
|
||||
if (refreshAllBtn) {
|
||||
refreshAllBtn.addEventListener('click', refreshAllData);
|
||||
}
|
||||
}
|
||||
|
||||
function showDeleteModal(message, onConfirm) {
|
||||
const modal = document.getElementById('delete-modal');
|
||||
const messageEl = document.getElementById('delete-message');
|
||||
const confirmBtn = document.getElementById('confirm-delete-btn');
|
||||
|
||||
if (modal && messageEl && confirmBtn) {
|
||||
messageEl.textContent = message;
|
||||
modal.classList.add('modal-show');
|
||||
|
||||
// Setup confirm button action
|
||||
confirmBtn.onclick = function() {
|
||||
closeDeleteModal();
|
||||
if (typeof onConfirm === 'function') {
|
||||
onConfirm();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
const modal = document.getElementById('delete-modal');
|
||||
if (modal) {
|
||||
modal.classList.remove('modal-show');
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.getElementById('toast-notification');
|
||||
const messageEl = document.getElementById('toast-message');
|
||||
const iconEl = document.getElementById('toast-icon');
|
||||
|
||||
if (toast && messageEl && iconEl) {
|
||||
messageEl.textContent = message;
|
||||
|
||||
// Set icon based on type
|
||||
const iconSvg = getToastIcon(type);
|
||||
iconEl.innerHTML = iconSvg;
|
||||
|
||||
// Show toast
|
||||
toast.classList.add('toast-show');
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(closeToast, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function closeToast() {
|
||||
const toast = document.getElementById('toast-notification');
|
||||
if (toast) {
|
||||
toast.classList.remove('toast-show');
|
||||
}
|
||||
}
|
||||
|
||||
function getToastIcon(type) {
|
||||
switch(type) {
|
||||
case 'success':
|
||||
return '<svg class="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>';
|
||||
case 'error':
|
||||
return '<svg class="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>';
|
||||
case 'warning':
|
||||
return '<svg class="h-6 w-6 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>';
|
||||
case 'info':
|
||||
default:
|
||||
return '<svg class="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Data Loading
|
||||
*/
|
||||
function loadDashboardData() {
|
||||
// Load dashboard stats
|
||||
loadStats();
|
||||
|
||||
// Load recent activity
|
||||
loadRecentActivity();
|
||||
|
||||
// Load system status
|
||||
loadSystemStatus();
|
||||
|
||||
// Setup refresh buttons
|
||||
const refreshActivityBtn = document.getElementById('refresh-activity-btn');
|
||||
if (refreshActivityBtn) {
|
||||
refreshActivityBtn.addEventListener('click', loadRecentActivity);
|
||||
}
|
||||
|
||||
const refreshSystemBtn = document.getElementById('refresh-system-btn');
|
||||
if (refreshSystemBtn) {
|
||||
refreshSystemBtn.addEventListener('click', loadSystemStatus);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await fetch('/api/stats');
|
||||
const data = await response.json();
|
||||
|
||||
// Update dashboard counters
|
||||
document.getElementById('total-users-count').textContent = data.total_users || 0;
|
||||
document.getElementById('total-printers-count').textContent = data.total_printers || 0;
|
||||
document.getElementById('active-jobs-count').textContent = data.active_jobs || 0;
|
||||
|
||||
// Update scheduler status
|
||||
updateSchedulerStatusIndicator(data.scheduler_status || false);
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error);
|
||||
showToast('Fehler beim Laden der Statistiken', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateSchedulerStatusIndicator(isRunning) {
|
||||
const statusText = document.getElementById('scheduler-status');
|
||||
const indicator = document.getElementById('scheduler-indicator');
|
||||
|
||||
if (statusText && indicator) {
|
||||
if (isRunning) {
|
||||
statusText.textContent = 'Aktiv';
|
||||
statusText.classList.add('text-green-600', 'dark:text-green-400');
|
||||
statusText.classList.remove('text-red-600', 'dark:text-red-400');
|
||||
|
||||
indicator.classList.add('bg-green-500');
|
||||
indicator.classList.remove('bg-red-500', 'bg-gray-300');
|
||||
} else {
|
||||
statusText.textContent = 'Inaktiv';
|
||||
statusText.classList.add('text-red-600', 'dark:text-red-400');
|
||||
statusText.classList.remove('text-green-600', 'dark:text-green-400');
|
||||
|
||||
indicator.classList.add('bg-red-500');
|
||||
indicator.classList.remove('bg-green-500', 'bg-gray-300');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentActivity() {
|
||||
const container = document.getElementById('recent-activity-container');
|
||||
if (!container) return;
|
||||
|
||||
// Show loading state
|
||||
container.innerHTML = `
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<svg class="animate-spin h-8 w-8 text-accent-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/activity/recent');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.activities && data.activities.length > 0) {
|
||||
const activities = data.activities;
|
||||
const html = activities.map(activity => `
|
||||
<div class="p-3 rounded-lg bg-light-surface dark:bg-dark-surface border border-light-border dark:border-dark-border">
|
||||
<div class="flex items-start">
|
||||
<div class="w-2 h-2 rounded-full bg-accent-primary mt-2 mr-3 flex-shrink-0"></div>
|
||||
<div>
|
||||
<p class="text-sm text-light-text dark:text-dark-text">${activity.description}</p>
|
||||
<p class="text-xs text-light-text-muted dark:text-dark-text-muted mt-1">
|
||||
${formatDateTime(activity.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = html;
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<p class="text-light-text-muted dark:text-dark-text-muted">Keine Aktivitäten gefunden</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading activities:', error);
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<p class="text-red-600 dark:text-red-400">Fehler beim Laden der Aktivitäten</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystemStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/stats');
|
||||
const data = await response.json();
|
||||
|
||||
// Update system stats
|
||||
document.getElementById('total-print-time').textContent =
|
||||
formatPrintTime(data.total_print_time_hours);
|
||||
document.getElementById('completed-jobs-count').textContent =
|
||||
data.total_jobs_completed || 0;
|
||||
document.getElementById('total-material-used').textContent =
|
||||
formatMaterialUsed(data.total_material_used);
|
||||
document.getElementById('last-updated-time').textContent =
|
||||
data.last_updated ? formatDateTime(data.last_updated) : '-';
|
||||
} catch (error) {
|
||||
console.error('Error loading system status:', error);
|
||||
showToast('Fehler beim Laden des Systemstatus', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatPrintTime(hours) {
|
||||
if (!hours) return '-';
|
||||
return `${hours} Stunden`;
|
||||
}
|
||||
|
||||
function formatMaterialUsed(grams) {
|
||||
if (!grams) return '-';
|
||||
return `${grams} g`;
|
||||
}
|
||||
|
||||
function formatDateTime(dateString) {
|
||||
if (!dateString) return '-';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Global refresh function
|
||||
*/
|
||||
function refreshAllData() {
|
||||
// Get active section
|
||||
const activeSection = document.querySelector('.admin-section.active');
|
||||
if (activeSection) {
|
||||
const sectionId = activeSection.id;
|
||||
const section = sectionId.replace('-section', '');
|
||||
|
||||
// Reload data based on active section
|
||||
switch(section) {
|
||||
case 'dashboard':
|
||||
loadDashboardData();
|
||||
break;
|
||||
case 'users':
|
||||
loadUsers();
|
||||
break;
|
||||
case 'printers':
|
||||
loadPrinters();
|
||||
break;
|
||||
case 'scheduler':
|
||||
loadSchedulerStatus();
|
||||
break;
|
||||
case 'logs':
|
||||
loadLogs();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
showToast('Daten aktualisiert', 'success');
|
||||
}
|
||||
/**
|
||||
* Benutzer laden und anzeigen
|
||||
*/
|
||||
function loadUsers() {
|
||||
const usersContainer = document.getElementById('users-container');
|
||||
if (!usersContainer) return;
|
||||
|
||||
// Lade-Animation anzeigen
|
||||
usersContainer.innerHTML = `
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 dark:border-indigo-400"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Benutzer vom Server laden
|
||||
fetch('/api/users')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Fehler beim Laden der Benutzer');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
renderUsers(data.users);
|
||||
updateUserStatistics(data.users);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler beim Laden der Benutzer:', error);
|
||||
usersContainer.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<div class="text-red-600 dark:text-red-400 text-xl mb-2">Fehler beim Laden der Benutzer</div>
|
||||
<p class="text-gray-600 dark:text-gray-400">${error.message}</p>
|
||||
<button onclick="loadUsers()" class="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drucker laden und anzeigen
|
||||
*/
|
||||
function loadPrinters() {
|
||||
const printersContainer = document.getElementById('printers-container');
|
||||
if (!printersContainer) return;
|
||||
|
||||
// Lade-Animation anzeigen
|
||||
printersContainer.innerHTML = `
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 dark:border-indigo-400"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Drucker vom Server laden
|
||||
fetch('/api/printers')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Fehler beim Laden der Drucker');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
renderPrinters(data.printers);
|
||||
updatePrinterStatistics(data.printers);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler beim Laden der Drucker:', error);
|
||||
printersContainer.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<div class="text-red-600 dark:text-red-400 text-xl mb-2">Fehler beim Laden der Drucker</div>
|
||||
<p class="text-gray-600 dark:text-gray-400">${error.message}</p>
|
||||
<button onclick="loadPrinters()" class="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduler-Status laden und anzeigen
|
||||
*/
|
||||
function loadSchedulerStatus() {
|
||||
const schedulerContainer = document.getElementById('scheduler-container');
|
||||
if (!schedulerContainer) return;
|
||||
|
||||
// Lade-Animation anzeigen
|
||||
schedulerContainer.innerHTML = `
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 dark:border-indigo-400"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Scheduler-Status vom Server laden
|
||||
fetch('/api/scheduler/status')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Fehler beim Laden des Scheduler-Status');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
renderSchedulerStatus(data);
|
||||
updateSchedulerControls(data.active);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler beim Laden des Scheduler-Status:', error);
|
||||
schedulerContainer.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<div class="text-red-600 dark:text-red-400 text-xl mb-2">Fehler beim Laden des Scheduler-Status</div>
|
||||
<p class="text-gray-600 dark:text-gray-400">${error.message}</p>
|
||||
<button onclick="loadSchedulerStatus()" class="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs laden und anzeigen
|
||||
*/
|
||||
function loadLogs() {
|
||||
const logsContainer = document.getElementById('logs-container');
|
||||
if (!logsContainer) return;
|
||||
|
||||
// Lade-Animation anzeigen
|
||||
logsContainer.innerHTML = `
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 dark:border-indigo-400"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Logs vom Server laden
|
||||
fetch('/api/logs')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Fehler beim Laden der Logs');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
window.logsData = data.logs;
|
||||
window.filteredLogs = [...data.logs];
|
||||
renderLogs();
|
||||
updateLogStatistics();
|
||||
scrollLogsToBottom();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler beim Laden der Logs:', error);
|
||||
logsContainer.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<div class="text-red-600 dark:text-red-400 text-xl mb-2">Fehler beim Laden der Logs</div>
|
||||
<p class="text-gray-600 dark:text-gray-400">${error.message}</p>
|
||||
<button onclick="loadLogs()" class="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
1080
backend/app/static/js/admin-panel.js
Normal file
1080
backend/app/static/js/admin-panel.js
Normal file
File diff suppressed because it is too large
Load Diff
64
backend/app/static/js/admin.js
Normal file
64
backend/app/static/js/admin.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Admin Dashboard JavaScript
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize progress bars
|
||||
initProgressBars();
|
||||
|
||||
// Initialize confirmation dialogs for delete buttons
|
||||
initConfirmDialogs();
|
||||
|
||||
// Add automatic fade-out for flash messages after 5 seconds
|
||||
initFlashMessages();
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize progress bars by setting the width based on data-width attribute
|
||||
*/
|
||||
function initProgressBars() {
|
||||
const progressBars = document.querySelectorAll('.progress-bar-fill');
|
||||
|
||||
progressBars.forEach(bar => {
|
||||
const width = bar.getAttribute('data-width');
|
||||
if (width) {
|
||||
// Use setTimeout to allow for a smooth animation
|
||||
setTimeout(() => {
|
||||
bar.style.width = `${width}%`;
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize confirmation dialogs for delete buttons
|
||||
*/
|
||||
function initConfirmDialogs() {
|
||||
const confirmForms = document.querySelectorAll('form[onsubmit*="confirm"]');
|
||||
|
||||
confirmForms.forEach(form => {
|
||||
form.onsubmit = function() {
|
||||
const message = this.getAttribute('onsubmit').match(/confirm\('([^']+)'\)/)[1];
|
||||
return confirm(message);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auto-hide for flash messages
|
||||
*/
|
||||
function initFlashMessages() {
|
||||
const flashMessages = document.querySelectorAll('.flash-messages .alert');
|
||||
|
||||
flashMessages.forEach(message => {
|
||||
// Auto-hide messages after 5 seconds
|
||||
setTimeout(() => {
|
||||
message.style.opacity = '0';
|
||||
message.style.transition = 'opacity 0.5s ease';
|
||||
|
||||
// Remove from DOM after fade out
|
||||
setTimeout(() => {
|
||||
message.remove();
|
||||
}, 500);
|
||||
}, 5000);
|
||||
});
|
||||
}
|
14
backend/app/static/js/charts/apexcharts.min.js
vendored
Normal file
14
backend/app/static/js/charts/apexcharts.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
291
backend/app/static/js/charts/chart-adapter.js
Normal file
291
backend/app/static/js/charts/chart-adapter.js
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* MYP Platform Chart-Adapter
|
||||
* Verbindet bestehende API/Logik mit ApexCharts
|
||||
* Version: 1.0.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Überbrückt die bestehende renderChart Funktion mit der neuen ApexCharts Implementation
|
||||
* @param {HTMLElement} container - Der Diagramm-Container
|
||||
* @param {Object} data - Die Diagrammdaten vom API-Endpunkt
|
||||
*/
|
||||
function renderChart(container, data) {
|
||||
// Überprüfen, ob die Daten vorhanden sind
|
||||
if (!data || (Array.isArray(data) && data.length === 0) || Object.keys(data).length === 0) {
|
||||
showEmptyState(container, 'Keine Daten', 'Es sind keine Daten für dieses Diagramm verfügbar.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Container-ID überprüfen und ggf. generieren
|
||||
if (!container.id) {
|
||||
container.id = 'chart-' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
// Chart-Typ basierend auf Container-ID oder Attributen bestimmen
|
||||
let chartType = container.getAttribute('data-chart-type');
|
||||
if (!chartType) {
|
||||
if (container.id === 'job-status-chart') {
|
||||
chartType = 'pie';
|
||||
container.setAttribute('data-chart-type', 'pie');
|
||||
} else if (container.id === 'printer-usage-chart') {
|
||||
chartType = 'bar';
|
||||
container.setAttribute('data-chart-type', 'bar');
|
||||
} else if (container.id.includes('line')) {
|
||||
chartType = 'line';
|
||||
container.setAttribute('data-chart-type', 'line');
|
||||
} else if (container.id.includes('area')) {
|
||||
chartType = 'area';
|
||||
container.setAttribute('data-chart-type', 'area');
|
||||
} else if (container.id.includes('bar')) {
|
||||
chartType = 'bar';
|
||||
container.setAttribute('data-chart-type', 'bar');
|
||||
} else if (container.id.includes('pie')) {
|
||||
chartType = 'pie';
|
||||
container.setAttribute('data-chart-type', 'pie');
|
||||
} else if (container.id.includes('donut')) {
|
||||
chartType = 'donut';
|
||||
container.setAttribute('data-chart-type', 'donut');
|
||||
} else {
|
||||
// Standard-Typ
|
||||
chartType = 'line';
|
||||
container.setAttribute('data-chart-type', 'line');
|
||||
}
|
||||
}
|
||||
|
||||
// Daten für das Diagramm vorbereiten
|
||||
let chartData = {};
|
||||
|
||||
// Daten-Transformation basierend auf Container-ID
|
||||
if (container.id === 'job-status-chart') {
|
||||
chartData = transformJobStatusData(data);
|
||||
} else if (container.id === 'printer-usage-chart') {
|
||||
chartData = transformPrinterUsageData(data);
|
||||
} else {
|
||||
// Generischer Ansatz für andere Diagrammtypen
|
||||
chartData = transformGenericData(data, chartType);
|
||||
}
|
||||
|
||||
// Existierendes Chart zerstören, falls vorhanden
|
||||
if (activeCharts[container.id]) {
|
||||
destroyChart(container.id);
|
||||
}
|
||||
|
||||
// Daten als Attribut am Container speichern
|
||||
container.setAttribute('data-chart-data', JSON.stringify(chartData));
|
||||
|
||||
// Neues Chart erstellen
|
||||
createChart(container, chartType, chartData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformiert Job-Status-Daten für Pie-Chart
|
||||
* @param {Object} data - Rohdaten vom API-Endpunkt
|
||||
* @returns {Object} - Formatierte Daten für ApexCharts
|
||||
*/
|
||||
function transformJobStatusData(data) {
|
||||
// Werte für Pie-Chart extrahieren
|
||||
const series = [
|
||||
data.scheduled || 0,
|
||||
data.active || 0,
|
||||
data.completed || 0,
|
||||
data.cancelled || 0
|
||||
];
|
||||
|
||||
// Labels für die Diagramm-Segmente
|
||||
const labels = ['Geplant', 'Aktiv', 'Abgeschlossen', 'Abgebrochen'];
|
||||
|
||||
// Benutzerdefinierte Farben
|
||||
const colors = [
|
||||
MYP_CHART_COLORS.info, // Blau für geplant
|
||||
MYP_CHART_COLORS.primary, // Primär für aktiv
|
||||
MYP_CHART_COLORS.success, // Grün für abgeschlossen
|
||||
MYP_CHART_COLORS.warning // Gelb für abgebrochen
|
||||
];
|
||||
|
||||
// Zusätzliche Optionen
|
||||
const options = {
|
||||
colors: colors,
|
||||
chart: {
|
||||
height: 320
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: '0%' // Vollständiger Kreis (kein Donut)
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
series: series,
|
||||
labels: labels,
|
||||
options: options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformiert Drucker-Nutzungsdaten für Bar-Chart
|
||||
* @param {Object} data - Rohdaten vom API-Endpunkt
|
||||
* @returns {Object} - Formatierte Daten für ApexCharts
|
||||
*/
|
||||
function transformPrinterUsageData(data) {
|
||||
// Prüfen, ob Daten ein Array sind
|
||||
if (!Array.isArray(data)) {
|
||||
console.error('Drucker-Nutzungsdaten müssen ein Array sein:', data);
|
||||
return {
|
||||
series: [],
|
||||
categories: [],
|
||||
options: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Druckernamen für X-Achse extrahieren
|
||||
const categories = data.map(item => item.printer_name || 'Unbekannt');
|
||||
|
||||
// Datenreihen für Jobs und Stunden erstellen
|
||||
const jobsSeries = {
|
||||
name: 'Jobs',
|
||||
data: data.map(item => item.job_count || 0)
|
||||
};
|
||||
|
||||
const hoursSeries = {
|
||||
name: 'Stunden',
|
||||
data: data.map(item => Math.round((item.print_hours || 0) * 10) / 10)
|
||||
};
|
||||
|
||||
// Zusätzliche Optionen
|
||||
const options = {
|
||||
colors: [MYP_CHART_COLORS.primary, MYP_CHART_COLORS.success],
|
||||
chart: {
|
||||
height: 320
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
columnWidth: '60%',
|
||||
borderRadius: 4
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
xaxis: {
|
||||
categories: categories
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
series: [jobsSeries, hoursSeries],
|
||||
categories: categories,
|
||||
options: options
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformiert generische Daten für verschiedene Diagrammtypen
|
||||
* @param {Object|Array} data - Rohdaten vom API-Endpunkt
|
||||
* @param {string} chartType - Art des Diagramms
|
||||
* @returns {Object} - Formatierte Daten für ApexCharts
|
||||
*/
|
||||
function transformGenericData(data, chartType) {
|
||||
// Standard-Ergebnisobjekt
|
||||
const result = {
|
||||
series: [],
|
||||
categories: [],
|
||||
labels: [],
|
||||
options: {}
|
||||
};
|
||||
|
||||
// Arrays verarbeiten
|
||||
if (Array.isArray(data)) {
|
||||
if (chartType === 'pie' || chartType === 'donut' || chartType === 'radial') {
|
||||
// Für Kreisdiagramme
|
||||
result.series = data.map(item => item.value || 0);
|
||||
result.labels = data.map(item => item.name || item.label || 'Unbekannt');
|
||||
} else {
|
||||
// Für Linien-, Flächen- und Balkendiagramme
|
||||
// Annahme: Erste Datenreihe
|
||||
result.series = [{
|
||||
name: 'Werte',
|
||||
data: data.map(item => item.value || 0)
|
||||
}];
|
||||
result.categories = data.map(item => item.name || item.label || item.date || 'Unbekannt');
|
||||
}
|
||||
}
|
||||
// Objekte mit Datenreihen verarbeiten
|
||||
else if (data.series && Array.isArray(data.series)) {
|
||||
result.series = data.series;
|
||||
|
||||
if (data.categories) {
|
||||
result.categories = data.categories;
|
||||
}
|
||||
|
||||
if (data.labels) {
|
||||
result.labels = data.labels;
|
||||
}
|
||||
|
||||
if (data.options) {
|
||||
result.options = data.options;
|
||||
}
|
||||
}
|
||||
// Einfache Objekte verarbeiten
|
||||
else {
|
||||
// Für Kreisdiagramme: Alle Eigenschaften als Segmente verwenden
|
||||
if (chartType === 'pie' || chartType === 'donut' || chartType === 'radial') {
|
||||
const seriesData = [];
|
||||
const labelData = [];
|
||||
|
||||
Object.keys(data).forEach(key => {
|
||||
if (typeof data[key] === 'number') {
|
||||
seriesData.push(data[key]);
|
||||
labelData.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
result.series = seriesData;
|
||||
result.labels = labelData;
|
||||
}
|
||||
// Für andere Diagrammtypen: Versuchen, Zeit-/Wertepaare zu finden
|
||||
else {
|
||||
const timeKeys = [];
|
||||
const values = [];
|
||||
|
||||
Object.keys(data).forEach(key => {
|
||||
if (typeof data[key] === 'number') {
|
||||
timeKeys.push(key);
|
||||
values.push(data[key]);
|
||||
}
|
||||
});
|
||||
|
||||
result.series = [{
|
||||
name: 'Werte',
|
||||
data: values
|
||||
}];
|
||||
result.categories = timeKeys;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt einen leeren Status-Container an
|
||||
* @param {HTMLElement} container - Der Diagramm-Container
|
||||
* @param {string} title - Titel der Meldung
|
||||
* @param {string} message - Meldungstext
|
||||
*/
|
||||
function showEmptyState(container, title, message) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<svg class="w-12 h-12 mx-auto text-slate-500 dark:text-slate-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-slate-700 dark:text-slate-300">${title}</h3>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 italic mt-1">${message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
431
backend/app/static/js/charts/chart-config.js
Normal file
431
backend/app/static/js/charts/chart-config.js
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* MYP Platform Chart-Konfigurationen
|
||||
* Basierend auf ApexCharts Bibliothek
|
||||
* Version: 1.0.0
|
||||
*/
|
||||
|
||||
// Standard Farben für Diagramme
|
||||
const MYP_CHART_COLORS = {
|
||||
primary: '#3b82f6', // Blau
|
||||
secondary: '#8b5cf6', // Lila
|
||||
success: '#10b981', // Grün
|
||||
warning: '#f59e0b', // Orange
|
||||
danger: '#ef4444', // Rot
|
||||
info: '#06b6d4', // Türkis
|
||||
gray: '#6b7280', // Grau
|
||||
gradient: {
|
||||
blue: ['#3b82f6', '#93c5fd'],
|
||||
purple: ['#8b5cf6', '#c4b5fd'],
|
||||
green: ['#10b981', '#6ee7b7'],
|
||||
red: ['#ef4444', '#fca5a5'],
|
||||
orange: ['#f59e0b', '#fcd34d'],
|
||||
}
|
||||
};
|
||||
|
||||
// Gemeinsame Grundeinstellungen für alle Diagramme
|
||||
const getBaseChartOptions = () => {
|
||||
return {
|
||||
chart: {
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
toolbar: {
|
||||
show: false
|
||||
},
|
||||
zoom: {
|
||||
enabled: false
|
||||
},
|
||||
animations: {
|
||||
enabled: true,
|
||||
easing: 'easeinout',
|
||||
speed: 800,
|
||||
animateGradually: {
|
||||
enabled: true,
|
||||
delay: 150
|
||||
},
|
||||
dynamicAnimation: {
|
||||
enabled: true,
|
||||
speed: 350
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
theme: 'dark',
|
||||
style: {
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Inter, sans-serif'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
borderColor: '#334155',
|
||||
strokeDashArray: 4,
|
||||
position: 'back',
|
||||
xaxis: {
|
||||
lines: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
lines: {
|
||||
show: true
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
horizontalAlign: 'center',
|
||||
offsetY: 8,
|
||||
fontSize: '14px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
markers: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
strokeWidth: 0,
|
||||
radius: 4
|
||||
},
|
||||
itemMargin: {
|
||||
horizontal: 10,
|
||||
vertical: 0
|
||||
}
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 3
|
||||
},
|
||||
xaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
colors: '#94a3b8'
|
||||
}
|
||||
},
|
||||
axisBorder: {
|
||||
show: false
|
||||
},
|
||||
axisTicks: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
style: {
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
colors: '#94a3b8'
|
||||
}
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 768,
|
||||
options: {
|
||||
chart: {
|
||||
height: '300px'
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
offsetY: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Liniendiagramm Konfiguration
|
||||
* @param {Array} series - Datenreihen
|
||||
* @param {Array} categories - X-Achsen Kategorien
|
||||
* @param {Object} customOptions - Benutzerdefinierte Optionen
|
||||
* @returns {Object} Konfigurationsobjekt für ApexCharts
|
||||
*/
|
||||
function getLineChartConfig(series, categories, customOptions = {}) {
|
||||
const baseOptions = getBaseChartOptions();
|
||||
|
||||
const defaultOptions = {
|
||||
chart: {
|
||||
...baseOptions.chart,
|
||||
type: 'line',
|
||||
height: 350
|
||||
},
|
||||
colors: [
|
||||
MYP_CHART_COLORS.primary,
|
||||
MYP_CHART_COLORS.secondary,
|
||||
MYP_CHART_COLORS.success
|
||||
],
|
||||
series: series || [],
|
||||
xaxis: {
|
||||
...baseOptions.xaxis,
|
||||
categories: categories || []
|
||||
}
|
||||
};
|
||||
|
||||
// Optionen zusammenführen
|
||||
return mergeDeep(mergeDeep({}, baseOptions), mergeDeep(defaultOptions, customOptions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flächendiagramm Konfiguration
|
||||
* @param {Array} series - Datenreihen
|
||||
* @param {Array} categories - X-Achsen Kategorien
|
||||
* @param {Object} customOptions - Benutzerdefinierte Optionen
|
||||
* @returns {Object} Konfigurationsobjekt für ApexCharts
|
||||
*/
|
||||
function getAreaChartConfig(series, categories, customOptions = {}) {
|
||||
const baseOptions = getBaseChartOptions();
|
||||
|
||||
const defaultOptions = {
|
||||
chart: {
|
||||
...baseOptions.chart,
|
||||
type: 'area',
|
||||
height: 350
|
||||
},
|
||||
colors: [
|
||||
MYP_CHART_COLORS.primary,
|
||||
MYP_CHART_COLORS.success
|
||||
],
|
||||
series: series || [],
|
||||
xaxis: {
|
||||
...baseOptions.xaxis,
|
||||
categories: categories || []
|
||||
},
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
shadeIntensity: 1,
|
||||
opacityFrom: 0.7,
|
||||
opacityTo: 0.3,
|
||||
stops: [0, 90, 100]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Optionen zusammenführen
|
||||
return mergeDeep(mergeDeep({}, baseOptions), mergeDeep(defaultOptions, customOptions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Balkendiagramm Konfiguration
|
||||
* @param {Array} series - Datenreihen
|
||||
* @param {Array} categories - X-Achsen Kategorien
|
||||
* @param {Object} customOptions - Benutzerdefinierte Optionen
|
||||
* @returns {Object} Konfigurationsobjekt für ApexCharts
|
||||
*/
|
||||
function getBarChartConfig(series, categories, customOptions = {}) {
|
||||
const baseOptions = getBaseChartOptions();
|
||||
|
||||
const defaultOptions = {
|
||||
chart: {
|
||||
...baseOptions.chart,
|
||||
type: 'bar',
|
||||
height: 350
|
||||
},
|
||||
colors: [
|
||||
MYP_CHART_COLORS.primary,
|
||||
MYP_CHART_COLORS.secondary
|
||||
],
|
||||
series: series || [],
|
||||
xaxis: {
|
||||
...baseOptions.xaxis,
|
||||
categories: categories || []
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
horizontal: false,
|
||||
columnWidth: '70%',
|
||||
borderRadius: 6,
|
||||
dataLabels: {
|
||||
position: 'top'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Optionen zusammenführen
|
||||
return mergeDeep(mergeDeep({}, baseOptions), mergeDeep(defaultOptions, customOptions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kreisdiagramm Konfiguration
|
||||
* @param {Array} series - Datenreihen (Werte)
|
||||
* @param {Array} labels - Beschriftungen
|
||||
* @param {Object} customOptions - Benutzerdefinierte Optionen
|
||||
* @returns {Object} Konfigurationsobjekt für ApexCharts
|
||||
*/
|
||||
function getPieChartConfig(series, labels, customOptions = {}) {
|
||||
const baseOptions = getBaseChartOptions();
|
||||
|
||||
const defaultOptions = {
|
||||
chart: {
|
||||
...baseOptions.chart,
|
||||
type: 'pie',
|
||||
height: 350
|
||||
},
|
||||
colors: [
|
||||
MYP_CHART_COLORS.primary,
|
||||
MYP_CHART_COLORS.success,
|
||||
MYP_CHART_COLORS.warning,
|
||||
MYP_CHART_COLORS.danger,
|
||||
MYP_CHART_COLORS.info
|
||||
],
|
||||
series: series || [],
|
||||
labels: labels || [],
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
},
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 480,
|
||||
options: {
|
||||
chart: {
|
||||
width: 300
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Optionen zusammenführen
|
||||
return mergeDeep(mergeDeep({}, baseOptions), mergeDeep(defaultOptions, customOptions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Donut-Diagramm Konfiguration
|
||||
* @param {Array} series - Datenreihen (Werte)
|
||||
* @param {Array} labels - Beschriftungen
|
||||
* @param {Object} customOptions - Benutzerdefinierte Optionen
|
||||
* @returns {Object} Konfigurationsobjekt für ApexCharts
|
||||
*/
|
||||
function getDonutChartConfig(series, labels, customOptions = {}) {
|
||||
const baseOptions = getBaseChartOptions();
|
||||
|
||||
const defaultOptions = {
|
||||
chart: {
|
||||
...baseOptions.chart,
|
||||
type: 'donut',
|
||||
height: 350
|
||||
},
|
||||
colors: [
|
||||
MYP_CHART_COLORS.primary,
|
||||
MYP_CHART_COLORS.success,
|
||||
MYP_CHART_COLORS.warning,
|
||||
MYP_CHART_COLORS.danger,
|
||||
MYP_CHART_COLORS.info
|
||||
],
|
||||
series: series || [],
|
||||
labels: labels || [],
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
},
|
||||
plotOptions: {
|
||||
pie: {
|
||||
donut: {
|
||||
size: '70%',
|
||||
labels: {
|
||||
show: true,
|
||||
name: {
|
||||
show: true
|
||||
},
|
||||
value: {
|
||||
show: true,
|
||||
formatter: function(val) {
|
||||
return val;
|
||||
}
|
||||
},
|
||||
total: {
|
||||
show: true,
|
||||
formatter: function(w) {
|
||||
return w.globals.seriesTotals.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Optionen zusammenführen
|
||||
return mergeDeep(mergeDeep({}, baseOptions), mergeDeep(defaultOptions, customOptions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Radial-Diagramm Konfiguration
|
||||
* @param {Array} series - Datenreihen (Werte)
|
||||
* @param {Array} labels - Beschriftungen
|
||||
* @param {Object} customOptions - Benutzerdefinierte Optionen
|
||||
* @returns {Object} Konfigurationsobjekt für ApexCharts
|
||||
*/
|
||||
function getRadialChartConfig(series, labels, customOptions = {}) {
|
||||
const baseOptions = getBaseChartOptions();
|
||||
|
||||
const defaultOptions = {
|
||||
chart: {
|
||||
...baseOptions.chart,
|
||||
type: 'radialBar',
|
||||
height: 350
|
||||
},
|
||||
colors: [
|
||||
MYP_CHART_COLORS.primary,
|
||||
MYP_CHART_COLORS.success,
|
||||
MYP_CHART_COLORS.warning
|
||||
],
|
||||
series: series || [],
|
||||
labels: labels || [],
|
||||
plotOptions: {
|
||||
radialBar: {
|
||||
dataLabels: {
|
||||
name: {
|
||||
fontSize: '22px',
|
||||
},
|
||||
value: {
|
||||
fontSize: '16px',
|
||||
},
|
||||
total: {
|
||||
show: true,
|
||||
label: 'Gesamt',
|
||||
formatter: function(w) {
|
||||
return w.globals.seriesTotals.reduce((a, b) => a + b, 0) + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Optionen zusammenführen
|
||||
return mergeDeep(mergeDeep({}, baseOptions), mergeDeep(defaultOptions, customOptions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper-Funktion zum tiefen Zusammenführen von Objekten
|
||||
*/
|
||||
function mergeDeep(target, source) {
|
||||
const isObject = obj => obj && typeof obj === 'object';
|
||||
|
||||
if (!isObject(target) || !isObject(source)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const output = { ...target };
|
||||
|
||||
Object.keys(source).forEach(key => {
|
||||
if (isObject(source[key])) {
|
||||
if (!(key in target)) {
|
||||
output[key] = source[key];
|
||||
} else {
|
||||
output[key] = mergeDeep(target[key], source[key]);
|
||||
}
|
||||
} else {
|
||||
output[key] = source[key];
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
400
backend/app/static/js/charts/chart-renderer.js
Normal file
400
backend/app/static/js/charts/chart-renderer.js
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* MYP Platform Chart-Renderer
|
||||
* Erstellt und verwaltet Diagramme mit ApexCharts
|
||||
* Version: 1.0.0
|
||||
*/
|
||||
|
||||
// Speicher für aktive Chart-Instanzen
|
||||
const activeCharts = {};
|
||||
|
||||
/**
|
||||
* Initialisiert alle Diagramme auf der Seite
|
||||
*/
|
||||
function initCharts() {
|
||||
// Prüfen, ob ApexCharts verfügbar ist
|
||||
if (typeof ApexCharts === 'undefined') {
|
||||
console.error('ApexCharts ist nicht geladen. Bitte ApexCharts vor chart-renderer.js einbinden.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Alle Diagramm-Container mit data-chart-type Attribut finden
|
||||
const chartContainers = document.querySelectorAll('[data-chart-type]');
|
||||
|
||||
// Für jeden Container ein Diagramm erstellen
|
||||
chartContainers.forEach(container => {
|
||||
const chartId = container.id;
|
||||
const chartType = container.getAttribute('data-chart-type');
|
||||
|
||||
// Prüfen, ob Container eine ID hat
|
||||
if (!chartId) {
|
||||
console.error('Chart-Container benötigt eine ID:', container);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bereits erstellte Charts nicht neu initialisieren
|
||||
if (activeCharts[chartId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Daten aus data-chart-data Attribut laden (als JSON)
|
||||
let chartData = {};
|
||||
try {
|
||||
const dataAttr = container.getAttribute('data-chart-data');
|
||||
if (dataAttr) {
|
||||
chartData = JSON.parse(dataAttr);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Fehler beim Parsen der Chart-Daten für ${chartId}:`, error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Chart basierend auf Typ erstellen
|
||||
createChart(container, chartType, chartData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein einzelnes Diagramm
|
||||
* @param {HTMLElement} container - Der Container für das Diagramm
|
||||
* @param {string} chartType - Typ des Diagramms (line, area, bar, pie, donut, radial)
|
||||
* @param {Object} chartData - Daten und Optionen für das Diagramm
|
||||
*/
|
||||
function createChart(container, chartType, chartData = {}) {
|
||||
const chartId = container.id;
|
||||
let chartOptions = {};
|
||||
|
||||
// Diagramm-Typ-spezifische Konfiguration laden
|
||||
switch(chartType.toLowerCase()) {
|
||||
case 'line':
|
||||
chartOptions = getLineChartConfig(
|
||||
chartData.series || [],
|
||||
chartData.categories || [],
|
||||
chartData.options || {}
|
||||
);
|
||||
break;
|
||||
|
||||
case 'area':
|
||||
chartOptions = getAreaChartConfig(
|
||||
chartData.series || [],
|
||||
chartData.categories || [],
|
||||
chartData.options || {}
|
||||
);
|
||||
break;
|
||||
|
||||
case 'bar':
|
||||
chartOptions = getBarChartConfig(
|
||||
chartData.series || [],
|
||||
chartData.categories || [],
|
||||
chartData.options || {}
|
||||
);
|
||||
break;
|
||||
|
||||
case 'pie':
|
||||
chartOptions = getPieChartConfig(
|
||||
chartData.series || [],
|
||||
chartData.labels || [],
|
||||
chartData.options || {}
|
||||
);
|
||||
break;
|
||||
|
||||
case 'donut':
|
||||
chartOptions = getDonutChartConfig(
|
||||
chartData.series || [],
|
||||
chartData.labels || [],
|
||||
chartData.options || {}
|
||||
);
|
||||
break;
|
||||
|
||||
case 'radial':
|
||||
chartOptions = getRadialChartConfig(
|
||||
chartData.series || [],
|
||||
chartData.labels || [],
|
||||
chartData.options || {}
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unbekannter Chart-Typ: ${chartType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dark Mode Anpassungen
|
||||
updateChartTheme(chartOptions);
|
||||
|
||||
// Chart erstellen und speichern
|
||||
try {
|
||||
const chart = new ApexCharts(container, chartOptions);
|
||||
chart.render();
|
||||
|
||||
// Referenz speichern
|
||||
activeCharts[chartId] = {
|
||||
instance: chart,
|
||||
type: chartType,
|
||||
lastData: chartData
|
||||
};
|
||||
|
||||
return chart;
|
||||
} catch (error) {
|
||||
console.error(`Fehler beim Erstellen des Charts ${chartId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert ein bestehendes Diagramm mit neuen Daten
|
||||
* @param {string} chartId - ID des Diagramm-Containers
|
||||
* @param {Object} newData - Neue Daten für das Diagramm
|
||||
*/
|
||||
function updateChart(chartId, newData) {
|
||||
const chartInfo = activeCharts[chartId];
|
||||
|
||||
if (!chartInfo) {
|
||||
console.error(`Chart mit ID ${chartId} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chart = chartInfo.instance;
|
||||
|
||||
// Aktualisieren basierend auf Chart-Typ
|
||||
if (chartInfo.type === 'pie' || chartInfo.type === 'donut' || chartInfo.type === 'radial') {
|
||||
// Für Pie/Donut/Radial-Charts
|
||||
chart.updateSeries(newData.series || []);
|
||||
if (newData.labels) {
|
||||
chart.updateOptions({
|
||||
labels: newData.labels
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Für Line/Area/Bar-Charts
|
||||
chart.updateSeries(newData.series || []);
|
||||
if (newData.categories) {
|
||||
chart.updateOptions({
|
||||
xaxis: {
|
||||
categories: newData.categories
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Zusätzliche Optionen aktualisieren
|
||||
if (newData.options) {
|
||||
chart.updateOptions(newData.options);
|
||||
}
|
||||
|
||||
// Gespeicherte Daten aktualisieren
|
||||
chartInfo.lastData = {
|
||||
...chartInfo.lastData,
|
||||
...newData
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt Diagrammdaten über AJAX und aktualisiert das Diagramm
|
||||
* @param {string} chartId - ID des Diagramm-Containers
|
||||
* @param {string} url - URL zur Datenbeschaffung
|
||||
* @param {Function} successCallback - Callback nach erfolgreicher Aktualisierung
|
||||
*/
|
||||
function loadChartData(chartId, url, successCallback) {
|
||||
const container = document.getElementById(chartId);
|
||||
|
||||
if (!container) {
|
||||
console.error(`Container mit ID ${chartId} nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lade-Animation anzeigen
|
||||
container.classList.add('chart-loading');
|
||||
|
||||
// Daten vom Server laden
|
||||
fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Netzwerkantwort war nicht ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Lade-Animation entfernen
|
||||
container.classList.remove('chart-loading');
|
||||
|
||||
const chartInfo = activeCharts[chartId];
|
||||
|
||||
// Chart erstellen, falls nicht vorhanden
|
||||
if (!chartInfo) {
|
||||
const chartType = container.getAttribute('data-chart-type');
|
||||
if (chartType) {
|
||||
createChart(container, chartType, data);
|
||||
} else {
|
||||
console.error(`Kein Chart-Typ für ${chartId} definiert.`);
|
||||
}
|
||||
} else {
|
||||
// Bestehendes Chart aktualisieren
|
||||
updateChart(chartId, data);
|
||||
}
|
||||
|
||||
// Callback aufrufen, falls vorhanden
|
||||
if (typeof successCallback === 'function') {
|
||||
successCallback(data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler beim Laden der Chart-Daten:', error);
|
||||
container.classList.remove('chart-loading');
|
||||
|
||||
// Fehlermeldung im Container anzeigen
|
||||
container.innerHTML = `
|
||||
<div class="chart-error">
|
||||
<svg class="w-10 h-10 text-red-500 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium">Fehler beim Laden</h3>
|
||||
<p class="text-sm text-gray-500">Die Diagrammdaten konnten nicht geladen werden.</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Farbthemen basierend auf dem Dark Mode
|
||||
* @param {Object} chartOptions - Chart-Optionen Objekt
|
||||
*/
|
||||
function updateChartTheme(chartOptions) {
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
|
||||
// Theme anpassen
|
||||
if (isDarkMode) {
|
||||
// Dark Mode Einstellungen
|
||||
chartOptions.theme = {
|
||||
mode: 'dark',
|
||||
palette: 'palette1'
|
||||
};
|
||||
|
||||
chartOptions.grid = {
|
||||
...chartOptions.grid,
|
||||
borderColor: '#334155'
|
||||
};
|
||||
|
||||
// Text Farben anpassen
|
||||
chartOptions.xaxis = {
|
||||
...chartOptions.xaxis,
|
||||
labels: {
|
||||
...chartOptions.xaxis?.labels,
|
||||
style: {
|
||||
...chartOptions.xaxis?.labels?.style,
|
||||
colors: '#94a3b8'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chartOptions.yaxis = {
|
||||
...chartOptions.yaxis,
|
||||
labels: {
|
||||
...chartOptions.yaxis?.labels,
|
||||
style: {
|
||||
...chartOptions.yaxis?.labels?.style,
|
||||
colors: '#94a3b8'
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Light Mode Einstellungen
|
||||
chartOptions.theme = {
|
||||
mode: 'light',
|
||||
palette: 'palette1'
|
||||
};
|
||||
|
||||
chartOptions.grid = {
|
||||
...chartOptions.grid,
|
||||
borderColor: '#e2e8f0'
|
||||
};
|
||||
|
||||
// Text Farben anpassen
|
||||
chartOptions.xaxis = {
|
||||
...chartOptions.xaxis,
|
||||
labels: {
|
||||
...chartOptions.xaxis?.labels,
|
||||
style: {
|
||||
...chartOptions.xaxis?.labels?.style,
|
||||
colors: '#64748b'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chartOptions.yaxis = {
|
||||
...chartOptions.yaxis,
|
||||
labels: {
|
||||
...chartOptions.yaxis?.labels,
|
||||
style: {
|
||||
...chartOptions.yaxis?.labels?.style,
|
||||
colors: '#64748b'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return chartOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-Listener für Dark Mode Änderungen
|
||||
*/
|
||||
function setupDarkModeListener() {
|
||||
window.addEventListener('darkModeChanged', function(event) {
|
||||
const isDark = event.detail?.isDark;
|
||||
|
||||
// Alle aktiven Charts aktualisieren
|
||||
Object.keys(activeCharts).forEach(chartId => {
|
||||
const chartInfo = activeCharts[chartId];
|
||||
const chart = chartInfo.instance;
|
||||
|
||||
// Theme aktualisieren
|
||||
const updatedOptions = updateChartTheme({
|
||||
grid: chart.opts.grid,
|
||||
xaxis: chart.opts.xaxis,
|
||||
yaxis: chart.opts.yaxis
|
||||
});
|
||||
|
||||
// Chart aktualisieren
|
||||
chart.updateOptions({
|
||||
theme: updatedOptions.theme,
|
||||
grid: updatedOptions.grid,
|
||||
xaxis: updatedOptions.xaxis,
|
||||
yaxis: updatedOptions.yaxis
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt alle Chart-Instanzen
|
||||
*/
|
||||
function destroyAllCharts() {
|
||||
Object.keys(activeCharts).forEach(chartId => {
|
||||
const chartInfo = activeCharts[chartId];
|
||||
if (chartInfo && chartInfo.instance) {
|
||||
chartInfo.instance.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Aktive Charts zurücksetzen
|
||||
Object.keys(activeCharts).forEach(key => delete activeCharts[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt eine spezifische Chart-Instanz
|
||||
* @param {string} chartId - ID des Diagramm-Containers
|
||||
*/
|
||||
function destroyChart(chartId) {
|
||||
const chartInfo = activeCharts[chartId];
|
||||
|
||||
if (chartInfo && chartInfo.instance) {
|
||||
chartInfo.instance.destroy();
|
||||
delete activeCharts[chartId];
|
||||
}
|
||||
}
|
||||
|
||||
// DOM bereit Event-Listener
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initCharts();
|
||||
setupDarkModeListener();
|
||||
});
|
278
backend/app/static/js/dark-mode.js
Normal file
278
backend/app/static/js/dark-mode.js
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* MYP Platform Dark Mode Handler
|
||||
* Version: 6.0.0
|
||||
*/
|
||||
|
||||
// Sofort ausführen, um FOUC zu vermeiden (Flash of Unstyled Content)
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
// Speicherort für Dark Mode-Einstellung
|
||||
const STORAGE_KEY = 'myp-dark-mode';
|
||||
|
||||
// DOM-Elemente
|
||||
let darkModeToggle;
|
||||
const html = document.documentElement;
|
||||
|
||||
// Initialisierung beim Laden der Seite
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
|
||||
// Prüft System-Präferenz und gespeicherte Einstellung
|
||||
function shouldUseDarkMode() {
|
||||
// Lokale Speichereinstellung prüfen
|
||||
const savedMode = localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
// Prüfen ob es eine gespeicherte Einstellung gibt
|
||||
if (savedMode !== null) {
|
||||
return savedMode === 'true';
|
||||
}
|
||||
|
||||
// Ansonsten Systemeinstellung verwenden
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
// Setzt Dark/Light Mode
|
||||
function setDarkMode(enable) {
|
||||
// Deaktiviere Übergänge temporär, um Flackern zu vermeiden
|
||||
html.classList.add('disable-transitions');
|
||||
|
||||
// Dark Mode Klasse am HTML-Element setzen
|
||||
if (enable) {
|
||||
html.classList.add('dark');
|
||||
html.setAttribute('data-theme', 'dark');
|
||||
html.style.colorScheme = 'dark';
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
html.setAttribute('data-theme', 'light');
|
||||
html.style.colorScheme = 'light';
|
||||
}
|
||||
|
||||
// Speichern in LocalStorage
|
||||
localStorage.setItem(STORAGE_KEY, enable);
|
||||
|
||||
// Update ThemeColor Meta-Tag
|
||||
updateMetaThemeColor(enable);
|
||||
|
||||
// Wenn Toggle existiert, aktualisiere Icon
|
||||
if (darkModeToggle) {
|
||||
updateDarkModeToggle(enable);
|
||||
}
|
||||
|
||||
// Event für andere Komponenten
|
||||
window.dispatchEvent(new CustomEvent('darkModeChanged', {
|
||||
detail: {
|
||||
isDark: enable,
|
||||
source: 'dark-mode-toggle',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}));
|
||||
|
||||
// Event auch als eigenen Event-Typ versenden (rückwärtskompatibel)
|
||||
const eventName = enable ? 'darkModeEnabled' : 'darkModeDisabled';
|
||||
window.dispatchEvent(new CustomEvent(eventName, {
|
||||
detail: { timestamp: new Date().toISOString() }
|
||||
}));
|
||||
|
||||
// Übergänge nach kurzer Verzögerung wieder aktivieren
|
||||
setTimeout(function() {
|
||||
html.classList.remove('disable-transitions');
|
||||
}, 100);
|
||||
|
||||
// Erfolgsmeldung in die Konsole
|
||||
console.log(`Dark Mode ${enable ? 'aktiviert' : 'deaktiviert'}`);
|
||||
}
|
||||
|
||||
// Aktualisiert das Theme-Color Meta-Tag
|
||||
function updateMetaThemeColor(isDark) {
|
||||
// Alle Theme-Color Meta-Tags aktualisieren
|
||||
const metaTags = [
|
||||
document.getElementById('metaThemeColor'),
|
||||
document.querySelector('meta[name="theme-color"]'),
|
||||
document.querySelector('meta[name="theme-color"][media="(prefers-color-scheme: light)"]'),
|
||||
document.querySelector('meta[name="theme-color"][media="(prefers-color-scheme: dark)"]')
|
||||
];
|
||||
|
||||
// CSS-Variablen für konsistente Farben verwenden
|
||||
const darkColor = getComputedStyle(document.documentElement).getPropertyValue('--color-bg') || '#0f172a';
|
||||
const lightColor = getComputedStyle(document.documentElement).getPropertyValue('--color-bg') || '#ffffff';
|
||||
|
||||
metaTags.forEach(tag => {
|
||||
if (tag) {
|
||||
// Für Media-spezifische Tags die entsprechende Farbe setzen
|
||||
if (tag.getAttribute('media') === '(prefers-color-scheme: dark)') {
|
||||
tag.setAttribute('content', darkColor);
|
||||
} else if (tag.getAttribute('media') === '(prefers-color-scheme: light)') {
|
||||
tag.setAttribute('content', lightColor);
|
||||
} else {
|
||||
// Für nicht-Media-spezifische Tags die aktuelle Farbe setzen
|
||||
tag.setAttribute('content', isDark ? darkColor : lightColor);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Aktualisiert das Aussehen des Toggle-Buttons
|
||||
function updateDarkModeToggle(isDark) {
|
||||
// Aria-Attribute für Barrierefreiheit
|
||||
darkModeToggle.setAttribute('aria-pressed', isDark.toString());
|
||||
darkModeToggle.title = isDark ? "Light Mode aktivieren" : "Dark Mode aktivieren";
|
||||
|
||||
// Stile aktualisieren mit Tailwind-Klassen
|
||||
if (isDark) {
|
||||
darkModeToggle.classList.remove('bg-indigo-600', 'hover:bg-indigo-700');
|
||||
darkModeToggle.classList.add('bg-slate-800', 'hover:bg-slate-700', 'text-amber-400');
|
||||
darkModeToggle.setAttribute('data-tooltip', 'Light Mode aktivieren');
|
||||
} else {
|
||||
darkModeToggle.classList.remove('bg-slate-800', 'hover:bg-slate-700', 'text-amber-400');
|
||||
darkModeToggle.classList.add('bg-indigo-600', 'hover:bg-indigo-700');
|
||||
darkModeToggle.setAttribute('data-tooltip', 'Dark Mode aktivieren');
|
||||
}
|
||||
|
||||
// Icon aktualisieren - ohne innerHTML für CSP-Kompatibilität
|
||||
const icon = darkModeToggle.querySelector('svg');
|
||||
if (icon) {
|
||||
// Animationsklasse hinzufügen
|
||||
icon.classList.add('animate-spin-once');
|
||||
|
||||
// Nach Animation wieder entfernen
|
||||
setTimeout(() => {
|
||||
icon.classList.remove('animate-spin-once');
|
||||
}, 300);
|
||||
|
||||
const pathElement = icon.querySelector('path');
|
||||
if (pathElement) {
|
||||
// Sonnen- oder Mond-Symbol
|
||||
if (isDark) {
|
||||
pathElement.setAttribute("d", "M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z");
|
||||
} else {
|
||||
pathElement.setAttribute("d", "M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialisierungsfunktion
|
||||
function initialize() {
|
||||
// Toggle-Button finden
|
||||
darkModeToggle = document.getElementById('darkModeToggle');
|
||||
|
||||
// Wenn kein Toggle existiert, erstelle einen
|
||||
if (!darkModeToggle) {
|
||||
createDarkModeToggle();
|
||||
}
|
||||
|
||||
// Event-Listener für Dark Mode Toggle
|
||||
if (darkModeToggle) {
|
||||
darkModeToggle.addEventListener('click', function() {
|
||||
const isDark = !shouldUseDarkMode();
|
||||
setDarkMode(isDark);
|
||||
});
|
||||
}
|
||||
|
||||
// Tastenkombination: Strg+Shift+D
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'D') {
|
||||
const isDark = !shouldUseDarkMode();
|
||||
setDarkMode(isDark);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Auf Systemeinstellungsänderungen reagieren
|
||||
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
// Moderne Event-API verwenden
|
||||
try {
|
||||
darkModeMediaQuery.addEventListener('change', function(e) {
|
||||
// Nur anwenden, wenn keine benutzerdefinierte Einstellung gespeichert ist
|
||||
if (localStorage.getItem(STORAGE_KEY) === null) {
|
||||
setDarkMode(e.matches);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Fallback für ältere Browser
|
||||
darkModeMediaQuery.addListener(function(e) {
|
||||
if (localStorage.getItem(STORAGE_KEY) === null) {
|
||||
setDarkMode(e.matches);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialer Zustand
|
||||
setDarkMode(shouldUseDarkMode());
|
||||
|
||||
// Animation für den korrekten Modus hinzufügen
|
||||
const animClass = shouldUseDarkMode() ? 'dark-mode-transition' : 'light-mode-transition';
|
||||
document.body.classList.add(animClass);
|
||||
|
||||
// Animation entfernen nach Abschluss
|
||||
setTimeout(() => {
|
||||
document.body.classList.remove(animClass);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Erstellt ein Toggle-Element, falls keines existiert
|
||||
function createDarkModeToggle() {
|
||||
// Bestehende Header-Elemente finden
|
||||
const header = document.querySelector('header');
|
||||
const nav = document.querySelector('nav');
|
||||
const container = document.querySelector('.dark-mode-container') || header || nav;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
// Toggle-Button erstellen
|
||||
darkModeToggle = document.createElement('button');
|
||||
darkModeToggle.id = 'darkModeToggle';
|
||||
darkModeToggle.className = 'p-2 sm:p-3 rounded-full bg-indigo-600 text-white transition-all duration-300';
|
||||
darkModeToggle.setAttribute('aria-label', 'Dark Mode umschalten');
|
||||
darkModeToggle.setAttribute('title', 'Dark Mode aktivieren');
|
||||
darkModeToggle.setAttribute('data-tooltip', 'Dark Mode aktivieren');
|
||||
|
||||
// SVG-Icon erstellen (ohne innerHTML für Content Security Policy)
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("class", "w-4 h-4 sm:w-5 sm:h-5");
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
|
||||
// Path für das Icon
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
path.setAttribute("stroke-width", "2");
|
||||
path.setAttribute("d", "M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z");
|
||||
|
||||
// Elemente zusammenfügen
|
||||
svg.appendChild(path);
|
||||
darkModeToggle.appendChild(svg);
|
||||
|
||||
// Screenreader-Text hinzufügen
|
||||
const srText = document.createElement('span');
|
||||
srText.className = 'sr-only';
|
||||
srText.textContent = 'Dark Mode umschalten';
|
||||
darkModeToggle.appendChild(srText);
|
||||
|
||||
// Zum Container hinzufügen
|
||||
container.appendChild(darkModeToggle);
|
||||
}
|
||||
|
||||
// Sofort Dark/Light Mode anwenden (vor DOMContentLoaded)
|
||||
const isDark = shouldUseDarkMode();
|
||||
setDarkMode(isDark);
|
||||
})();
|
||||
|
||||
// Animationen für Spin-Effekt
|
||||
if (!document.querySelector('style#dark-mode-animations')) {
|
||||
const styleTag = document.createElement('style');
|
||||
styleTag.id = 'dark-mode-animations';
|
||||
styleTag.textContent = `
|
||||
@keyframes spin-once {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.animate-spin-once {
|
||||
animation: spin-once 0.3s ease-in-out;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleTag);
|
||||
}
|
354
backend/app/static/js/dashboard.js
Normal file
354
backend/app/static/js/dashboard.js
Normal file
@@ -0,0 +1,354 @@
|
||||
// Dashboard JavaScript - Externe Datei für CSP-Konformität
|
||||
|
||||
// Globale Variablen
|
||||
let dashboardData = {};
|
||||
let updateInterval;
|
||||
|
||||
// DOM-Elemente
|
||||
const elements = {
|
||||
activeJobs: null,
|
||||
scheduledJobs: null,
|
||||
availablePrinters: null,
|
||||
totalPrintTime: null,
|
||||
schedulerStatus: null,
|
||||
recentJobsList: null,
|
||||
recentActivitiesList: null,
|
||||
refreshBtn: null,
|
||||
schedulerToggleBtn: null
|
||||
};
|
||||
|
||||
// Initialisierung beim Laden der Seite
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeDashboard();
|
||||
});
|
||||
|
||||
function initializeDashboard() {
|
||||
// DOM-Elemente referenzieren
|
||||
elements.activeJobs = document.getElementById('active-jobs');
|
||||
elements.scheduledJobs = document.getElementById('scheduled-jobs');
|
||||
elements.availablePrinters = document.getElementById('available-printers');
|
||||
elements.totalPrintTime = document.getElementById('total-print-time');
|
||||
elements.schedulerStatus = document.getElementById('scheduler-status');
|
||||
elements.recentJobsList = document.getElementById('recent-jobs-list');
|
||||
elements.recentActivitiesList = document.getElementById('recent-activities-list');
|
||||
elements.refreshBtn = document.getElementById('refresh-btn');
|
||||
elements.schedulerToggleBtn = document.getElementById('scheduler-toggle-btn');
|
||||
|
||||
// Event-Listener hinzufügen
|
||||
if (elements.refreshBtn) {
|
||||
elements.refreshBtn.addEventListener('click', refreshDashboard);
|
||||
}
|
||||
|
||||
if (elements.schedulerToggleBtn) {
|
||||
elements.schedulerToggleBtn.addEventListener('click', toggleScheduler);
|
||||
}
|
||||
|
||||
// Initiales Laden der Daten
|
||||
loadDashboardData();
|
||||
loadRecentJobs();
|
||||
loadRecentActivities();
|
||||
loadSchedulerStatus();
|
||||
|
||||
// Auto-Update alle 30 Sekunden
|
||||
updateInterval = setInterval(function() {
|
||||
loadDashboardData();
|
||||
loadRecentJobs();
|
||||
loadRecentActivities();
|
||||
loadSchedulerStatus();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// Dashboard-Hauptdaten laden
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
dashboardData = await response.json();
|
||||
updateDashboardUI();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Dashboard-Daten:', error);
|
||||
showError('Fehler beim Laden der Dashboard-Daten');
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard-UI aktualisieren
|
||||
function updateDashboardUI() {
|
||||
if (elements.activeJobs) {
|
||||
elements.activeJobs.textContent = dashboardData.active_jobs || 0;
|
||||
}
|
||||
|
||||
if (elements.scheduledJobs) {
|
||||
elements.scheduledJobs.textContent = dashboardData.scheduled_jobs || 0;
|
||||
}
|
||||
|
||||
if (elements.availablePrinters) {
|
||||
elements.availablePrinters.textContent = dashboardData.available_printers || 0;
|
||||
}
|
||||
|
||||
if (elements.totalPrintTime) {
|
||||
const hours = Math.floor((dashboardData.total_print_time || 0) / 3600);
|
||||
const minutes = Math.floor(((dashboardData.total_print_time || 0) % 3600) / 60);
|
||||
elements.totalPrintTime.textContent = `${hours}h ${minutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// Aktuelle Jobs laden
|
||||
async function loadRecentJobs() {
|
||||
try {
|
||||
const response = await fetch('/api/jobs/recent');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
updateRecentJobsList(data.jobs);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der aktuellen Jobs:', error);
|
||||
if (elements.recentJobsList) {
|
||||
elements.recentJobsList.innerHTML = '<li class="list-group-item text-danger">Fehler beim Laden</li>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Jobs-Liste aktualisieren
|
||||
function updateRecentJobsList(jobs) {
|
||||
if (!elements.recentJobsList) return;
|
||||
|
||||
if (!jobs || jobs.length === 0) {
|
||||
elements.recentJobsList.innerHTML = '<li class="list-group-item text-muted">Keine aktuellen Jobs</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
const jobsHtml = jobs.map(job => {
|
||||
const statusClass = getStatusClass(job.status);
|
||||
const timeAgo = formatTimeAgo(job.created_at);
|
||||
|
||||
return `
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>${escapeHtml(job.name)}</strong><br>
|
||||
<small class="text-muted">${escapeHtml(job.printer_name)} • ${timeAgo}</small>
|
||||
</div>
|
||||
<span class="badge ${statusClass}">${getStatusText(job.status)}</span>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
elements.recentJobsList.innerHTML = jobsHtml;
|
||||
}
|
||||
|
||||
// Aktuelle Aktivitäten laden
|
||||
async function loadRecentActivities() {
|
||||
try {
|
||||
const response = await fetch('/api/activity/recent');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
updateRecentActivitiesList(data.activities);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Aktivitäten:', error);
|
||||
if (elements.recentActivitiesList) {
|
||||
elements.recentActivitiesList.innerHTML = '<li class="list-group-item text-danger">Fehler beim Laden</li>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aktivitäten-Liste aktualisieren
|
||||
function updateRecentActivitiesList(activities) {
|
||||
if (!elements.recentActivitiesList) return;
|
||||
|
||||
if (!activities || activities.length === 0) {
|
||||
elements.recentActivitiesList.innerHTML = '<li class="list-group-item text-muted">Keine aktuellen Aktivitäten</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
const activitiesHtml = activities.map(activity => {
|
||||
const timeAgo = formatTimeAgo(activity.timestamp);
|
||||
|
||||
return `
|
||||
<li class="list-group-item">
|
||||
<div>${escapeHtml(activity.description)}</div>
|
||||
<small class="text-muted">${timeAgo}</small>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
elements.recentActivitiesList.innerHTML = activitiesHtml;
|
||||
}
|
||||
|
||||
// Scheduler-Status laden
|
||||
async function loadSchedulerStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/scheduler/status');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
updateSchedulerStatus(data.running);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Scheduler-Status:', error);
|
||||
if (elements.schedulerStatus) {
|
||||
elements.schedulerStatus.innerHTML = '<span class="badge bg-secondary">Unbekannt</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduler-Status aktualisieren
|
||||
function updateSchedulerStatus(isRunning) {
|
||||
if (!elements.schedulerStatus) return;
|
||||
|
||||
const statusClass = isRunning ? 'bg-success' : 'bg-danger';
|
||||
const statusText = isRunning ? 'Aktiv' : 'Gestoppt';
|
||||
|
||||
elements.schedulerStatus.innerHTML = `<span class="badge ${statusClass}">${statusText}</span>`;
|
||||
|
||||
if (elements.schedulerToggleBtn) {
|
||||
elements.schedulerToggleBtn.textContent = isRunning ? 'Scheduler stoppen' : 'Scheduler starten';
|
||||
elements.schedulerToggleBtn.className = isRunning ? 'btn btn-danger btn-sm' : 'btn btn-success btn-sm';
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduler umschalten
|
||||
async function toggleScheduler() {
|
||||
try {
|
||||
const isRunning = dashboardData.scheduler_running;
|
||||
const endpoint = isRunning ? '/api/scheduler/stop' : '/api/scheduler/start';
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(result.message);
|
||||
// Status sofort neu laden
|
||||
setTimeout(loadSchedulerStatus, 1000);
|
||||
} else {
|
||||
showError(result.error || 'Unbekannter Fehler');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Umschalten des Schedulers:', error);
|
||||
showError('Fehler beim Umschalten des Schedulers');
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard manuell aktualisieren
|
||||
function refreshDashboard() {
|
||||
if (elements.refreshBtn) {
|
||||
elements.refreshBtn.disabled = true;
|
||||
elements.refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Aktualisiere...';
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
loadDashboardData(),
|
||||
loadRecentJobs(),
|
||||
loadRecentActivities(),
|
||||
loadSchedulerStatus()
|
||||
]).finally(() => {
|
||||
if (elements.refreshBtn) {
|
||||
elements.refreshBtn.disabled = false;
|
||||
elements.refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> Aktualisieren';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Hilfsfunktionen
|
||||
function getStatusClass(status) {
|
||||
const statusClasses = {
|
||||
'pending': 'bg-warning',
|
||||
'printing': 'bg-primary',
|
||||
'completed': 'bg-success',
|
||||
'failed': 'bg-danger',
|
||||
'cancelled': 'bg-secondary',
|
||||
'scheduled': 'bg-info'
|
||||
};
|
||||
return statusClasses[status] || 'bg-secondary';
|
||||
}
|
||||
|
||||
function getStatusText(status) {
|
||||
const statusTexts = {
|
||||
'pending': 'Wartend',
|
||||
'printing': 'Druckt',
|
||||
'completed': 'Abgeschlossen',
|
||||
'failed': 'Fehlgeschlagen',
|
||||
'cancelled': 'Abgebrochen',
|
||||
'scheduled': 'Geplant'
|
||||
};
|
||||
return statusTexts[status] || status;
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp) {
|
||||
const now = new Date();
|
||||
const time = new Date(timestamp);
|
||||
const diffMs = now - time;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'Gerade eben';
|
||||
if (diffMins < 60) return `vor ${diffMins} Min`;
|
||||
if (diffHours < 24) return `vor ${diffHours} Std`;
|
||||
return `vor ${diffDays} Tag${diffDays > 1 ? 'en' : ''}`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
showNotification(message, 'success');
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
showNotification(message, 'danger');
|
||||
}
|
||||
|
||||
function showNotification(message, type) {
|
||||
// Einfache Notification - kann später durch Toast-System ersetzt werden
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
|
||||
alertDiv.style.top = '20px';
|
||||
alertDiv.style.right = '20px';
|
||||
alertDiv.style.zIndex = '9999';
|
||||
alertDiv.innerHTML = `
|
||||
${escapeHtml(message)}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(alertDiv);
|
||||
|
||||
// Automatisch nach 5 Sekunden entfernen
|
||||
setTimeout(() => {
|
||||
if (alertDiv.parentNode) {
|
||||
alertDiv.parentNode.removeChild(alertDiv);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Cleanup beim Verlassen der Seite
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (updateInterval) {
|
||||
clearInterval(updateInterval);
|
||||
}
|
||||
});
|
521
backend/app/static/js/offline-app.js
Normal file
521
backend/app/static/js/offline-app.js
Normal file
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* MYP Platform Offline-First Application
|
||||
* Version: 2.0.0
|
||||
* Description: Offline-fähige PWA für die MYP Plattform
|
||||
*/
|
||||
|
||||
class MYPApp {
|
||||
constructor() {
|
||||
this.isOffline = !navigator.onLine;
|
||||
this.registerTime = new Date().toISOString();
|
||||
this.darkMode = document.documentElement.classList.contains('dark');
|
||||
this.setupOfflineDetection();
|
||||
this.setupServiceWorker();
|
||||
this.setupLocalStorage();
|
||||
this.setupUI();
|
||||
this.setupThemeListeners();
|
||||
|
||||
// Debug-Info für die Entwicklung
|
||||
console.log(`MYP App initialisiert um ${this.registerTime}`);
|
||||
console.log(`Initiale Netzwerkverbindung: ${navigator.onLine ? 'Online' : 'Offline'}`);
|
||||
console.log(`Aktueller Modus: ${this.darkMode ? 'Dark Mode' : 'Light Mode'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Überwacht die Netzwerkverbindung
|
||||
*/
|
||||
setupOfflineDetection() {
|
||||
window.addEventListener('online', () => {
|
||||
this.isOffline = false;
|
||||
document.body.classList.remove('offline-mode');
|
||||
console.log('Netzwerkverbindung wiederhergestellt!');
|
||||
|
||||
// Bei Wiederverbindung: Synchronisiere Daten
|
||||
this.syncOfflineData();
|
||||
|
||||
// Event an andere Komponenten senden
|
||||
window.dispatchEvent(new CustomEvent('networkStatusChange', {
|
||||
detail: { isOffline: false }
|
||||
}));
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOffline = true;
|
||||
document.body.classList.add('offline-mode');
|
||||
console.log('Netzwerkverbindung verloren!');
|
||||
|
||||
// Event an andere Komponenten senden
|
||||
window.dispatchEvent(new CustomEvent('networkStatusChange', {
|
||||
detail: { isOffline: true }
|
||||
}));
|
||||
});
|
||||
|
||||
// Klasse setzen, wenn initial offline
|
||||
if (this.isOffline) {
|
||||
document.body.classList.add('offline-mode');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert den Service Worker für Offline-Funktionalität
|
||||
*/
|
||||
setupServiceWorker() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const swPath = '/sw.js';
|
||||
|
||||
navigator.serviceWorker.register(swPath, {
|
||||
scope: '/'
|
||||
}).then(registration => {
|
||||
console.log('Service Worker erfolgreich registriert mit Scope:', registration.scope);
|
||||
|
||||
// Status des Service Workers überprüfen
|
||||
if (registration.installing) {
|
||||
console.log('Service Worker wird installiert');
|
||||
} else if (registration.waiting) {
|
||||
console.log('Service Worker wartet auf Aktivierung');
|
||||
} else if (registration.active) {
|
||||
console.log('Service Worker ist aktiv');
|
||||
}
|
||||
|
||||
// Auf Updates überwachen
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
console.log(`Service Worker Status: ${newWorker.state}`);
|
||||
|
||||
// Bei Aktivierung Cache aktualisieren
|
||||
if (newWorker.state === 'activated') {
|
||||
this.fetchAndCacheAppData();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Prüfen auf Update beim App-Start
|
||||
registration.update();
|
||||
|
||||
}).catch(error => {
|
||||
console.error('Service Worker Registrierung fehlgeschlagen:', error);
|
||||
// Fallback auf lokalen Cache wenn Service Worker fehlschlägt
|
||||
this.setupLocalCache();
|
||||
});
|
||||
|
||||
// Kontrolltransfer abfangen
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
console.log('Service Worker Controller hat gewechselt');
|
||||
});
|
||||
|
||||
} else {
|
||||
console.warn('Service Worker werden von diesem Browser nicht unterstützt');
|
||||
// Fallback auf lokalen Cache wenn Service Worker nicht unterstützt werden
|
||||
this.setupLocalCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lokaler Cache-Fallback wenn Service Worker nicht verfügbar
|
||||
*/
|
||||
setupLocalCache() {
|
||||
console.log('Verwende lokalen Cache als Fallback');
|
||||
|
||||
// Wichtige App-Daten sofort laden und cachen
|
||||
this.fetchAndCacheAppData();
|
||||
|
||||
// Timer für regelmäßige Aktualisierung des Cache
|
||||
setInterval(() => {
|
||||
if (navigator.onLine) {
|
||||
this.fetchAndCacheAppData();
|
||||
}
|
||||
}, 15 * 60 * 1000); // Alle 15 Minuten
|
||||
}
|
||||
|
||||
/**
|
||||
* Laden und Cachen wichtiger API-Endpunkte
|
||||
*/
|
||||
fetchAndCacheAppData() {
|
||||
if (!navigator.onLine) return;
|
||||
|
||||
const endpoints = [
|
||||
'/api/printers',
|
||||
'/api/jobs',
|
||||
'/api/schedule',
|
||||
'/api/status'
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
fetch(endpoint)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
localStorage.setItem(`cache_${endpoint}`, JSON.stringify({
|
||||
timestamp: new Date().getTime(),
|
||||
data: data
|
||||
}));
|
||||
console.log(`Daten für ${endpoint} gecached`);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Fehler beim Cachen von ${endpoint}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lokaler Speicher für Offline-Daten
|
||||
*/
|
||||
setupLocalStorage() {
|
||||
// Speicher für Offline-Bearbeitung einrichten
|
||||
if (!localStorage.getItem('offlineChanges')) {
|
||||
localStorage.setItem('offlineChanges', JSON.stringify([]));
|
||||
}
|
||||
|
||||
// Speicher für App-Konfiguration einrichten
|
||||
if (!localStorage.getItem('appConfig')) {
|
||||
const defaultConfig = {
|
||||
theme: 'system',
|
||||
notifications: true,
|
||||
dataSync: true,
|
||||
lastSync: null
|
||||
};
|
||||
localStorage.setItem('appConfig', JSON.stringify(defaultConfig));
|
||||
}
|
||||
|
||||
// Aufräumen alter Cache-Einträge (älter als 7 Tage)
|
||||
this.cleanupOldCache(7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt alte Cache-Einträge
|
||||
* @param {number} daysOld - Alter in Tagen
|
||||
*/
|
||||
cleanupOldCache(daysOld) {
|
||||
const now = new Date().getTime();
|
||||
const maxAge = daysOld * 24 * 60 * 60 * 1000;
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
|
||||
// Nur Cache-Einträge prüfen
|
||||
if (key.startsWith('cache_')) {
|
||||
try {
|
||||
const item = JSON.parse(localStorage.getItem(key));
|
||||
if (item && item.timestamp && (now - item.timestamp > maxAge)) {
|
||||
localStorage.removeItem(key);
|
||||
console.log(`Alter Cache-Eintrag entfernt: ${key}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Fehler beim Verarbeiten von Cache-Eintrag ${key}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronisiert offline getätigte Änderungen
|
||||
*/
|
||||
syncOfflineData() {
|
||||
if (!navigator.onLine) return;
|
||||
|
||||
const offlineChanges = JSON.parse(localStorage.getItem('offlineChanges') || '[]');
|
||||
|
||||
if (offlineChanges.length === 0) {
|
||||
console.log('Keine Offline-Änderungen zu synchronisieren');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${offlineChanges.length} Offline-Änderungen werden synchronisiert...`);
|
||||
|
||||
// Status-Indikator für Synchronisierung anzeigen
|
||||
document.body.classList.add('syncing');
|
||||
|
||||
const syncPromises = offlineChanges.map(change => {
|
||||
return fetch(change.url, {
|
||||
method: change.method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Offline-Change': 'true'
|
||||
},
|
||||
body: JSON.stringify(change.data)
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fehler ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log(`Änderung erfolgreich synchronisiert: ${change.url}`);
|
||||
return { success: true, change };
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Synchronisierung fehlgeschlagen für ${change.url}:`, error);
|
||||
return { success: false, change, error };
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(syncPromises)
|
||||
.then(results => {
|
||||
// Fehlgeschlagene Änderungen behalten
|
||||
const failedChanges = results
|
||||
.filter(result => !result.success)
|
||||
.map(result => result.change);
|
||||
|
||||
localStorage.setItem('offlineChanges', JSON.stringify(failedChanges));
|
||||
|
||||
// App-Konfiguration aktualisieren
|
||||
const appConfig = JSON.parse(localStorage.getItem('appConfig') || '{}');
|
||||
appConfig.lastSync = new Date().toISOString();
|
||||
localStorage.setItem('appConfig', JSON.stringify(appConfig));
|
||||
|
||||
// Status-Indikator entfernen
|
||||
document.body.classList.remove('syncing');
|
||||
|
||||
// Event-Benachrichtigung
|
||||
const syncEvent = new CustomEvent('offlineDataSynced', {
|
||||
detail: {
|
||||
total: offlineChanges.length,
|
||||
succeeded: offlineChanges.length - failedChanges.length,
|
||||
failed: failedChanges.length
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(syncEvent);
|
||||
|
||||
console.log(`Synchronisierung abgeschlossen: ${offlineChanges.length - failedChanges.length} erfolgreich, ${failedChanges.length} fehlgeschlagen`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt Offline-Änderung zum Synchronisierungsstapel hinzu
|
||||
* @param {string} url - API-Endpunkt
|
||||
* @param {string} method - HTTP-Methode (POST, PUT, DELETE)
|
||||
* @param {object} data - Zu sendende Daten
|
||||
*/
|
||||
addOfflineChange(url, method, data) {
|
||||
const offlineChanges = JSON.parse(localStorage.getItem('offlineChanges') || '[]');
|
||||
|
||||
offlineChanges.push({
|
||||
id: Date.now().toString(36) + Math.random().toString(36).substr(2, 5),
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
localStorage.setItem('offlineChanges', JSON.stringify(offlineChanges));
|
||||
console.log(`Offline-Änderung gespeichert: ${method} ${url}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-Wechsel überwachen und reagieren
|
||||
*/
|
||||
setupThemeListeners() {
|
||||
// Auf Dark Mode Änderungen reagieren
|
||||
window.addEventListener('darkModeChanged', (e) => {
|
||||
this.darkMode = e.detail.isDark;
|
||||
this.updateAppTheme();
|
||||
|
||||
// App-Konfiguration aktualisieren
|
||||
const appConfig = JSON.parse(localStorage.getItem('appConfig') || '{}');
|
||||
appConfig.theme = this.darkMode ? 'dark' : 'light';
|
||||
localStorage.setItem('appConfig', JSON.stringify(appConfig));
|
||||
|
||||
console.log(`Theme geändert: ${this.darkMode ? 'Dark Mode' : 'Light Mode'}`);
|
||||
});
|
||||
|
||||
// Bei Systemthemen-Änderung prüfen, ob wir automatisch wechseln sollen
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
const appConfig = JSON.parse(localStorage.getItem('appConfig') || '{}');
|
||||
|
||||
// Nur reagieren, wenn der Nutzer "system" als Theme gewählt hat
|
||||
if (appConfig.theme === 'system') {
|
||||
this.darkMode = e.matches;
|
||||
this.updateAppTheme();
|
||||
console.log(`Systemthema geändert: ${this.darkMode ? 'Dark Mode' : 'Light Mode'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme im UI aktualisieren
|
||||
*/
|
||||
updateAppTheme() {
|
||||
// Theme-color Meta-Tag aktualisieren für Mobile Browser
|
||||
const metaThemeColor = document.getElementById('metaThemeColor') ||
|
||||
document.querySelector('meta[name="theme-color"]');
|
||||
if (metaThemeColor) {
|
||||
metaThemeColor.setAttribute('content', this.darkMode ? '#0f172a' : '#ffffff');
|
||||
}
|
||||
|
||||
// Dynamische UI-Elemente anpassen
|
||||
this.updateUIForTheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* UI-Elemente für das aktuelle Theme anpassen
|
||||
*/
|
||||
updateUIForTheme() {
|
||||
// Offline-Banner und Sync-Indikator anpassen
|
||||
const offlineBanner = document.getElementById('offline-banner');
|
||||
const syncIndicator = document.getElementById('sync-indicator');
|
||||
|
||||
if (offlineBanner) {
|
||||
if (this.darkMode) {
|
||||
offlineBanner.classList.add('dark-theme');
|
||||
offlineBanner.classList.remove('light-theme');
|
||||
} else {
|
||||
offlineBanner.classList.add('light-theme');
|
||||
offlineBanner.classList.remove('dark-theme');
|
||||
}
|
||||
}
|
||||
|
||||
if (syncIndicator) {
|
||||
if (this.darkMode) {
|
||||
syncIndicator.classList.add('dark-theme');
|
||||
syncIndicator.classList.remove('light-theme');
|
||||
} else {
|
||||
syncIndicator.classList.add('light-theme');
|
||||
syncIndicator.classList.remove('dark-theme');
|
||||
}
|
||||
}
|
||||
|
||||
// Andere dynamische UI-Elemente hier anpassen
|
||||
}
|
||||
|
||||
/**
|
||||
* UI-Komponenten initialisieren
|
||||
*/
|
||||
setupUI() {
|
||||
// Offline-Modus Banner einfügen wenn nicht vorhanden
|
||||
if (!document.getElementById('offline-banner')) {
|
||||
const banner = document.createElement('div');
|
||||
banner.id = 'offline-banner';
|
||||
banner.className = `hidden fixed top-0 left-0 right-0 bg-amber-500 dark:bg-amber-600 text-white text-center py-2 px-4 z-50 ${this.darkMode ? 'dark-theme' : 'light-theme'}`;
|
||||
banner.textContent = 'Sie sind offline. Einige Funktionen sind eingeschränkt.';
|
||||
document.body.prepend(banner);
|
||||
|
||||
// Anzeigen wenn offline
|
||||
if (this.isOffline) {
|
||||
banner.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Event-Listener für Online/Offline-Status
|
||||
window.addEventListener('online', () => banner.classList.add('hidden'));
|
||||
window.addEventListener('offline', () => banner.classList.remove('hidden'));
|
||||
}
|
||||
|
||||
// Synchronisierungs-Indikator einfügen wenn nicht vorhanden
|
||||
if (!document.getElementById('sync-indicator')) {
|
||||
const indicator = document.createElement('div');
|
||||
indicator.id = 'sync-indicator';
|
||||
indicator.className = `hidden fixed bottom-4 right-4 bg-indigo-600 dark:bg-indigo-700 text-white text-sm rounded-full py-1 px-3 z-50 flex items-center ${this.darkMode ? 'dark-theme' : 'light-theme'}`;
|
||||
|
||||
const spinnerSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
spinnerSvg.setAttribute('class', 'animate-spin -ml-1 mr-2 h-4 w-4 text-white');
|
||||
spinnerSvg.setAttribute('fill', 'none');
|
||||
spinnerSvg.setAttribute('viewBox', '0 0 24 24');
|
||||
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('class', 'opacity-25');
|
||||
circle.setAttribute('cx', '12');
|
||||
circle.setAttribute('cy', '12');
|
||||
circle.setAttribute('r', '10');
|
||||
circle.setAttribute('stroke', 'currentColor');
|
||||
circle.setAttribute('stroke-width', '4');
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('class', 'opacity-75');
|
||||
path.setAttribute('fill', 'currentColor');
|
||||
path.setAttribute('d', 'M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z');
|
||||
|
||||
spinnerSvg.appendChild(circle);
|
||||
spinnerSvg.appendChild(path);
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = 'Synchronisiere...';
|
||||
|
||||
indicator.appendChild(spinnerSvg);
|
||||
indicator.appendChild(text);
|
||||
document.body.appendChild(indicator);
|
||||
|
||||
// Anzeigen wenn synchronisiert wird
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||
if (document.body.classList.contains('syncing')) {
|
||||
indicator.classList.remove('hidden');
|
||||
} else {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, { attributes: true });
|
||||
}
|
||||
|
||||
// Mobile Optimierungen
|
||||
this.setupMobileOptimizations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile Optimierungen
|
||||
*/
|
||||
setupMobileOptimizations() {
|
||||
// Service Worker-Status auf mobilen Geräten überwachen und UI entsprechend anpassen
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
// Verbessert Touch-Handhabung auf Mobilgeräten
|
||||
this.setupTouchFeedback();
|
||||
|
||||
// Viewport-Höhe für mobile Browser anpassen (Adressleisten-Problem)
|
||||
this.fixMobileViewportHeight();
|
||||
|
||||
// Scroll-Wiederherstellung deaktivieren für eine bessere UX auf Mobilgeräten
|
||||
if ('scrollRestoration' in history) {
|
||||
history.scrollRestoration = 'manual';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch-Feedback für mobile Geräte
|
||||
*/
|
||||
setupTouchFeedback() {
|
||||
// Aktive Klasse für Touch-Feedback hinzufügen
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' ||
|
||||
e.target.closest('button') || e.target.closest('a')) {
|
||||
const element = e.target.tagName === 'BUTTON' || e.target.tagName === 'A' ?
|
||||
e.target : (e.target.closest('button') || e.target.closest('a'));
|
||||
element.classList.add('touch-active');
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', function() {
|
||||
const activeElements = document.querySelectorAll('.touch-active');
|
||||
activeElements.forEach(el => el.classList.remove('touch-active'));
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport-Höhe für mobile Browser fixieren
|
||||
*/
|
||||
fixMobileViewportHeight() {
|
||||
// Mobile Viewport-Höhe berechnen und als CSS-Variable setzen
|
||||
const setViewportHeight = () => {
|
||||
const vh = window.innerHeight * 0.01;
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||
};
|
||||
|
||||
// Initial setzen und bei Größenänderung aktualisieren
|
||||
setViewportHeight();
|
||||
window.addEventListener('resize', setViewportHeight);
|
||||
|
||||
// Auch bei Orientierungsänderung aktualisieren
|
||||
window.addEventListener('orientationchange', () => {
|
||||
setTimeout(setViewportHeight, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// App initialisieren wenn DOM geladen ist
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.myp = new MYPApp();
|
||||
});
|
82
backend/app/static/js/service-worker.js
Normal file
82
backend/app/static/js/service-worker.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// Backup Service Worker for MYP Platform
|
||||
const CACHE_NAME = 'myp-platform-backup-v1';
|
||||
|
||||
// Assets to cache
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/',
|
||||
'/dashboard',
|
||||
'/static/css/tailwind.min.css',
|
||||
'/static/css/tailwind-dark.min.css',
|
||||
'/static/js/ui-components.js',
|
||||
'/static/js/offline-app.js',
|
||||
'/static/favicon.ico'
|
||||
];
|
||||
|
||||
// Install event - cache core assets
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('Backup SW: Installing...');
|
||||
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
console.log('Backup SW: Caching assets');
|
||||
return cache.addAll(ASSETS_TO_CACHE);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Backup SW: Assets cached');
|
||||
return self.skipWaiting();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('Backup SW: Activating...');
|
||||
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
console.log('Backup SW: Deleting old cache', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Backup SW: Activated');
|
||||
return self.clients.claim();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - cache first, network fallback
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return fetch(event.request)
|
||||
.then((fetchResponse) => {
|
||||
// Don't cache non-success responses
|
||||
if (!fetchResponse || fetchResponse.status !== 200 || fetchResponse.type !== 'basic') {
|
||||
return fetchResponse;
|
||||
}
|
||||
|
||||
// Cache successful responses
|
||||
const responseToCache = fetchResponse.clone();
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
|
||||
return fetchResponse;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
432
backend/app/static/js/sw.js
Normal file
432
backend/app/static/js/sw.js
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* MYP Platform Service Worker
|
||||
* Offline-First Caching Strategy
|
||||
*/
|
||||
|
||||
// MYP Platform Service Worker
|
||||
const CACHE_NAME = 'myp-platform-cache-v1';
|
||||
const STATIC_CACHE = 'myp-static-v1';
|
||||
const DYNAMIC_CACHE = 'myp-dynamic-v1';
|
||||
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/',
|
||||
'/dashboard',
|
||||
'/static/css/tailwind.min.css',
|
||||
'/static/css/tailwind-dark.min.css',
|
||||
'/static/js/ui-components.js',
|
||||
'/static/js/offline-app.js',
|
||||
'/static/icons/mercedes-logo.svg',
|
||||
'/static/icons/icon-144x144.png',
|
||||
'/static/favicon.ico'
|
||||
];
|
||||
|
||||
// Static files patterns
|
||||
const STATIC_PATTERNS = [
|
||||
/\.css$/,
|
||||
/\.js$/,
|
||||
/\.svg$/,
|
||||
/\.png$/,
|
||||
/\.ico$/,
|
||||
/\.woff2?$/
|
||||
];
|
||||
|
||||
// API request patterns to avoid caching
|
||||
const API_PATTERNS = [
|
||||
/^\/api\//,
|
||||
/^\/auth\//,
|
||||
/^\/api\/jobs/,
|
||||
/^\/api\/printers/,
|
||||
/^\/api\/stats/
|
||||
];
|
||||
|
||||
// Install event - cache core assets
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('Service Worker: Installing...');
|
||||
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE)
|
||||
.then((cache) => {
|
||||
console.log('Service Worker: Caching static files');
|
||||
return cache.addAll(ASSETS_TO_CACHE);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Service Worker: Static files cached');
|
||||
return self.skipWaiting();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Service Worker: Error caching static files', error);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('Service Worker: Activating...');
|
||||
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE) {
|
||||
console.log('Service Worker: Deleting old cache', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Service Worker: Activated');
|
||||
return self.clients.claim();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - handle requests
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests and unsupported schemes for caching
|
||||
if (request.method !== 'GET' ||
|
||||
(url.protocol !== 'http:' && url.protocol !== 'https:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple network-first approach
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
// Cache successful static responses
|
||||
if (response && response.status === 200 && isStaticFile(url.pathname) &&
|
||||
(url.protocol === 'http:' || url.protocol === 'https:')) {
|
||||
const responseClone = response.clone();
|
||||
caches.open(STATIC_CACHE).then((cache) => {
|
||||
cache.put(request, responseClone);
|
||||
}).catch(err => {
|
||||
console.warn('Failed to cache response:', err);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback to cache if network fails
|
||||
return caches.match(request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Check if request is for a static file
|
||||
function isStaticFile(pathname) {
|
||||
return STATIC_PATTERNS.some(pattern => pattern.test(pathname));
|
||||
}
|
||||
|
||||
// Check if request is an API request
|
||||
function isAPIRequest(pathname) {
|
||||
return API_PATTERNS.some(pattern => pattern.test(pathname));
|
||||
}
|
||||
|
||||
// Check if request is for a page
|
||||
function isPageRequest(request) {
|
||||
return request.mode === 'navigate';
|
||||
}
|
||||
|
||||
// MYP Platform Service Worker
|
||||
const STATIC_FILES = [
|
||||
'/',
|
||||
'/static/css/tailwind.min.css',
|
||||
'/static/css/tailwind-dark.min.css',
|
||||
'/static/js/ui-components.js',
|
||||
'/static/js/offline-app.js',
|
||||
'/login',
|
||||
'/dashboard'
|
||||
];
|
||||
|
||||
// API endpoints to cache
|
||||
const API_CACHE_PATTERNS = [
|
||||
/^\/api\/dashboard/,
|
||||
/^\/api\/printers/,
|
||||
/^\/api\/jobs/,
|
||||
/^\/api\/stats/
|
||||
];
|
||||
|
||||
// Handle static file requests - Cache First strategy
|
||||
async function handleStaticFile(request) {
|
||||
try {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
// Cache successful responses
|
||||
if (networkResponse.ok) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
console.error('Service Worker: Error handling static file', error);
|
||||
|
||||
// Return cached version if available
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Return offline fallback
|
||||
return new Response('Offline - Datei nicht verfügbar', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle API requests - Network First with cache fallback
|
||||
async function handleAPIRequest(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip caching for chrome-extension URLs
|
||||
if (url.protocol === 'chrome-extension:') {
|
||||
try {
|
||||
return await fetch(request);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch from chrome-extension:', error);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Fehler beim Zugriff auf chrome-extension',
|
||||
offline: true
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Try network first
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
if (networkResponse.ok) {
|
||||
// Cache successful GET responses for specific endpoints
|
||||
if (request.method === 'GET' && shouldCacheAPIResponse(url.pathname)) {
|
||||
const cache = await caches.open(DYNAMIC_CACHE);
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
}
|
||||
|
||||
throw new Error(`HTTP ${networkResponse.status}`);
|
||||
} catch (error) {
|
||||
console.log('Service Worker: Network failed for API request, trying cache');
|
||||
|
||||
// Try cache fallback for GET requests
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
// Add offline header to indicate cached response
|
||||
const response = cachedResponse.clone();
|
||||
response.headers.set('X-Served-By', 'ServiceWorker-Cache');
|
||||
return response;
|
||||
}
|
||||
|
||||
// Return offline response
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Offline - Daten nicht verfügbar',
|
||||
offline: true,
|
||||
timestamp: new Date().toISOString()
|
||||
}), {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Served-By': 'ServiceWorker-Offline'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle page requests - Network First with offline fallback
|
||||
async function handlePageRequest(request) {
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
if (networkResponse.ok) {
|
||||
// Cache successful page responses
|
||||
const cache = await caches.open(DYNAMIC_CACHE);
|
||||
cache.put(request, networkResponse.clone());
|
||||
return networkResponse;
|
||||
}
|
||||
|
||||
throw new Error(`HTTP ${networkResponse.status}`);
|
||||
} catch (error) {
|
||||
console.log('Service Worker: Network failed for page request, trying cache');
|
||||
|
||||
// Try cache fallback
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Fallback offline response
|
||||
return caches.match('/offline.html') || new Response(
|
||||
'<html><body><h1>Offline</h1><p>Sie sind momentan offline. Bitte überprüfen Sie Ihre Internetverbindung.</p></body></html>',
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if API response should be cached
|
||||
function shouldCacheAPIResponse(pathname) {
|
||||
return API_CACHE_PATTERNS.some(pattern => pattern.test(pathname));
|
||||
}
|
||||
|
||||
// Background sync for offline actions
|
||||
self.addEventListener('sync', (event) => {
|
||||
console.log('Service Worker: Background sync triggered', event.tag);
|
||||
|
||||
if (event.tag === 'background-sync') {
|
||||
event.waitUntil(doBackgroundSync());
|
||||
}
|
||||
});
|
||||
|
||||
// Perform background sync
|
||||
async function doBackgroundSync() {
|
||||
try {
|
||||
// Get pending requests from IndexedDB or localStorage
|
||||
const pendingRequests = await getPendingRequests();
|
||||
|
||||
for (const request of pendingRequests) {
|
||||
try {
|
||||
await fetch(request.url, request.options);
|
||||
await removePendingRequest(request.id);
|
||||
console.log('Service Worker: Synced request', request.url);
|
||||
} catch (error) {
|
||||
console.error('Service Worker: Failed to sync request', request.url, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Service Worker: Background sync failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get pending requests (placeholder - implement with IndexedDB)
|
||||
async function getPendingRequests() {
|
||||
// This would typically use IndexedDB to store pending requests
|
||||
// For now, return empty array
|
||||
return [];
|
||||
}
|
||||
|
||||
// Remove pending request (placeholder - implement with IndexedDB)
|
||||
async function removePendingRequest(id) {
|
||||
// This would typically remove the request from IndexedDB
|
||||
console.log('Service Worker: Removing pending request', id);
|
||||
}
|
||||
|
||||
// Push notification handling
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('Service Worker: Push notification received');
|
||||
|
||||
const options = {
|
||||
body: 'Sie haben neue Benachrichtigungen',
|
||||
icon: '/static/icons/icon-192x192.png',
|
||||
badge: '/static/icons/badge-72x72.png',
|
||||
vibrate: [100, 50, 100],
|
||||
data: {
|
||||
dateOfArrival: Date.now(),
|
||||
primaryKey: 1
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
action: 'explore',
|
||||
title: 'Anzeigen',
|
||||
icon: '/static/icons/checkmark.png'
|
||||
},
|
||||
{
|
||||
action: 'close',
|
||||
title: 'Schließen',
|
||||
icon: '/static/icons/xmark.png'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification('MYP Platform', options)
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click handling
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('Service Worker: Notification clicked');
|
||||
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'explore') {
|
||||
event.waitUntil(
|
||||
clients.openWindow('/dashboard')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Message handling from main thread
|
||||
self.addEventListener('message', (event) => {
|
||||
console.log('Service Worker: Message received', event.data);
|
||||
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
|
||||
if (event.data && event.data.type === 'CACHE_URLS') {
|
||||
event.waitUntil(
|
||||
cacheUrls(event.data.urls)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Cache specific URLs
|
||||
async function cacheUrls(urls) {
|
||||
try {
|
||||
const cache = await caches.open(DYNAMIC_CACHE);
|
||||
await cache.addAll(urls);
|
||||
console.log('Service Worker: URLs cached', urls);
|
||||
} catch (error) {
|
||||
console.error('Service Worker: Error caching URLs', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic background sync (if supported)
|
||||
self.addEventListener('periodicsync', (event) => {
|
||||
console.log('Service Worker: Periodic sync triggered', event.tag);
|
||||
|
||||
if (event.tag === 'content-sync') {
|
||||
event.waitUntil(syncContent());
|
||||
}
|
||||
});
|
||||
|
||||
// Sync content periodically
|
||||
async function syncContent() {
|
||||
try {
|
||||
// Sync critical data in background
|
||||
const endpoints = ['/api/dashboard', '/api/jobs'];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const response = await fetch(endpoint);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(DYNAMIC_CACHE);
|
||||
cache.put(endpoint, response.clone());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Service Worker: Error syncing', endpoint, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Service Worker: Content sync failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Service Worker: Script loaded');
|
1039
backend/app/static/js/ui-components.js
Normal file
1039
backend/app/static/js/ui-components.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user