"feat: Add database backup script

This commit is contained in:
Till Tomczak 2025-05-29 18:23:25 +02:00
parent e464fb9587
commit deda6d6c38
4 changed files with 958 additions and 3 deletions

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,910 @@
{% extends "base.html" %}
{% block title %}Gastanträge Verwaltung{% endblock %}
{% block extra_css %}
<style>
.stats-card {
transition: transform 0.2s ease-in-out;
}
.stats-card:hover {
transform: translateY(-2px);
}
.urgent-request {
background-color: #fef2f2;
border-left: 4px solid #ef4444;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.status-pending {
background-color: #fef3c7;
color: #d97706;
}
.status-approved {
background-color: #d1fae5;
color: #059669;
}
.status-rejected {
background-color: #fee2e2;
color: #dc2626;
}
.action-button {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 0.375rem;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.action-button:hover {
transform: scale(1.05);
}
.btn-approve {
background-color: #10b981;
color: white;
}
.btn-approve:hover {
background-color: #059669;
}
.btn-reject {
background-color: #ef4444;
color: white;
}
.btn-reject:hover {
background-color: #dc2626;
}
.btn-view {
background-color: #3b82f6;
color: white;
}
.btn-view:hover {
background-color: #2563eb;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: white;
margin: 15% auto;
padding: 20px;
border-radius: 8px;
width: 80%;
max-width: 500px;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: black;
}
.loading {
display: none;
text-align: center;
padding: 20px;
}
.spinner {
border: 4px solid #f3f4f6;
border-top: 4px solid #3b82f6;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900">Gastanträge Verwaltung</h1>
<p class="text-gray-600 mt-1">Verwalten Sie alle Gastanfragen für 3D-Druckaufträge</p>
</div>
<div class="flex space-x-2">
<button id="refreshBtn" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors">
<i class="fas fa-sync-alt mr-2"></i>Aktualisieren
</button>
<button id="exportBtn" class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition-colors">
<i class="fas fa-download mr-2"></i>Exportieren
</button>
</div>
</div>
<!-- Statistiken -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="stats-card bg-white p-6 rounded-lg shadow-lg">
<div class="flex items-center">
<div class="p-3 rounded-full bg-blue-100 text-blue-600">
<i class="fas fa-list-ul text-xl"></i>
</div>
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-500">Gesamt</h3>
<p class="text-2xl font-bold text-gray-900" id="total-count">-</p>
</div>
</div>
</div>
<div class="stats-card bg-white p-6 rounded-lg shadow-lg">
<div class="flex items-center">
<div class="p-3 rounded-full bg-yellow-100 text-yellow-600">
<i class="fas fa-clock text-xl"></i>
</div>
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-500">Offen</h3>
<p class="text-2xl font-bold text-yellow-600" id="pending-count">-</p>
</div>
</div>
</div>
<div class="stats-card bg-white p-6 rounded-lg shadow-lg">
<div class="flex items-center">
<div class="p-3 rounded-full bg-green-100 text-green-600">
<i class="fas fa-check text-xl"></i>
</div>
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-500">Genehmigt</h3>
<p class="text-2xl font-bold text-green-600" id="approved-count">-</p>
</div>
</div>
</div>
<div class="stats-card bg-white p-6 rounded-lg shadow-lg">
<div class="flex items-center">
<div class="p-3 rounded-full bg-red-100 text-red-600">
<i class="fas fa-times text-xl"></i>
</div>
<div class="ml-4">
<h3 class="text-sm font-medium text-gray-500">Abgelehnt</h3>
<p class="text-2xl font-bold text-red-600" id="rejected-count">-</p>
</div>
</div>
</div>
</div>
<!-- Filter und Suche -->
<div class="bg-white p-4 rounded-lg shadow mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label for="statusFilter" class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select id="statusFilter" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="all">Alle Status</option>
<option value="pending">Offen</option>
<option value="approved">Genehmigt</option>
<option value="rejected">Abgelehnt</option>
</select>
</div>
<div>
<label for="urgentFilter" class="block text-sm font-medium text-gray-700 mb-1">Priorität</label>
<select id="urgentFilter" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="all">Alle</option>
<option value="urgent">Nur dringende</option>
<option value="normal">Normal</option>
</select>
</div>
<div>
<label for="sortOrder" class="block text-sm font-medium text-gray-700 mb-1">Sortierung</label>
<select id="sortOrder" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="newest">Neueste zuerst</option>
<option value="oldest">Älteste zuerst</option>
<option value="urgent">Dringende zuerst</option>
</select>
</div>
<div>
<label for="searchInput" class="block text-sm font-medium text-gray-700 mb-1">Suchen</label>
<input type="text" id="searchInput" placeholder="Name, E-Mail oder Datei..."
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
<!-- Loading Indicator -->
<div id="loadingIndicator" class="loading">
<div class="spinner"></div>
<p class="mt-2 text-gray-600">Lade Gastanträge...</p>
</div>
<!-- Anträge Liste -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Gastanträge</h3>
<p class="text-sm text-gray-600 mt-1">Alle eingegangenen Anfragen für Gastzugang</p>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Antragsteller
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Datei & Details
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Erstellt
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr>
</thead>
<tbody id="requestsTable" class="bg-white divide-y divide-gray-200">
<!-- Wird dynamisch gefüllt -->
</tbody>
</table>
</div>
<!-- Pagination -->
<div id="paginationContainer" class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div class="text-sm text-gray-700">
Zeige <span id="resultsInfo">0-0 von 0</span> Einträgen
</div>
<div class="flex space-x-2">
<button id="prevPageBtn" class="px-3 py-1 border border-gray-300 rounded text-sm hover:bg-gray-50 disabled:opacity-50" disabled>
Zurück
</button>
<button id="nextPageBtn" class="px-3 py-1 border border-gray-300 rounded text-sm hover:bg-gray-50 disabled:opacity-50" disabled>
Weiter
</button>
</div>
</div>
</div>
</div>
<!-- Genehmigungsmodal -->
<div id="approveModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2 class="text-xl font-bold mb-4">Antrag genehmigen</h2>
<form id="approveForm">
<div class="mb-4">
<label for="approvalNotes" class="block text-sm font-medium text-gray-700 mb-2">
Genehmigungsnotizen (optional)
</label>
<textarea id="approvalNotes" rows="3"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Zusätzliche Notizen oder Anweisungen..."></textarea>
</div>
<div class="mb-4">
<label for="assignedPrinter" class="block text-sm font-medium text-gray-700 mb-2">
Drucker zuweisen (optional)
</label>
<select id="assignedPrinter" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Kein Drucker zugewiesen</option>
<!-- Wird dynamisch gefüllt -->
</select>
</div>
<div class="flex justify-end space-x-2">
<button type="button" id="cancelApproval" class="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50">
Abbrechen
</button>
<button type="submit" class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600">
Genehmigen
</button>
</div>
</form>
</div>
</div>
<!-- Ablehnungsmodal -->
<div id="rejectModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2 class="text-xl font-bold mb-4">Antrag ablehnen</h2>
<form id="rejectForm">
<div class="mb-4">
<label for="rejectionReason" class="block text-sm font-medium text-gray-700 mb-2">
Ablehnungsgrund <span class="text-red-500">*</span>
</label>
<textarea id="rejectionReason" rows="3" required
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Bitte geben Sie einen Grund für die Ablehnung an..."></textarea>
</div>
<div class="flex justify-end space-x-2">
<button type="button" id="cancelRejection" class="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-50">
Abbrechen
</button>
<button type="submit" class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600">
Ablehnen
</button>
</div>
</form>
</div>
</div>
<!-- Detailmodal -->
<div id="detailModal" class="modal">
<div class="modal-content" style="max-width: 700px;">
<span class="close">&times;</span>
<h2 class="text-xl font-bold mb-4">Antrag Details</h2>
<div id="detailContent">
<!-- Wird dynamisch gefüllt -->
</div>
</div>
</div>
<!-- Notification Toast -->
<div id="notification" class="fixed top-4 right-4 p-4 rounded-lg shadow-lg hidden transition-all duration-300 z-50">
<div class="flex items-center">
<span id="notificationIcon" class="mr-2"></span>
<span id="notificationMessage"></span>
<button id="notificationClose" class="ml-4 text-white hover:text-gray-200">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<script>
// Globale Variablen
let currentRequests = [];
let currentPage = 0;
let pageSize = 20;
let totalRequests = 0;
let currentRequestId = null;
// Initialisierung
document.addEventListener('DOMContentLoaded', function() {
initializeEventListeners();
loadAvailablePrinters();
loadGuestRequests();
});
function initializeEventListeners() {
// Filter und Suche
document.getElementById('refreshBtn').addEventListener('click', () => loadGuestRequests());
document.getElementById('exportBtn').addEventListener('click', exportRequests);
document.getElementById('statusFilter').addEventListener('change', () => {
currentPage = 0;
loadGuestRequests();
});
document.getElementById('urgentFilter').addEventListener('change', () => {
currentPage = 0;
loadGuestRequests();
});
document.getElementById('sortOrder').addEventListener('change', () => {
currentPage = 0;
loadGuestRequests();
});
document.getElementById('searchInput').addEventListener('input', debounce(() => {
currentPage = 0;
loadGuestRequests();
}, 500));
// Pagination
document.getElementById('prevPageBtn').addEventListener('click', () => {
if (currentPage > 0) {
currentPage--;
loadGuestRequests();
}
});
document.getElementById('nextPageBtn').addEventListener('click', () => {
if ((currentPage + 1) * pageSize < totalRequests) {
currentPage++;
loadGuestRequests();
}
});
// Modal Event Listeners
setupModalEventListeners();
}
function setupModalEventListeners() {
// Modals schließen
document.querySelectorAll('.close').forEach(closeBtn => {
closeBtn.addEventListener('click', closeAllModals);
});
// Cancel buttons
document.getElementById('cancelApproval').addEventListener('click', closeAllModals);
document.getElementById('cancelRejection').addEventListener('click', closeAllModals);
// Form submissions
document.getElementById('approveForm').addEventListener('submit', handleApproval);
document.getElementById('rejectForm').addEventListener('submit', handleRejection);
// Notification close
document.getElementById('notificationClose').addEventListener('click', hideNotification);
// Close modals when clicking outside
window.addEventListener('click', function(event) {
if (event.target.classList.contains('modal')) {
closeAllModals();
}
});
}
async function loadGuestRequests() {
try {
showLoading(true);
const status = document.getElementById('statusFilter').value;
const urgent = document.getElementById('urgentFilter').value;
const sort = document.getElementById('sortOrder').value;
const search = document.getElementById('searchInput').value;
const params = new URLSearchParams({
limit: pageSize.toString(),
offset: (currentPage * pageSize).toString()
});
if (status !== 'all') params.append('status', status);
if (urgent !== 'all') params.append('urgent', urgent);
if (sort) params.append('sort', sort);
if (search) params.append('search', search);
const response = await fetch(`/api/admin/guest-requests?${params}`);
const data = await response.json();
if (data.success) {
currentRequests = data.requests;
totalRequests = data.total;
updateStatistics(data.stats);
renderRequestsTable(data.requests);
updatePagination();
} else {
showNotification('Fehler beim Laden der Anträge: ' + (data.message || 'Unbekannter Fehler'), 'error');
}
} catch (error) {
console.error('Fehler beim Laden der Anträge:', error);
showNotification('Fehler beim Laden der Anträge: ' + error.message, 'error');
} finally {
showLoading(false);
}
}
async function loadAvailablePrinters() {
try {
const response = await fetch('/api/printers');
const data = await response.json();
if (data.success) {
const select = document.getElementById('assignedPrinter');
select.innerHTML = '<option value="">Kein Drucker zugewiesen</option>';
data.printers.forEach(printer => {
const option = document.createElement('option');
option.value = printer.id;
option.textContent = `${printer.name} (${printer.location || 'Unbekannt'})`;
select.appendChild(option);
});
}
} catch (error) {
console.error('Fehler beim Laden der Drucker:', error);
}
}
function updateStatistics(stats) {
document.getElementById('total-count').textContent = stats.total || 0;
document.getElementById('pending-count').textContent = stats.pending || 0;
document.getElementById('approved-count').textContent = stats.approved || 0;
document.getElementById('rejected-count').textContent = stats.rejected || 0;
}
function renderRequestsTable(requests) {
const tbody = document.getElementById('requestsTable');
tbody.innerHTML = '';
if (requests.length === 0) {
const row = document.createElement('tr');
row.innerHTML = `
<td colspan="5" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-inbox text-4xl mb-4"></i>
<p>Keine Gastanträge gefunden</p>
</td>
`;
tbody.appendChild(row);
return;
}
requests.forEach(request => {
const row = createRequestRow(request);
tbody.appendChild(row);
});
}
function createRequestRow(request) {
const row = document.createElement('tr');
// Berechne ob dringend
const now = new Date();
const createdAt = new Date(request.created_at);
const hoursOld = (now - createdAt) / (1000 * 60 * 60);
const isUrgent = hoursOld > 24 && request.status === 'pending';
if (isUrgent) {
row.classList.add('urgent-request');
}
row.innerHTML = `
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div>
<div class="text-sm font-medium text-gray-900">
${escapeHtml(request.name)}
${isUrgent ? '<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800"><i class="fas fa-exclamation-triangle mr-1"></i>Dringend</span>' : ''}
</div>
<div class="text-sm text-gray-500">${escapeHtml(request.email || 'Keine E-Mail')}</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">
<i class="fas fa-file mr-1"></i>${escapeHtml(request.file_name || 'Keine Datei')}
</div>
<div class="text-sm text-gray-500">
<i class="fas fa-clock mr-1"></i>${request.duration_minutes || 0} Min,
<i class="fas fa-copy ml-2 mr-1"></i>${request.copies || 1} Kopien
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="status-badge status-${request.status}">
${getStatusIcon(request.status)} ${getStatusText(request.status)}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div>${formatDate(request.created_at)}</div>
<div class="text-xs text-gray-400">${formatTimeAgo(request.created_at)}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex space-x-2">
${request.status === 'pending' ? `
<button onclick="showApproveModal(${request.id})"
class="action-button btn-approve" title="Genehmigen">
<i class="fas fa-check"></i> Genehmigen
</button>
<button onclick="showRejectModal(${request.id})"
class="action-button btn-reject" title="Ablehnen">
<i class="fas fa-times"></i> Ablehnen
</button>
` : ''}
<button onclick="showRequestDetails(${request.id})"
class="action-button btn-view" title="Details anzeigen">
<i class="fas fa-eye"></i> Details
</button>
</div>
</td>
`;
return row;
}
function getStatusIcon(status) {
switch (status) {
case 'pending': return '<i class="fas fa-clock"></i>';
case 'approved': return '<i class="fas fa-check"></i>';
case 'rejected': return '<i class="fas fa-times"></i>';
default: return '<i class="fas fa-question"></i>';
}
}
function getStatusText(status) {
switch (status) {
case 'pending': return 'Offen';
case 'approved': return 'Genehmigt';
case 'rejected': return 'Abgelehnt';
default: return 'Unbekannt';
}
}
function showApproveModal(requestId) {
currentRequestId = requestId;
document.getElementById('approveModal').style.display = 'block';
document.getElementById('approvalNotes').value = '';
document.getElementById('assignedPrinter').value = '';
}
function showRejectModal(requestId) {
currentRequestId = requestId;
document.getElementById('rejectModal').style.display = 'block';
document.getElementById('rejectionReason').value = '';
}
async function showRequestDetails(requestId) {
try {
const response = await fetch(`/api/guest-requests/${requestId}`);
const data = await response.json();
if (data.success) {
const request = data.request;
const detailContent = document.getElementById('detailContent');
detailContent.innerHTML = `
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h3 class="font-semibold text-gray-900 mb-2">Antragsteller</h3>
<p><strong>Name:</strong> ${escapeHtml(request.name)}</p>
<p><strong>E-Mail:</strong> ${escapeHtml(request.email || 'Nicht angegeben')}</p>
<p><strong>IP-Adresse:</strong> ${escapeHtml(request.author_ip || 'Unbekannt')}</p>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">Druckdetails</h3>
<p><strong>Dateiname:</strong> ${escapeHtml(request.file_name || 'Keine Datei')}</p>
<p><strong>Dauer:</strong> ${request.duration_minutes || 0} Minuten</p>
<p><strong>Kopien:</strong> ${request.copies || 1}</p>
${request.file_size_mb ? `<p><strong>Dateigröße:</strong> ${request.file_size_mb} MB</p>` : ''}
</div>
<div class="md:col-span-2">
<h3 class="font-semibold text-gray-900 mb-2">Grund</h3>
<p class="bg-gray-50 p-3 rounded">${escapeHtml(request.reason || 'Kein Grund angegeben')}</p>
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">Status</h3>
<p><strong>Aktueller Status:</strong> <span class="status-badge status-${request.status}">${getStatusText(request.status)}</span></p>
<p><strong>Erstellt am:</strong> ${formatDate(request.created_at)}</p>
${request.updated_at ? `<p><strong>Aktualisiert am:</strong> ${formatDate(request.updated_at)}</p>` : ''}
</div>
<div>
<h3 class="font-semibold text-gray-900 mb-2">Bearbeitung</h3>
${request.approved_at ? `
<p><strong>Genehmigt am:</strong> ${formatDate(request.approved_at)}</p>
${request.approved_by_name ? `<p><strong>Genehmigt von:</strong> ${escapeHtml(request.approved_by_name)}</p>` : ''}
${request.approval_notes ? `<p><strong>Notizen:</strong> ${escapeHtml(request.approval_notes)}</p>` : ''}
${request.otp_code ? `<p><strong>OTP-Code:</strong> <code class="bg-gray-100 px-2 py-1 rounded">${request.otp_code}</code></p>` : ''}
` : ''}
${request.rejected_at ? `
<p><strong>Abgelehnt am:</strong> ${formatDate(request.rejected_at)}</p>
${request.rejected_by_name ? `<p><strong>Abgelehnt von:</strong> ${escapeHtml(request.rejected_by_name)}</p>` : ''}
${request.rejection_reason ? `<p><strong>Grund:</strong> ${escapeHtml(request.rejection_reason)}</p>` : ''}
` : ''}
</div>
</div>
`;
document.getElementById('detailModal').style.display = 'block';
} else {
showNotification('Fehler beim Laden der Details: ' + (data.message || 'Unbekannter Fehler'), 'error');
}
} catch (error) {
console.error('Fehler beim Laden der Details:', error);
showNotification('Fehler beim Laden der Details', 'error');
}
}
async function handleApproval(event) {
event.preventDefault();
if (!currentRequestId) return;
try {
const notes = document.getElementById('approvalNotes').value;
const printerId = document.getElementById('assignedPrinter').value;
const requestBody = { notes };
if (printerId) {
requestBody.printer_id = parseInt(printerId);
}
const response = await fetch(`/api/guest-requests/${currentRequestId}/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify(requestBody)
});
const data = await response.json();
if (data.success) {
showNotification(`Antrag erfolgreich genehmigt. OTP-Code: ${data.otp_code}`, 'success');
closeAllModals();
loadGuestRequests();
} else {
showNotification('Fehler beim Genehmigen: ' + (data.message || 'Unbekannter Fehler'), 'error');
}
} catch (error) {
console.error('Fehler beim Genehmigen:', error);
showNotification('Fehler beim Genehmigen: ' + error.message, 'error');
}
}
async function handleRejection(event) {
event.preventDefault();
if (!currentRequestId) return;
const reason = document.getElementById('rejectionReason').value.trim();
if (!reason) {
showNotification('Bitte geben Sie einen Ablehnungsgrund an', 'error');
return;
}
try {
const response = await fetch(`/api/guest-requests/${currentRequestId}/reject`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({ reason })
});
const data = await response.json();
if (data.success) {
showNotification('Antrag erfolgreich abgelehnt', 'success');
closeAllModals();
loadGuestRequests();
} else {
showNotification('Fehler beim Ablehnen: ' + (data.message || 'Unbekannter Fehler'), 'error');
}
} catch (error) {
console.error('Fehler beim Ablehnen:', error);
showNotification('Fehler beim Ablehnen: ' + error.message, 'error');
}
}
async function exportRequests() {
try {
const status = document.getElementById('statusFilter').value;
const params = new URLSearchParams();
if (status !== 'all') {
params.append('status', status);
}
const response = await fetch(`/api/admin/guest-requests/export?${params}`);
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `gastantraege_${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showNotification('Export erfolgreich heruntergeladen', 'success');
} else {
showNotification('Fehler beim Export', 'error');
}
} catch (error) {
console.error('Fehler beim Export:', error);
showNotification('Fehler beim Export: ' + error.message, 'error');
}
}
function updatePagination() {
const start = currentPage * pageSize + 1;
const end = Math.min((currentPage + 1) * pageSize, totalRequests);
document.getElementById('resultsInfo').textContent = `${start}-${end} von ${totalRequests}`;
document.getElementById('prevPageBtn').disabled = currentPage === 0;
document.getElementById('nextPageBtn').disabled = (currentPage + 1) * pageSize >= totalRequests;
}
function closeAllModals() {
document.querySelectorAll('.modal').forEach(modal => {
modal.style.display = 'none';
});
currentRequestId = null;
}
function showLoading(show) {
document.getElementById('loadingIndicator').style.display = show ? 'block' : 'none';
}
function showNotification(message, type = 'info') {
const notification = document.getElementById('notification');
const icon = document.getElementById('notificationIcon');
const messageEl = document.getElementById('notificationMessage');
// Icon und Farbe basierend auf Type
let iconClass, bgClass;
switch (type) {
case 'success':
iconClass = 'fas fa-check-circle';
bgClass = 'bg-green-500';
break;
case 'error':
iconClass = 'fas fa-exclamation-circle';
bgClass = 'bg-red-500';
break;
case 'warning':
iconClass = 'fas fa-exclamation-triangle';
bgClass = 'bg-yellow-500';
break;
default:
iconClass = 'fas fa-info-circle';
bgClass = 'bg-blue-500';
}
icon.className = iconClass;
notification.className = `fixed top-4 right-4 p-4 rounded-lg shadow-lg text-white transition-all duration-300 z-50 ${bgClass}`;
messageEl.textContent = message;
notification.classList.remove('hidden');
// Auto-hide nach 5 Sekunden
setTimeout(() => {
hideNotification();
}, 5000);
}
function hideNotification() {
document.getElementById('notification').classList.add('hidden');
}
function getCsrfToken() {
return document.querySelector('meta[name=csrf-token]')?.getAttribute('content') || '';
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
function escapeHtml(text) {
if (!text) return '';
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
function formatDate(dateString) {
if (!dateString) return 'Unbekannt';
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function formatTimeAgo(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffInMinutes = Math.floor((now - date) / (1000 * 60));
if (diffInMinutes < 60) {
return `vor ${diffInMinutes} Min`;
} else if (diffInMinutes < 1440) {
return `vor ${Math.floor(diffInMinutes / 60)} Std`;
} else {
return `vor ${Math.floor(diffInMinutes / 1440)} Tagen`;
}
}
</script>
{% endblock %}

View File

@ -174,12 +174,35 @@ def migrate_guest_requests_table(cursor):
existing_columns = get_table_columns(cursor, 'guest_requests')
# Vollständige Definition aller erwarteten Spalten basierend auf dem GuestRequest Modell
required_columns = {
'id': 'INTEGER PRIMARY KEY',
'name': 'VARCHAR(100) NOT NULL',
'email': 'VARCHAR(120)',
'reason': 'TEXT',
'duration_min': 'INTEGER', # Bestehende Spalte für Backward-Kompatibilität
'duration_minutes': 'INTEGER', # Neue Spalte für API-Kompatibilität - HIER IST DAS PROBLEM!
'created_at': 'DATETIME DEFAULT CURRENT_TIMESTAMP',
'status': 'VARCHAR(20) DEFAULT "pending"',
'printer_id': 'INTEGER',
'otp_code': 'VARCHAR(100)',
'job_id': 'INTEGER',
'author_ip': 'VARCHAR(50)',
'otp_used_at': 'DATETIME',
'file_name': 'VARCHAR(255)',
'file_path': 'VARCHAR(500)',
'copies': 'INTEGER DEFAULT 1',
'processed_by': 'INTEGER',
'processed_at': 'DATETIME',
'approval_notes': 'TEXT',
'rejection_reason': 'TEXT',
'otp_used_at': 'DATETIME'
'updated_at': 'DATETIME DEFAULT CURRENT_TIMESTAMP',
'approved_at': 'DATETIME',
'rejected_at': 'DATETIME',
'approved_by': 'INTEGER',
'rejected_by': 'INTEGER',
'otp_expires_at': 'DATETIME',
'assigned_printer_id': 'INTEGER'
}
migrations_performed = []
@ -187,12 +210,34 @@ def migrate_guest_requests_table(cursor):
for column_name, column_def in required_columns.items():
if column_name not in existing_columns:
try:
cursor.execute(f"ALTER TABLE guest_requests ADD COLUMN {column_name} {column_def}")
logger.info(f"Spalte '{column_name}' zu guest_requests hinzugefügt")
# Spezielle Behandlung für updated_at mit Trigger
if column_name == 'updated_at':
cursor.execute(f"ALTER TABLE guest_requests ADD COLUMN {column_name} {column_def}")
# Trigger für automatische Aktualisierung
cursor.execute("""
CREATE TRIGGER IF NOT EXISTS update_guest_requests_updated_at
AFTER UPDATE ON guest_requests
BEGIN
UPDATE guest_requests SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END
""")
logger.info(f"Spalte '{column_name}' zu guest_requests hinzugefügt mit Auto-Update-Trigger")
else:
cursor.execute(f"ALTER TABLE guest_requests ADD COLUMN {column_name} {column_def}")
logger.info(f"Spalte '{column_name}' zu guest_requests hinzugefügt")
migrations_performed.append(column_name)
except Exception as e:
logger.error(f"Fehler beim Hinzufügen der Spalte '{column_name}' zu guest_requests: {str(e)}")
# Wenn duration_minutes hinzugefügt wurde, kopiere Werte von duration_min
if 'duration_minutes' in migrations_performed:
try:
cursor.execute("UPDATE guest_requests SET duration_minutes = duration_min WHERE duration_minutes IS NULL")
logger.info("Werte von duration_min zu duration_minutes kopiert")
except Exception as e:
logger.error(f"Fehler beim Kopieren der duration_min Werte: {str(e)}")
return len(migrations_performed) > 0
def create_missing_tables(cursor):