Initial Commit

This commit is contained in:
2026-04-10 14:48:52 +02:00
commit fad7d9a757
81 changed files with 39657 additions and 0 deletions
+315
View File
@@ -0,0 +1,315 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends 'base.html' %}
{% block content %}
<div class="container">
<h1>Ausleihen verwalten</h1>
<p>Liste aller aktiven und geplanten Ausleihen. Sie können einzelne Einträge zurücksetzen oder bei Schäden eine Rechnung als PDF erstellen.</p>
{% if entries and entries|length > 0 %}
<!-- Filter Section -->
<div style="margin-bottom: 20px; padding: 15px; background-color: #f5f5f5; border-radius: 5px;">
<h3>Filter</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
<div>
<label for="user-filter" style="display: block; margin-bottom: 5px; font-weight: bold;">Benutzer:</label>
<input
type="text"
id="user-filter"
placeholder="Nach Benutzer filtern..."
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 3px;"
/>
</div>
<div>
<label for="item-filter" style="display: block; margin-bottom: 5px; font-weight: bold;">Element:</label>
<input
type="text"
id="item-filter"
placeholder="Nach Element filtern..."
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 3px;"
/>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div>
<label for="status-filter" style="display: block; margin-bottom: 5px; font-weight: bold;">Status:</label>
<select
id="status-filter"
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 3px;"
>
<option value="">Alle</option>
<option value="active">Aktiv</option>
<option value="planned">Geplant</option>
</select>
</div>
<div style="display: flex; align-items: flex-end;">
<button
id="reset-filters"
class="btn btn-secondary"
style="width: 100%;"
>
Filter zurücksetzen
</button>
</div>
</div>
</div>
<table class="table" style="width:100%; border-collapse: collapse;">
<thead>
<tr>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Status</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Element</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Element-ID</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Benutzer</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Start</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Ende</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Stunde</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Preis</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Rechnung</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Notizen</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Aktion</th>
</tr>
</thead>
<tbody id="borrowing-table-body">
{% for e in entries %}
<tr class="borrowing-row"
data-user="{{ e.user }}"
data-item="{{ e.item_name }}"
data-status="{{ e.status }}"
data-borrow-id="{{ e.id }}"
data-item-id="{{ e.item_id }}"
data-item-name="{{ e.item_name }}"
data-item-code="{{ e.item_code }}"
data-item-cost="{{ e.item_cost_raw }}"
data-user-name="{{ e.user }}">
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.status }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.item_name }}</td>
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace;">{{ e.item_id }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.user }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.start }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.end }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.period }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.item_cost }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">
{% if e.invoice_number %}
<div style="font-weight:600;">{{ e.invoice_number }}</div>
<div style="font-size:0.85rem; color:#666;">{{ e.invoice_amount }}</div>
{% if e.invoice_paid %}
<div style="display:inline-block; margin-top:6px; padding:2px 8px; border-radius:999px; background:#dcfce7; color:#166534; font-size:0.75rem; font-weight:700;">Bezahlt</div>
{% if e.invoice_paid_at %}
<div style="font-size:0.75rem; color:#666; margin-top:4px;">am {{ e.invoice_paid_at }}</div>
{% endif %}
{% else %}
<div style="display:inline-block; margin-top:6px; padding:2px 8px; border-radius:999px; background:#fee2e2; color:#991b1b; font-size:0.75rem; font-weight:700;">Offen</div>
{% endif %}
{% else %}
<div style="font-size:0.9rem; color:#666;">Noch keine Rechnung</div>
{% endif %}
</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ e.notes }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">
<div style="display:flex; flex-wrap:wrap; gap:8px;">
<button type="button" class="btn btn-danger" onclick="openInvoiceModal(this)" {% if e.status != 'active' %}disabled{% endif %}>Rechnung erstellen</button>
{% if e.invoice_number and ((not e.invoice_paid) or e.has_damage) %}
<form method="post" action="{{ url_for('admin_finalize_invoice_and_repair', borrow_id=e.id) }}" onsubmit="return confirm('Rechnung als bezahlt und Element als repariert markieren?');">
<button type="submit" class="btn btn-success">
{% if not e.invoice_paid and e.has_damage %}
Bezahlt + repariert
{% elif not e.invoice_paid %}
Als bezahlt markieren
{% else %}
Als repariert markieren
{% endif %}
</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-warning">Zurücksetzen</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div id="no-results" style="display: none; text-align: center; padding: 20px; color: #666;">
<p>Keine Einträge mit den aktuellen Filtern gefunden.</p>
</div>
{% else %}
<div class="empty-state">
<p>Keine aktiven oder geplanten Ausleihen gefunden.</p>
</div>
{% endif %}
</div>
<div id="invoice-modal" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
<div style="max-width:760px; margin:40px auto; background:#fff; border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
<div>
<h2 style="margin:0;">Rechnung erstellen</h2>
<p style="margin:6px 0 0; color:#666;">Preis wird aus den Anschaffungskosten vorbelegt und kann bei Bedarf angepasst werden.</p>
</div>
<button type="button" class="btn btn-secondary" onclick="closeInvoiceModal()">Schließen</button>
</div>
<form id="invoice-form" method="post" action="">
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
<div>
<label for="invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label>
<input id="invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
</div>
<div>
<label for="invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label>
<input id="invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
</div>
<div>
<label for="invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label>
<input id="invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
</div>
<div>
<label for="invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label>
<input id="invoice-amount" name="invoice_amount" type="text" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px;" placeholder="z.B. 12,50">
</div>
</div>
<div style="margin-bottom:16px;">
<label for="damage-reason" style="display:block; font-weight:700; margin-bottom:6px;">Schadensbeschreibung</label>
<textarea id="damage-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea>
</div>
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;">
<label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" name="mark_destroyed" checked>
Element als zerstört markieren
</label>
<label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" name="close_borrowing" checked>
Ausleihe abschließen
</label>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px;">
<button type="button" class="btn btn-secondary" onclick="closeInvoiceModal()">Abbrechen</button>
<button type="submit" class="btn btn-danger">PDF-Rechnung erstellen</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const userFilter = document.getElementById('user-filter');
const itemFilter = document.getElementById('item-filter');
const statusFilter = document.getElementById('status-filter');
const resetButton = document.getElementById('reset-filters');
const tableBody = document.getElementById('borrowing-table-body');
const noResults = document.getElementById('no-results');
function applyFilters() {
const userValue = userFilter.value.toLowerCase();
const itemValue = itemFilter.value.toLowerCase();
const statusValue = statusFilter.value;
let visibleCount = 0;
document.querySelectorAll('.borrowing-row').forEach(row => {
const user = row.getAttribute('data-user').toLowerCase();
const item = row.getAttribute('data-item').toLowerCase();
const status = row.getAttribute('data-status');
const userMatch = user.includes(userValue);
const itemMatch = item.includes(itemValue);
const statusMatch = statusValue === '' || status === statusValue;
const shouldShow = userMatch && itemMatch && statusMatch;
if (shouldShow) {
row.style.display = '';
visibleCount++;
} else {
row.style.display = 'none';
}
});
// Show/hide no results message
if (visibleCount === 0) {
noResults.style.display = 'block';
} else {
noResults.style.display = 'none';
}
}
function resetFilters() {
userFilter.value = '';
itemFilter.value = '';
statusFilter.value = '';
applyFilters();
}
function normalizeMoneyInput(value) {
if (!value) {
return '';
}
return String(value).replace(' EUR', '').trim();
}
window.openInvoiceModal = function(button) {
const row = button.closest('.borrowing-row');
if (!row) {
return;
}
const borrowId = row.getAttribute('data-borrow-id') || '';
const itemName = row.getAttribute('data-item-name') || '';
const borrower = row.getAttribute('data-user-name') || '';
const itemCode = row.getAttribute('data-item-code') || '';
const itemCost = row.getAttribute('data-item-cost') || '';
const modal = document.getElementById('invoice-modal');
const form = document.getElementById('invoice-form');
const amountField = document.getElementById('invoice-amount');
const reasonField = document.getElementById('damage-reason');
form.action = "{{ url_for('admin_create_invoice', borrow_id='__BORROW_ID__') }}".replace('__BORROW_ID__', borrowId);
document.getElementById('invoice-item').value = itemName;
document.getElementById('invoice-borrower').value = borrower;
document.getElementById('invoice-code').value = itemCode;
amountField.value = normalizeMoneyInput(itemCost);
reasonField.value = 'Das Element ' + itemName + ' wurde während der Ausleihe beschädigt oder zerstört.';
modal.style.display = 'block';
amountField.focus();
};
window.closeInvoiceModal = function() {
const modal = document.getElementById('invoice-modal');
if (modal) {
modal.style.display = 'none';
}
};
const modal = document.getElementById('invoice-modal');
if (modal) {
modal.addEventListener('click', function(event) {
if (event.target === modal) {
closeInvoiceModal();
}
});
}
// Add event listeners
userFilter.addEventListener('input', applyFilters);
itemFilter.addEventListener('input', applyFilters);
statusFilter.addEventListener('change', applyFilters);
resetButton.addEventListener('click', resetFilters);
});
</script>
{% endblock %}
+915
View File
@@ -0,0 +1,915 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
<!DOCTYPE html>
<html lang="en" data-module="inventory">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>{% block title %}Inventarsystem{% endblock %}</title>
{% block head %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/planned_appointments.css') }}">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<style>
/* ===== MODULE DETECTION & SETUP ===== */
:root {
--module-primary-color: #2c3e50;
--module-primary-light: #34495e;
--module-accent-color: #3498db;
--module-accent-light: rgba(52, 152, 219, 0.1);
--module-brand-icon: "📦";
}
html[data-module="library"] {
--module-primary-color: #6c4e71;
--module-primary-light: #7d5a8a;
--module-accent-color: #9b59b6;
--module-accent-light: rgba(155, 89, 182, 0.1);
--module-brand-icon: "📚";
}
/* Global styles */
body {
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
background:
radial-gradient(1200px 500px at 10% -10%, rgba(86, 145, 200, 0.08), transparent 55%),
radial-gradient(900px 420px at 95% 0%, rgba(52, 98, 150, 0.09), transparent 52%),
linear-gradient(180deg, #f2f5f8, #e7edf4);
margin: 0;
padding: 0;
padding-bottom: 30px;
color: #1f2d3d;
}
.container {
padding-top: 20px;
}
/* Navigation styles */
.navbar {
box-shadow: 0 4px 14px rgba(2, 6, 23, 0.24);
background-color: var(--module-primary-color) !important;
}
.navbar-brand {
font-weight: bold;
font-size: 1.4rem;
}
.navbar-brand::before {
content: attr(data-icon);
margin-right: 8px;
}
.navbar-nav .nav-link {
font-size: 1rem;
font-weight: 600;
padding: 0.6rem 0.85rem;
border-radius: 8px;
}
.navbar-nav .nav-link:hover,
.navbar-nav .nav-link:focus {
background-color: rgba(255, 255, 255, 0.12);
}
.nav-priority-link {
background: var(--module-accent-light);
border: 1px solid var(--module-accent-color);
}
.admin-nav-section {
margin-left: auto;
display: flex;
gap: 15px;
}
.admin-nav-section .nav-item {
background-color: var(--module-accent-color);
color: white;
padding: 5px 10px;
border-radius: 4px;
text-decoration: none;
transition: all 0.2s ease;
}
.admin-nav-section .nav-item:hover {
opacity: 0.8;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Admin dropdown styling */
.nav-item.dropdown .nav-link.dropdown-toggle {
color: #ffffff;
font-weight: 500;
position: relative;
}
.nav-item.dropdown .nav-link.dropdown-toggle:hover {
color: #ffffff;
}
/* Consistent spacing for admin nav items */
.navbar-nav .nav-item {
margin-right: 5px;
}
.navbar-nav {
align-items: center;
}
.navbar-nav .nav-item:last-child {
margin-right: 0;
}
/* Flash messages */
.flashes {
margin-top: 20px;
margin-bottom: 20px;
}
.flash {
padding: 12px 20px;
margin-bottom: 15px;
border-radius: 5px;
font-weight: 500;
}
.flash.success {
background-color: #d4edda;
color: #155724;
border-left: 5px solid #28a745;
}
.flash.error {
background-color: #f8d7da;
color: #721c24;
border-left: 5px solid #dc3545;
}
.flash.info {
background-color: #e2e3e5;
color: #383d41;
border-left: 5px solid #6c757d;
}
/* Dropdown menus - consistent with system design */
.dropdown-menu {
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 8px 0;
min-width: 200px;
margin-top: 5px;
}
.dropdown-item {
padding: 10px 16px;
font-size: 0.9rem;
color: #495057;
transition: all 0.2s ease;
border: none;
background: none;
}
.dropdown-item:hover,
.dropdown-item:focus {
background-color: #f8f9fa;
color: #007bff;
transform: translateX(2px);
}
/* Navigation dropdown specific styles */
.nav-item.dropdown .dropdown-toggle {
position: relative;
transition: all 0.2s ease;
}
.nav-item.dropdown .dropdown-toggle:hover {
background-color: rgba(255,255,255,0.1);
border-radius: 4px;
}
.nav-item.dropdown .dropdown-toggle::after {
transition: transform 0.2s ease;
}
.nav-item.dropdown.show .dropdown-toggle::after {
transform: rotate(180deg);
}
/* User account dropdown specific styles */
.dropdown-menu-end {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.dropdown-divider {
margin: 6px 0;
border-color: #dee2e6;
}
/* Version display */
.text-info {
opacity: 0.8;
}
/* Mobile responsive navigation styles */
@media screen and (max-width: 768px) {
.navbar-nav .dropdown-menu {
border: none;
box-shadow: none;
background-color: rgba(52, 58, 64, 0.95);
margin-left: 15px;
margin-top: 5px;
border-radius: 6px;
}
.navbar-nav .dropdown-item {
color: #ffffff;
padding: 12px 20px;
font-size: 0.9rem;
}
.navbar-nav .dropdown-item:hover,
.navbar-nav .dropdown-item:focus {
background-color: rgba(255,255,255,0.1);
color: #ffffff;
transform: none;
}
.nav-item.dropdown .dropdown-toggle {
padding: 8px 12px;
}
/* Better touch targets for mobile */
.dropdown-toggle::after {
margin-left: 8px;
}
/* Account dropdown stays light theme on mobile */
.dropdown-menu-end {
background-color: #ffffff;
border: 1px solid #dee2e6;
}
.dropdown-menu-end .dropdown-item {
color: #495057;
}
.dropdown-menu-end .dropdown-item:hover {
background-color: #f8f9fa;
color: #007bff;
}
}
/* Module Selector Bar Styles */
.module-selector-bar {
background: linear-gradient(90deg, var(--module-primary-color) 0%, var(--module-primary-light) 100%);
border-bottom: 3px solid var(--module-accent-color);
padding: 10px 16px;
display: flex;
justify-content: flex-start;
gap: 12px;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.module-selector-bar .module-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgba(255, 255, 255, 0.7);
font-weight: 800;
margin-right: 8px;
}
.module-selector-bar .module-tabs {
display: flex;
gap: 8px;
align-items: center;
flex: 1;
}
.module-selector-bar .module-tab {
padding: 7px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1.5px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
display: inline-block;
}
.module-selector-bar .module-tab:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.4);
color: #fff;
transform: translateY(-2px);
}
.module-selector-bar .module-tab.active {
background: var(--module-accent-color);
border-color: rgba(255, 255, 255, 0.8);
color: #fff;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
}
.module-separator {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.25);
margin: 0 4px;
}
@media (max-width: 768px) {
.module-selector-bar {
padding: 8px 12px;
gap: 8px;
}
.module-selector-bar .module-label {
display: none;
}
.module-selector-bar .module-tab {
padding: 5px 12px;
font-size: 0.9rem;
}
.module-separator {
height: 18px;
}
}
</style>
{% endblock %}
</head>
<body>
<!-- Module Selector Bar -->
{% if 'username' in session %}
<div class="module-selector-bar" id="moduleBar">
<span class="module-label">Module:</span>
<div class="module-tabs">
<a href="{{ url_for('home') }}"
class="module-tab"
id="inventoryModule"
data-module="inventory">
📦 Inventarsystem
</a>
{% if library_module_enabled %}
<div class="module-separator"></div>
<a href="{{ url_for('library_view') }}"
class="module-tab"
id="libraryModule"
data-module="library">
📚 Bibliothek
</a>
{% endif %}
</div>
</div>
{% endif %}
<script>
(function() {
// Detect current module based on URL and update data-module attribute
const currentPath = window.location.pathname;
const htmlTag = document.documentElement;
// Detect module and pages
const isLibraryModule = currentPath.includes('/library') ||
currentPath.includes('/library_admin') ||
currentPath.includes('/library_loans') ||
currentPath.includes('/student_cards');
const currentModule = isLibraryModule ? 'library' : 'inventory';
htmlTag.setAttribute('data-module', currentModule);
// Update module selector tabs
const invModule = document.getElementById('inventoryModule');
const libModule = document.getElementById('libraryModule');
if (currentModule === 'library') {
if (libModule) libModule.classList.add('active');
if (invModule) invModule.classList.remove('active');
} else {
if (invModule) invModule.classList.add('active');
if (libModule) libModule.classList.remove('active');
}
// Show/hide appropriate navbar based on current module
const invNavbar = document.getElementById('inventoryNavbar');
const libNavbar = document.getElementById('libraryNavbar');
if (currentModule === 'library') {
if (invNavbar) invNavbar.style.display = 'none';
if (libNavbar) libNavbar.style.display = '';
} else {
if (invNavbar) invNavbar.style.display = '';
if (libNavbar) libNavbar.style.display = 'none';
}
})();
</script>
<nav class="navbar navbar-expand-lg navbar-dark" id="inventoryNavbar">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('home') }}" data-icon="📦">Inventarsystem</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#inventoryNavContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="inventoryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList">
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link" href="{{ url_for('home') }}">Artikel</a>
</li>
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
<li class="nav-item">
<a class="nav-link nav-priority-link" href="{{ url_for('upload_admin') }}"> Hochladen</a>
</li>
{% endif %}
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
⋯ Mehr
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
{% if 'username' in session %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
<li><h6 class="dropdown-header">Verwaltung</h6></li>
<li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
<li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">System</h6></li>
<li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li>
<li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li>
</ul>
</li>
<li class="nav-item d-none" id="inv-nav-overflow-anchor" aria-hidden="true"></li>
</ul>
<div class="d-flex">
{% if 'username' in session %}
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2">
<button class="btn btn-secondary dropdown-toggle" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
👤
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
</div>
</nav>
{% if library_module_enabled %}
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar" style="display: none;">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('library_view') }}" data-icon="📚">Bibliothek</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#libraryNavContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="libraryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link" href="{{ url_for('library_view') }}">Medien</a>
</li>
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
<li class="nav-item">
<a class="nav-link nav-priority-link" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
</li>
{% endif %}
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
⋯ Mehr
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
{% if 'username' in session %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
{% if student_cards_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Schülerausweise</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">System</h6></li>
<li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li>
<li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li>
</ul>
</li>
<li class="nav-item d-none" id="lib-nav-overflow-anchor" aria-hidden="true"></li>
</ul>
<div class="d-flex">
{% if 'username' in session %}
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2">
<button class="btn btn-secondary dropdown-toggle" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false">
👤
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libUserMenuDropdown">
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
</div>
</nav>
{% endif %}
<script>
(function() {
// Show/hide appropriate navbar based on current module
const currentPath = window.location.pathname;
const invNavbar = document.getElementById('inventoryNavbar');
const libNavbar = document.getElementById('libraryNavbar');
if (currentPath.includes('/library')) {
if (invNavbar) invNavbar.style.display = 'none';
if (libNavbar) libNavbar.style.display = '';
} else {
if (invNavbar) invNavbar.style.display = '';
if (libNavbar) libNavbar.style.display = 'none';
}
})();
</script>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<!-- Cookie consent banner -->
<style>
#cookie-banner { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(33,37,41,.98); color: #fff; padding: 14px 16px; display: none; z-index: 2000; box-shadow: 0 -2px 8px rgba(0,0,0,.25); }
#cookie-banner .cb-inner { max-width: 1100px; margin: 0 auto; display:flex; gap:12px; align-items:center; justify-content:space-between; }
#cookie-banner .cb-text { font-size: .95rem; line-height:1.35; margin-right: 12px; }
#cookie-banner .cb-actions { display:flex; gap:8px; flex-shrink:0; }
#cookie-banner .btn-accept { background:#28a745; border:2px solid #28a745; color:#fff; padding:6px 12px; border-radius:4px; cursor:pointer; }
#cookie-banner .btn-decline { background:transparent; border:2px solid #ffc107; color:#ffc107; padding:6px 12px; border-radius:4px; cursor:pointer; }
#cookie-banner .btn-accept:hover { background:#218838; border-color:#218838; }
#cookie-banner .btn-decline:hover { background:#ffc107; color:#212529; }
#onboarding-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.6);
display: none;
align-items: center;
justify-content: center;
z-index: 2100;
padding: 16px;
}
#onboarding-modal {
width: min(680px, 100%);
background: #ffffff;
border-radius: 14px;
border: 1px solid #dbe3ee;
box-shadow: 0 18px 35px rgba(15, 23, 42, 0.25);
padding: 20px;
}
#onboarding-modal h3 {
margin: 0 0 8px;
color: #0f172a;
font-size: 1.2rem;
font-weight: 800;
}
#onboarding-modal p {
margin: 0;
color: #334155;
line-height: 1.45;
}
.onboarding-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.onboarding-actions .btn {
border-radius: 10px;
}
.dynamic-overflow-control .dropdown-menu {
min-width: 240px;
max-width: min(420px, 85vw);
max-height: 70vh;
overflow-y: auto;
}
.dynamic-overflow-control .dropdown-header {
font-weight: 800;
color: var(--module-accent-color);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
</style>
<div id="cookie-banner" role="dialog" aria-live="polite" aria-label="Cookie-Hinweis">
<div class="cb-inner">
<div class="cb-text">
Wir verwenden technisch notwendige Cookies, um Ihre Sitzung zu verwalten und die Anwendung bereitzustellen. Bitte akzeptieren Sie Cookies, um fortzufahren.
</div>
<div class="cb-actions">
<button class="btn-decline" id="cookie-decline">Ablehnen</button>
<button class="btn-accept" id="cookie-accept">Akzeptieren</button>
</div>
</div>
</div>
<div id="onboarding-overlay" role="dialog" aria-modal="true" aria-label="Tutorial Vorschlag">
<div id="onboarding-modal">
<h3>Wollen Sie eine Vorstellung des Produkts?</h3>
<p>
Wir empfehlen ein kurzes Tutorial (2-5 Minuten), damit Sie die Kernfunktionen anhand einer vorgefertigten Seite kennenlernen und gut auf den Schulalltagsbetrieb vorbereitet sind.
</p>
<div class="onboarding-actions">
<button type="button" class="btn btn-primary btn-sm" id="onboarding-start">Ja, Tutorial starten</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="onboarding-later">Später erinnern</button>
<button type="button" class="btn btn-outline-dark btn-sm" id="onboarding-dismiss">Nicht mehr anzeigen</button>
</div>
</div>
</div>
<script>
(function(){
function getCookie(name){
const v = document.cookie.split(';').map(s=>s.trim());
for(const c of v){ if(c.startsWith(name+'=')) return decodeURIComponent(c.split('=')[1]); }
return null;
}
function setCookie(name, value, days){
const d = new Date(); d.setTime(d.getTime() + (days*24*60*60*1000));
document.cookie = name + '=' + encodeURIComponent(value) + ';expires=' + d.toUTCString() + ';path=/;SameSite=Lax';
}
function showBanner(){ var el = document.getElementById('cookie-banner'); if(el) el.style.display = 'block'; }
function hideBanner(){ var el = document.getElementById('cookie-banner'); if(el) el.style.display = 'none'; }
// If not decided yet, show banner and block app until decision
const consent = getCookie('cookie_consent');
if(!consent){
showBanner();
// Optionally blur content until consent
document.body.style.filter = 'none';
}
document.getElementById('cookie-accept')?.addEventListener('click', function(){
setCookie('cookie_consent','accepted',365);
hideBanner();
});
document.getElementById('cookie-decline')?.addEventListener('click', function(){
setCookie('cookie_consent','declined',365);
window.location.href = 'https://www.ecosia.org/';
});
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }};
const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }};
const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null;
const onboardingOverlay = document.getElementById('onboarding-overlay');
function showOnboarding(){
if (onboardingOverlay) {
onboardingOverlay.style.display = 'flex';
}
}
function hideOnboarding(){
if (onboardingOverlay) {
onboardingOverlay.style.display = 'none';
}
}
if (onboardingKey && !isTutorialPage && !isLoginPage) {
const decision = localStorage.getItem(onboardingKey);
if (!decision || decision === 'later') {
showOnboarding();
}
}
document.getElementById('onboarding-start')?.addEventListener('click', function(){
if (onboardingKey) {
localStorage.setItem(onboardingKey, 'started');
}
window.location.href = {{ url_for('tutorial_page')|tojson }};
});
document.getElementById('onboarding-later')?.addEventListener('click', function(){
if (onboardingKey) {
localStorage.setItem(onboardingKey, 'later');
}
hideOnboarding();
});
document.getElementById('onboarding-dismiss')?.addEventListener('click', function(){
if (onboardingKey) {
localStorage.setItem(onboardingKey, 'dismissed');
}
hideOnboarding();
});
const navList = document.getElementById('inventoryNavList');
const navOverflowAnchor = document.getElementById('inv-nav-overflow-anchor');
const libraryNavList = document.getElementById('libraryNavList');
const libNavOverflowAnchor = document.getElementById('lib-nav-overflow-anchor');
function initNavbarOverflow(navList, navOverflowAnchor) {
if (!navList || !navOverflowAnchor) return;
function collectTopLevelNavSources() {
if (!navList) return [];
return Array.from(navList.children).filter(function(li){
if (!(li instanceof HTMLElement)) return false;
if (!li.classList.contains('nav-item')) return false;
if (li.id === navOverflowAnchor.id) return false;
if (li.dataset.overflowControl === 'true') return false;
if (li.dataset.navFixed === 'true') return false;
return li.style.display !== 'none';
});
}
function clearOverflowControls() {
if (!navList) return;
navList.querySelectorAll('[data-overflow-control="true"]').forEach(function(node){
node.remove();
});
}
function restoreAllNavItems() {
if (!navList) return;
navList.querySelectorAll('li.nav-item').forEach(function(li){
if (li.id === navOverflowAnchor.id) return;
if (li.dataset.overflowControl === 'true') return;
li.style.display = '';
});
clearOverflowControls();
}
function createOverflowControl() {
const li = document.createElement('li');
li.className = 'nav-item dropdown';
li.dataset.overflowControl = 'true';
li.innerHTML =
'<a class="nav-link dropdown-toggle" href="#" id="overflowMenuToggle" role="button" data-bs-toggle="dropdown" aria-expanded="false">⋮ Weitere</a>' +
'<ul class="dropdown-menu" aria-labelledby="overflowMenuToggle" id="overflowMenu"></ul>';
return li;
}
function appendSourceToOverflowMenu(sourceItem, menu) {
const topLink = sourceItem.querySelector(':scope > a.nav-link');
if (!topLink) return;
const childMenu = sourceItem.querySelector(':scope > ul.dropdown-menu');
const sourceLabel = topLink.textContent.trim();
if (!childMenu) {
const entry = document.createElement('li');
entry.innerHTML = '<a class="dropdown-item" href="' + topLink.getAttribute('href') + '">' + sourceLabel + '</a>';
menu.appendChild(entry);
return;
}
const header = document.createElement('li');
header.innerHTML = '<h6 class="dropdown-header">' + sourceLabel + '</h6>';
menu.appendChild(header);
childMenu.querySelectorAll(':scope > li > a.dropdown-item').forEach(function(subLink){
const subEntry = document.createElement('li');
subEntry.innerHTML = '<a class="dropdown-item" style="padding-left: 2.5rem;" href="' + subLink.getAttribute('href') + '">' + subLink.textContent.trim() + '</a>';
menu.appendChild(subEntry);
});
const divider = document.createElement('li');
divider.innerHTML = '<hr class="dropdown-divider">';
menu.appendChild(divider);
}
function rebuildOverflowControl(hiddenSources) {
clearOverflowControls();
if (!navList || !navOverflowAnchor || hiddenSources.length === 0) return;
const control = createOverflowControl();
const menu = control.querySelector('ul.dropdown-menu');
hiddenSources.forEach(function(source){
appendSourceToOverflowMenu(source, menu);
});
const trailingDivider = menu.querySelector('li:last-child .dropdown-divider');
if (trailingDivider) {
trailingDivider.parentElement.remove();
}
navList.insertBefore(control, navOverflowAnchor);
}
function adaptNavbarByWidth() {
if (!navList || !navOverflowAnchor) return;
if (window.innerWidth < 992) {
restoreAllNavItems();
return;
}
restoreAllNavItems();
const hiddenSources = [];
let candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
const toHide = candidates[candidates.length - 1];
toHide.style.display = 'none';
hiddenSources.unshift(toHide);
candidates = collectTopLevelNavSources();
}
rebuildOverflowControl(hiddenSources);
candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
const toHide = candidates[candidates.length - 1];
toHide.style.display = 'none';
hiddenSources.unshift(toHide);
rebuildOverflowControl(hiddenSources);
candidates = collectTopLevelNavSources();
}
}
let resizeTimer = null;
function debounceAdapt() {
if (resizeTimer) {
window.clearTimeout(resizeTimer);
}
resizeTimer = window.setTimeout(adaptNavbarByWidth, 120);
}
adaptNavbarByWidth();
window.addEventListener('resize', debounceAdapt);
}
// Initialize overflow control for inventory navbar
if (navList && navOverflowAnchor) {
initNavbarOverflow(navList, navOverflowAnchor);
}
// Initialize overflow control for library navbar
if (libraryNavList && libNavOverflowAnchor) {
initNavbarOverflow(libraryNavList, libNavOverflowAnchor);
}
})();
</script>
<!-- Mobile compatibility scripts -->
<script src="{{ url_for('static', filename='js/mobile_compatibility.js') }}"></script>
<script src="{{ url_for('static', filename='js/ios_fixes.js') }}"></script>
</body>
</html>
+144
View File
@@ -0,0 +1,144 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}Passwort ändern - Inventarsystem{% endblock %}
{% block content %}
<div class="container">
<div class="password-change-form">
<h2>Passwort ändern</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('change_password') }}">
<div class="form-group">
<label for="current_password">Aktuelles Passwort:</label>
<input type="password" id="current_password" name="current_password" required>
</div>
<div class="form-group">
<label for="new_password">Neues Passwort:</label>
<input type="password" id="new_password" name="new_password" required>
<small class="form-text text-muted">Das Passwort sollte mindestens 6 Zeichen lang sein.</small>
</div>
<div class="form-group">
<label for="confirm_password">Neues Passwort bestätigen:</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Passwort ändern</button>
<a href="{{ url_for('home') }}" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
<style>
.password-change-form {
max-width: 500px;
margin: 50px auto;
padding: 30px;
background-color: #f8f9fa;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h2 {
text-align: center;
margin-bottom: 30px;
color: #343a40;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 16px;
}
.form-text {
display: block;
margin-top: 5px;
font-size: 14px;
}
.form-actions {
display: flex;
justify-content: space-between;
margin-top: 30px;
}
.btn {
padding: 10px 20px;
font-size: 16px;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background-color: #007bff;
color: white;
border: none;
}
.btn-primary:hover {
background-color: #0069d9;
}
.btn-secondary {
background-color: #6c757d;
color: white;
border: none;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.alert {
padding: 12px;
margin-bottom: 20px;
border-radius: 4px;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
</style>
{% endblock %}
+288
View File
@@ -0,0 +1,288 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
<!-- Edit Item Functions -->
<script>
// Function to check if a file is a video
function isVideoFile(filename) {
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.m4v', '.3gp'];
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return videoExtensions.includes(extension);
}
// Load location options for edit modal
function loadLocationOptions() {
fetch('/get_predefined_locations')
.then(response => response.json())
.then(data => {
const ortSelect = document.getElementById('edit-location');
if (ortSelect) {
// Clear existing options except the first one
while (ortSelect.children.length > 1) {
ortSelect.removeChild(ortSelect.lastChild);
}
// Add new options - data.locations contains the array
data.locations.forEach(location => {
const option = document.createElement('option');
option.value = location;
option.textContent = location;
ortSelect.appendChild(option);
});
}
})
.catch(error => {
console.error('Error loading location options:', error);
});
}
// Edit modal functions
function openEditModal(itemId) {
if (typeof openEditModalFromServer === 'function') {
openEditModalFromServer(itemId);
return;
}
// Find the item data from allItems array
const item = allItems.find(i => i._id === itemId);
if (!item) {
console.error('Item not found:', itemId);
return;
}
// Populate the edit form with current item data
document.getElementById('edit-item-id').value = item._id;
document.getElementById('edit-name').value = item.Name || '';
document.getElementById('edit-description').value = item.Beschreibung || '';
document.getElementById('edit-code4').value = item.Code_4 || '';
document.getElementById('edit-year').value = item.Anschaffungsjahr || '';
document.getElementById('edit-cost').value = item.Anschaffungskosten || '';
// Set reservable status (default to true if undefined)
const reservierbarCheckbox = document.getElementById('edit-reservierbar');
if (reservierbarCheckbox) {
reservierbarCheckbox.checked = item.Reservierbar !== false;
}
// Load location options
loadLocationOptions();
// Set the current location
setTimeout(() => {
const locationSelect = document.getElementById('edit-location');
if (locationSelect && item.Ort) {
locationSelect.value = item.Ort;
}
}, 100);
// Handle filter arrays - set current values
const filter1Array = Array.isArray(item.Filter) ? item.Filter : (item.Filter ? [item.Filter] : []);
const filter2Array = Array.isArray(item.Filter2) ? item.Filter2 : (item.Filter2 ? [item.Filter2] : []);
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []);
// Set filter dropdowns (up to 4 each)
for (let i = 1; i <= 4; i++) {
// Filter 1
const filter1Select = document.getElementById(`edit-filter1-${i}`);
if (filter1Select) {
filter1Select.value = filter1Array[i-1] || '';
}
// Filter 2
const filter2Select = document.getElementById(`edit-filter2-${i}`);
if (filter2Select) {
filter2Select.value = filter2Array[i-1] || '';
}
// Filter 3
const filter3Select = document.getElementById(`edit-filter3-${i}`);
if (filter3Select) {
filter3Select.value = filter3Array[i-1] || '';
}
}
// Populate existing images
populateExistingImages(item.Images || []);
// Show the modal
document.getElementById('edit-modal').style.display = 'block';
}
function closeEditModal() {
document.getElementById('edit-modal').style.display = 'none';
}
// Function to add new location (for edit modal)
function addNewLocation(prefix) {
// Use different input IDs based on whether we're in edit mode
const inputId = prefix === 'edit' ? 'edit-new-location-input' : 'new-location-input';
const selectId = prefix === 'edit' ? 'edit-location' : 'ort';
const newLocationInput = document.getElementById(inputId);
const newLocation = newLocationInput.value.trim();
if (!newLocation) {
alert('Bitte geben Sie einen Ort ein.');
return;
}
// Add to dropdown
const ortSelect = document.getElementById(selectId);
const option = document.createElement('option');
option.value = newLocation;
option.textContent = newLocation;
ortSelect.appendChild(option);
ortSelect.value = newLocation;
// Hide the input field
document.getElementById(prefix + '-new-location-container').style.display = 'none';
newLocationInput.value = '';
// Save to server
fetch('/add_location_value', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'value=' + encodeURIComponent(newLocation)
})
.then(response => response.json())
.then(data => {
if (!data.success) {
console.warn('Failed to save location to server:', data.error);
}
})
.catch(error => {
console.error('Error saving location:', error);
});
}
// Function to cancel adding a new location
function cancelAddLocation(prefix) {
const containerId = prefix === 'edit' ? 'edit-new-location-container' : 'new-location-container';
const inputId = prefix === 'edit' ? 'edit-new-location-input' : 'new-location-input';
document.getElementById(containerId).style.display = 'none';
document.getElementById(inputId).value = '';
}
// Function to populate existing images in edit modal
function populateExistingImages(images) {
const previewContainer = document.getElementById('edit-image-preview-container');
if (!previewContainer) return;
previewContainer.innerHTML = '';
if (!images || images.length === 0) {
previewContainer.innerHTML = '<p>Keine Bilder vorhanden</p>';
return;
}
images.forEach((imageName, index) => {
const preview = document.createElement('div');
preview.className = 'image-preview-item';
const isVideo = isVideoFile(imageName);
const mediaHtml = isVideo
? `<video src="/uploads/${imageName}" style="max-width: 150px; max-height: 150px; object-fit: cover;" controls preload="metadata"></video>`
: `<img src="/uploads/${imageName}" alt="Image ${index + 1}" style="max-width: 150px; max-height: 150px; object-fit: cover;">`;
preview.innerHTML = `
${mediaHtml}
<div class="image-controls">
<button type="button" onclick="removeExistingImage('${imageName}', this)" style="background: #dc3545; color: white; border: none; padding: 5px 10px; border-radius: 3px; cursor: pointer; margin-left: 10px;">Entfernen</button>
</div>
`;
previewContainer.appendChild(preview);
// Add hidden input for the image
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'existing_images';
hiddenInput.value = imageName;
previewContainer.appendChild(hiddenInput);
});
}
// Function to remove an existing image
function removeExistingImage(imageName, button) {
try {
// First, determine context - are we in edit mode or main view?
const inEditMode = !!document.getElementById('edit-item-form');
// Always remove the preview element (works in both contexts)
const previewItem = button.closest('.image-preview-item');
if (previewItem) {
previewItem.remove();
}
// If we're in edit mode, handle form inputs
if (inEditMode) {
// Find and remove the corresponding hidden input in the edit form
const editForm = document.getElementById('edit-item-form');
if (editForm) {
// Remove from existing images
const existingInputs = editForm.querySelectorAll('input[name="existing_images"]');
existingInputs.forEach(input => {
if (input.value === imageName) {
input.remove();
}
});
// Add to removed images
const removedInput = document.createElement('input');
removedInput.type = 'hidden';
removedInput.name = 'removed_images';
removedInput.value = imageName;
editForm.appendChild(removedInput);
}
} else {
// In main view, we may need different logic
console.log(`Image ${imageName} removed from display in main view`);
// Add any main view specific handling here
}
} catch (error) {
console.error(`Error in removeExistingImage: ${error.message}`);
}
}
// Validate file types for image uploads
document.addEventListener('DOMContentLoaded', function() {
// Check if we're in the edit item context
if (!document.getElementById('edit-item-form')) {
console.log('Edit item form not found, skipping edit item functions initialization');
return; // Exit early if we're not in the edit item context
}
const imageInput = document.getElementById('edit-new-images');
const previewContainer = document.getElementById('edit-image-preview-container');
if (imageInput) {
imageInput.addEventListener('change', function(e) {
// Validate file types before preview
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif',
'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska',
'video/webm', 'video/x-flv', 'video/mp4', 'video/3gpp'];
const files = this.files;
let hasInvalidFile = false;
for (let i = 0; i < files.length; i++) {
if (!allowedTypes.includes(files[i].type)) {
hasInvalidFile = true;
// Clear the file input to prevent submission
this.value = '';
alert('Fehler: Datei "' + files[i].name + '" hat ein nicht unterstütztes Format. Erlaubte Formate: JPG, JPEG, PNG, GIF, MP4, MOV, AVI, MKV, WEBM, FLV, M4V, 3GP');
return; // Stop processing
}
}
// Continue with regular preview handling...
});
}
});
</script>
+60
View File
@@ -0,0 +1,60 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}Impressum - Inventarsystem{% endblock %}
{% block content %}
<div class="container my-4">
<div class="impressum-content bg-white p-4 shadow rounded">
<h1 class="mb-4 text-center">Impressum</h1>
<p class="text-center mb-5">(Angaben gemäß § 5 TMG)</p>
<div class="impressum-section mb-4">
<h3 class="mb-3">Kontaktinformationen</h3>
<p><strong>Name:</strong> . ..</p>
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p>
<p><strong>E-Mail:</strong> <a href="mailto:kontakt@example.com">kontakt@example.com</a></p>
<p><strong>Telefon:</strong> +49 123 456789</p>
</div>
<div class="impressum-section mb-4">
<h3 class="mb-3">Verantwortlich für den Inhalt</h3>
<p>(nach § 55 Abs. 2 RStV)</p>
<p>...<br>
Musterstraße 123<br>
12345 Musterstadt<br>
Deutschland</p>
</div>
<div class="impressum-section mb-4">
<h3 class="mb-3">Haftungsausschluss</h3>
<h4 class="mb-2">Haftung für Inhalte</h4>
<p>Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
<h4 class="mb-2">Haftung für Links</h4>
<p>Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar.</p>
</div>
<div class="impressum-section mb-4">
<h3 class="mb-3">Urheberrecht</h3>
<p>Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.</p>
<p>Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen.</p>
</div>
<div class="impressum-section mb-4">
<h3 class="mb-3">Datenschutz</h3>
<p>Die Nutzung unserer Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Soweit auf unseren Seiten personenbezogene Daten (beispielsweise Name, Anschrift oder E-Mail-Adressen) erhoben werden, erfolgt dies, soweit möglich, stets auf freiwilliger Basis. Diese Daten werden ohne Ihre ausdrückliche Zustimmung nicht an Dritte weitergegeben.</p>
<p>Wir weisen darauf hin, dass die Datenübertragung im Internet (z.B. bei der Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich.</p>
<p>Der Nutzung von im Rahmen der Impressumspflicht veröffentlichten Kontaktdaten durch Dritte zur Übersendung von nicht ausdrücklich angeforderter Werbung und Informationsmaterialien wird hiermit ausdrücklich widersprochen. Die Betreiber der Seiten behalten sich ausdrücklich rechtliche Schritte im Falle der unverlangten Zusendung von Werbeinformationen, etwa durch Spam-Mails, vor.</p>
</div>
</div>
</div>
{% endblock %}
+488
View File
@@ -0,0 +1,488 @@
{% extends "base.html" %}
{% block title %}Bibliothek - {{ APP_VERSION }}{% endblock %}
{% block content %}
<div class="content-container">
<!-- Header Section -->
<div class="header-section">
<div class="header-title-group">
<h1>📚 Bibliothek</h1>
<p class="subtitle">Bücher, CDs und weitere Medien</p>
</div>
<div class="header-controls">
<!-- Favorites Toggle -->
<div class="view-switch">
<button id="favoriteToggle" class="toggle-btn" aria-label="Favoriten anzeigen/verbergen">
<span class="toggle-icon"></span>
<span class="toggle-label">Favoriten</span>
</button>
</div>
<!-- View Mode Toggle -->
<div class="view-switch">
<button id="viewModeToggle" class="toggle-btn" aria-label="Ansichtsmodus wechseln">
<span class="toggle-icon"></span>
<span class="toggle-label">Tabelle</span>
</button>
</div>
</div>
</div>
<!-- Search & Filter Section -->
<div class="search-filter-section">
<input type="text" id="searchInput" placeholder="Nach Titel, Autor, ISBN suchen..." class="search-input">
<div class="filter-group" id="filterGroup">
<button class="filter-btn" id="filterBtn" aria-expanded="false" aria-label="Filter öffnen">
🔍 Filter <span class="filter-arrow"></span>
</button>
<div class="filter-dropdown" id="filterDropdown" style="display: none;">
<div class="filter-section">
<h4>Medientyp</h4>
<div class="filter-options">
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="book"> Buch</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="cd"> CD/DVD</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="type" value="other"> Sonstige</label>
</div>
</div>
<div class="filter-section">
<h4>Status</h4>
<div class="filter-options">
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="available"> Verfügbar</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="borrowed"> Ausgeliehen</label>
<label><input type="checkbox" class="filter-checkbox" data-filter="status" value="reserved"> Reserviert</label>
</div>
</div>
<button id="clearFiltersBtn" class="button">Filter zurücksetzen</button>
</div>
</div>
<!-- Barcode Scanner -->
<button id="scannerBtn" class="scan-button" aria-expanded="false" aria-label="Scanner öffnen">
📱 Scanner <span class="scanner-arrow"></span>
</button>
</div>
<!-- Scanner Panel -->
<div id="qrContainer" class="qr-container" style="display: none;">
<div id="qr-reader" style="width: auto; height: 300px;"></div>
<span id="qr-result" style="display: none;"></span>
</div>
<!-- Table Header (only in table mode) -->
<div class="table-header" id="tableHeader" style="display: none;">
<div class="table-row">
<div class="table-cell title-cell">Titel</div>
<div class="table-cell author-cell">Autor/Künstler</div>
<div class="table-cell isbn-cell">ISBN/Code</div>
<div class="table-cell type-cell">Typ</div>
<div class="table-cell status-cell">Status</div>
<div class="table-cell actions-cell">Aktionen</div>
</div>
</div>
<!-- Items Container -->
<div id="itemsContainer" class="items-container">
{% if library_items %}
{% for item in library_items %}
<div class="item-card library-item" data-item-id="{{ item._id }}" data-type="{{ item.get('ItemType', 'book') }}" data-status="{{ 'borrowed' if item.get('Verfuegbar') == False else 'available' }}">
<!-- Card Mode View -->
<div class="item-content card-mode">
<div class="item-image-wrapper">
{% if item.get('Image') %}
<img src="/uploads/{{ item.Image }}" alt="{{ item.Name }}" class="item-image">
{% else %}
<div class="placeholder-image">📚</div>
{% endif %}
</div>
<div class="item-details">
<h3 class="item-name">{{ item.Name }}</h3>
{% if item.get('Author') %}
<p class="item-author">Von: {{ item.Author }}</p>
{% endif %}
{% if item.get('ISBN') %}
<p class="item-isbn">ISBN: {{ item.ISBN }}</p>
{% endif %}
<div class="item-meta">
<span class="item-type">{{ item.get('ItemType', 'Medium') }}</span>
<span class="item-status {% if item.get('Verfuegbar') == False %}borrowed{% else %}available{% endif %}">
{% if item.get('Verfuegbar') == False %}Ausgeliehen{% else %}Verfügbar{% endif %}
</span>
</div>
</div>
</div>
<!-- Table Mode View -->
<div class="item-content table-mode" style="display: none;">
<div class="table-row">
<div class="table-cell title-cell">
<span class="descriptor">Titel:</span>
<span class="value">{{ item.Name }}</span>
</div>
<div class="table-cell author-cell">
<span class="descriptor">Autor:</span>
<span class="value">{{ item.get('Author', '—') }}</span>
</div>
<div class="table-cell isbn-cell">
<span class="descriptor">ISBN:</span>
<span class="value">{{ item.get('ISBN', '—') }}</span>
</div>
<div class="table-cell type-cell">
<span class="descriptor">Typ:</span>
<span class="value">{{ item.get('ItemType', 'Medium') }}</span>
</div>
<div class="table-cell status-cell">
<span class="descriptor">Status:</span>
<span class="value {% if item.get('Verfuegbar') == False %}borrowed{% else %}available{% endif %}">
{% if item.get('Verfuegbar') == False %}Ausgeliehen{% else %}Verfügbar{% endif %}
</span>
</div>
<div class="table-cell actions-cell">
<button class="action-button details-btn" data-item-id="{{ item._id }}">Details</button>
</div>
</div>
</div>
<!-- Action Buttons (Card Mode) -->
<div class="item-actions card-mode" style="display: flex;">
<button class="action-button details-btn" data-item-id="{{ item._id }}">Details</button>
{% if item.get('Verfuegbar') == True %}
<button class="action-button borrow-btn" data-item-id="{{ item._id }}">Ausleihen</button>
{% else %}
<button class="action-button return-btn" data-item-id="{{ item._id }}" disabled>Ausgeliehen</button>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Keine Bibliotheks-Medien gefunden</p>
</div>
{% endif %}
</div>
</div>
<!-- Detail Modal -->
<div id="detailModal" class="modal" style="display: none;">
<div class="modal-content">
<button class="modal-close" aria-label="Schließen"></button>
<div class="modal-body" id="modalBody">
<!-- Filled by JavaScript -->
</div>
</div>
</div>
<!-- Borrow Modal -->
<div id="borrowModal" class="modal" style="display: none;">
<div class="modal-content">
<h2>Ausleihen</h2>
<form id="borrowForm">
<input type="hidden" id="borrowItemId" name="item_id">
<div class="form-group">
<label for="studentIdInput">Schülerausweis-ID (optional):</label>
<input type="text" id="studentIdInput" name="student_id" placeholder="Ausweis scannen oder eingeben" class="form-input">
</div>
<div class="form-group">
<label for="borrowDaysInput">Ausleih-Dauer (Tage):</label>
<input type="number" id="borrowDaysInput" name="borrow_days" min="1" max="365" class="form-input" value="14">
</div>
<div class="form-actions">
<button type="submit" class="button primary-button">Ausleihen</button>
<button type="button" class="button cancel-button" onclick="document.getElementById('borrowModal').style.display='none'">Abbrechen</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js"></script>
<script>
// View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
function setViewMode(mode) {
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
const container = document.getElementById('itemsContainer');
const tableHeader = document.getElementById('tableHeader');
const items = container.querySelectorAll('.item-card');
items.forEach(item => {
const cardContent = item.querySelector('.item-content.card-mode');
const cardActions = item.querySelector('.item-actions.card-mode');
const tableContent = item.querySelector('.item-content.table-mode');
if (mode === 'table') {
if (cardContent) cardContent.style.display = 'none';
if (cardActions) cardActions.style.display = 'none';
if (tableContent) tableContent.style.display = 'block';
} else {
if (cardContent) cardContent.style.display = 'flex';
if (cardActions) cardActions.style.display = 'flex';
if (tableContent) tableContent.style.display = 'none';
}
});
tableHeader.style.display = mode === 'table' ? 'block' : 'none';
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
}
// Initialize view mode
document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
setViewMode(savedMode);
document.getElementById('viewModeToggle').addEventListener('click', function() {
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
const newMode = currentMode === 'card' ? 'table' : 'card';
setViewMode(newMode);
});
// Details button handlers
document.querySelectorAll('.details-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
const itemId = this.dataset.itemId;
fetch(`/get_item_details/${itemId}`)
.then(r => r.json())
.then(data => {
if (data.success) {
displayLibraryItemDetail(data.item);
document.getElementById('detailModal').style.display = 'flex';
}
});
});
});
// Borrow button handlers
document.querySelectorAll('.borrow-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.getElementById('borrowItemId').value = this.dataset.itemId;
document.getElementById('borrowModal').style.display = 'flex';
});
});
// Borrow form submit
document.getElementById('borrowForm').addEventListener('submit', function(e) {
e.preventDefault();
const itemId = document.getElementById('borrowItemId').value;
const studentId = document.getElementById('studentIdInput').value || null;
const borrowDays = document.getElementById('borrowDaysInput').value || 14;
fetch('/borrow_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
item_id: itemId,
student_id: studentId,
borrow_days: borrowDays
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
alert('Erfolgreich ausgeliehen!');
location.reload();
} else {
alert('Fehler: ' + data.message);
}
});
});
// Modal close handlers
document.querySelectorAll('.modal-close').forEach(btn => {
btn.addEventListener('click', function() {
this.closest('.modal').style.display = 'none';
});
});
// Close modals on outside click
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', function(e) {
if (e.target === this) this.style.display = 'none';
});
});
});
function displayLibraryItemDetail(item) {
const modalBody = document.getElementById('modalBody');
const returnBtn = item.Verfuegbar === false ? `<button class="action-button return-btn" onclick="returnItem('${item._id}')">Zurückgeben</button>` : '';
const borrowBtn = item.Verfuegbar === true ? `<button class="action-button borrow-btn" onclick="openBorrowModal('${item._id}')">Ausleihen</button>` : '';
modalBody.innerHTML = `
<h2>${item.Name}</h2>
<div class="detail-content">
${item.Image ? `<img src="/uploads/${item.Image}" alt="${item.Name}" style="max-width: 200px; margin-bottom: 20px;">` : ''}
<p><strong>Autor:</strong> ${item.Author || '—'}</p>
<p><strong>ISBN:</strong> ${item.ISBN || '—'}</p>
<p><strong>Typ:</strong> ${item.ItemType || 'Medium'}</p>
<p><strong>Status:</strong> ${item.Verfuegbar === false ? 'Ausgeliehen' : 'Verfügbar'}</p>
${item.User ? `<p><strong>Ausgeliehen von:</strong> ${item.User}</p>` : ''}
${item.Beschreibung ? `<p><strong>Beschreibung:</strong> ${item.Beschreibung}</p>` : ''}
<div class="modal-actions">
${borrowBtn}
${returnBtn}
</div>
</div>
`;
}
function openBorrowModal(itemId) {
document.getElementById('borrowItemId').value = itemId;
document.getElementById('borrowModal').style.display = 'flex';
document.getElementById('detailModal').style.display = 'none';
}
function returnItem(itemId) {
if (confirm('Wirklich zurückgeben?')) {
fetch('/return_item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({item_id: itemId})
})
.then(r => r.json())
.then(data => {
if (data.success) {
alert('Erfolgreich zurückgegeben!');
location.reload();
}
});
}
}
// Filter dropdown toggle with auto-close
function closeAllFilters() {
document.querySelectorAll('.filter-dropdown').forEach(d => d.style.display = 'none');
document.getElementById('filterBtn').setAttribute('aria-expanded', 'false');
document.getElementById('filterBtn').classList.remove('open');
}
document.getElementById('filterBtn').addEventListener('click', function(e) {
e.stopPropagation();
const dropdown = document.getElementById('filterDropdown');
const isOpen = dropdown.style.display !== 'none';
closeAllFilters();
if (!isOpen) {
dropdown.style.display = 'block';
this.setAttribute('aria-expanded', 'true');
this.classList.add('open');
}
});
document.addEventListener('click', closeAllFilters);
document.getElementById('filterDropdown').addEventListener('click', e => e.stopPropagation());
// Clear filters
document.getElementById('clearFiltersBtn').addEventListener('click', function() {
document.querySelectorAll('.filter-checkbox').forEach(c => c.checked = false);
closeAllFilters();
});
// Favorites toggle
document.getElementById('favoriteToggle').addEventListener('click', function() {
this.classList.toggle('open');
// TODO: Implement favorites filtering
});
// Scanner toggle
let scanner = null;
document.getElementById('scannerBtn').addEventListener('click', function(e) {
e.stopPropagation();
const container = document.getElementById('qrContainer');
const isOpen = container.style.display !== 'none';
if (isOpen) {
container.style.display = 'none';
if (scanner) scanner.clear();
this.classList.remove('open');
this.setAttribute('aria-expanded', 'false');
} else {
container.style.display = 'block';
this.classList.add('open');
this.setAttribute('aria-expanded', 'true');
if (!scanner) {
scanner = new Html5Qrcode('qr-reader');
scanner.start(
{ facingMode: "environment" },
{ fps: 10, qrbox: 250 },
onScanSuccess,
onScanError
);
}
}
});
function onScanSuccess(decodedText, decodedResult) {
const studentId = decodedText.trim();
document.getElementById('studentIdInput').value = studentId;
alert('Ausweis gescannt: ' + studentId);
}
function onScanError(error) {
// Silently ignore scan errors
}
</script>
<style>
.library-item .item-image-wrapper {
width: 120px;
height: 160px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.library-item .placeholder-image {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: white;
}
.library-item .item-author {
font-size: 0.9rem;
color: #666;
margin: 4px 0;
}
.library-item .item-isbn {
font-size: 0.85rem;
color: #999;
font-family: monospace;
margin: 4px 0;
}
.library-item .item-type {
display: inline-block;
background: #e8f0fe;
color: #1967d2;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
}
.library-item .item-status.available {
background: #e6f4ea;
color: #137333;
}
.library-item .item-status.borrowed {
background: #fce8e6;
color: #b3261e;
}
</style>
{% endblock %}
+590
View File
@@ -0,0 +1,590 @@
{% extends "base.html" %}
{% block title %}Bibliotheks-Ausleihen - {{ APP_VERSION }}{% endblock %}
{% block content %}
<style>
.library-admin-shell {
max-width: 1460px;
margin: 0 auto;
padding: 18px 20px 32px;
}
.library-admin-hero {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 18px;
padding: 20px 22px;
background: linear-gradient(135deg, #ffffff 0%, #f7f9fc 100%);
border: 1px solid #dbe4ee;
border-radius: 18px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
.library-admin-hero h1 {
margin: 0 0 6px 0;
font-size: 1.9rem;
}
.library-admin-hero p {
margin: 0;
color: #5b6472;
}
.hero-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.hero-actions .btn {
white-space: nowrap;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.summary-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 14px 16px;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.summary-card .label {
display: block;
font-size: 0.85rem;
color: #6b7280;
margin-bottom: 4px;
}
.summary-card .value {
font-size: 1.6rem;
font-weight: 800;
color: #1f2937;
}
.filter-bar {
display: grid;
grid-template-columns: 1fr 220px 220px;
gap: 10px;
margin-bottom: 16px;
}
.filter-bar input,
.filter-bar select {
width: 100%;
padding: 11px 14px;
border: 1px solid #d6dde6;
border-radius: 12px;
background: #fff;
}
.panel {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 18px;
padding: 18px;
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.06);
margin-bottom: 18px;
}
.panel h2 {
margin: 0 0 12px 0;
font-size: 1.2rem;
}
.library-table {
width: 100%;
border-collapse: collapse;
}
.library-table th,
.library-table td {
padding: 12px 10px;
border-bottom: 1px solid #edf2f7;
vertical-align: top;
text-align: left;
}
.library-table th {
font-size: 0.84rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
background: #f8fafc;
}
.library-table tr:hover td {
background: #fbfdff;
}
.mono {
font-family: monospace;
font-size: 0.92rem;
}
.badge-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
}
.badge-active { background: #dbeafe; color: #1d4ed8; }
.badge-planned { background: #fef3c7; color: #b45309; }
.badge-completed { background: #dcfce7; color: #166534; }
.badge-open { background: #fee2e2; color: #991b1b; }
.badge-paid { background: #dcfce7; color: #166534; }
.badge-damaged { background: #fee2e2; color: #991b1b; }
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.row-actions form {
margin: 0;
}
.row-actions .btn {
white-space: nowrap;
}
.muted {
color: #6b7280;
font-size: 0.92rem;
}
.empty-state {
padding: 28px 16px;
text-align: center;
color: #6b7280;
}
@media (max-width: 900px) {
.library-admin-hero {
flex-direction: column;
}
.filter-bar {
grid-template-columns: 1fr;
}
.library-table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
</style>
<div class="library-admin-shell">
<div class="library-admin-hero">
<div>
<h1>Bibliotheks-Ausleihen</h1>
<p>Nur Bibliotheksmedien. Hier kannst du offene Ausleihen abschließen, Rechnungen bezahlen und defekte Medien direkt zurücksetzen.</p>
</div>
<div class="hero-actions">
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
<a class="btn btn-primary" href="{{ url_for('library_admin') }}">Bücher hochladen</a>
<a class="btn btn-outline-secondary" href="{{ url_for('admin_borrowings') }}">Alle Ausleihen</a>
</div>
</div>
<div class="summary-grid">
<div class="summary-card">
<span class="label">Ausleihen</span>
<div class="value">{{ loan_entries|length }}</div>
</div>
<div class="summary-card">
<span class="label">Defekte Medien</span>
<div class="value">{{ damaged_items|length }}</div>
</div>
<div class="summary-card">
<span class="label">Direkt reparierbar</span>
<div class="value">{{ damaged_items|length }}</div>
</div>
</div>
<div class="filter-bar">
<input id="library-search" type="text" placeholder="Nach Element, Benutzer, Ausweis oder Rechnung suchen...">
<select id="loan-status-filter">
<option value="">Alle Ausleihen</option>
<option value="active">Aktiv</option>
<option value="planned">Geplant</option>
<option value="completed">Abgeschlossen</option>
</select>
<select id="damage-filter">
<option value="all">Alle Einträge</option>
<option value="damage">Nur defekt</option>
<option value="clean">Nur ohne Schaden</option>
</select>
</div>
<div class="panel">
<h2>Bibliotheks-Ausleihen</h2>
{% if loan_entries %}
<table class="library-table" id="loans-table">
<thead>
<tr>
<th>Status</th>
<th>Element</th>
<th>Benutzer</th>
<th>Zeit</th>
<th>Rechnung</th>
<th>Schaden</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for e in loan_entries %}
<tr class="loan-row" data-borrow-id="{{ e.id }}" data-item-id="{{ e.item_id }}" data-item-name="{{ e.item_name }}" data-item-code="{{ e.item_code }}" data-item-cost="{{ e.item_cost_raw }}" data-user-name="{{ e.user }}" data-search="{{ (e.item_name ~ ' ' ~ e.item_code ~ ' ' ~ e.user ~ ' ' ~ e.invoice_number ~ ' ' ~ e.item_author ~ ' ' ~ e.item_isbn)|lower }}" data-status="{{ e.status }}" data-has-damage="{{ '1' if e.has_damage else '0' }}">
<td>
{% if e.status == 'active' %}
<span class="badge-pill badge-active">Aktiv</span>
{% elif e.status == 'planned' %}
<span class="badge-pill badge-planned">Geplant</span>
{% else %}
<span class="badge-pill badge-completed">Abgeschlossen</span>
{% endif %}
</td>
<td>
<div><strong>{{ e.item_name }}</strong></div>
<div class="muted">{{ e.item_author or '—' }}</div>
<div class="mono">{{ e.item_code or '—' }}</div>
<div style="margin-top:6px;">
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=e.item_id) }}">Rechnungen</a>
</div>
</td>
<td>
<div>{{ e.user }}</div>
<div class="muted">{{ e.start }}{% if e.end %} bis {{ e.end }}{% endif %}</div>
</td>
<td>
<div>{{ e.period or '—' }}</div>
{% if e.notes %}<div class="muted">{{ e.notes }}</div>{% endif %}
</td>
<td>
{% if e.invoice_number %}
<div class="mono">{{ e.invoice_number }}</div>
<div class="muted">{{ e.invoice_amount }}</div>
<div style="margin-top:6px;">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a>
</div>
{% if e.invoice_paid %}
<span class="badge-pill badge-paid">Bezahlt</span>
{% if e.invoice_paid_at %}<div class="muted">am {{ e.invoice_paid_at }}</div>{% endif %}
{% else %}
<span class="badge-pill badge-open">Offen</span>
{% endif %}
{% else %}
<span class="muted">Keine Rechnung</span>
{% endif %}
</td>
<td>
{% if e.has_damage %}
<span class="badge-pill badge-damaged">{{ e.damage_count }} Schaden{% if e.damage_count != 1 %}s{% endif %}</span>
{% if e.damage_text %}<div class="muted" style="margin-top:6px;">{{ e.damage_text }}</div>{% endif %}
{% else %}
<span class="muted">Kein Schaden</span>
{% endif %}
</td>
<td>
<div class="row-actions">
{% if e.status == 'active' %}
<button type="button" class="btn btn-outline-danger btn-sm" onclick="openDamageReportPrompt(this)">Schaden melden</button>
{% endif %}
{% if e.invoice_number and ((not e.invoice_paid) or e.has_damage) %}
<form method="post" action="{{ url_for('admin_finalize_invoice_and_repair', borrow_id=e.id) }}" onsubmit="return confirm('Rechnung als bezahlt und Element als repariert markieren?');">
<button type="submit" class="btn btn-success btn-sm">
{% if not e.invoice_paid and e.has_damage %}
Bezahlt + repariert
{% elif not e.invoice_paid %}
Als bezahlt markieren
{% else %}
Als repariert markieren
{% endif %}
</button>
</form>
{% elif e.has_damage %}
<form method="post" action="{{ url_for('mark_damage_repaired', id=e.item_id) }}" onsubmit="return confirm('Medium als repariert markieren?');">
<button type="submit" class="btn btn-success btn-sm">Als repariert markieren</button>
</form>
{% endif %}
{% if e.status in ['active', 'planned'] %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-warning btn-sm">Zurücksetzen</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div id="loans-empty" class="empty-state" style="display:none;">Keine passenden Bibliotheks-Ausleihen gefunden.</div>
{% else %}
<div class="empty-state">Keine Bibliotheks-Ausleihen gefunden.</div>
{% endif %}
</div>
<div class="panel">
<h2>Defekte Bibliotheksmedien ohne aktive Ausleihe</h2>
{% if damaged_items %}
<table class="library-table" id="damaged-table">
<thead>
<tr>
<th>Element</th>
<th>Code</th>
<th>Schaden</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for item in damaged_items %}
<tr class="damaged-row" data-search="{{ (item.name ~ ' ' ~ item.code ~ ' ' ~ item.author ~ ' ' ~ item.isbn ~ ' ' ~ item.damage_text)|lower }}" data-status="damage" data-has-damage="1">
<td>
<div><strong>{{ item.name }}</strong></div>
<div class="muted">{{ item.author or '—' }}</div>
<div class="muted">{{ item.isbn or '—' }}</div>
</td>
<td class="mono">{{ item.code or '—' }}</td>
<td>
<span class="badge-pill badge-damaged">{{ item.damage_count }} Schaden{% if item.damage_count != 1 %}s{% endif %}</span>
{% if item.damage_text %}<div class="muted" style="margin-top:6px;">{{ item.damage_text }}</div>{% endif %}
</td>
<td>
{% if item.available %}
<span class="badge-pill badge-completed">Bereit zum Zurücksetzen</span>
{% else %}
<span class="badge-pill badge-open">Nicht verfügbar</span>
{% endif %}
{% if item.last_updated %}<div class="muted" style="margin-top:6px;">{{ item.last_updated }}</div>{% endif %}
</td>
<td>
<div class="row-actions" style="margin-bottom:8px;">
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=item.id) }}">Rechnungen</a>
</div>
<form method="post" action="{{ url_for('mark_damage_repaired', id=item.id) }}" onsubmit="return confirm('Dieses Medium als repariert und wieder verfügbar markieren?');">
<button type="submit" class="btn btn-success btn-sm">Als repariert markieren</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div id="damaged-empty" class="empty-state" style="display:none;">Keine passenden defekten Medien gefunden.</div>
{% else %}
<div class="empty-state">Keine defekten Bibliotheksmedien ohne aktive Ausleihe gefunden.</div>
{% endif %}
</div>
</div>
<div id="damage-invoice-modal" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
<div style="max-width:760px; margin:40px auto; background:#fff; border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:18px;">
<div>
<h2 style="margin:0;">Rechnung erstellen</h2>
<p style="margin:6px 0 0; color:#666;">Die Rechnung nutzt das bestehende Rechnungssystem und kann direkt nach der Schadensmeldung erstellt werden.</p>
</div>
<button type="button" class="btn btn-secondary" onclick="closeDamageInvoiceModal()">Schließen</button>
</div>
<form id="damage-invoice-form" method="post" action="">
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:16px; margin-bottom:16px;">
<div>
<label for="damage-invoice-item" style="display:block; font-weight:700; margin-bottom:6px;">Element</label>
<input id="damage-invoice-item" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
</div>
<div>
<label for="damage-invoice-borrower" style="display:block; font-weight:700; margin-bottom:6px;">Schüler</label>
<input id="damage-invoice-borrower" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
</div>
<div>
<label for="damage-invoice-code" style="display:block; font-weight:700; margin-bottom:6px;">Element-ID / Code</label>
<input id="damage-invoice-code" type="text" readonly style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; background:#f8fafc;">
</div>
<div>
<label for="damage-invoice-amount" style="display:block; font-weight:700; margin-bottom:6px;">Preis</label>
<input id="damage-invoice-amount" name="invoice_amount" type="text" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px;" placeholder="z.B. 12,50">
</div>
</div>
<div style="margin-bottom:16px;">
<label for="damage-invoice-reason" style="display:block; font-weight:700; margin-bottom:6px;">Schadensbeschreibung</label>
<textarea id="damage-invoice-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea>
</div>
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;">
<label style="display:flex; align-items:center; gap:8px;">
<input id="damage-invoice-destroyed" type="checkbox" name="mark_destroyed" checked>
Element als zerstört markieren
</label>
<label style="display:flex; align-items:center; gap:8px;">
<input id="damage-invoice-close" type="checkbox" name="close_borrowing" checked>
Ausleihe abschließen
</label>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px;">
<button type="button" class="btn btn-secondary" onclick="closeDamageInvoiceModal()">Abbrechen</button>
<button type="submit" class="btn btn-danger">PDF-Rechnung erstellen</button>
</div>
</form>
</div>
</div>
<script>
(function() {
const searchInput = document.getElementById('library-search');
const statusFilter = document.getElementById('loan-status-filter');
const damageFilter = document.getElementById('damage-filter');
const loanRows = Array.from(document.querySelectorAll('.loan-row'));
const damagedRows = Array.from(document.querySelectorAll('.damaged-row'));
const loansEmpty = document.getElementById('loans-empty');
const damagedEmpty = document.getElementById('damaged-empty');
const damageInvoiceModal = document.getElementById('damage-invoice-modal');
const damageInvoiceForm = document.getElementById('damage-invoice-form');
const damageInvoiceItem = document.getElementById('damage-invoice-item');
const damageInvoiceBorrower = document.getElementById('damage-invoice-borrower');
const damageInvoiceCode = document.getElementById('damage-invoice-code');
const damageInvoiceAmount = document.getElementById('damage-invoice-amount');
const damageInvoiceReason = document.getElementById('damage-invoice-reason');
function openDamageReportPrompt(button) {
const row = button.closest('.loan-row');
if (!row) {
return;
}
const itemId = row.dataset.itemId || '';
const itemName = row.dataset.itemName || 'Bibliotheksmedium';
const noteInput = prompt('Schadensmeldung für dieses Bibliotheksmedium:\nNotiz zum Schaden (optional):', '');
if (noteInput === null) {
return;
}
const description = noteInput.trim() || 'Schaden erneut gemeldet';
fetch(`/report_damage/${itemId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description })
})
.then(response => response.json().then(data => ({ ok: response.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
throw new Error(data.message || 'Fehler beim Speichern der Schadensmeldung.');
}
if (confirm('Schaden gespeichert. Soll direkt eine Rechnung erstellt werden?')) {
openDamageInvoiceModal(row, description);
return;
}
window.location.reload();
})
.catch(error => {
alert(error.message || 'Fehler beim Speichern der Schadensmeldung.');
});
}
window.openDamageReportPrompt = openDamageReportPrompt;
function openDamageInvoiceModal(row, description) {
if (!damageInvoiceModal || !damageInvoiceForm) {
return;
}
const borrowId = row.dataset.borrowId || '';
const itemName = row.dataset.itemName || '';
const borrower = row.dataset.userName || '';
const itemCode = row.dataset.itemCode || '';
const itemCost = row.dataset.itemCost || '';
damageInvoiceForm.action = "{{ url_for('admin_create_invoice', borrow_id='__BORROW_ID__') }}".replace('__BORROW_ID__', borrowId);
damageInvoiceItem.value = itemName;
damageInvoiceBorrower.value = borrower;
damageInvoiceCode.value = itemCode;
damageInvoiceAmount.value = String(itemCost).replace(' EUR', '').trim();
damageInvoiceReason.value = description || `Schaden gemeldet für ${itemName}`;
damageInvoiceModal.style.display = 'block';
damageInvoiceAmount.focus();
}
window.openDamageInvoiceModal = openDamageInvoiceModal;
function closeDamageInvoiceModal() {
if (damageInvoiceModal) {
damageInvoiceModal.style.display = 'none';
}
}
if (damageInvoiceModal) {
damageInvoiceModal.addEventListener('click', function(event) {
if (event.target === damageInvoiceModal) {
closeDamageInvoiceModal();
}
});
}
window.closeDamageInvoiceModal = closeDamageInvoiceModal;
function applyFilters() {
const search = (searchInput.value || '').trim().toLowerCase();
const status = statusFilter.value;
const damage = damageFilter.value;
let visibleLoans = 0;
loanRows.forEach(row => {
const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || '';
const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status;
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && damageMatch;
row.style.display = show ? '' : 'none';
if (show) visibleLoans++;
});
if (loansEmpty) loansEmpty.style.display = visibleLoans === 0 ? 'block' : 'none';
let visibleDamaged = 0;
damagedRows.forEach(row => {
const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || '';
const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status || status === '';
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && damageMatch;
row.style.display = show ? '' : 'none';
if (show) visibleDamaged++;
});
if (damagedEmpty) damagedEmpty.style.display = visibleDamaged === 0 ? 'block' : 'none';
}
searchInput.addEventListener('input', applyFilters);
statusFilter.addEventListener('change', applyFilters);
damageFilter.addEventListener('change', applyFilters);
applyFilters();
})();
</script>
{% endblock %}
+188
View File
@@ -0,0 +1,188 @@
{% extends "base.html" %}
{% block title %}Rechnungen Element - {{ APP_VERSION }}{% endblock %}
{% block content %}
<style>
.invoice-history-shell {
max-width: 1180px;
margin: 0 auto;
padding: 20px;
}
.invoice-history-head {
background: linear-gradient(135deg, #ffffff 0%, #f7f9fc 100%);
border: 1px solid #dbe4ee;
border-radius: 16px;
padding: 18px 20px;
margin-bottom: 16px;
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.07);
}
.invoice-history-head h1 {
margin: 0 0 6px 0;
font-size: 1.55rem;
}
.head-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
color: #4b5563;
font-size: 0.95rem;
margin-bottom: 12px;
}
.head-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.invoice-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 18px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.invoice-table {
width: 100%;
border-collapse: collapse;
}
.invoice-table th,
.invoice-table td {
padding: 12px 10px;
border-bottom: 1px solid #edf2f7;
vertical-align: top;
text-align: left;
}
.invoice-table th {
font-size: 0.83rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #64748b;
background: #f8fafc;
}
.invoice-table tr:hover td {
background: #fbfdff;
}
.badge-pill {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
}
.badge-open { background: #fee2e2; color: #991b1b; }
.badge-paid { background: #dcfce7; color: #166534; }
.badge-active { background: #dbeafe; color: #1d4ed8; }
.badge-completed { background: #dcfce7; color: #166534; }
.badge-planned { background: #fef3c7; color: #b45309; }
.mono {
font-family: monospace;
font-size: 0.92rem;
}
.muted {
color: #6b7280;
font-size: 0.9rem;
}
.empty-state {
text-align: center;
color: #6b7280;
padding: 36px 20px;
}
@media (max-width: 900px) {
.invoice-table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
</style>
<div class="invoice-history-shell">
<div class="invoice-history-head">
<h1>Rechnungshistorie pro Element</h1>
<div class="head-meta">
<span><strong>Element:</strong> {{ item.name or '—' }}</span>
<span><strong>Code:</strong> {{ item.code or '—' }}</span>
<span><strong>Autor:</strong> {{ item.author or '—' }}</span>
<span><strong>ISBN:</strong> {{ item.isbn or '—' }}</span>
</div>
<div class="head-actions">
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Bibliotheks-Ausleihenverwaltung</a>
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
</div>
</div>
<div class="invoice-card">
{% if invoices %}
<table class="invoice-table">
<thead>
<tr>
<th>Rechnung</th>
<th>Betrag</th>
<th>Ausleihe</th>
<th>Status</th>
<th>Schaden</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for row in invoices %}
<tr>
<td>
<div class="mono">{{ row.invoice_number or '—' }}</div>
<div class="muted">erstellt {{ row.invoice_created_at or '—' }}</div>
{% if row.invoice_created_by %}<div class="muted">von {{ row.invoice_created_by }}</div>{% endif %}
</td>
<td>{{ row.invoice_amount }}</td>
<td>
<div>{{ row.borrow_user or '—' }}</div>
<div class="muted">{{ row.borrow_start or '' }}{% if row.borrow_end %} bis {{ row.borrow_end }}{% endif %}</div>
{% if row.borrow_status == 'active' %}
<span class="badge-pill badge-active">Aktiv</span>
{% elif row.borrow_status == 'planned' %}
<span class="badge-pill badge-planned">Geplant</span>
{% else %}
<span class="badge-pill badge-completed">Abgeschlossen</span>
{% endif %}
</td>
<td>
{% if row.invoice_paid %}
<span class="badge-pill badge-paid">Bezahlt</span>
{% if row.invoice_paid_at %}<div class="muted">am {{ row.invoice_paid_at }}</div>{% endif %}
{% if row.invoice_paid_by %}<div class="muted">durch {{ row.invoice_paid_by }}</div>{% endif %}
{% else %}
<span class="badge-pill badge-open">Offen</span>
{% endif %}
</td>
<td>
<div class="muted">{{ row.invoice_reason or 'Keine Beschreibung' }}</div>
</td>
<td>
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=row.borrow_id) }}" target="_blank" rel="noopener">PDF öffnen</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">Für dieses Element wurden noch keine Rechnungen gespeichert.</div>
{% endif %}
</div>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
+394
View File
@@ -0,0 +1,394 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}License - Inventarsystem{% endblock %}
{% block content %}
<div class="container">
<div class="content">
<h1>Lizenzinformationen &amp; Rechtliches</h1>
<!-- Tab navigation -->
<ul class="nav nav-tabs mt-3" id="legalTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-eula" data-bs-toggle="tab" data-bs-target="#pane-eula"
type="button" role="tab">EULA</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-notice" data-bs-toggle="tab" data-bs-target="#pane-notice"
type="button" role="tab">NOTICE</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-privacy" data-bs-toggle="tab" data-bs-target="#pane-privacy"
type="button" role="tab">Datenschutz</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-security" data-bs-toggle="tab" data-bs-target="#pane-security"
type="button" role="tab">Sicherheit</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-data" data-bs-toggle="tab" data-bs-target="#pane-data"
type="button" role="tab">Datenverarbeitung</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-legal" data-bs-toggle="tab" data-bs-target="#pane-legal"
type="button" role="tab">Rechtsgrundlage</button>
</li>
</ul>
<div class="tab-content" id="legalTabContent">
<!-- ── EULA ─────────────────────────────────────────────────────── -->
<div class="tab-pane fade show active" id="pane-eula" role="tabpanel">
<div class="license-content">
<!-- EULA -->
<h2>Endbenutzer-Lizenzvertrag (EULA) und Nutzungsbedingungen</h2>
<p><strong>Softwareprojekt:</strong> Inventarsystem<br>
<strong>Urheberrechtshalter (Lizenzgeber):</strong> AIIrondev<br>
<strong>Gültigkeit:</strong> Stand 2026</p>
<hr>
<h3>Präambel</h3>
<p>Dieser Endbenutzer-Lizenzvertrag (im Folgenden „Vertrag") stellt eine rechtsgültige Vereinbarung zwischen Ihnen
(im Folgenden „Lizenznehmer") und dem Urheber <strong>AIIrondev</strong> (im Folgenden „Lizenzgeber") dar.
Durch den Zugriff auf den Quellcode, die Installation, das Kopieren oder die sonstige Nutzung der Software
„Inventarsystem" (im Folgenden „Produkt") erklärt sich der Lizenznehmer mit den nachfolgenden Bedingungen
vollumfänglich einverstanden.</p>
<p>Sollte der Lizenznehmer den Bedingungen dieses Vertrags nicht zustimmen, ist jegliche Nutzung,
Vervielfältigung oder Distribution des Produkts mit sofortiger Wirkung untersagt.</p>
<hr>
<h3>§ 1 Gegenstand der Lizenz und Eigentumsrechte</h3>
<ol>
<li>Das Produkt wird dem Lizenznehmer unter Vorbehalt lizenziert, nicht verkauft. Sämtliche
Eigentumsrechte, Urheberrechte und sonstigen geistigen Eigentumsrechte am Produkt sowie an allen
Kopien davon verbleiben ausschließlich beim Lizenzgeber.</li>
<li>Diese Lizenz gewährt lediglich ein eingeschränktes Nutzungsrecht unter den in diesem Vertrag
explizit genannten Bedingungen.</li>
</ol>
<h3>§ 2 Zulässiger Nutzungskreis (Privatnutzung)</h3>
<ol>
<li>Die unentgeltliche Nutzung des Produkts ist ausschließlich <strong>natürlichen Personen für den
rein privaten, häuslichen Gebrauch</strong> gestattet.</li>
<li>Die Nutzung umfasst die Verwaltung privater Bestände ohne jegliche Gewinnerzielungsabsicht.</li>
<li>Jegliche Nutzung durch <strong>Institutionelle Nutzer</strong> (einschließlich, aber nicht
beschränkt auf: Unternehmen, Einzelunternehmer, Freiberufler, Vereine, Bildungseinrichtungen,
Behörden oder NGOs) ist ausdrücklich <strong>untersagt</strong> und bedarf einer gesonderten,
schriftlichen Lizenzvereinbarung mit dem Lizenzgeber.</li>
</ol>
<h3>§ 3 Funktionale Einschränkungen und Support</h3>
<ol>
<li>Dem Lizenznehmer wird das Produkt in der jeweils vorliegenden Fassung bereitgestellt („As-Is").</li>
<li>Für die kostenlose Privatnutzung besteht <strong>kein Anspruch</strong> auf:
<ul>
<li>Technischen Support oder Beratung.</li>
<li>Bereitstellung von Sicherheits-Updates oder Patches.</li>
<li>Gewährleistung der Kompatibilität mit spezifischen Hardware- oder Softwareumgebungen.</li>
</ul>
</li>
<li>Der Lizenzgeber behält sich das Recht vor, Funktionen in zukünftigen Versionen zu ändern,
einzuschränken oder kostenpflichtig zu gestalten.</li>
</ol>
<h3>§ 4 Wahrung der Urheberbezeichnung (Branding-Klausel)</h3>
<ol>
<li>Das Produkt verfügt über fest integrierte Urheberrechtshinweise, insbesondere die Kennzeichnung
<strong>„Powered by AIIrondev"</strong> in der Benutzeroberfläche (Footer/Menü).</li>
<li>Es ist dem Lizenznehmer untersagt, diese Hinweise zu entfernen, zu modifizieren, zu verdecken oder
deren Sichtbarkeit durch technische Maßnahmen (z. B. Manipulation von CSS, JavaScript oder
Metadaten) zu beeinträchtigen.</li>
<li>Das Entfernen dieser Hinweise führt zum sofortigen und automatischen Erlöschen der
Nutzungslizenz.</li>
</ol>
<h3>§ 5 Verbot der kommerziellen Verwertung &amp; SaaS</h3>
<ol>
<li>Die Bereitstellung des Produkts als Dienstleistung für Dritte (Software-as-a-Service, SaaS),
insbesondere gegen Entgelt oder zur Generierung von Werbeeinnahmen, ist strikt untersagt.</li>
<li>Das Hosting auf öffentlichen Servern mit dem Ziel, Dritten ohne eigene Installation Zugriff auf
die Funktionalität zu gewähren, ist nur mit ausdrücklicher schriftlicher Genehmigung des
Lizenzgebers zulässig.</li>
</ol>
<h3>§ 6 Modifikationen und Contributions</h3>
<ol>
<li>Der Lizenznehmer darf den Quellcode für den privaten Eigenbedarf modifizieren.</li>
<li>Im Falle einer Veröffentlichung von Modifikationen oder der Einreichung von
Verbesserungsvorschlägen (z. B. Pull Requests auf GitHub) räumt der Lizenznehmer dem Lizenzgeber
ein unwiderrufliches, weltweites, zeitlich unbeschränktes und kostenfreies Nutzungs- und
Verwertungsrecht an diesen Änderungen ein. Der Lizenzgeber ist berechtigt, diese Änderungen in
das Hauptprodukt zu übernehmen und unter dieser oder einer anderen Lizenz zu vertreiben.</li>
</ol>
<h3>§ 7 Haftungsbeschränkung</h3>
<ol>
<li>Die Haftung des Lizenzgebers für Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung des
Produkts entstehen (einschließlich Datenverlust, Betriebsunterbrechung oder entgangener Gewinn),
ist auf Vorsatz und grobe Fahrlässigkeit beschränkt.</li>
<li>Der Lizenzgeber übernimmt keine Haftung für die Richtigkeit der mit dem Produkt verwalteten
Daten.</li>
</ol>
<h3>§ 8 Rechtswahl und Gerichtsstand</h3>
<ol>
<li>Es gilt ausschließlich das Recht der <strong>Bundesrepublik Deutschland</strong> unter Ausschluss
des UN-Kaufrechts (CISG).</li>
<li>Soweit gesetzlich zulässig, wird als Gerichtsstand für alle Streitigkeiten aus diesem Vertrag der
Sitz des Lizenzgebers vereinbart.</li>
<li>Sollten einzelne Bestimmungen dieses Vertrags unwirksam sein, bleibt die Wirksamkeit der übrigen
Bestimmungen unberührt (Salvatorische Klausel).</li>
</ol>
<hr>
<div class="license-exception-notice">
<h3>⚠️ Anfragen für Ausnahmegenehmigungen (Kommerzielle Lizenzen)</h3>
<p>
Bitte kontaktieren Sie den Urheber direkt über das GitHub-Profil:
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">AIIrondev auf GitHub</a>
</p>
</div>
</div><!-- /.license-content -->
</div><!-- /#pane-eula -->
<!-- ── NOTICE ────────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-notice" role="tabpanel">
<div class="license-content">
<h2>NOTICE Urheberrechtliche Hinweise</h2>
<p><strong>Inventarsystem</strong><br>Copyright © 2025-2026 AIIrondev</p>
<p>Dieses Projekt steht unter dem Inventarsystem EULA (Endbenutzer-Lizenzvertrag). Der vollständige Lizenztext
befindet sich in <code>Legal/LICENSE</code>. Die Software wird ausschließlich für den privaten,
nicht-kommerziellen Gebrauch bereitgestellt. Unerlaubte kommerzielle Nutzung, SaaS-Hosting oder das Entfernen
von Branding ist untersagt. Anfragen für kommerzielle Lizenzen:
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">https://github.com/AIIrondev</a></p>
<hr>
<h3>Drittanbieter-Hinweise</h3>
<p>Dieses Projekt nutzt oder referenziert die folgende Drittanbieter-Software. Deren jeweilige Lizenzen gelten
wie angegeben. Dieser NOTICE-Abschnitt dient ausschließlich der Attribution und ändert keine Lizenzbedingungen.</p>
<h4>Python-Pakete (Laufzeit)</h4>
<ul>
<li>Flask (BSD-3-Clause) © Pallets</li>
<li>Werkzeug (BSD-3-Clause) © Pallets</li>
<li>Jinja2 (BSD-3-Clause) © Pallets (indirekt via Flask)</li>
<li>Click (BSD-3-Clause) © Pallets (indirekt via Flask)</li>
<li>Gunicorn (MIT) © 2009-2025 Benoit Chesneau and contributors</li>
<li>PyMongo (Apache-2.0) © MongoDB, Inc.</li>
<li>APScheduler (MIT) © 2009-2025 Alex Grönholm and contributors</li>
<li>Pillow (HPND/FreeType-style) © 1995-2011 Fredrik Lundh; © 2010-2025 Alex Clark and contributors</li>
<li>python-dateutil (BSD-3-Clause) © 2017-2025 dateutil team; © 2003-2011 Gustavo Niemeyer</li>
<li>pytz (MIT) © 2003-2025 Stuart Bishop</li>
<li>requests (Apache-2.0) © 2011-2025 Kenneth Reitz and contributors</li>
<li>qrcode (BSD-3-Clause) © 2010-2025 Lincoln Loop and contributors</li>
</ul>
<h4>Frontend / Laufzeit-Assets</h4>
<ul>
<li>html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas)</li>
</ul>
<h4>Systemkomponenten (nicht im Repository enthalten, ggf. im Deployment genutzt)</h4>
<ul>
<li>NGINX (2-clause BSD) © Nginx, Inc. and/or its licensors</li>
<li>FFmpeg (LGPL/GPL, je nach Konfiguration) © 2000-2025 FFmpeg developers</li>
</ul>
<hr>
</div>
</div><!-- /#pane-notice -->
<!-- ── DATENSCHUTZ ───────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-privacy" role="tabpanel">
<div class="license-content">
<h2>Datenschutzerklärung (Privacy Policy)</h2>
<h3>1. Einleitung</h3>
<p>Der Schutz Ihrer persönlichen Daten ist uns ein wichtiges Anliegen. Dieses Dokument erläutert, wie das
„Inventarsystem" personenbezogene Daten verarbeitet.</p>
<h3>2. Verantwortliche Stelle</h3>
<p>Verantwortlich für die Datenverarbeitung ist der Betreiber der jeweiligen Instanz (Self-Hosted). Bei Fragen
wenden Sie sich bitte an den Administrator Ihrer Installation.</p>
<h3>3. Erhobene Daten</h3>
<p>Das System verarbeitet folgende Kategorien von Daten:</p>
<ul>
<li><strong>Benutzerdaten:</strong> Benutzername, (je nach Nutzer/Administrator Name und Nachname).</li>
<li><strong>Inventardaten:</strong> Bezeichnungen von Gegenständen, Mengen, Standorte, ggf. Zuordnungen zu Personen.</li>
<li><strong>Protokolldaten:</strong> Zeitstempel von Änderungen (Logs), IP-Adressen (serverseitig zur Sicherheit).</li>
</ul>
<h3>4. Zweck der Verarbeitung</h3>
<p>Die Daten werden ausschließlich zu folgenden Zwecken verarbeitet:</p>
<ul>
<li>Verwaltung und Organisation von Beständen.</li>
<li>Sicherstellung der Systemsicherheit.</li>
<li>Nachvollziehbarkeit von Bestandsänderungen.</li>
</ul>
<h3>5. Rechtsgrundlage (DSGVO)</h3>
<p>Die Verarbeitung erfolgt auf Basis von:</p>
<ul>
<li><strong>Art. 6 Abs. 1 lit. b DSGVO:</strong> Erfüllung eines Vertrages oder vorvertraglicher Maßnahmen.</li>
<li><strong>Art. 6 Abs. 1 lit. f DSGVO:</strong> Berechtigtes Interesse (effiziente Bestandsverwaltung).</li>
</ul>
<h3>6. Datenlöschung</h3>
<p>Daten werden gelöscht, sobald sie für die Erreichung des Zweckes ihrer Erhebung nicht mehr erforderlich sind,
sofern keine gesetzlichen Aufbewahrungspflichten entgegenstehen.</p>
</div>
</div><!-- /#pane-privacy -->
<!-- ── SICHERHEIT ────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-security" role="tabpanel">
<div class="license-content">
<h2>Sicherheitsrichtlinie (Security Policy)</h2>
<h3>Unterstützte Versionen</h3>
<p>Bitte nutzen Sie immer die neueste Version aus dem <code>main</code>-Branch, um Sicherheitsupdates zu erhalten.</p>
<h3>Datenschutzrechtliche Mechanismen</h3>
<ul>
<li><strong>Passwort-Hashing:</strong> Passwörter werden niemals im Klartext gespeichert.</li>
<li><strong>Session-Management:</strong> Automatisches Logout nach Inaktivität (konfigurierbar).</li>
<li><strong>Berechtigungskonzept:</strong> Rollenbasierte Zugriffskontrolle (RBAC), um den Zugriff auf
„Need-to-know"-Basis zu beschränken.</li>
</ul>
<h3>Meldung von Sicherheitslücken</h3>
<div class="license-exception-notice">
<h3>⚠️ Responsible Disclosure</h3>
<p>Falls Sie eine Sicherheitslücke entdecken, öffnen Sie bitte <strong>kein öffentliches Issue</strong>.
Kontaktieren Sie den Maintainer direkt über die
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">GitHub Security Advisory-Funktion</a>
oder per E-Mail.</p>
</div>
</div>
</div><!-- /#pane-security -->
<!-- ── DATENVERARBEITUNG ─────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-data" role="tabpanel">
<div class="license-content">
<h2>Dokumentation der Datenverarbeitung (VVT)</h2>
<h3>Systemübersicht</h3>
<p>Das <strong>Inventarsystem</strong> ist eine Anwendung zur Erfassung und Verwaltung von physischen Gütern.</p>
<h3>Datenfluss</h3>
<ol>
<li><strong>Eingabe:</strong> Nutzer gibt Daten über das Frontend/API ein.</li>
<li><strong>Speicherung:</strong> Daten werden in einer lokalen Datenbank verschlüsselt abgelegt.</li>
<li><strong>Ausgabe:</strong> Anzeige der Bestände für autorisierte Nutzer.</li>
</ol>
<h3>Technische und Organisatorische Maßnahmen (TOM)</h3>
<ul>
<li><strong>Zugangskontrolle:</strong> Authentifizierung über Benutzerkonten.</li>
<li><strong>Integrität:</strong> Validierung der Eingabedaten zur Vermeidung von Datenbankfehlern.</li>
<li><strong>Verfügbarkeit:</strong> Empfohlene Backup-Strategien für die Datenbank.</li>
</ul>
<h3>Empfänger der Daten</h3>
<p>Die Daten verbleiben innerhalb der kontrollierten Umgebung der Installation. Es findet kein automatisierter
Export an Dritte statt.</p>
</div>
</div><!-- /#pane-data -->
<!-- ── RECHTSGRUNDLAGE ───────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-legal" role="tabpanel">
<div class="license-content">
<h2>Rechtsgrundlage der Nutzung</h2>
<p>Um das Inventarsystem DSGVO-konform zu betreiben, muss der Betreiber eine der folgenden Grundlagen festlegen:</p>
<ol>
<li><strong>Innerbetriebliche Nutzung:</strong> Die Verarbeitung ist zur Durchführung des
Beschäftigungsverhältnisses erforderlich (§ 26 BDSG / Art. 88 DSGVO).</li>
<li><strong>Berechtigtes Interesse:</strong> Das Unternehmen hat ein berechtigtes Interesse daran, zu wissen,
wo sich Arbeitsmittel befinden (Art. 6 Abs. 1 lit. f DSGVO).</li>
<li><strong>Einwilligung:</strong> Falls private Daten der Nutzer erfasst werden, muss eine explizite
Einwilligung vorliegen (Art. 6 Abs. 1 lit. a DSGVO).</li>
</ol>
<div class="license-exception-notice" style="background-color:#f8d7da; border-color:#f5c2c7; border-left-color:#dc3545;">
<h3 style="color:#842029;">⚠️ Hinweis</h3>
<p>Dieses Dokument stellt <strong>keine Rechtsberatung</strong> dar. Bitte konsultieren Sie im Zweifelsfall
einen Fachanwalt für Datenschutzrecht.</p>
</div>
</div>
</div><!-- /#pane-legal -->
</div><!-- /.tab-content -->
</div>
</div>
<style>
#legalTabs .nav-link {
color: #495057;
}
#legalTabs .nav-link.active {
font-weight: 600;
color: #0d6efd;
}
.license-content {
background-color: #f8f9fa;
padding: 20px 30px;
border-radius: 0 0 5px 5px;
border: 1px solid #dee2e6;
border-top: none;
}
.license-content h2 {
margin-top: 10px;
}
.license-content h3 {
margin-top: 1.4em;
color: #343a40;
}
.license-content h4 {
margin-top: 1.1em;
color: #495057;
}
.license-content ol,
.license-content ul {
padding-left: 1.5em;
line-height: 1.7;
}
.license-content li {
margin-bottom: 0.4em;
}
.license-content hr {
margin: 25px 0;
border-color: #dee2e6;
}
.license-exception-notice {
margin: 20px 0;
padding: 15px 20px;
background-color: #fff3cd;
border: 1px solid #ffc107;
border-left: 5px solid #ffc107;
border-radius: 5px;
}
.license-exception-notice h3 {
margin-top: 0;
color: #856404;
}
.license-exception-notice p {
margin-bottom: 0;
}
.license-exception-notice a {
font-weight: bold;
color: #0d6efd;
}
</style>
{% endblock %}
+126
View File
@@ -0,0 +1,126 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<h2 class="text-center">Login</h2>
<form method="POST" action="{{ url_for('login') }}">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<div class="password-input-container">
<input type="password" class="form-control" id="password" name="password" required>
<button type="button" class="password-toggle-btn" id="togglePassword" aria-label="Show password">
<input type="checkbox" checked="checked">
<svg class="eye" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 576 512"><path d="M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z"></path></svg>
<svg class="eye-slash" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 640 512"><path d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zM223.1 149.5C248.6 126.2 282.7 112 320 112c79.5 0 144 64.5 144 144c0 24.9-6.3 48.3-17.4 68.7L408 294.5c8.4-19.3 10.6-41.4 4.8-63.3c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3c0 10.2-2.4 19.8-6.6 28.3l-90.3-70.8zM373 389.9c-16.4 6.5-34.3 10.1-53 10.1c-79.5 0-144-64.5-144-144c0-6.9 .5-13.6 1.4-20.2L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5L373 389.9z"></path></svg>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const togglePassword = document.getElementById('togglePassword');
const passwordInput = document.getElementById('password');
const passwordContainer = document.querySelector('.password-input-container');
togglePassword.addEventListener('click', function() {
// Toggle the password field type
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
passwordInput.setAttribute('type', type);
// Toggle the visual state of the container
passwordContainer.classList.toggle('password-visible');
//Toggle visability of eye according to state
// Update aria-label for accessibility
const isVisible = type === 'text';
togglePassword.setAttribute('aria-label', isVisible ? 'Hide password' : 'Show password');
});
});
</script>
<style>
.password-input-container {
display: flex;
gap: 10px;
}
#togglePassword {
border-radius: 5px;
}
#togglePassword {
--color: #a5a5b0;
--size: 20px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
cursor: pointer;
font-size: var(--size);
user-select: none;
fill: var(--color);
width: 15%;
}
#togglePassword .eye-slash {
position: absolute;
animation: keyframes-fill .5s;
display: none;
}
#togglePassword .eye {
position: absolute;
animation: keyframes-fill .5s;
}
/* ------ On check event ------ */
#togglePassword input:checked ~ .eye-slash {
display: block;
}
#togglePassword input:checked ~ .eye {
display: none;
}
/* ------ Hide the default checkbox ------ */
#togglePassword input {
position: absolute;
z-index: 100;
opacity: 0;
cursor: pointer;
height: 100%;
width: 100%;
}
/* ------ Animation ------ */
@keyframes keyframes-fill {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.2);
}
}
</style>
{% endblock %}
+378
View File
@@ -0,0 +1,378 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}System Logs{% endblock %}
{% block content %}
<div class="container">
<h1>System-Protokoll</h1>
{% set conflicts = items | selectattr('ConflictDetected') | list %}
{% if conflicts %}
<div class="conflict-summary-banner" id="conflictSummaryBanner">
<strong>⚠ {{ conflicts | length }} Buchungskonflikt{{ 's' if conflicts | length > 1 else '' }} erkannt</strong>
Geplante Reservierungen wurden aktiv, obwohl der Gegenstand bereits ausgeliehen war.
<button onclick="document.getElementById('conflictSummaryBanner').style.display='none'" class="banner-close"></button>
</div>
{% endif %}
<div class="filter-controls">
<input type="text" id="searchInput" placeholder="Suchen..." onkeyup="filterTable()">
<div class="date-range">
<label for="startDate">Von:</label>
<input type="date" id="startDate" onchange="filterTable()">
<label for="endDate">Bis:</label>
<input type="date" id="endDate" onchange="filterTable()">
</div>
</div>
<div class="table-container">
<table id="logsTable">
<thead>
<tr>
<th onclick="sortTable(0)">Typ ↕</th>
<th onclick="sortTable(1)">Item Name ↕</th>
<th onclick="sortTable(2)">Benutzer ↕</th>
<th onclick="sortTable(3)">Status ↕</th>
<th onclick="sortTable(4)">Ausgeliehen am ↕</th>
<th onclick="sortTable(5)">Zurückgegeben am ↕</th>
<th>Dauer</th>
<th>Hinweis</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.EventType or 'Ausleihe' }}</td>
<td>{{ item.Item }}</td>
<td>{{ item.User }}</td>
<td>
<span class="status-badge status-{{ item.Status }}">{{ item.Status }}</span>
</td>
<td>{{ item.Start }}</td>
<td>{{ item.End }}</td>
<td>{{ item.Duration }}</td>
<td>
{% if item.ConflictNote %}
<span title="{{ item.ConflictNote }}">{{ item.ConflictNote }}</span>
{% endif %}
{% if item.ConflictDetected %}
<span class="conflict-badge" title="{{ item.ConflictNote }}">⚠ Konflikt</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="navigation-buttons">
<a href="{{ url_for('home') }}" class="button">Zurück zum Dashboard</a>
<button onclick="exportToCSV()" class="export-button">Als CSV exportieren</button>
</div>
</div>
<script>
function filterTable() {
const input = document.getElementById('searchInput').value.toLowerCase();
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const table = document.getElementById('logsTable');
const rows = table.getElementsByTagName('tr');
for (let i = 1; i < rows.length; i++) {
let show = true;
const cells = rows[i].getElementsByTagName('td');
// Text search
if (input) {
let textMatch = false;
for (let j = 0; j < cells.length; j++) {
if (cells[j].textContent.toLowerCase().indexOf(input) > -1) {
textMatch = true;
break;
}
}
show = textMatch;
}
// Date range filtering (uses Start in column 4 and End in column 5)
if (show && startDate) {
const rowDate = new Date(cells[4].textContent);
show = rowDate >= new Date(startDate);
}
if (show && endDate) {
const rowDate = new Date(cells[5].textContent);
show = rowDate <= new Date(endDate);
}
rows[i].style.display = show ? '' : 'none';
}
}
function sortTable(n) {
const table = document.getElementById('logsTable');
let switching = true;
let dir = 'asc';
let switchcount = 0;
while (switching) {
switching = false;
const rows = table.rows;
for (let i = 1; i < rows.length - 1; i++) {
let shouldSwitch = false;
const x = rows[i].getElementsByTagName('td')[n];
const y = rows[i + 1].getElementsByTagName('td')[n];
// Check if date column for proper date comparison (Start=4, End=5)
if (n === 4 || n === 5) {
const dateX = new Date(x.innerHTML);
const dateY = new Date(y.innerHTML);
if (dir === 'asc') {
if (dateX > dateY) {
shouldSwitch = true;
break;
}
} else if (dir === 'desc') {
if (dateX < dateY) {
shouldSwitch = true;
break;
}
}
} else {
if (dir === 'asc') {
if (x.innerHTML.toLowerCase() > y.innerHTML.toLowerCase()) {
shouldSwitch = true;
break;
}
} else if (dir === 'desc') {
if (x.innerHTML.toLowerCase() < y.innerHTML.toLowerCase()) {
shouldSwitch = true;
break;
}
}
}
}
if (shouldSwitch) {
rows[i].parentNode.insertBefore(rows[i + 1], rows[i]);
switching = true;
switchcount++;
} else {
if (switchcount === 0 && dir === 'asc') {
dir = 'desc';
switching = true;
}
}
}
}
function exportToCSV() {
const table = document.getElementById('logsTable');
let csv = [];
const rows = table.querySelectorAll('tr');
for (let i = 0; i < rows.length; i++) {
const row = [], cols = rows[i].querySelectorAll('td, th');
for (let j = 0; j < cols.length; j++) {
// Escape double-quotes with double-quotes
let data = cols[j].innerText.replace(/"/g, '""');
// Add quotes if the content contains comma or newline
row.push('"' + data + '"');
}
csv.push(row.join(','));
}
// Download CSV file
const csvFile = new Blob([csv.join('\n')], {type: 'text/csv'});
const downloadLink = document.createElement('a');
downloadLink.download = 'ausleihungen_' + new Date().toISOString().slice(0,10) + '.csv';
downloadLink.href = window.URL.createObjectURL(csvFile);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
}
</script>
<style>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #333;
}
.filter-controls {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
}
#searchInput {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
width: 200px;
}
.date-range {
display: flex;
align-items: center;
gap: 10px;
}
.date-range input {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: capitalize;
color: #fff;
}
.status-planned { background-color: #6c757d; }
.status-active { background-color: #17a2b8; }
.status-completed { background-color: #28a745; }
.status-cancelled { background-color: #dc3545; }
.status-gemeldet { background-color: #fd7e14; }
.status-repariert { background-color: #20c997; }
.conflict-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffc107;
cursor: help;
white-space: nowrap;
}
.conflict-summary-banner {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-left: 4px solid #fd7e14;
color: #856404;
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.banner-close {
background: none;
border: none;
font-size: 1rem;
cursor: pointer;
color: #856404;
flex-shrink: 0;
padding: 0 4px;
}
.banner-close:hover { color: #533f03; }
.table-container {
overflow-x: auto;
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #007bff;
color: white;
cursor: pointer;
}
th:hover {
background-color: #0056b3;
}
tbody tr:hover {
background-color: #f5f5f5;
}
.navigation-buttons {
display: flex;
justify-content: space-between;
}
.button, .export-button {
padding: 10px 15px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.button:hover, .export-button:hover {
background-color: #0056b3;
}
.export-button {
background-color: #28a745;
}
.export-button:hover {
background-color: #218838;
}
@media (max-width: 768px) {
.filter-controls {
flex-direction: column;
gap: 10px;
}
#searchInput {
width: 100%;
}
.date-range {
width: 100%;
justify-content: space-between;
}
}
</style>
{% endblock %}
+4170
View File
File diff suppressed because it is too large Load Diff
+4987
View File
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}Filter verwalten{% endblock %}
{% block content %}
<div class="container">
<h1 class="mb-4">Filterwerte verwalten</h1>
<div class="row">
<!-- Filter 1: Unterrichtsfach -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">Unterrichtsfach (Filter 1)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
<div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neues Unterrichtsfach..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
<h5 class="mb-3">Vorhandene Werte</h5>
{% if filter1_values %}
<div class="list-group">
{% for value in filter1_values %}
<div class="list-group-item d-flex justify-content-between align-items-center">
{{ value }}
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">Keine Werte definiert.</div>
{% endif %}
</div>
</div>
</div>
<!-- Filter 2: Jahrgangsstufe -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">Jahrgangsstufe (Filter 2)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
<div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neue Jahrgangsstufe..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
<h5 class="mb-3">Vorhandene Werte</h5>
{% if filter2_values %}
<div class="list-group">
{% for value in filter2_values %}
<div class="list-group-item d-flex justify-content-between align-items-center">
{{ value }}
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">Keine Werte definiert.</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="alert alert-warning">
<strong>Hinweis:</strong> Beim Entfernen eines Filterwertes wird dieser nicht aus bestehenden Objekten entfernt.
Bereits verwendete Werte bleiben in den Objekten erhalten.
</div>
<div class="mt-4">
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
</div>
</div>
{% endblock %}
+200
View File
@@ -0,0 +1,200 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}Orte verwalten{% endblock %}
{% block content %}
<div class="container">
<h1>Orte verwalten</h1>
<div class="card">
<div class="card-header">
<h2>Neuen Ort hinzufügen</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_location_value') }}">
<div class="form-group">
<label for="location-value">Ort:</label>
<input type="text" id="location-value" name="value" required placeholder="Neuen Ort eingeben">
</div>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</form>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h2>Vorhandene Orte</h2>
</div>
<div class="card-body">
{% if location_values %}
<div class="location-values-list">
{% for value in location_values %}
<div class="location-value-item">
<span class="location-value">{{ value }}</span>
<form method="POST" action="{{ url_for('remove_location_value', value=value) }}" class="inline-form">
<button type="submit" class="btn btn-danger btn-sm"
onclick="return confirm('Sind Sie sicher, dass Sie diesen Ort entfernen möchten?')">
Entfernen
</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-values-message">
Es wurden noch keine Orte definiert.
</div>
{% endif %}
</div>
</div>
<div class="actions-container">
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Übersicht</a>
</div>
</div>
<style>
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #343a40;
}
.card {
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
margin-bottom: 20px;
background-color: #fff;
border: 1px solid #e9ecef;
}
.card-header {
background-color: #f8f9fa;
padding: 15px 20px;
border-bottom: 1px solid #e9ecef;
}
.card-header h2 {
margin: 0;
font-size: 1.25rem;
color: #495057;
}
.card-body {
padding: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
}
.btn {
padding: 8px 16px;
border-radius: 4px;
font-weight: bold;
border: none;
cursor: pointer;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0069d9;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-sm {
padding: 4px 8px;
font-size: 0.875rem;
}
.mt-4 {
margin-top: 1.5rem;
}
.location-values-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
}
.location-value-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
border-left: 3px solid #6c757d;
}
.location-value {
font-weight: 500;
color: #495057;
}
.no-values-message {
color: #6c757d;
font-style: italic;
text-align: center;
padding: 20px;
}
.inline-form {
display: inline;
}
.actions-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
{% endblock %}
+642
View File
@@ -0,0 +1,642 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}Meine Ausleihungen{% endblock %}
{% block content %}
<style>
/* ...existing styles... */
/* Reset button styling for item cards */
.item-reset-button {
background-color: #ffc107;
color: #212529;
border: 2px solid #ffc107;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 0.85rem;
text-decoration: none;
display: inline-block;
text-align: center;
transition: all 0.3s ease;
margin-left: 10px;
min-width: 90px;
}
.item-reset-button:hover {
background-color: #e0a800;
border-color: #d39e00;
transform: translateY(-1px);
box-shadow: 0 3px 6px rgba(255, 193, 7, 0.3);
}
.item-reset-button:active {
transform: translateY(0);
box-shadow: 0 1px 3px rgba(255, 193, 7, 0.3);
}
/* Admin-only indicator */
.admin-only-button {
opacity: 0.7;
pointer-events: none;
}
.admin-only-tooltip {
position: relative;
display: inline-block;
}
.admin-only-tooltip:hover::after {
content: "Nur für Administratoren verfügbar";
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background-color: #333;
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
}
/* Card action buttons container */
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 15px;
align-items: center;
}
.card-actions form {
display: inline-block;
margin: 0;
}
/* Update action-buttons-container to also use flex layout */
.action-buttons-container {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.action-buttons-container form {
display: inline-block;
margin: 0;
}
/* Ensure buttons have consistent spacing */
.action-buttons-container .return-button,
.action-buttons-container .cancel-button {
margin-right: 0;
flex: 0 0 auto;
width: auto;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.card-actions {
flex-direction: column;
align-items: stretch;
}
.item-reset-button {
margin-left: 0;
margin-top: 5px;
}
}
</style>
<div class="container">
<div class="content">
<h1>Meine aktuellen Ausleihungen</h1>
{% if not items and not planned_items %}
<div class="no-items-message">
Sie haben derzeit keine Objekte ausgeliehen oder geplant.
</div>
{% else %}
{% if items %}
<h2 class="section-title">Aktive Ausleihungen</h2>
<div class="borrowed-items">
{% for item in items %}
<div class="borrowed-item">
<div class="item-header">
<h3>{{ item.Name }}</h3>
{% if item.UserExemplarCount is defined and item.UserExemplarCount > 0 %}
<span class="exemplar-count">{{ item.UserExemplarCount }} Exemplar(e)</span>
{% endif %}
{% if item.ActiveAppointment %}
<span class="appointment-status status-active">Aktiv</span>
{% endif %}
</div>
<div class="item-details">
<div class="item-info">
<p><strong>Ort:</strong> {{ item.Ort or '-' }}</p>
{% if item.Filter is defined %}
<p><strong>Unterrichtsfach:</strong>
{% if item.Filter is string %}
{{ item.Filter }}
{% elif item.Filter | length > 0 %}
{{ item.Filter | join(', ') }}
{% else %}
-
{% endif %}
</p>
{% endif %}
{% if item.Filter2 is defined %}
<p><strong>Jahrgangsstufe:</strong>
{% if item.Filter2 is string %}
{{ item.Filter2 }}
{% elif item.Filter2 | length > 0 %}
{{ item.Filter2 | join(', ') }}
{% else %}
-
{% endif %}
</p>
{% endif %}
<p><strong>Barcode:</strong> {{ item.Code_4 or '-' }}</p>
{% if item.UserExemplars is defined and item.UserExemplars %}
<div class="exemplar-details">
<p><strong>Ausgeliehene Exemplare:</strong></p>
<ul class="exemplar-list">
{% for exemplar in item.UserExemplars %}
<li>
Exemplar {{ exemplar.number }}
{% if exemplar.date %}<span class="borrow-date">(seit {{ exemplar.date }})</span>{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if item.Images and item.Images | length > 0 %}
<div class="item-image">
<img src="{{ url_for('uploaded_file', filename=item.Images[0]) }}" alt="{{ item.Name }}">
</div>
{% endif %}
</div>
<div class="item-actions">
{% if item.AppointmentData is defined and item.AppointmentData %}
<!-- Item with appointment data (from active/planned appointments) -->
<div class="action-buttons-container">
<form method="POST" action="{{ url_for('zurueckgeben', id=item._id) }}">
<input type="hidden" name="source_page" value="my_borrowed_items">
<button class="return-button" type="submit">Zurückgeben</button>
</form>
<form method="POST" action="{{ url_for('cancel_ausleihung_route', id=item.AppointmentData.id) }}" onsubmit="return confirm('Möchten Sie diese aktive Ausleihung wirklich stornieren?');">
<button class="cancel-button" type="submit">Stornieren</button>
</form>
<!-- Add reset button for admins -->
{% if user_is_admin %}
<button type="button"
class="item-reset-button"
onclick="resetItemBorrowingStatus('{{ item._id }}')"
title="Status des Items zurücksetzen">
Status zurücksetzen
</button>
{% endif %}
</div>
{% else %}
<!-- Regular borrowed item without appointment data -->
<div class="card-actions">
<form method="POST" action="{{ url_for('zurueckgeben', id=item._id) }}">
<input type="hidden" name="source_page" value="my_borrowed_items">
{% if item.UserExemplarCount is defined and item.UserExemplarCount > 1 %}
<div class="return-count-group">
<label for="exemplare_count_{{ loop.index }}">Anzahl zurückgeben:</label>
<input type="number" id="exemplare_count_{{ loop.index }}" name="exemplare_count"
min="1" max="{{ item.UserExemplarCount }}" value="1" class="exemplare-count-input">
</div>
{% endif %}
<button class="return-button" type="submit">Zurückgeben</button>
</form>
<!-- Add reset button for every borrowed item -->
{% if user_is_admin %}
<button type="button"
class="item-reset-button"
onclick="resetItemBorrowingStatus('{{ item._id }}')"
title="Status des Items zurücksetzen">
Status zurücksetzen
</button>
{% else %}
<div class="admin-only-tooltip">
<button type="button"
class="item-reset-button admin-only-button"
title="Nur für Administratoren verfügbar">
Status zurücksetzen
</button>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if planned_items %}
<h2 class="section-title">Geplante Ausleihungen</h2>
<div class="borrowed-items planned-items">
{% for item in planned_items %}
<div class="borrowed-item planned-item">
<div class="item-header">
<h3>{{ item.Name }}</h3>
{% set status = item.AppointmentData.status | default('planned') %}
<span class="appointment-status status-{{ status }}">
{% if status == 'planned' %}
Geplant
{% elif status == 'active' %}
Aktiv
{% elif status == 'completed' %}
Abgeschlossen
{% elif status == 'cancelled' %}
Storniert
{% else %}
{{ status }}
{% endif %}
</span>
</div>
<div class="item-details">
<div class="item-info">
<p><strong>Ort:</strong> {{ item.Ort or '-' }}</p>
{% if item.Filter is defined %}
<p><strong>Unterrichtsfach:</strong>
{% if item.Filter is string %}
{{ item.Filter }}
{% elif item.Filter | length > 0 %}
{{ item.Filter | join(', ') }}
{% else %}
-
{% endif %}
</p>
{% endif %}
{% if item.Filter2 is defined %}
<p><strong>Jahrgangsstufe:</strong>
{% if item.Filter2 is string %}
{{ item.Filter2 }}
{% elif item.Filter2 | length > 0 %}
{{ item.Filter2 | join(', ') }}
{% else %}
-
{% endif %}
</p>
{% endif %}
<p><strong>Barcode:</strong> {{ item.Code_4 or '-' }}</p>
<div class="appointment-details">
<p><strong>Termin:</strong></p>
{% set start_date = item.AppointmentData.start %}
{% set end_date = item.AppointmentData.end %}
<p class="appointment-date">
{% if item.AppointmentData.period %}
{% if start_date %}{{ start_date.strftime('%d.%m.%Y') }}{% else %}Unbekannt{% endif %}
<span class="appointment-period">{{ item.AppointmentData.period }}. Stunde</span>
{% else %}
{% if start_date and end_date and start_date.date() != end_date.date() %}
{{ start_date.strftime('%d.%m.%Y') }} - {{ end_date.strftime('%d.%m.%Y') }}
<span class="appointment-period">Mehrtägig</span>
{% else %}
{% if start_date %}{{ start_date.strftime('%d.%m.%Y') }}{% else %}Unbekannt{% endif %}
{% endif %}
{% endif %}
</p>
{% if item.AppointmentData.notes %}
<p class="appointment-notes"><i>{{ item.AppointmentData.notes }}</i></p>
{% endif %}
</div>
</div>
{% if item.Images and item.Images | length > 0 %}
<div class="item-image">
<img src="{{ url_for('uploaded_file', filename=item.Images[0]) }}" alt="{{ item.Name }}">
</div>
{% endif %}
</div>
<div class="item-actions">
<div class="card-actions">
<form method="POST" action="{{ url_for('cancel_ausleihung_route', id=item.AppointmentData.id) }}" onsubmit="return confirm('Möchten Sie diesen geplanten Termin wirklich stornieren?');">
<button class="cancel-button" type="submit">Stornieren</button>
</form>
<!-- Add reset button for planned items too -->
{% if user_is_admin %}
<button type="button"
class="item-reset-button"
onclick="resetItemBorrowingStatus('{{ item._id }}')"
title="Status des Items zurücksetzen">
Status zurücksetzen
</button>
{% else %}
<div class="admin-only-tooltip">
<button type="button"
class="item-reset-button admin-only-button"
title="Nur für Administratoren verfügbar">
Status zurücksetzen
</button>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>
</div>
<!-- Include reset item functions -->
{% include "reset_item_functions.html" %}
<!-- Include the new reset modal -->
{% include "reset_item_modal.html" %}
<!-- Add user admin status check -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Check if current user is admin and store globally
fetch('/user_status')
.then(response => response.json())
.then(data => {
window.currentUserIsAdmin = data.is_admin || false;
})
.catch(error => {
console.error('Error checking user status:', error);
window.currentUserIsAdmin = false;
});
});
</script>
<style>
.borrowed-items {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 20px;
}
.borrowed-item {
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
border-bottom: 1px solid #e9ecef;
padding-bottom: 10px;
}
.item-header h3 {
margin: 0;
color: #343a40;
}
.exemplar-count {
background-color: #17a2b8;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: bold;
}
.item-details {
display: flex;
gap: 20px;
}
.item-info {
flex: 1;
}
.item-image {
flex: 0 0 150px;
}
.item-image img {
width: 100%;
max-height: 150px;
object-fit: cover;
border-radius: 4px;
}
.exemplar-details {
margin-top: 10px;
background-color: #f8f9fa;
padding: 10px;
border-radius: 5px;
border-left: 3px solid #6c757d;
}
.exemplar-list {
margin: 5px 0 0 0;
padding-left: 20px;
}
.exemplar-list li {
margin-bottom: 5px;
}
.borrow-date {
color: #6c757d;
font-size: 0.9em;
font-style: italic;
}
.item-actions {
margin-top: 15px;
display: flex;
justify-content: flex-end;
}
.return-button {
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.return-button:hover {
background-color: #0069d9;
}
.return-count-group {
display: flex;
align-items: center;
gap: 10px;
margin-right: 15px;
}
.exemplare-count-input {
width: 60px;
padding: 5px;
border: 1px solid #ced4da;
border-radius: 4px;
}
.no-items-message {
text-align: center;
padding: 30px;
background-color: #f8f9fa;
border-radius: 5px;
color: #6c757d;
margin-top: 20px;
}
/* Planned items styling */
.section-title {
margin-top: 30px;
margin-bottom: 20px;
font-size: 1.5rem;
color: #343a40;
border-bottom: 2px solid #e9ecef;
padding-bottom: 10px;
}
.planned-badge {
background-color: #17a2b8;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: bold;
}
.planned-item {
border-left: 4px solid #17a2b8;
}
.appointment-details {
margin-top: 10px;
background-color: #e1f5fe;
padding: 10px;
border-radius: 5px;
border-left: 3px solid #17a2b8;
}
.appointment-date {
font-weight: bold;
color: #0277bd;
}
.appointment-period {
font-weight: normal;
font-style: italic;
margin-left: 10px;
}
.appointment-notes {
margin-top: 5px;
font-style: italic;
}
.action-buttons-container {
display: flex;
gap: 10px;
justify-content: space-between;
}
.action-buttons-container form {
flex: 1;
}
.action-buttons-container .return-button,
.action-buttons-container .cancel-button {
width: 100%;
}
.cancel-button {
background-color: #dc3545;
color: white;
border: none;
border-radius: 5px;
padding: 10px 15px;
cursor: pointer;
font-weight: bold;
}
.cancel-button:hover {
background-color: #c82333;
}
.appointment-status {
padding: 2px 8px;
border-radius: 10px;
font-size: 0.8em;
font-weight: bold;
color: white;
margin-left: 10px;
}
.status-planned {
background-color: #03a9f4;
}
.status-active {
background-color: #4CAF50;
}
.status-completed {
background-color: #9E9E9E;
}
.status-cancelled {
background-color: #F44336;
}
/* Responsive layout */
@media (max-width: 768px) {
.item-details {
flex-direction: column-reverse;
}
.item-image {
flex: 0 0 auto;
text-align: center;
margin-bottom: 15px;
}
.item-image img {
max-width: 250px;
}
}
</style>
{% endblock %}
+338
View File
@@ -0,0 +1,338 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}Register{% endblock %}
{% block content %}
<div class="container">
<div class="header-section">
<h1>Neuen Benutzer registrieren</h1>
<p class="subtitle">Erstellen Sie ein neues Benutzerkonto und legen Sie Zugriffsrechte fest</p>
</div>
<div class="flash-container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">
<span class="flash-icon">{% if category == 'success' %}✓{% else %}!{% endif %}</span>
<span class="flash-message">{{ message }}</span>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<div class="content">
<div class="form-card">
<form method="POST" action="{{ url_for('register') }}">
<div class="form-group">
<label for="username">Benutzername</label>
<div class="input-container">
<span class="input-icon">👤</span>
<input type="text" id="username" name="username" placeholder="Geben Sie einen Benutzernamen ein" required>
</div>
<label for="name">Name</label>
<div class="input-container">
<span class="input-icon">👤</span>
<input type="text" id="name" name="name" placeholder="Geben Sie den Namen ein" required>
</div>
<label for="last-name">Nachname</label>
<div class="input-container">
<span class="input-icon">👤</span>
<input type="text" id="last-name" name="last-name" placeholder="Geben Sie den Nachnamen ein" required>
</div>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<a class="richtlinen">Das Password muss mindestens 6 Zeichen beinhalten mit Sonderzeichen, groß und klein Buchstaben sowie Zahlen!</a>
<div class="input-container">
<span class="input-icon">🔒</span>
<input type="password" id="password" name="password" placeholder="Geben Sie ein sicheres Passwort ein" required>
</div>
</div>
{% if student_cards_module_enabled %}
<div class="form-group">
<label style="display:flex; align-items:center; gap:8px; color:#1f2937;">
<input type="checkbox" id="is-student" name="is_student" style="width:auto;">
Schülerkonto mit Ausweis
</label>
<div id="student-fields" style="display:none; margin-top:10px;">
<label for="student-card-id">Ausweis-ID</label>
<div class="input-container">
<span class="input-icon">🪪</span>
<input type="text" id="student-card-id" name="student_card_id" placeholder="z.B. S-2026-001">
</div>
<label for="max-borrow-days" style="margin-top:10px;">Standard-Ausleihdauer (Tage)</label>
<div class="input-container">
<span class="input-icon">📅</span>
<input type="number" id="max-borrow-days" name="max_borrow_days" min="1" max="{{ student_max_borrow_days }}" value="{{ student_default_borrow_days }}">
</div>
</div>
</div>
{% endif %}
<div class="form-group form-actions">
<button type="submit" class="action-button register-button">Benutzer registrieren</button>
</div>
</form>
</div>
</div>
</div>
<style>
:root {
--primary-color: #3498db;
--primary-dark: #2980b9;
--success-color: #2ecc71;
--error-color: #e74c3c;
--text-color: #333;
--light-bg: #f9f9f9;
--border-radius: 8px;
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
--transition: all 0.3s ease;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--light-bg);
margin: 0;
padding: 0;
color: var(--text-color);
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 2rem auto;
padding: 0 1.5rem;
}
.header-section {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #eee;
}
.header-section h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
.subtitle {
font-size: 1.1rem;
color: #777;
margin-top: 0;
}
.content {
background-color: #fff;
padding: 2rem;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
margin-bottom: 2rem;
}
.form-card {
max-width: 500px;
margin: 0 auto;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--primary-dark);
}
.input-container {
position: relative;
}
.input-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: #aaa;
font-size: 1.2rem;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 0.8rem 1rem 0.8rem 3rem;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-size: 1rem;
transition: var(--transition);
box-sizing: border-box;
}
input[type="text"]:focus,
input[type="password"]:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
outline: none;
}
input::placeholder {
color: #aaa;
}
.form-actions {
margin-top: 2rem;
}
.action-button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 30px;
cursor: pointer;
font-weight: 600;
transition: var(--transition);
width: 100%;
font-size: 1rem;
}
.action-button:hover {
background-color: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.register-button {
background-color: var(--success-color);
}
.register-button:hover {
background-color: #27ae60;
}
.navigation-buttons {
text-align: center;
margin: 1.5rem 0;
}
.back-button {
display: inline-flex;
align-items: center;
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
transition: var(--transition);
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
}
.back-button:hover {
background-color: rgba(52, 152, 219, 0.1);
}
.back-icon {
margin-right: 0.5rem;
font-size: 1.2rem;
}
.flash-container {
margin-bottom: 1.5rem;
}
.flash {
display: flex;
align-items: center;
padding: 1rem;
margin-bottom: 1rem;
border-radius: var(--border-radius);
animation: fadeIn 0.3s ease-in-out;
}
.flash-icon {
margin-right: 0.8rem;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
}
.flash.success {
background-color: #d4edda;
color: #155724;
border-left: 4px solid var(--success-color);
}
.flash.error {
background-color: #f8d7da;
color: #721c24;
border-left: 4px solid var(--error-color);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.container {
padding: 0 1rem;
margin: 1rem auto;
}
.content {
padding: 1.5rem;
}
.header-section h1 {
font-size: 2rem;
}
}
.richtlinen{
color: #ec0920;
}
</style>
{% if student_cards_module_enabled %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const studentCheckbox = document.getElementById('is-student');
const studentFields = document.getElementById('student-fields');
const studentCardInput = document.getElementById('student-card-id');
if (!studentCheckbox || !studentFields || !studentCardInput) {
return;
}
function toggleStudentFields() {
const isChecked = studentCheckbox.checked;
studentFields.style.display = isChecked ? 'block' : 'none';
studentCardInput.required = isChecked;
}
studentCheckbox.addEventListener('change', toggleStudentFields);
toggleStudentFields();
});
</script>
{% endif %}
{% endblock %}
+307
View File
@@ -0,0 +1,307 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
<!--
Reset Item Functions
====================
This template contains JavaScript functions for resetting item borrowing status.
It handles cleaning up inconsistent states between items and their borrowing records.
-->
<script>
/**
* Reset an item's borrowing status completely
*/
function resetItemBorrowingStatus(itemId) {
// Show confirmation dialog
const confirmMessage = `Sind Sie sicher, dass Sie den Ausleihstatus dieses Items zurücksetzen möchten?\n\n` +
`Dies wird:\n` +
`- Das Item als verfügbar markieren\n` +
`- Alle aktiven Ausleihungen beenden\n` +
`- Den Ausleihstatus vollständig zurücksetzen\n\n` +
`Diese Aktion kann nicht rückgängig gemacht werden.`;
if (!confirm(confirmMessage)) {
return;
}
// Show loading indicator
const loadingDiv = createLoadingIndicator('Item wird zurückgesetzt...');
document.body.appendChild(loadingDiv);
// Send reset request to server
fetch(`/reset_item/${itemId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
'reset_type': 'complete',
'timestamp': new Date().toISOString()
})
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.message || data.error || `HTTP error! status: ${response.status}`);
});
}
return response.json();
})
.then(data => {
document.body.removeChild(loadingDiv);
if (data.success) {
showSuccessMessage('Item-Status wurde erfolgreich zurückgesetzt!');
// Reload the page to reflect changes
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
throw new Error(data.message || 'Unbekannter Fehler beim Zurücksetzen');
}
})
.catch(error => {
if (loadingDiv.parentNode) {
document.body.removeChild(loadingDiv);
}
console.error('Error resetting item:', error);
showErrorMessage(`Fehler beim Zurücksetzen des Items: ${error.message}`);
});
}
/**
* Create a loading indicator element
*/
function createLoadingIndicator(message) {
const loadingDiv = document.createElement('div');
loadingDiv.className = 'reset-loading-overlay';
loadingDiv.innerHTML = `
<div class="reset-loading-content">
<div class="reset-loading-spinner"></div>
<div class="reset-loading-message">${message}</div>
</div>
`;
// Add styles if not already present
if (!document.getElementById('reset-styles')) {
const style = document.createElement('style');
style.id = 'reset-styles';
style.textContent = `
.reset-loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
}
.reset-loading-content {
background: white;
padding: 30px;
border-radius: 8px;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
max-width: 400px;
width: 90%;
}
.reset-loading-spinner {
width: 40px;
height: 40px;
margin: 0 auto 20px;
border: 4px solid #f3f3f3;
border-top: 4px solid #dc3545;
border-radius: 50%;
animation: reset-spin 1s linear infinite;
}
.reset-loading-message {
color: #333;
font-size: 16px;
font-weight: 500;
}
@keyframes reset-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.reset-success-message {
position: fixed;
top: 20px;
right: 20px;
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
border-radius: 5px;
padding: 15px 20px;
z-index: 10002;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
font-weight: 500;
max-width: 400px;
animation: reset-slide-in 0.3s ease, reset-fade-out 0.5s ease 2.5s forwards;
}
.reset-error-message {
position: fixed;
top: 20px;
right: 20px;
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 5px;
padding: 15px 40px 15px 20px;
z-index: 10002;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
max-width: 400px;
animation: reset-slide-in 0.3s ease;
position: relative;
}
.reset-error-content {
font-weight: 500;
line-height: 1.4;
}
.reset-error-close {
position: absolute;
top: 10px;
right: 15px;
background: none;
border: none;
font-size: 24px;
color: #721c24;
cursor: pointer;
padding: 0;
line-height: 1;
}
.reset-error-close:hover {
color: #491217;
}
@keyframes reset-slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes reset-fade-out {
to {
opacity: 0;
transform: translateX(100%);
}
}
`;
document.head.appendChild(style);
}
return loadingDiv;
}
/**
* Show a success message to the user
*/
function showSuccessMessage(message) {
const successDiv = document.createElement('div');
successDiv.className = 'reset-success-message';
successDiv.textContent = message;
document.body.appendChild(successDiv);
// Remove after 3 seconds
setTimeout(() => {
if (successDiv.parentNode) {
successDiv.parentNode.removeChild(successDiv);
}
}, 3000);
}
/**
* Show an error message to the user
*/
function showErrorMessage(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'reset-error-message';
errorDiv.innerHTML = `
<div class="reset-error-content">
<strong>⚠️ Fehler</strong><br>
${message}
</div>
<button class="reset-error-close" onclick="this.parentElement.remove()">×</button>
`;
document.body.appendChild(errorDiv);
// Auto-remove after 5 seconds
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.parentNode.removeChild(errorDiv);
}
}, 5000);
}
/**
* Check if an item has borrowing issues that would warrant a reset
*/
function checkForBorrowingIssues(item) {
// Check for inconsistent availability status
if (!item.Verfuegbar && !item.User) {
return true; // Item marked as unavailable but no user assigned
}
// Check for exemplar status issues
if (item.ExemplareStatus && Array.isArray(item.ExemplareStatus)) {
const totalExemplare = item.Exemplare || 1;
const borrowedCount = item.ExemplareStatus.length;
if (borrowedCount > totalExemplare) {
return true; // More exemplars borrowed than exist
}
if (borrowedCount > 0 && item.Verfuegbar) {
return true; // Has borrowed exemplars but marked as available
}
}
// Check for stale borrower information
if (!item.Verfuegbar && item.BorrowerInfo) {
const borrowTime = item.BorrowerInfo.borrowTime;
if (borrowTime) {
try {
const borrowDate = new Date(borrowTime);
const now = new Date();
const daysDiff = (now - borrowDate) / (1000 * 60 * 60 * 24);
// If borrowed for more than 30 days, might be stale
if (daysDiff > 30) {
return true;
}
} catch (e) {
// Invalid date format
return true;
}
}
}
return false;
}
</script>
+816
View File
@@ -0,0 +1,816 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
<!--
Reset Item Modal
================
This template contains a dedicated modal window for resetting item borrowing status.
It provides detailed information about what will be reset and requires confirmation.
-->
<!-- Reset Item Modal -->
<div id="reset-item-modal" class="reset-modal-overlay" style="display: none;">
<div class="reset-modal-container">
<div class="reset-modal-header">
<h2>
<i class="reset-icon">⚠️</i>
Item-Status zurücksetzen
</h2>
<button class="reset-modal-close" onclick="closeResetModal()">&times;</button>
</div>
<div class="reset-modal-content">
<div class="reset-item-info">
<h3 id="reset-item-name">Item wird geladen...</h3>
<div class="reset-item-details">
<p><strong>ID:</strong> <span id="reset-item-id">-</span></p>
<p><strong>Ort:</strong> <span id="reset-item-location">-</span></p>
<p><strong>Aktueller Status:</strong> <span id="reset-item-status">-</span></p>
<p><strong>Aktuelle Ausleiher:</strong> <span id="reset-item-borrower">-</span></p>
</div>
</div>
<div class="reset-warning-section">
<div class="reset-warning-box">
<h4>⚠️ Achtung: Diese Aktion kann nicht rückgängig gemacht werden!</h4>
<p>Das Zurücksetzen des Item-Status wird folgende Aktionen durchführen:</p>
</div>
<div class="reset-actions-list">
<div class="reset-action-item">
<input type="checkbox" id="reset-availability" checked disabled>
<label for="reset-availability">
<strong>Item als verfügbar markieren</strong>
<span class="reset-action-desc">Das Item wird als "Verfügbar" gesetzt</span>
</label>
</div>
<div class="reset-action-item">
<input type="checkbox" id="reset-borrower" checked disabled>
<label for="reset-borrower">
<strong>Ausleiher-Informationen entfernen</strong>
<span class="reset-action-desc">Alle Benutzer-Zuordnungen werden gelöscht</span>
</label>
</div>
<div class="reset-action-item" id="reset-exemplar-section" style="display: none;">
<input type="checkbox" id="reset-exemplars" checked disabled>
<label for="reset-exemplars">
<strong>Exemplar-Status zurücksetzen</strong>
<span class="reset-action-desc">Alle Exemplar-Ausleihungen werden beendet</span>
</label>
</div>
<div class="reset-action-item" id="reset-borrowings-section">
<input type="checkbox" id="reset-borrowings" checked disabled>
<label for="reset-borrowings">
<strong>Aktive Ausleihungen beenden</strong>
<span class="reset-action-desc">Alle aktiven/geplanten Ausleihungen werden als "abgeschlossen" markiert</span>
</label>
<div id="active-borrowings-list" class="active-borrowings"></div>
</div>
</div>
</div>
<div class="reset-options-section">
<h4>Reset-Optionen:</h4>
<div class="reset-option">
<input type="checkbox" id="reset-create-log" checked>
<label for="reset-create-log">Log-Eintrag für diese Aktion erstellen</label>
</div>
<div class="reset-option">
<input type="checkbox" id="reset-notify-users">
<label for="reset-notify-users">Betroffene Benutzer benachrichtigen (falls möglich)</label>
</div>
</div>
<div class="reset-confirmation-section">
<div class="reset-confirmation-box">
<input type="checkbox" id="reset-final-confirmation">
<label for="reset-final-confirmation">
<strong>Ich bestätige, dass ich dieses Item zurücksetzen möchte</strong>
</label>
</div>
<div class="reset-reason-section">
<label for="reset-reason">Grund für das Zurücksetzen (optional):</label>
<textarea id="reset-reason" placeholder="z.B. Inkonsistenter Status, Item fälschlicherweise als ausgeliehen markiert, etc."></textarea>
</div>
</div>
</div>
<div class="reset-modal-footer">
<button class="reset-cancel-btn" onclick="closeResetModal()">
Abbrechen
</button>
<button class="reset-confirm-btn" id="reset-execute-btn" onclick="executeItemReset()" disabled>
<i class="reset-execute-icon">🔄</i>
Item zurücksetzen
</button>
</div>
</div>
</div>
<style>
/* Reset Modal Styles */
.reset-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(3px);
animation: resetModalFadeIn 0.3s ease;
}
@keyframes resetModalFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.reset-modal-container {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 700px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
position: relative;
animation: resetModalSlideIn 0.4s ease;
}
@keyframes resetModalSlideIn {
from {
transform: translateY(-50px) scale(0.9);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
.reset-modal-header {
background: linear-gradient(135deg, #dc3545, #c82333);
color: white;
padding: 20px;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.reset-modal-header h2 {
margin: 0;
font-size: 1.4rem;
display: flex;
align-items: center;
gap: 10px;
}
.reset-icon {
font-size: 1.6rem;
}
.reset-modal-close {
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
padding: 0;
width: 40px;
height: 40px;
border-radius: 50%;
transition: background-color 0.3s ease;
}
.reset-modal-close:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.reset-modal-content {
padding: 25px;
}
.reset-item-info {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin-bottom: 25px;
border-left: 4px solid #007bff;
}
.reset-item-info h3 {
margin: 0 0 15px 0;
color: #333;
font-size: 1.3rem;
}
.reset-item-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.reset-item-details p {
margin: 5px 0;
color: #666;
}
.reset-item-details strong {
color: #333;
}
.reset-warning-section {
margin-bottom: 25px;
}
.reset-warning-box {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.reset-warning-box h4 {
margin: 0 0 10px 0;
color: #856404;
font-size: 1.1rem;
}
.reset-warning-box p {
margin: 0;
color: #856404;
}
.reset-actions-list {
space-y: 15px;
}
.reset-action-item {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
margin-bottom: 10px;
transition: border-color 0.3s ease;
}
.reset-action-item:hover {
border-color: #007bff;
}
.reset-action-item input[type="checkbox"] {
margin-right: 12px;
transform: scale(1.2);
}
.reset-action-item label {
display: flex;
flex-direction: column;
cursor: pointer;
font-weight: 500;
color: #333;
}
.reset-action-desc {
font-weight: normal;
color: #666;
font-size: 0.9rem;
margin-top: 5px;
}
.active-borrowings {
margin-top: 10px;
padding: 10px;
background: #f8f9fa;
border-radius: 5px;
font-size: 0.9rem;
}
.borrowing-entry {
padding: 5px 0;
border-bottom: 1px solid #dee2e6;
}
.borrowing-entry:last-child {
border-bottom: none;
}
.reset-options-section {
background: #f1f3f4;
border-radius: 8px;
padding: 20px;
margin-bottom: 25px;
}
.reset-options-section h4 {
margin: 0 0 15px 0;
color: #333;
}
.reset-option {
margin-bottom: 10px;
}
.reset-option input[type="checkbox"] {
margin-right: 10px;
transform: scale(1.1);
}
.reset-option label {
color: #555;
font-weight: 500;
cursor: pointer;
}
.reset-confirmation-section {
border: 2px solid #dc3545;
border-radius: 8px;
padding: 20px;
background: #fdf2f2;
}
.reset-confirmation-box {
margin-bottom: 15px;
}
.reset-confirmation-box input[type="checkbox"] {
margin-right: 10px;
transform: scale(1.2);
}
.reset-confirmation-box label {
font-weight: bold;
color: #721c24;
cursor: pointer;
}
.reset-reason-section label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.reset-reason-section textarea {
width: 100%;
height: 80px;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 5px;
font-family: inherit;
resize: vertical;
background: white;
}
.reset-reason-section textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 5px rgba(0, 123, 255, 0.3);
}
.reset-modal-footer {
padding: 20px 25px;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: flex-end;
gap: 15px;
background: #f8f9fa;
border-radius: 0 0 12px 12px;
}
.reset-cancel-btn {
background: #6c757d;
color: white;
border: none;
padding: 12px 25px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
}
.reset-cancel-btn:hover {
background: #5a6268;
transform: translateY(-1px);
}
.reset-confirm-btn {
background: #dc3545;
color: white;
border: none;
padding: 12px 25px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.reset-confirm-btn:hover:not(:disabled) {
background: #c82333;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(220, 53, 69, 0.3);
}
.reset-confirm-btn:disabled {
background: #cccccc;
cursor: not-allowed;
transform: none;
}
.reset-execute-icon {
font-size: 1rem;
}
/* Loading states */
.reset-loading .reset-execute-icon {
animation: resetSpin 1s linear infinite;
}
@keyframes resetSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Responsive adjustments */
@media (max-width: 768px) {
.reset-modal-container {
width: 95%;
margin: 10px;
max-height: 95vh;
}
.reset-modal-content {
padding: 20px;
}
.reset-item-details {
grid-template-columns: 1fr;
}
.reset-modal-footer {
flex-direction: column;
}
.reset-cancel-btn,
.reset-confirm-btn {
width: 100%;
justify-content: center;
}
}
/* Success animation */
.reset-success {
animation: resetSuccess 0.6s ease;
}
@keyframes resetSuccess {
0% { transform: scale(1); }
50% { transform: scale(1.05); background-color: #28a745; }
100% { transform: scale(1); }
}
</style>
<script>
// Global variable to store the current item being reset
let currentResetItem = null;
/**
* Opens the reset modal for a specific item
*/
function openResetModal(itemId) {
currentResetItem = itemId;
// Show the modal
document.getElementById('reset-item-modal').style.display = 'flex';
// Load item details
loadItemDetailsForReset(itemId);
// Reset form state
resetModalFormState();
// Add event listeners
setupResetModalEventListeners();
}
/**
* Closes the reset modal
*/
function closeResetModal() {
document.getElementById('reset-item-modal').style.display = 'none';
currentResetItem = null;
resetModalFormState();
}
/**
* Loads item details for the reset modal
*/
function loadItemDetailsForReset(itemId) {
// Show loading state
document.getElementById('reset-item-name').textContent = 'Lade Item-Details...';
document.getElementById('reset-item-id').textContent = itemId;
document.getElementById('reset-item-location').textContent = 'Wird geladen...';
document.getElementById('reset-item-status').textContent = 'Wird geladen...';
document.getElementById('reset-item-borrower').textContent = 'Wird geladen...';
// Fetch item details
fetch(`/get_item/${itemId}`)
.then(response => response.json())
.then(data => {
if (data.status === 'success' && data.item) {
const item = data.item;
// Update item information
document.getElementById('reset-item-name').textContent = item.Name || 'Unbekanntes Item';
document.getElementById('reset-item-location').textContent = item.Ort || 'Kein Ort angegeben';
// Determine status
let statusText = 'Verfügbar';
let borrowerText = 'Nicht ausgeliehen';
if (!item.Verfuegbar) {
statusText = 'Ausgeliehen';
borrowerText = item.User || 'Unbekannter Benutzer';
} else if (item.ExemplareStatus && item.ExemplareStatus.length > 0) {
statusText = `${item.ExemplareStatus.length} Exemplar(e) ausgeliehen`;
const borrowers = item.ExemplareStatus.map(ex => ex.user).join(', ');
borrowerText = borrowers;
} else if (item.BorrowerInfo) {
statusText = 'Möglicherweise ausgeliehen (inkonsistent)';
borrowerText = item.BorrowerInfo.username || 'Unbekannt';
}
document.getElementById('reset-item-status').textContent = statusText;
document.getElementById('reset-item-borrower').textContent = borrowerText;
// Show/hide exemplar section
const exemplarSection = document.getElementById('reset-exemplar-section');
if (item.ExemplareStatus && item.ExemplareStatus.length > 0) {
exemplarSection.style.display = 'block';
} else {
exemplarSection.style.display = 'none';
}
// Load active borrowings
loadActiveBorrowingsForReset(itemId);
} else {
document.getElementById('reset-item-name').textContent = 'Fehler beim Laden';
document.getElementById('reset-item-status').textContent = 'Unbekannt';
document.getElementById('reset-item-borrower').textContent = 'Unbekannt';
}
})
.catch(error => {
console.error('Error loading item details:', error);
document.getElementById('reset-item-name').textContent = 'Fehler beim Laden';
});
}
/**
* Loads active borrowings for the item
*/
function loadActiveBorrowingsForReset(itemId) {
fetch(`/get_ausleihung_by_item/${itemId}`)
.then(response => response.json())
.then(data => {
const borrowingsList = document.getElementById('active-borrowings-list');
if (data.status === 'success' && data.ausleihung) {
const borrowing = data.ausleihung;
borrowingsList.innerHTML = `
<div class="borrowing-entry">
<strong>Benutzer:</strong> ${borrowing.User || 'Unbekannt'}<br>
<strong>Start:</strong> ${borrowing.Start ? new Date(borrowing.Start).toLocaleString('de-DE') : 'Unbekannt'}<br>
<strong>Status:</strong> ${borrowing.Status || 'Unbekannt'}
</div>
`;
} else {
borrowingsList.innerHTML = '<em>Keine aktiven Ausleihungen gefunden</em>';
}
})
.catch(error => {
console.error('Error loading borrowings:', error);
document.getElementById('active-borrowings-list').innerHTML = '<em>Fehler beim Laden der Ausleihungen</em>';
});
}
/**
* Resets the modal form state
*/
function resetModalFormState() {
// Reset checkboxes
document.getElementById('reset-create-log').checked = true;
document.getElementById('reset-notify-users').checked = false;
document.getElementById('reset-final-confirmation').checked = false;
// Clear reason textarea
document.getElementById('reset-reason').value = '';
// Disable execute button
document.getElementById('reset-execute-btn').disabled = true;
}
/**
* Sets up event listeners for the reset modal
*/
function setupResetModalEventListeners() {
// Confirmation checkbox listener
const confirmationCheckbox = document.getElementById('reset-final-confirmation');
const executeButton = document.getElementById('reset-execute-btn');
confirmationCheckbox.addEventListener('change', function() {
executeButton.disabled = !this.checked;
});
// ESC key to close modal
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape' && document.getElementById('reset-item-modal').style.display === 'flex') {
closeResetModal();
}
});
// Click outside modal to close
document.getElementById('reset-item-modal').addEventListener('click', function(event) {
if (event.target === this) {
closeResetModal();
}
});
}
/**
* Executes the item reset
*/
function executeItemReset() {
if (!currentResetItem) {
alert('Kein Item ausgewählt');
return;
}
const executeButton = document.getElementById('reset-execute-btn');
const originalText = executeButton.innerHTML;
// Show loading state
executeButton.classList.add('reset-loading');
executeButton.disabled = true;
executeButton.innerHTML = '<i class="reset-execute-icon">🔄</i> Wird zurückgesetzt...';
// Collect form data
const resetData = {
item_id: currentResetItem,
create_log: document.getElementById('reset-create-log').checked,
notify_users: document.getElementById('reset-notify-users').checked,
reason: document.getElementById('reset-reason').value.trim() || 'Item-Status Reset über Admin-Interface',
timestamp: new Date().toISOString()
};
// Send reset request
fetch(`/reset_item/${currentResetItem}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(resetData)
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.message || data.error || `HTTP error! status: ${response.status}`);
});
}
return response.json();
})
.then(data => {
executeButton.classList.remove('reset-loading');
if (data.success) {
// Show success state
executeButton.classList.add('reset-success');
executeButton.innerHTML = '<i class="reset-execute-icon">✅</i> Erfolgreich zurückgesetzt!';
// Show success message
showResetSuccessMessage(data.message);
// Close modal after delay
setTimeout(() => {
closeResetModal();
// Reload the page to reflect changes
window.location.reload();
}, 2000);
} else {
throw new Error(data.message || 'Unbekannter Fehler beim Zurücksetzen');
}
})
.catch(error => {
executeButton.classList.remove('reset-loading');
executeButton.disabled = false;
executeButton.innerHTML = originalText;
console.error('Error resetting item:', error);
showResetErrorMessage(`Fehler beim Zurücksetzen: ${error.message}`);
});
}
/**
* Shows a success message
*/
function showResetSuccessMessage(message) {
const successDiv = document.createElement('div');
successDiv.className = 'reset-success-message';
successDiv.innerHTML = `
<strong>✅ Erfolg!</strong><br>
${message}
`;
successDiv.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
border-radius: 8px;
padding: 15px 20px;
z-index: 10003;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-weight: 500;
max-width: 400px;
animation: resetSlideIn 0.3s ease, resetFadeOut 0.5s ease 3s forwards;
`;
document.body.appendChild(successDiv);
setTimeout(() => {
if (successDiv.parentNode) {
successDiv.parentNode.removeChild(successDiv);
}
}, 3500);
}
/**
* Shows an error message
*/
function showResetErrorMessage(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'reset-error-message';
errorDiv.innerHTML = `
<strong>⚠️ Fehler</strong><br>
${message}
<button onclick="this.parentElement.remove()" style="
position: absolute;
top: 10px;
right: 15px;
background: none;
border: none;
font-size: 20px;
color: #721c24;
cursor: pointer;
">×</button>
`;
errorDiv.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 8px;
padding: 15px 40px 15px 20px;
z-index: 10003;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 400px;
animation: resetSlideIn 0.3s ease;
position: relative;
`;
document.body.appendChild(errorDiv);
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.parentNode.removeChild(errorDiv);
}
}, 5000);
}
// Update the original resetItemBorrowingStatus function to use the new modal
function resetItemBorrowingStatus(itemId) {
openResetModal(itemId);
}
</script>
@@ -0,0 +1,156 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schülerausweis - Barcode PDF Download</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
text-align: center;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
max-width: 500px;
width: 100%;
}
.icon {
font-size: 60px;
margin-bottom: 20px;
}
h1 {
color: #333;
font-size: 28px;
margin-bottom: 10px;
}
p {
color: #666;
font-size: 14px;
margin-bottom: 30px;
line-height: 1.6;
}
.info-box {
background: #f0f0f0;
border-left: 4px solid #667eea;
padding: 15px;
margin-bottom: 30px;
text-align: left;
border-radius: 4px;
}
.info-box strong {
color: #333;
}
.info-box p {
margin: 5px 0;
font-size: 13px;
}
.btn-group {
display: flex;
gap: 12px;
flex-direction: column;
}
.btn {
padding: 14px 28px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background: #d0d0d0;
}
.status {
font-size: 12px;
color: #999;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📇</div>
<h1>Schülerausweis-Download</h1>
<p>Generieren Sie eine PDF mit Barcodes aller Schülerausweise zum direkten Drucken</p>
<div class="info-box">
<strong>📌 Was wird heruntergeladen?</strong>
<p>Eine druckfertige PDF mit:</p>
<p>✓ Alle Schülerausweise</p>
<p>✓ Scanbare CODE128 Barcodes</p>
<p>✓ Optimiert für A4-Druck (2 Karten pro Seite)</p>
<p>✓ Professionelle Qualität</p>
</div>
<div class="btn-group">
<a href="{{ download_link }}" class="btn btn-primary">
📥 PDF herunterladen & Drucken
</a>
<button onclick="window.history.back()" class="btn btn-secondary">
Zurück
</button>
</div>
<div class="status">
<p>Generiert am: <span id="timestamp"></span></p>
</div>
</div>
<script>
document.getElementById('timestamp').textContent = new Date().toLocaleString('de-DE');
</script>
</body>
</html>
+358
View File
@@ -0,0 +1,358 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}Schülerausweise - Inventarsystem{% endblock %}
{% block content %}
<style>
.student-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 10px;
}
.student-card-form {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 15px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-weight: 600;
margin-bottom: 5px;
color: #333;
}
.form-group input,
.form-group select {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 5px rgba(0, 123, 255, 0.3);
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.form-actions button {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
}
.btn-save {
background: #28a745;
color: white;
}
.btn-save:hover {
background: #218838;
}
.btn-cancel {
background: #6c757d;
color: white;
}
.btn-cancel:hover {
background: #5a6268;
}
.cards-table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.cards-table thead {
background: #007bff;
color: white;
font-weight: 600;
}
.cards-table th,
.cards-table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #eee;
}
.cards-table tbody tr:hover {
background: #f9f9f9;
}
.cards-table tbody tr:last-child td {
border-bottom: none;
}
.card-actions {
display: flex;
gap: 8px;
}
.btn-edit, .btn-delete {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.btn-edit {
background: #007bff;
color: white;
}
.btn-edit:hover {
background: #0056b3;
}
.btn-delete {
background: #dc3545;
color: white;
}
.btn-delete:hover {
background: #c82333;
}
.btn-print {
padding: 10px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
text-decoration: none;
display: inline-block;
}
.btn-print {
background: #17a2b8;
color: white;
}
.btn-print:hover {
background: #138496;
}
.btn-export {
background: #6f42c1;
color: white;
}
.btn-export:hover {
background: #5a32a3;
}
.export-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.empty-state {
text-align: center;
padding: 40px;
color: #666;
}
.empty-state p {
font-size: 16px;
margin: 10px 0;
}
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
.student-card-header {
flex-direction: column;
align-items: flex-start;
}
.cards-table {
font-size: 14px;
}
.cards-table th,
.cards-table td {
padding: 8px 10px;
}
.card-actions {
flex-direction: column;
}
}
</style>
<div class="container">
<div class="student-card-header">
<div>
<h1>📚 Schülerausweise (Bibliotek)</h1>
</div>
<div class="export-buttons">
<a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a>
<a href="{{ url_for('library_admin') }}" class="btn btn-primary">← Zur Bibliotheks-Upload</a>
</div>
</div>
<!-- Add/Edit Form -->
<div class="student-card-form">
<h2>{% if edit_mode %}Ausweis bearbeiten{% else %}Neuer Schülerausweis{% endif %}</h2>
<form method="POST" action="{{ url_for('student_cards_admin') }}">
{% if edit_mode %}
<input type="hidden" name="action" value="edit">
<input type="hidden" name="card_id" value="{{ form_data.get('card_id', '') }}">
{% else %}
<input type="hidden" name="action" value="add">
{% endif %}
<div class="form-row">
<div class="form-group">
<label for="ausweis_id">Ausweis-ID *</label>
<input type="text" id="ausweis_id" name="ausweis_id" required
value="{{ form_data.get('ausweis_id', '') }}"
placeholder="z.B. SIS2024001">
</div>
<div class="form-group">
<label for="student_name">Schüler Name *</label>
<input type="text" id="student_name" name="student_name" required
value="{{ form_data.get('student_name', '') }}"
placeholder="z.B. Max Mustermann">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="default_borrow_days">Standard Ausleihdauer (Tage) *</label>
<input type="number" id="default_borrow_days" name="default_borrow_days"
min="1" max="365" required
value="{{ form_data.get('default_borrow_days', config.get('default', 14)) }}">
</div>
<div class="form-group">
<label for="class_name">Klasse</label>
<input type="text" id="class_name" name="class_name"
value="{{ form_data.get('class_name', '') }}"
placeholder="z.B. 10A">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="notes">Notizen</label>
<textarea id="notes" name="notes" rows="3"
placeholder="Optionale Notizen..."
style="padding: 10px; border: 1px solid #ddd; border-radius: 4px;">{{ form_data.get('notes', '') }}</textarea>
</div>
</div>
<div class="form-actions">
{% if edit_mode %}
<a href="{{ url_for('student_cards_admin') }}" class="btn-cancel">Abbrechen</a>
{% endif %}
<button type="submit" class="btn-save">
{% if edit_mode %}Speichern{% else %}Hinzufügen{% endif %}
</button>
</div>
</form>
</div>
<!-- Cards List -->
<div>
<h2>Registrierte Ausweise</h2>
{% if student_cards %}
<table class="cards-table">
<thead>
<tr>
<th>Ausweis-ID</th>
<th>Schüler Name</th>
<th>Klasse</th>
<th>Standard Ausleihdauer</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for card in student_cards %}
<tr>
<td><strong>{{ card.AusweisId }}</strong></td>
<td>{{ card.SchülerName }}</td>
<td>{{ card.Klasse or '—' }}</td>
<td>{{ card.StandardAusleihdauer }} Tage</td>
<td>{{ card.Erstellt.strftime('%d.%m.%Y') if card.get('Erstellt') else '—' }}</td>
<td>
<div class="card-actions">
<form method="GET" style="display: inline;">
<input type="hidden" name="edit" value="{{ card._id }}">
<button type="submit" class="btn-edit">Bearbeiten</button>
</form>
<a href="{{ url_for('student_card_single_barcode_download', card_id=card._id) }}" class="btn-export" style="text-decoration: none; display: inline-block;">📥 PDF</a>
<form method="POST" style="display: inline;" onsubmit="return confirm('Wirklich löschen?');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="card_id" value="{{ card._id }}">
<button type="submit" class="btn-delete">Löschen</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>Keine Schülerausweise registriert.</p>
<p>Fügen Sie ein neues Ausweis oben hinzu.</p>
</div>
{% endif %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcode.js"></script>
<script>
// All PDF exports now go through backend routes
</script>
{% endblock %}
+1629
View File
File diff suppressed because it is too large Load Diff
+445
View File
@@ -0,0 +1,445 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}Tutorial - {{ APP_VERSION }}{% endblock %}
{% block content %}
<style>
.tutorial-shell {
max-width: 1120px;
margin: 0 auto;
padding: 24px 0 14px;
}
.tutorial-shell,
.tutorial-shell p,
.tutorial-shell li,
.tutorial-shell button,
.tutorial-shell a {
font-size: 1.03rem;
line-height: 1.55;
}
.tutorial-hero {
background: linear-gradient(135deg, #16324f 0%, #27455f 55%, #3c5a73 100%);
color: #f8fafc;
border-radius: 18px;
padding: 30px;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.18);
margin-bottom: 18px;
}
.tutorial-hero h1 {
margin: 0 0 8px;
font-size: clamp(1.8rem, 3vw, 2.35rem);
font-weight: 900;
}
.tutorial-hero p {
margin: 0;
opacity: 0.98;
font-size: 1.08rem;
}
.tutorial-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 14px;
}
.tutorial-pill {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 999px;
padding: 6px 14px;
font-size: 0.88rem;
font-weight: 700;
}
.tutorial-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 14px;
}
.tutorial-side {
grid-column: span 12;
background: #ffffff;
border: 1px solid #cfdbe8;
border-radius: 16px;
padding: 16px;
position: sticky;
top: 14px;
height: fit-content;
}
.tutorial-side h2 {
margin: 0 0 8px;
font-size: 1.08rem;
color: #0f172a;
font-weight: 800;
}
.tutorial-side p {
margin: 0 0 14px;
color: #334155;
font-size: 1rem;
}
.workflow-nav {
display: grid;
gap: 7px;
margin-top: 10px;
}
.workflow-nav button {
border: 1px solid #dbe3ee;
background: #f8fafc;
color: #0f172a;
border-radius: 12px;
padding: 11px 12px;
text-align: left;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
transition: all 180ms ease;
}
.workflow-nav button:hover {
background: #eff6ff;
border-color: #bfdbfe;
transform: translateX(2px);
}
.workflow-nav button.active {
background: #dbeafe;
border-color: #93c5fd;
color: #1d4ed8;
}
.workflow-main {
grid-column: span 12;
display: grid;
gap: 12px;
}
.workflow-step {
grid-column: span 12;
background: #ffffff;
border: 1px solid #cfdbe8;
border-radius: 16px;
padding: 18px;
opacity: 0;
transform: translateY(8px) scale(0.995);
pointer-events: none;
max-height: 0;
overflow: hidden;
transition: opacity 220ms ease, transform 220ms ease;
}
.workflow-step.active {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
max-height: 1300px;
}
.workflow-step h3 {
margin: 0 0 8px;
font-size: 1.35rem;
color: #0f172a;
}
.workflow-step p {
margin: 0 0 10px;
color: #334155;
font-size: 1.03rem;
}
.workflow-step ul {
margin: 0;
padding-left: 18px;
color: #334155;
font-size: 1.02rem;
}
.workflow-step li {
margin-bottom: 5px;
}
.tutorial-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tutorial-actions .btn,
.workflow-controls .btn {
border-radius: 12px;
padding: 10px 14px;
font-size: 1rem;
}
.workflow-controls {
display: flex;
gap: 8px;
margin-top: 14px;
flex-wrap: wrap;
}
.workflow-controls .btn {
min-width: 120px;
}
.tutorial-note {
margin-top: 14px;
background: #ecfeff;
border: 1px solid #bae6fd;
color: #0c4a6e;
border-radius: 14px;
padding: 14px;
font-size: 1rem;
}
@media (min-width: 960px) {
.tutorial-side {
grid-column: span 4;
}
.workflow-main {
grid-column: span 8;
}
}
</style>
<div class="tutorial-shell">
<section class="tutorial-hero">
<h1>Kurzer Rundgang in 2-5 Minuten</h1>
<p>Die Seite ist bewusst einfach gehalten. Sie begleitet Sie Schritt für Schritt, ohne viel Lesen und ohne komplizierte Fachbegriffe.</p>
<div class="tutorial-meta">
<span class="tutorial-pill">Dauer: ca. 4 Minuten</span>
<span class="tutorial-pill">Format: Ruhig und geführt</span>
<span class="tutorial-pill">Benutzer: {{ username }}</span>
</div>
</section>
<div class="tutorial-grid">
<aside class="tutorial-side">
<h2>Schritte</h2>
<p>Folgen Sie einfach jedem Schritt. Sie können jederzeit vor- oder zurückgehen.</p>
<div class="workflow-nav" id="workflowNav">
<button type="button" data-target-step="0">1. Startseite</button>
<button type="button" data-target-step="1">2. {{ 'Artikel erfassen' if is_admin else 'Artikel finden' }}</button>
{% if library_module_enabled %}
<button type="button" data-target-step="2">3. Bibliotek</button>
{% endif %}
<button type="button" data-target-step="{{ '3' if library_module_enabled else '2' }}">4. Alltag</button>
</div>
<div class="workflow-controls">
<button type="button" class="btn btn-outline-secondary btn-sm" id="prevStepBtn">Zurück</button>
<button type="button" class="btn btn-primary btn-sm" id="nextStepBtn">Nächster Schritt</button>
</div>
</aside>
<section class="workflow-main" id="workflowMain">
<article class="workflow-step" data-step-index="0" data-step-key="dashboard">
<h3>Startbereich</h3>
<p>Hier starten Sie. Das ist der sichere Ausgangspunkt für alle weiteren Schritte.</p>
<ul>
<li>Sie sehen den Überblick.</li>
<li>Oben liegen die wichtigsten Wege.</li>
<li>Alles ist gross und einfach lesbar.</li>
</ul>
<div class="tutorial-actions">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('home') }}">Startseite öffnen</a>
</div>
</article>
<article class="workflow-step" data-step-index="1" data-step-key="items">
{% if is_admin %}
<h3>Artikel anlegen</h3>
<p>Hier legen Sie einen neuen Artikel an. Es sind nur wenige Felder erforderlich.</p>
<ul>
<li><strong>Name:</strong> Wie heisst der Artikel?</li>
<li><strong>Ort:</strong> Wo soll er gelagert werden?</li>
<li><strong>Beschreibung:</strong> Was ist daran interessant?</li>
<li><strong>Code:</strong> Eine Nummer zum schnellen Finden.</li>
</ul>
<div class="tutorial-actions">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('upload_admin') }}">Neuen Artikel anlegen</a>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('home_admin') }}">Zurück</a>
</div>
{% else %}
<h3>Artikel finden und ausleihen</h3>
<p>Als Nutzerin/Nutzer arbeiten Sie hauptsaechlich mit vorhandenen Artikeln und Ausleihen.</p>
<ul>
<li>Nutzen Sie Filter und Suche, um Material schnell zu finden.</li>
<li>Öffnen Sie <strong>Details</strong>, um Ort, Beschreibung und Verfügbarkeit zu sehen.</li>
<li>Mit <strong>Ausleihen</strong> buchen Sie den Artikel direkt.</li>
<li>In <strong>Meine Ausleihen</strong> sehen Sie, was aktuell auf Ihren Namen laeuft.</li>
</ul>
<div class="tutorial-actions">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('home') }}">Zur Startseite</a>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
</div>
{% endif %}
</article>
{% if library_module_enabled %}
<article class="workflow-step" data-step-index="2" data-step-key="library">
<h3>Bibliotek-Bereich</h3>
<p>Dieser Bereich ist speziell für Bücher und Medienausleihe.</p>
<ul>
{% if is_admin %}
<li><strong>Bücher hochladen:</strong> Neue Bücher in die Sammlung aufnehmen.</li>
<li><strong>Ausleihen ansehen:</strong> Wer hat welches Buch ausgeliehen?</li>
<li><strong>Defekte bearbeiten:</strong> Defekte Bücher markieren oder reparieren.</li>
<li><strong>Rechnungen:</strong> Kosten und Rechnungen verwalten.</li>
{% else %}
<li><strong>Medium suchen:</strong> Titel, Autor oder Barcode direkt finden.</li>
<li><strong>Verfuegbarkeit pruefen:</strong> Schnell sehen, was aktuell ausleihbar ist.</li>
<li><strong>Ausleihen:</strong> In der Bibliothek direkt auf Ihren Namen buchen.</li>
<li><strong>Rückgabe-Status:</strong> Über "Meine Ausleihen" Fristen im Blick behalten.</li>
{% endif %}
</ul>
<div class="tutorial-actions">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('library_view') }}">Zur Bibliotek</a>
{% if is_admin %}
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_loans_admin') }}">Ausleihen ansehen</a>
{% endif %}
</div>
</article>
{% endif %}
<article class="workflow-step" data-step-index="{{ '3' if library_module_enabled else '2' }}" data-step-key="daily">
<h3>Tägliche Aufgaben</h3>
<p>Das ist der gewöhnliche Arbeitsablauf während eines Schultages.</p>
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Morgens:</p>
<p style="margin: 0; color: #334155;">Öffnen Sie die Startseite und prüfen Sie offene Reservierungen oder Rückgabefristen.</p>
</div>
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Mittags:</p>
<p style="margin: 0; color: #334155;">Neue Artikel oder Bücher hinzufügen oder Ausleihungen begründen.</p>
</div>
<div style="background: #f8fafc; border: 1px solid #dbe3ee; border-radius: 12px; padding: 14px; margin: 14px 0;">
<p style="margin-bottom: 10px; font-weight: 700; color: #0f172a;">Nachmittags:</p>
<p style="margin: 0; color: #334155;">Rückgaben buchen und kurz prüfen, ob alles in Ordnung ist.</p>
</div>
<div class="tutorial-actions">
{% if is_admin %}
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_borrowings') }}">Admin-Bereich</a>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('logs') }}">Protokolle</a>
{% else %}
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a>
{% endif %}
</div>
</article>
</section>
</div>
<div class="tutorial-note">
Tipp: Sie können das Training jederzeit wieder öffnen und in Ruhe weiterklicken.
</div>
</div>
<script>
(function () {
const stepEls = Array.from(document.querySelectorAll('.workflow-step'));
const navBtns = Array.from(document.querySelectorAll('#workflowNav button[data-target-step]'));
const nextBtn = document.getElementById('nextStepBtn');
const prevBtn = document.getElementById('prevStepBtn');
const tutorialStateKey = 'inventarsystem_tutorial_state_v3_{{ username }}';
if (!stepEls.length) return;
let currentStep = 0;
let prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function loadState() {
try {
const raw = localStorage.getItem(tutorialStateKey);
if (!raw) return;
const parsed = JSON.parse(raw);
currentStep = Number.isInteger(parsed.currentStep) ? parsed.currentStep : 0;
} catch (e) {
currentStep = 0;
}
}
function saveState() {
localStorage.setItem(tutorialStateKey, JSON.stringify({
currentStep: currentStep
}));
}
function clampStep() {
if (currentStep < 0) currentStep = 0;
if (currentStep >= stepEls.length) currentStep = stepEls.length - 1;
}
function renderStep() {
clampStep();
stepEls.forEach(function (stepEl, idx) {
stepEl.classList.toggle('active', idx === currentStep);
if (idx === currentStep && !prefersReducedMotion) {
stepEl.animate(
[{ opacity: 0, transform: 'translateY(10px)' }, { opacity: 1, transform: 'translateY(0)' }],
{ duration: 260, easing: 'ease-out' }
);
}
});
navBtns.forEach(function (btn) {
const target = Number(btn.dataset.targetStep);
btn.classList.toggle('active', target === currentStep);
});
prevBtn.disabled = currentStep <= 0;
nextBtn.textContent = currentStep >= stepEls.length - 1 ? 'Fertig' : 'Nächster Schritt';
saveState();
}
navBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
currentStep = Number(btn.dataset.targetStep) || 0;
renderStep();
});
});
prevBtn.addEventListener('click', function () {
currentStep -= 1;
renderStep();
});
nextBtn.addEventListener('click', function () {
currentStep += 1;
renderStep();
});
loadState();
renderStep();
})();
</script>
{% endblock %}
+1837
View File
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}Benutzer verwalten{% endblock %}
{% block content %}
<div class="container">
<h1>Benutzer verwalten</h1>
<div class="user-management-container">
<h2>Benutzer</h2>
<div class="filter-bar mb-3">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label for="filter-search" class="form-label">Suche</label>
<input type="text" class="form-control" id="filter-search" placeholder="Benutzername, Vor- oder Nachname...">
</div>
<div class="col-md-2">
<label for="filter-admin" class="form-label">Rolle</label>
<select class="form-select" id="filter-admin">
<option value="">Alle</option>
<option value="ja">Administrator</option>
<option value="nein">Standardbenutzer</option>
</select>
</div>
<div class="col-md-2">
<label for="filter-column" class="form-label">Sortieren nach</label>
<select class="form-select" id="filter-column">
<option value="0">Benutzername</option>
<option value="1">Vorname</option>
<option value="2">Nachname</option>
<option value="3">Rolle</option>
</select>
</div>
<div class="col-md-2">
<label for="filter-direction" class="form-label">Reihenfolge</label>
<select class="form-select" id="filter-direction">
<option value="asc">Aufsteigend</option>
<option value="desc">Absteigend</option>
</select>
</div>
<div class="col-md-2">
<button class="btn btn-secondary w-100" onclick="resetFilters()">Filter zurücksetzen</button>
</div>
</div>
<div class="mt-2 text-muted small" id="filter-count"></div>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover" id="user-table">
<thead class="thead-dark">
<tr>
<th>Benutzername</th>
<th>Vorname</th>
<th>Nachname</th>
<th>Administrator</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.username }}</td>
<td>{{ user.name if user.name else user.username }}</td>
<td>{{ user.last_name if user.last_name else '' }}</td>
<td>{{ "Ja" if user.admin else "Nein" }}</td>
<td class="actions">
<form method="POST" action="{{ url_for('delete_user') }}" class="d-inline">
<input type="hidden" name="username" value="{{ user.username }}">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Sind Sie sicher, dass Sie den Benutzer {{ user.username }} löschen möchten?')">
Löschen
</button>
</form>
<button type="button" class="btn btn-primary btn-sm"
data-username="{{ user.username }}"
data-firstname="{{ user.name if user.name else '' }}"
data-lastname="{{ user.last_name if user.last_name else '' }}"
onclick="openEditUserModal(this)">
Bearbeiten
</button>
<button type="button" class="btn btn-warning btn-sm"
onclick="openResetPasswordModal('{{ user.username }}')">
Passwort zurücksetzen
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Edit User Modal -->
<div class="modal fade" id="editUserModal" tabindex="-1" aria-labelledby="editUserModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editUserModalLabel">Benutzer bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" action="{{ url_for('admin_update_user_name') }}">
<div class="modal-body">
<input type="hidden" id="edit-username" name="username">
<div class="mb-3">
<label for="edit-username-display" class="form-label">Benutzer:</label>
<input type="text" class="form-control" id="edit-username-display" disabled>
</div>
<div class="mb-3">
<label for="edit-name" class="form-label">Vorname:</label>
<input type="text" class="form-control" id="edit-name" name="name">
</div>
<div class="mb-3">
<label for="edit-last-name" class="form-label">Nachname:</label>
<input type="text" class="form-control" id="edit-last-name" name="last_name">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
</div>
<!-- Reset Password Modal -->
<div class="modal fade" id="resetPasswordModal" tabindex="-1" aria-labelledby="resetPasswordModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="resetPasswordModalLabel">Passwort zurücksetzen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" action="{{ url_for('admin_reset_user_password') }}">
<div class="modal-body">
<input type="hidden" id="reset-username" name="username">
<div class="mb-3">
<label for="username-display" class="form-label">Benutzer:</label>
<input type="text" class="form-control" id="username-display" disabled>
</div>
<div class="mb-3">
<label for="new-password" class="form-label">Neues Passwort:</label>
<input type="password" class="form-control" id="new-password" name="new_password" required>
<div class="password-requirements small text-muted mt-1">
<p>Das Passwort muss mindestens 6 Zeichen lang sein.</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Passwort zurücksetzen</button>
</div>
</form>
</div>
</div>
</div>
<script>
function applyFilters() {
var search = document.getElementById('filter-search').value.toLowerCase();
var adminFilter = document.getElementById('filter-admin').value.toLowerCase();
var colIndex = parseInt(document.getElementById('filter-column').value);
var direction = document.getElementById('filter-direction').value;
var table = document.getElementById('user-table');
var tbody = table.querySelector('tbody');
var rows = Array.from(tbody.querySelectorAll('tr'));
var visible = rows.filter(function(row) {
var cells = row.querySelectorAll('td');
var username = cells[0] ? cells[0].textContent.toLowerCase() : '';
var firstname = cells[1] ? cells[1].textContent.toLowerCase() : '';
var lastname = cells[2] ? cells[2].textContent.toLowerCase() : '';
var admin = cells[3] ? cells[3].textContent.toLowerCase().trim() : '';
var matchesSearch = !search ||
username.includes(search) ||
firstname.includes(search) ||
lastname.includes(search);
var matchesAdmin = !adminFilter || admin === adminFilter;
return matchesSearch && matchesAdmin;
});
var hidden = rows.filter(function(row) { return visible.indexOf(row) === -1; });
hidden.forEach(function(row) { row.style.display = 'none'; });
visible.sort(function(a, b) {
var cellA = a.querySelectorAll('td')[colIndex];
var cellB = b.querySelectorAll('td')[colIndex];
var valA = cellA ? cellA.textContent.trim().toLowerCase() : '';
var valB = cellB ? cellB.textContent.trim().toLowerCase() : '';
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
visible.forEach(function(row) {
row.style.display = '';
tbody.appendChild(row);
});
var countEl = document.getElementById('filter-count');
countEl.textContent = visible.length + ' von ' + rows.length + ' Benutzer angezeigt';
}
function resetFilters() {
document.getElementById('filter-search').value = '';
document.getElementById('filter-admin').value = '';
document.getElementById('filter-column').value = '0';
document.getElementById('filter-direction').value = 'asc';
applyFilters();
}
document.addEventListener('DOMContentLoaded', function() {
applyFilters();
document.getElementById('filter-search').addEventListener('input', applyFilters);
document.getElementById('filter-admin').addEventListener('change', applyFilters);
document.getElementById('filter-column').addEventListener('change', applyFilters);
document.getElementById('filter-direction').addEventListener('change', applyFilters);
});
function openEditUserModal(button) {
var username = button.getAttribute('data-username');
var name = button.getAttribute('data-firstname');
var lastName = button.getAttribute('data-lastname');
document.getElementById('edit-username').value = username;
document.getElementById('edit-username-display').value = username;
document.getElementById('edit-name').value = name;
document.getElementById('edit-last-name').value = lastName;
var modal = new bootstrap.Modal(document.getElementById('editUserModal'));
modal.show();
}
function openResetPasswordModal(username) {
document.getElementById('reset-username').value = username;
document.getElementById('username-display').value = username;
// Open modal using Bootstrap
var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
modal.show();
}
</script>
<style>
.user-management-container {
margin-top: 20px;
}
.actions {
white-space: nowrap;
}
.actions form {
display: inline-block;
margin-right: 5px;
}
.filter-bar {
background-color: #f8f9fa;
padding: 15px;
border-radius: 6px;
border: 1px solid #dee2e6;
}
.password-requirements {
background-color: #f8f9fa;
padding: 10px;
border-radius: 4px;
margin-top: 10px;
}
.password-requirements p {
margin-bottom: 5px;
}
</style>
{% endblock %}