"feat: Add database backup script
This commit is contained in:
parent
e464fb9587
commit
deda6d6c38
Binary file not shown.
BIN
backend/app/database/myp.db.backup_20250529_182203
Normal file
BIN
backend/app/database/myp.db.backup_20250529_182203
Normal file
Binary file not shown.
910
backend/app/templates/admin_guest_requests_overview.html
Normal file
910
backend/app/templates/admin_guest_requests_overview.html
Normal 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">×</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">×</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">×</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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
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 %}
|
@ -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):
|
||||
|
Loading…
x
Reference in New Issue
Block a user