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:
2025-05-25 20:33:38 +02:00
parent e21104611f
commit 2d33753b94
1288 changed files with 247388 additions and 3249 deletions

View 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>
`;
});
}

File diff suppressed because it is too large Load Diff

View 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);
});
}

File diff suppressed because one or more lines are too long

View 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>
`;
}

View 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;
}

View 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();
});

View 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);
}

View 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);
}
});

View 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();
});

View 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
View 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');

File diff suppressed because it is too large Load Diff