1576 lines
57 KiB
HTML
1576 lines
57 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}📚 Bibliothek - Tabellenansicht - {{ APP_VERSION }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
/* =========================================
|
|
1. Modals & Overlays
|
|
========================================= */
|
|
.modal {
|
|
display: none; /* Managed by JS (none/flex) */
|
|
position: fixed;
|
|
inset: 0; /* Modern shorthand for top: 0, right: 0, bottom: 0, left: 0 */
|
|
z-index: 1000;
|
|
background-color: rgba(0, 0, 0, 0.6);
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
}
|
|
|
|
.modal-content {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
max-width: 900px; /* Consolidated from conflicting 600px/900px rules */
|
|
max-height: 90vh;
|
|
background-color: var(--ui-surface, #fff);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.close {
|
|
position: absolute;
|
|
top: 15px;
|
|
right: 20px;
|
|
color: #aaa;
|
|
font-size: 28px;
|
|
font-weight: bold;
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
transition: color 0.2s ease;
|
|
}
|
|
|
|
.close:hover,
|
|
.close:focus {
|
|
color: var(--ui-text, #000);
|
|
text-decoration: none;
|
|
}
|
|
|
|
#detailContent {
|
|
overflow-y: auto;
|
|
padding-right: 10px;
|
|
}
|
|
|
|
/* =========================================
|
|
2. Layout & Headers
|
|
========================================= */
|
|
.library-table-container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
.library-header {
|
|
margin-bottom: 25px;
|
|
}
|
|
|
|
.library-header h1 {
|
|
margin: 0 0 5px 0;
|
|
font-size: 2em;
|
|
color: var(--ui-text);
|
|
}
|
|
|
|
.library-header p {
|
|
margin: 0;
|
|
color: #666;
|
|
font-size: 0.95em;
|
|
}
|
|
|
|
/* =========================================
|
|
3. Search, Filter & Scan Panels
|
|
========================================= */
|
|
.library-search-bar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.library-search-input {
|
|
flex: 1;
|
|
min-width: 250px;
|
|
padding: 12px 16px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 6px;
|
|
font-size: 16px;
|
|
background: #fff;
|
|
}
|
|
|
|
.library-scan-panel {
|
|
background: var(--ui-surface);
|
|
border: 1px solid #d9dde4;
|
|
border-radius: 8px;
|
|
padding: 14px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.library-scan-controls {
|
|
display: grid;
|
|
grid-template-columns: 220px minmax(220px, 1fr) auto auto;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}
|
|
|
|
.library-scan-controls select,
|
|
.library-scan-controls input {
|
|
padding: 10px 12px;
|
|
border: 1px solid #d0d7e2;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
background: var(--ui-surface);
|
|
}
|
|
|
|
.library-scan-status {
|
|
margin-top: 10px;
|
|
font-size: 0.92em;
|
|
color: #4b5563;
|
|
}
|
|
|
|
.library-scan-status.ok { color: #1f7a38; }
|
|
.library-scan-status.warn { color: #9a6700; }
|
|
.library-scan-status.error { color: #b42318; }
|
|
|
|
/* Filters */
|
|
.library-filter-toggle-btn {
|
|
padding: 10px 16px;
|
|
background: #f0f2f5;
|
|
border: 1px solid #ddd;
|
|
border-radius: 6px;
|
|
font-weight: 500;
|
|
color: var(--ui-text);
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.library-filter-toggle-btn:hover {
|
|
background: #e4e6eb;
|
|
border-color: #999;
|
|
}
|
|
|
|
.library-filter-toggle-btn.active {
|
|
background: #e0e7ff;
|
|
border-color: #4f46e5;
|
|
color: #4f46e5;
|
|
}
|
|
|
|
.library-filter-panel {
|
|
display: none;
|
|
background: #fff;
|
|
border: 1px solid #ddd;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|
}
|
|
|
|
.library-filter-panel.open {
|
|
display: block;
|
|
}
|
|
|
|
.filter-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 15px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.filter-row:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.filter-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.filter-item label {
|
|
margin-bottom: 6px;
|
|
font-weight: 600;
|
|
font-size: 0.9em;
|
|
color: var(--ui-text);
|
|
}
|
|
|
|
.filter-item input,
|
|
.filter-item select {
|
|
padding: 8px 12px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
background: #fff;
|
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
}
|
|
|
|
.filter-item input:focus,
|
|
.filter-item select:focus {
|
|
outline: none;
|
|
border-color: #4f46e5;
|
|
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
|
|
}
|
|
|
|
.filter-buttons {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 15px;
|
|
padding-top: 15px;
|
|
border-top: 1px solid #eee;
|
|
}
|
|
|
|
.filter-buttons .button {
|
|
flex: 1;
|
|
padding: 8px 16px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
/* =========================================
|
|
4. Tables & Data Display
|
|
========================================= */
|
|
.library-items-table {
|
|
width: 100%;
|
|
background: #fff;
|
|
border-collapse: collapse;
|
|
border: 1px solid #ddd;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
}
|
|
|
|
.library-items-table thead {
|
|
background: var(--ui-surface-soft);
|
|
border-bottom: 2px solid #ddd;
|
|
}
|
|
|
|
.library-items-table th {
|
|
padding: 14px 16px;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
font-size: 0.9em;
|
|
color: var(--ui-text);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.library-items-table td {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid #eee;
|
|
color: #555;
|
|
font-size: 0.95em;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.library-items-table tbody tr:hover {
|
|
background: var(--ui-surface-soft);
|
|
}
|
|
|
|
.library-items-table tbody tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.table-title {
|
|
font-weight: 600;
|
|
color: var(--ui-text);
|
|
}
|
|
|
|
.table-status {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
border-radius: 20px;
|
|
font-size: 0.85em;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.status-available { background: #e8f5e9; color: #2e7d32; }
|
|
.status-borrowed { background: #fff3e0; color: #e65100; }
|
|
.status-damaged { background: #fee2e2; color: #991b1b; }
|
|
|
|
.table-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.table-actions .button {
|
|
padding: 6px 12px;
|
|
font-size: 0.85em;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.library-empty-state {
|
|
padding: 60px 20px;
|
|
text-align: center;
|
|
color: #999;
|
|
}
|
|
|
|
.library-empty-state h3 {
|
|
margin-bottom: 10px;
|
|
font-size: 1.3em;
|
|
color: #666;
|
|
}
|
|
|
|
.library-load-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.library-load-hint {
|
|
font-size: 0.9em;
|
|
color: #6b7280;
|
|
}
|
|
|
|
/* =========================================
|
|
5. Edit Grid Forms
|
|
========================================= */
|
|
.edit-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 12px;
|
|
}
|
|
|
|
.edit-grid .full {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.edit-grid label {
|
|
display: block;
|
|
margin-bottom: 4px;
|
|
font-weight: 600;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.edit-grid input,
|
|
.edit-grid textarea,
|
|
.edit-grid select {
|
|
width: 100%;
|
|
padding: 8px 10px;
|
|
border: 1px solid #d0d7e2;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.edit-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 14px;
|
|
}
|
|
|
|
/* =========================================
|
|
6. Notifications / Toasts
|
|
========================================= */
|
|
.small-popup {
|
|
position: fixed;
|
|
bottom: 24px;
|
|
left: 50%;
|
|
z-index: 2000;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
max-width: 90%;
|
|
padding: 12px 16px;
|
|
background: rgba(17, 24, 39, 0.96);
|
|
color: #fff;
|
|
border-radius: 8px;
|
|
font-size: 0.95em;
|
|
transform: translateX(-50%);
|
|
box-shadow: 0 6px 24px rgba(2,6,23,0.6);
|
|
}
|
|
|
|
.small-popup.ok { background: rgba(16,185,129,0.95); color: #032; }
|
|
.small-popup.error { background: rgba(239,68,68,0.95); color: #210; }
|
|
|
|
.small-popup .close-x {
|
|
margin-left: 8px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* =========================================
|
|
7. Mobile Responsiveness
|
|
========================================= */
|
|
@media (max-width: 768px) {
|
|
.library-table-container { padding: 10px; }
|
|
.library-search-bar { flex-direction: column; }
|
|
.library-search-input { width: 100%; }
|
|
.library-items-table { font-size: 0.85em; }
|
|
|
|
.library-items-table th,
|
|
.library-items-table td {
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
.table-actions .button {
|
|
padding: 4px 8px;
|
|
font-size: 0.75em;
|
|
}
|
|
|
|
.filter-row,
|
|
.library-scan-controls {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.library-scan-reader-wrap { max-width: 100%; }
|
|
}
|
|
.library-scan-reader-wrap {
|
|
display: none;
|
|
margin-top: 15px;
|
|
max-width: 640px;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
background: #000;
|
|
border: 1px solid #d9dde4;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.library-scan-reader {
|
|
width: 100%;
|
|
min-height: 280px;
|
|
position: relative;
|
|
}
|
|
|
|
.library-scan-reader video {
|
|
width: 100%;
|
|
height: auto;
|
|
display: block;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.library-scan-reader canvas.drawing,
|
|
.library-scan-reader canvas.drawingBuffer {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
|
|
.detail-gallery-container {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 15px;
|
|
margin: 15px 0 20px 0;
|
|
width: 100%;
|
|
align-items: center;
|
|
}
|
|
|
|
.item-image-wrapper {
|
|
background-color: #f8fafc;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
padding: 6px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
|
}
|
|
|
|
.item-image {
|
|
max-width: 160px;
|
|
max-height: 200px;
|
|
width: auto;
|
|
height: auto;
|
|
display: block;
|
|
object-fit: contain;
|
|
border-radius: 4px;
|
|
}
|
|
</style>
|
|
|
|
<div class="library-table-container" id="libraryTableContainer" data-can-edit="{{ 1 if current_permissions.actions.get('can_edit', False) else 0 }}">
|
|
<!-- Header -->
|
|
<!-- Search and Filter Toggle -->
|
|
<!-- Customizable Filter Panel -->
|
|
<div class="library-header">
|
|
<h1>📚 Bibliothek</h1>
|
|
<p>Bücher, CDs, DVDs und weitere Medien</p>
|
|
</div>
|
|
<!-- Student card / quick scan workflow -->
|
|
<div class="library-search-bar">
|
|
<input
|
|
type="text"
|
|
id="librarySearch"
|
|
class="library-search-input"
|
|
placeholder="Nach Titel, ISBN suchen..."
|
|
>
|
|
<button
|
|
id="filterToggleBtn"
|
|
class="library-filter-toggle-btn"
|
|
aria-label="Filter öffnen/schließen"
|
|
>
|
|
🔍 Filter
|
|
</button>
|
|
</div>
|
|
<div class="library-scan-controls">
|
|
<select id="scanModeSelect" aria-label="Scan-Modus">
|
|
<option value="card_only">Nur Ausweis erfassen</option>
|
|
<option value="quick_toggle">Schnellmodus: Ausweis + Mediencode</option>
|
|
<option value="continuous">Dauermodus: 1x Ausweis, dann N Medien</option>
|
|
<option value="return_only">Nur Rückgabe (nur Mediencode)</option>
|
|
</select>
|
|
<input type="text" id="activeStudentCard" placeholder="Aktiver Ausweis (gescannt)">
|
|
<input type="text" id="manualItemCode" placeholder="Manueller Mediencode (optional)" style="min-width:180px;">
|
|
<button id="resetCardBtn" class="button" type="button">Ausweis löschen</button>
|
|
<button id="toggleScannerBtn" class="button" type="button">Scanner starten</button>
|
|
<label style="display:flex; align-items:center; gap:8px; margin-left:6px;">
|
|
<input type="checkbox" id="keyboardScannerToggle">
|
|
<span style="font-size:0.9em;">Physischer Scanner</span>
|
|
</label>
|
|
<button id="manualActionBtn" class="button" type="button" style="margin-left:6px; background:#4f46e5; color:white;">Code verarbeiten</button>
|
|
</div>
|
|
<div class="library-scan-reader-wrap" id="scanReaderWrap" style="display: none; margin-top: 15px;">
|
|
<div class="library-scan-reader" style="width: 100%; max-width: 640px; margin: 0 auto; overflow: hidden; border-radius: 8px; border: 2px solid #ccc;">
|
|
</div>
|
|
</div>
|
|
<div id="filterPanel" class="library-filter-panel">
|
|
<div class="filter-row">
|
|
<div class="filter-item">
|
|
<label for="filterISBN">ISBN:</label>
|
|
<input type="text" id="filterISBN" placeholder="z.B. 978-3...">
|
|
</div>
|
|
<div class="filter-item">
|
|
<label for="filterType">Medientyp:</label>
|
|
<select id="filterType">
|
|
<option value="">-- Alle Typen --</option>
|
|
<option value="book">Buch</option>
|
|
<option value="schoolbook">Schulbuch</option>
|
|
<option value="cd">CD</option>
|
|
<option value="dvd">DVD</option>
|
|
<option value="other">Sonstige</option>
|
|
</select>
|
|
</div>
|
|
<div class="filter-item">
|
|
<label for="filterStatus">Status:</label>
|
|
<select id="filterStatus">
|
|
<option value="">-- Alle Status --</option>
|
|
<option value="available">Verfügbar</option>
|
|
<option value="borrowed">Ausgeliehen</option>
|
|
<option value="damaged">Defekt/Zerstört</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="filter-buttons">
|
|
<button id="applyFilterBtn" class="button" style="background: #4f46e5; color: white;">Filter anwenden</button>
|
|
<button id="clearFilterBtn" class="button" style="background: #f0f2f5; color: var(--ui-text);">Zurücksetzen</button>
|
|
<div style="flex-grow: 1;"></div>
|
|
<button class="button" style="background: #10b981; color: white; display: flex; align-items: center; gap: 0.5rem;" onclick="window.location.href='/library/export/schulbuecher'">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
|
Schulbücher Excel
|
|
</button>
|
|
<button class="button" style="background: #10b981; color: white; display: flex; align-items: center; gap: 0.5rem;" onclick="window.location.href='/library/export/all_borrowed'">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
|
Alle ausgeliehen Excel
|
|
</button>
|
|
<button class="button" style="background: #10b981; color: white; display: flex; align-items: center; gap: 0.5rem;" onclick="window.location.href='/library/export/all_books'">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
|
Alle Bücher Excel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Items Table -->
|
|
<div id="tableContainer">
|
|
<table class="library-items-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 32%;">Titel</th>
|
|
<th style="width: 12%;">ISBN/Code</th>
|
|
<th style="width: 8%;">Typ</th>
|
|
<th style="width: 8%;">Anzahl</th>
|
|
<th style="width: 14%;">Status</th>
|
|
<th style="width: 26%;">Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="itemsTableBody">
|
|
<!-- Items will be loaded here -->
|
|
</tbody>
|
|
</table>
|
|
<div id="loadMoreRow" class="library-load-row" style="display:none;">
|
|
<span id="loadInfo" class="library-load-hint"></span>
|
|
<button id="loadMoreBtn" class="button" type="button">Mehr laden</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div id="emptyState" class="library-empty-state" style="display: none;">
|
|
<h3>📭 Keine Bücher gefunden</h3>
|
|
<p>Versuchen Sie, die Filter anzupassen oder suchen Sie nach anderen Begriffen.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js"></script>
|
|
|
|
<div id="detailModal" class="modal" style="display: none;">
|
|
<div class="modal-content">
|
|
<span class="close" onclick="closeDetailModal()">×</span>
|
|
<div id="detailContent"></div>
|
|
</div>
|
|
</div>
|
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
|
<script>
|
|
// =========================================================================
|
|
// 1. GLOBAL STATE DEFINITIONS
|
|
// =========================================================================
|
|
let libraryItems = [];
|
|
let filteredItems = [];
|
|
let visibleItems = [];
|
|
let currentSearchTerm = '';
|
|
let activeFilters = {
|
|
isbn: '',
|
|
type: '',
|
|
status: ''
|
|
};
|
|
let pagingState = {
|
|
offset: 0,
|
|
total: 0,
|
|
hasMore: true,
|
|
loading: false
|
|
};
|
|
const API_PAGE_SIZE = 120;
|
|
const INITIAL_RENDER_COUNT = 120;
|
|
const RENDER_BATCH_COUNT = 120;
|
|
let renderedCount = INITIAL_RENDER_COUNT;
|
|
let filterPanelOpen = false;
|
|
|
|
// Scanner Related State Variables
|
|
let scannerInstance = null;
|
|
let scannerRunning = false;
|
|
let activeScannerCallback = null;
|
|
let activeStudentCardId = '';
|
|
let lastScanValue = '';
|
|
let lastScanAt = 0;
|
|
// Keyboard-scanner support (physical scanners that act as keyboard wedges)
|
|
let keyboardScannerEnabled = false;
|
|
let keyboardScanBuffer = '';
|
|
let keyboardLastKeyAt = 0;
|
|
const KEYBOARD_SCAN_INTERCHAR_MS = 100; // max time between keystrokes to consider them one scan
|
|
let editLibraryState = {
|
|
itemId: '',
|
|
seriesGroupId: '',
|
|
groupMembers: []
|
|
};
|
|
|
|
const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1');
|
|
|
|
function isVideoFile(filename) {
|
|
if (!filename) return false;
|
|
return /\.(mp4|webm|ogg|mov)$/i.test(filename);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 2. DATA LOADING & FILTERING ENGINE
|
|
// =========================================================================
|
|
async function fetchLibraryPage(offset, limit) {
|
|
const response = await fetch(`/api/library_items?offset=${offset}&limit=${limit}`);
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load library items');
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function loadLibraryItems() {
|
|
if (pagingState.loading) return;
|
|
pagingState.loading = true;
|
|
try {
|
|
const firstPage = await fetchLibraryPage(0, API_PAGE_SIZE);
|
|
libraryItems = firstPage.items || [];
|
|
pagingState.offset = (firstPage.offset || 0) + (firstPage.count || libraryItems.length);
|
|
pagingState.total = firstPage.total || libraryItems.length;
|
|
pagingState.hasMore = Boolean(firstPage.has_more);
|
|
|
|
applyFiltersAndSearch(true);
|
|
|
|
if (pagingState.hasMore) {
|
|
loadRemainingLibraryItemsInBackground();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading library items:', error);
|
|
document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="8" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
|
|
} finally {
|
|
pagingState.loading = false;
|
|
}
|
|
}
|
|
|
|
async function loadRemainingLibraryItemsInBackground() {
|
|
while (pagingState.hasMore) {
|
|
try {
|
|
const page = await fetchLibraryPage(pagingState.offset, API_PAGE_SIZE);
|
|
const nextItems = page.items || [];
|
|
if (nextItems.length === 0) {
|
|
pagingState.hasMore = false;
|
|
break;
|
|
}
|
|
|
|
libraryItems = libraryItems.concat(nextItems);
|
|
pagingState.offset = (page.offset || pagingState.offset) + (page.count || nextItems.length);
|
|
pagingState.total = page.total || pagingState.total;
|
|
pagingState.hasMore = Boolean(page.has_more);
|
|
|
|
applyFiltersAndSearch(false);
|
|
|
|
if ('requestIdleCallback' in window) {
|
|
await new Promise(resolve => requestIdleCallback(() => resolve(), { timeout: 250 }));
|
|
} else {
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading additional library items:', error);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function filterItems(items) {
|
|
const isbn = activeFilters.isbn;
|
|
const type = activeFilters.type;
|
|
const status = activeFilters.status;
|
|
|
|
return items.filter(item => {
|
|
const isbnMatch = !isbn || (item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(isbn);
|
|
const typeMatch = !type || (item.ItemType || 'book') === type;
|
|
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
|
|
const statusMatch = !status || statusKey === status;
|
|
return isbnMatch && typeMatch && statusMatch;
|
|
});
|
|
}
|
|
|
|
function applySearch(items) {
|
|
if (!currentSearchTerm) return items;
|
|
return items.filter(item =>
|
|
(item.Name || '').toLowerCase().includes(currentSearchTerm) ||
|
|
(item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(currentSearchTerm)
|
|
);
|
|
}
|
|
|
|
function applyFiltersAndSearch(resetRenderCount) {
|
|
const filteredByPanel = filterItems(libraryItems);
|
|
filteredItems = applySearch(filteredByPanel);
|
|
if (resetRenderCount) {
|
|
renderedCount = Math.min(INITIAL_RENDER_COUNT, filteredItems.length);
|
|
} else {
|
|
renderedCount = Math.min(Math.max(renderedCount, INITIAL_RENDER_COUNT), filteredItems.length || INITIAL_RENDER_COUNT);
|
|
}
|
|
visibleItems = filteredItems;
|
|
renderItems();
|
|
}
|
|
|
|
function renderItems() {
|
|
const tbody = document.getElementById('itemsTableBody');
|
|
const emptyState = document.getElementById('emptyState');
|
|
const loadMoreRow = document.getElementById('loadMoreRow');
|
|
const loadInfo = document.getElementById('loadInfo');
|
|
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
|
|
|
if (visibleItems.length === 0) {
|
|
tbody.innerHTML = '';
|
|
emptyState.style.display = 'block';
|
|
loadMoreRow.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
emptyState.style.display = 'none';
|
|
const rowsToRender = visibleItems.slice(0, renderedCount);
|
|
|
|
tbody.innerHTML = rowsToRender.map(item => {
|
|
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
|
|
const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available');
|
|
const statusText = statusKey === 'damaged' ? 'Defekt/Zerstört' : (statusKey === 'borrowed' ? 'Ausgeliehen' : 'Verfügbar');
|
|
const actionLabel = statusKey === 'available' ? 'Ausleihen' : (statusKey === 'borrowed' ? 'Reservieren' : 'Nicht ausleihbar');
|
|
const actionDisabled = statusKey === 'damaged' ? 'disabled' : '';
|
|
|
|
return `
|
|
<tr>
|
|
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
|
|
<td>${escapeHtml(item.ISBN || '-')}</td>
|
|
<td>${getItemTypeLabel(item.ItemType || 'book')}</td>
|
|
<td style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td>
|
|
<td>
|
|
<span class="table-status ${statusClass}">
|
|
${statusText}
|
|
</span>
|
|
</td>
|
|
<td class="table-actions">
|
|
<button class="button" onclick="showItemDetail('${item._id}')">Details</button>
|
|
<button class="button" style="background: #28a745; color: white;" onclick="borrowItem('${item._id}')" ${actionDisabled}>
|
|
${actionLabel}
|
|
</button>
|
|
${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''}
|
|
${canEditLibraryItems ? `<button class="button" style="background:#dc2626;color:#fff; margin-left:6px;" onclick="confirmDeleteLibraryItem('${item._id}')">Löschen</button>` : ''}
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
const canLoadMoreRows = renderedCount < visibleItems.length;
|
|
if (canLoadMoreRows) {
|
|
loadMoreRow.style.display = 'flex';
|
|
loadMoreBtn.style.display = 'inline-block';
|
|
loadInfo.textContent = `${renderedCount} von ${visibleItems.length} angezeigt`;
|
|
} else {
|
|
const backgroundHint = pagingState.hasMore ? `Lade weitere Medien im Hintergrund (${libraryItems.length}/${pagingState.total || '?'})...` : `${visibleItems.length} Treffer`;
|
|
loadMoreRow.style.display = 'flex';
|
|
loadMoreBtn.style.display = 'none';
|
|
loadInfo.textContent = backgroundHint;
|
|
}
|
|
}
|
|
|
|
async function deleteLibraryItem(itemId) {
|
|
if (!itemId) return;
|
|
document.body.style.cursor = 'wait';
|
|
try {
|
|
const resp = await fetch(`/delete_library_item/${itemId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRFToken': '{{ csrf_token }}',
|
|
'X-CSRF-Token': '{{ csrf_token }}'
|
|
},
|
|
credentials: 'same-origin'
|
|
});
|
|
|
|
const ct = resp.headers.get('Content-Type') || '';
|
|
if (ct.includes('application/json')) {
|
|
const j = await resp.json();
|
|
if (!resp.ok || j.ok === false) {
|
|
alert(j.message || 'Fehler beim Löschen');
|
|
} else {
|
|
alert(j.message || 'Gelöscht');
|
|
}
|
|
loadLibraryItems();
|
|
} else {
|
|
location.reload();
|
|
}
|
|
} catch (e) {
|
|
console.error('Delete failed', e);
|
|
alert('Fehler beim Löschen des Elements.');
|
|
} finally {
|
|
document.body.style.cursor = '';
|
|
}
|
|
}
|
|
|
|
function confirmDeleteLibraryItem(itemId) {
|
|
if (!itemId) return;
|
|
if (!confirm('Dieses Bibliotheksmedium revisionssicher löschen? Diese Aktion deaktiviert alle zugehörigen Exemplare und archiviert Medien.')) return;
|
|
deleteLibraryItem(itemId);
|
|
}
|
|
|
|
async function startScanner(callback = null) {
|
|
if (scannerRunning) {
|
|
console.warn("Scanner is already running. Stopping it first...");
|
|
await stopScanner();
|
|
}
|
|
|
|
activeScannerCallback = callback;
|
|
|
|
const scannerWrap = document.querySelector('.library-scan-reader-wrap');
|
|
const toggleBtn = document.getElementById('toggleScannerBtn');
|
|
|
|
// 1. CRITICAL FIX: Make the container visible BEFORE Quagga initializes
|
|
// Quagga cannot calculate video dimensions if display is 'none'
|
|
if (scannerWrap) {
|
|
scannerWrap.style.display = 'block';
|
|
}
|
|
|
|
setScanStatus('Starte Kamera...', 'warn');
|
|
|
|
// 2. Initialize Quagga with a slight delay to allow the DOM to render the block
|
|
setTimeout(() => {
|
|
Quagga.init({
|
|
inputStream: {
|
|
name: "Live",
|
|
type: "LiveStream",
|
|
// This MUST match the inner div where the video should appear
|
|
target: document.querySelector('.library-scan-reader'),
|
|
constraints: {
|
|
width: 640,
|
|
height: 480,
|
|
facingMode: "environment" // Prefer back camera on mobile
|
|
}
|
|
},
|
|
locator: {
|
|
patchSize: "medium",
|
|
halfSample: true
|
|
},
|
|
decoder: {
|
|
// Keep only the barcode types you actually use to improve performance
|
|
readers: ["code_128_reader", "ean_reader", "code_39_reader"]
|
|
},
|
|
locate: true
|
|
}, function(err) {
|
|
if (err) {
|
|
// This will finally log the actual error (e.g., NotAllowedError) if permissions fail
|
|
console.error("Quagga initialization failed:", err);
|
|
setScanStatus('Kamera-Fehler: ' + (err.name || err), 'error');
|
|
|
|
if (scannerWrap) scannerWrap.style.display = 'none';
|
|
scannerRunning = false;
|
|
return;
|
|
}
|
|
|
|
Quagga.start();
|
|
scannerRunning = true;
|
|
setScanStatus('Scanner bereit.', 'ok');
|
|
|
|
if (toggleBtn) {
|
|
toggleBtn.textContent = 'Scanner stoppen';
|
|
}
|
|
});
|
|
}, 50);
|
|
}
|
|
|
|
async function stopScanner() {
|
|
if (!scannerRunning) return;
|
|
|
|
try {
|
|
Quagga.stop();
|
|
} catch (e) {
|
|
console.warn("Error stopping Quagga (it may not have been running):", e);
|
|
}
|
|
|
|
scannerRunning = false;
|
|
activeScannerCallback = null;
|
|
|
|
// Hide the container to free up UI space
|
|
const scannerWrap = document.querySelector('.library-scan-reader-wrap');
|
|
if (scannerWrap) {
|
|
scannerWrap.style.display = 'none';
|
|
}
|
|
|
|
const toggleBtn = document.getElementById('toggleScannerBtn');
|
|
if (toggleBtn) {
|
|
toggleBtn.textContent = 'Kamera Scanner'; // Reset button text
|
|
}
|
|
}
|
|
|
|
async function isStudentCardBarcode(code) {
|
|
if (!code) return false;
|
|
|
|
try {
|
|
const response = await fetch(`/api/is_student_card/${encodeURIComponent(code)}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
// Falls du in Flask CSRF-Protect nutzt, muss der Token mitgesendet werden:
|
|
'X-CSRFToken': '{{ csrf_token }}',
|
|
'X-CSRF-Token': '{{ csrf_token }}'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) return false;
|
|
|
|
// Wir parsen die Antwort. Flask jsonify({"is_student_card": true/false})
|
|
const data = await response.json();
|
|
return data === true || data.is_student_card === true;
|
|
|
|
} catch (error) {
|
|
console.error('Fehler bei der API-Prüfung des Ausweises:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Quagga.onDetected(function(data) {
|
|
if (!data || !data.codeResult || !data.codeResult.code) return;
|
|
|
|
const barcode = String(data.codeResult.code || '').trim();
|
|
console.log("Barcode detected:", barcode);
|
|
|
|
const currentCallback = activeScannerCallback;
|
|
stopScanner();
|
|
|
|
if (typeof currentCallback === "function") {
|
|
currentCallback(barcode);
|
|
} else {
|
|
handleScanSuccess(barcode);
|
|
}
|
|
});
|
|
|
|
// =========================================================================
|
|
// Keyboard scanner handling (physical scanners that send chars then Enter)
|
|
// =========================================================================
|
|
function keyboardScanKeydownHandler(e) {
|
|
// Only active when explicitly enabled
|
|
if (!keyboardScannerEnabled) return;
|
|
|
|
// Ignore if focus is in an input/textarea/contenteditable to avoid interfering with typing
|
|
const active = document.activeElement;
|
|
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) return;
|
|
|
|
const now = Date.now();
|
|
|
|
// If Enter/Return pressed -> finalize buffer
|
|
if (e.key === 'Enter') {
|
|
const code = keyboardScanBuffer.trim();
|
|
keyboardScanBuffer = '';
|
|
keyboardLastKeyAt = 0;
|
|
if (!code) return;
|
|
|
|
// Process exactly like a camera scan - centralized logic handles the rest!
|
|
handleScanSuccess(code);
|
|
return;
|
|
}
|
|
|
|
// Only accept common printable characters; ignore modifier keys
|
|
if (e.key.length === 1) {
|
|
// If time gap too big, start new buffer
|
|
if (keyboardLastKeyAt && (now - keyboardLastKeyAt) > KEYBOARD_SCAN_INTERCHAR_MS) {
|
|
keyboardScanBuffer = '';
|
|
}
|
|
keyboardScanBuffer += e.key;
|
|
keyboardLastKeyAt = now;
|
|
// Prevent default so scanner input doesn't accidentally move focus or trigger shortcuts
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
function handleScanSuccess(decodedText) {
|
|
const scannedCode = typeof normalizeScannedCode === "function" ? normalizeScannedCode(decodedText) : decodedText;
|
|
if (!scannedCode) return;
|
|
|
|
const now = Date.now();
|
|
if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) {
|
|
return; // Verhindert doppeltes Scannen in kurzer Zeit
|
|
}
|
|
lastScanValue = scannedCode;
|
|
lastScanAt = now;
|
|
|
|
// Den aktuellen Modus aus dem Dropdown auslesen
|
|
const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only';
|
|
|
|
// 1. Modus: Nur Rückgabe
|
|
if (mode === 'return_only') {
|
|
returnByCode(scannedCode);
|
|
return;
|
|
}
|
|
|
|
// 2. Modus: Nur Ausweis
|
|
if (mode === 'card_only') {
|
|
setActiveStudentCard(scannedCode);
|
|
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
|
|
return;
|
|
}
|
|
|
|
// 3. Modus: Schnellmodus (1x Ausweis, 1x Buch)
|
|
if (mode === 'quick_toggle') {
|
|
processQuickToggleScan(scannedCode);
|
|
return;
|
|
}
|
|
|
|
// 4. Modus: Dauermodus (1x Ausweis, Nx Bücher)
|
|
if (mode === 'continuous') {
|
|
processContinuousScan(scannedCode);
|
|
return;
|
|
}
|
|
}
|
|
|
|
async function processContinuousScan(scannedCode) {
|
|
setScanStatus('Überprüfe Code-Typ...', 'warn');
|
|
|
|
const isCard = await isStudentCardBarcode(scannedCode);
|
|
|
|
if (isCard) {
|
|
setActiveStudentCard(scannedCode);
|
|
setScanStatus(`Neuer Ausweis gesetzt: ${activeStudentCardId}. Bitte Medien scannen.`, 'ok');
|
|
showSmallConfirm(`Benutzer gewechselt zu: ${activeStudentCardId}`, 'ok');
|
|
|
|
setTimeout(() => {
|
|
if (document.getElementById('scanModeSelect').value === 'continuous') {
|
|
startScanner();
|
|
}
|
|
}, 1000);
|
|
return;
|
|
}
|
|
|
|
if (!activeStudentCardId) {
|
|
setScanStatus('Kein Ausweis aktiv! Bitte zuerst einen Schülerausweis scannen.', 'error');
|
|
showSmallConfirm('Bitte zuerst Ausweis scannen', 'error');
|
|
|
|
setTimeout(() => {
|
|
if (document.getElementById('scanModeSelect').value === 'continuous') {
|
|
startScanner();
|
|
}
|
|
}, 2000);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setScanStatus('Verarbeite Mediencode...', 'warn');
|
|
const response = await fetch('/api/library_scan_action', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': '{{ csrf_token }}',
|
|
'X-CSRF-Token': '{{ csrf_token }}'
|
|
},
|
|
body: JSON.stringify({
|
|
student_card_id: activeStudentCardId,
|
|
item_code: scannedCode
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (!response.ok || !result.ok) {
|
|
setScanStatus(result.message || 'Scan-Aktion fehlgeschlagen.', 'error');
|
|
showSmallConfirm(result.message || 'Aktion fehlgeschlagen.', 'error');
|
|
} else if (result.action === 'borrowed') {
|
|
setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok');
|
|
showSmallConfirm(`Ausgeliehen: ${result.item_name}`, 'ok');
|
|
} else if (result.action === 'returned') {
|
|
setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok');
|
|
showSmallConfirm(`Zurückgegeben: ${result.item_name}`, 'ok');
|
|
} else {
|
|
setScanStatus(result.message || 'Aktion durchgeführt.', 'ok');
|
|
showSmallConfirm(result.message || 'Erfolgreich', 'ok');
|
|
}
|
|
|
|
// Tabellen-Ansicht aktualisieren
|
|
if (typeof loadLibraryItems === "function") {
|
|
await loadLibraryItems();
|
|
}
|
|
} catch (err) {
|
|
console.error('Continuous scan action failed:', err);
|
|
setScanStatus('Fehler beim Verarbeiten des Scans.', 'error');
|
|
showSmallConfirm('Fehler im Netzwerk/System', 'error');
|
|
}
|
|
|
|
setTimeout(() => {
|
|
if (document.getElementById('scanModeSelect').value === 'continuous') {
|
|
startScanner();
|
|
}
|
|
}, 1500);
|
|
}
|
|
|
|
async function processQuickToggleScan(scannedCode) {
|
|
|
|
// 2. Wenn kein Ausweis gesetzt ist, wird der Code als Ausweis interpretiert
|
|
if (!activeStudentCardId) {
|
|
setActiveStudentCard(scannedCode);
|
|
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
|
|
return;
|
|
}
|
|
|
|
// 3. Ausleihe/Rückgabe verarbeiten (wenn Ausweis vorhanden)
|
|
try {
|
|
setScanStatus('Verarbeite Mediencode...', 'warn');
|
|
const response = await fetch('/api/library_scan_action', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
student_card_id: activeStudentCardId,
|
|
item_code: scannedCode
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (!response.ok || !result.ok) {
|
|
setScanStatus(result.message || 'Scan-Aktion fehlgeschlagen.', 'error');
|
|
return;
|
|
}
|
|
|
|
if (result.action === 'borrowed') {
|
|
setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok');
|
|
showSmallConfirm(`Ausgeliehen: ${result.item_name}`, 'ok');
|
|
} else if (result.action === 'returned') {
|
|
setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok');
|
|
showSmallConfirm(`Zurückgegeben: ${result.item_name}`, 'ok');
|
|
} else {
|
|
setScanStatus(result.message || 'Aktion durchgeführt.', 'ok');
|
|
showSmallConfirm(result.message || 'Aktion durchgeführt.', 'ok');
|
|
}
|
|
|
|
await loadLibraryItems();
|
|
} catch (err) {
|
|
console.error('Quick scan action failed:', err);
|
|
setScanStatus('Fehler beim Verarbeiten des Scans.', 'error');
|
|
}
|
|
}
|
|
|
|
async function returnByCode(code) {
|
|
if (!code) return;
|
|
setScanStatus('Verarbeite Rückgabe...', 'warn');
|
|
try {
|
|
const resp = await fetch('/api/library_return_by_code', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ item_code: code })
|
|
});
|
|
const result = await resp.json();
|
|
if (!resp.ok || !result.ok) {
|
|
setScanStatus(result.message || 'Rückgabe fehlgeschlagen.', 'error');
|
|
showSmallConfirm(result.message || 'Rückgabe fehlgeschlagen.', 'error');
|
|
return false;
|
|
}
|
|
setScanStatus(result.message || `Zurückgegeben: ${result.item_name || ''}`, 'ok');
|
|
showSmallConfirm(result.message || `Zurückgegeben: ${result.item_name || ''}`, 'ok');
|
|
await loadLibraryItems();
|
|
return true;
|
|
} catch (err) {
|
|
console.error('Return by code failed:', err);
|
|
setScanStatus('Fehler bei Rückgabe.', 'error');
|
|
showSmallConfirm('Fehler bei Rückgabe.', 'error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function scanIntoEditCode() {
|
|
const scanReaderWrap = document.getElementById('scanReaderWrap');
|
|
const editCodeInput = document.getElementById('edit-code4');
|
|
const scanEditBtn = document.getElementById('scan-edit-code-btn');
|
|
if (!scanReaderWrap || !editCodeInput || !scanEditBtn) {
|
|
return;
|
|
}
|
|
|
|
if (scannerRunning && scanReaderWrap.style.display !== 'none') {
|
|
stopScanner();
|
|
scanEditBtn.textContent = 'Barcode scannen';
|
|
return;
|
|
}
|
|
|
|
scanEditBtn.textContent = 'Scanner schließen';
|
|
|
|
startScanner(function(decodedText) {
|
|
editCodeInput.value = decodedText;
|
|
if(typeof validateCodeField === "function") {
|
|
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
|
|
}
|
|
scanEditBtn.textContent = 'Barcode scannen';
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// 4. UI INTERACTIONS & UTILITIES
|
|
// =========================================================================
|
|
function borrowItem(itemId) {
|
|
const selectedItem = (libraryItems || []).find(item => item._id === itemId);
|
|
if (selectedItem && selectedItem.LibraryDisplayStatus === 'damaged') {
|
|
alert('Dieses Medium ist als defekt/zerstört markiert und kann nicht ausgeliehen werden.');
|
|
return;
|
|
}
|
|
|
|
const defaultCardId = activeStudentCardId || '';
|
|
const cardId = (window.prompt('Bitte Bibliotheksausweis-ID eingeben:', defaultCardId) || '').trim().toUpperCase();
|
|
if (!cardId) {
|
|
alert('Ausleihe abgebrochen: Für Bibliotheksmedien ist eine gültige Bibliotheksausweis-ID erforderlich.');
|
|
return;
|
|
}
|
|
|
|
setActiveStudentCard(cardId);
|
|
|
|
const durationInput = (window.prompt('Ausleihdauer in Tagen (optional):') || '').trim();
|
|
const maxAvailable = Math.max(1, parseInt(selectedItem?.AvailableGroupedCount || selectedItem?.Quantity || 1, 10) || 1);
|
|
const countPrompt = (window.prompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1') || '').trim();
|
|
let borrowCount = parseInt(countPrompt || '1', 10);
|
|
if (!Number.isFinite(borrowCount) || borrowCount < 1) {
|
|
borrowCount = 1;
|
|
}
|
|
if (borrowCount > maxAvailable) {
|
|
alert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`);
|
|
return;
|
|
}
|
|
|
|
const form = document.createElement('form');
|
|
form.method = 'POST';
|
|
form.action = `/ausleihen/${itemId}`;
|
|
|
|
const csrfField = document.createElement('input');
|
|
csrfField.type = 'hidden';
|
|
csrfField.name = 'csrf_token';
|
|
csrfField.value = '{{ csrf_token }}';
|
|
form.appendChild(csrfField);
|
|
|
|
const cardField = document.createElement('input');
|
|
cardField.type = 'hidden';
|
|
cardField.name = 'borrower_card_id';
|
|
cardField.value = cardId;
|
|
form.appendChild(cardField);
|
|
|
|
const returnTargetField = document.createElement('input');
|
|
returnTargetField.type = 'hidden';
|
|
returnTargetField.name = 'return_to';
|
|
returnTargetField.value = 'library';
|
|
form.appendChild(returnTargetField);
|
|
|
|
if (durationInput) {
|
|
const durationField = document.createElement('input');
|
|
durationField.type = 'hidden';
|
|
durationField.name = 'borrow_duration_days';
|
|
durationField.value = durationInput;
|
|
form.appendChild(durationField);
|
|
}
|
|
|
|
const countField = document.createElement('input');
|
|
countField.type = 'hidden';
|
|
countField.name = 'exemplare_count';
|
|
countField.value = String(borrowCount || 1);
|
|
form.appendChild(countField);
|
|
|
|
document.body.appendChild(form);
|
|
form.submit();
|
|
}
|
|
|
|
function setScanStatus(message, kind = '') {
|
|
const el = document.getElementById('scanStatus');
|
|
if (!el) return;
|
|
el.textContent = message;
|
|
el.classList.remove('ok', 'warn', 'error');
|
|
if (kind) el.classList.add(kind);
|
|
}
|
|
|
|
function showSmallConfirm(message, kind='ok') {
|
|
// Append the small helper text in German
|
|
const helper = 'Sie können fortfahren. Dies ist nur eine kleine Benachrichtigung.';
|
|
const el = document.createElement('div');
|
|
el.className = `small-popup ${kind === 'error' ? 'error' : 'ok'}`;
|
|
el.innerHTML = `<div>${escapeHtml(String(message || ''))}</div><div style="opacity:0.9; margin-left:8px; font-size:0.85em;">${helper}</div><div class="close-x">×</div>`;
|
|
document.body.appendChild(el);
|
|
// close handler
|
|
el.querySelector('.close-x').addEventListener('click', () => {
|
|
if (el && el.parentNode) el.parentNode.removeChild(el);
|
|
});
|
|
// auto remove after 3 seconds
|
|
setTimeout(() => { try { if (el && el.parentNode) el.parentNode.removeChild(el); } catch(e){} }, 3000);
|
|
}
|
|
|
|
function setActiveStudentCard(cardId) {
|
|
activeStudentCardId = (cardId || '').trim().toUpperCase();
|
|
const input = document.getElementById('activeStudentCard');
|
|
if (input) input.value = activeStudentCardId;
|
|
}
|
|
|
|
function normalizeScannedCode(code) {
|
|
return (code || '').trim();
|
|
}
|
|
|
|
function getItemTypeLabel(type) {
|
|
const labels = { 'book': 'Buch', 'cd': 'CD', 'dvd': 'DVD', 'other': 'Sonstige', 'schoolbook': 'Schulbuch' };
|
|
return labels[type] || type;
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function showItemDetail(itemId) {
|
|
const detailContent = document.getElementById('detailContent');
|
|
const detailModal = document.getElementById('detailModal');
|
|
|
|
detailContent.innerHTML = '<p>Lade Details...</p>';
|
|
detailModal.style.display = 'flex';
|
|
|
|
fetch(`/api/item_detail/${itemId}`)
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error('Fehler beim Laden der Artikeldetails');
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
detailContent.innerHTML = data.html;
|
|
|
|
const imageArray = data.images || [];
|
|
|
|
if (Array.isArray(imageArray) && imageArray.length > 0) {
|
|
const imagesHtml = imageArray.map(image => {
|
|
const imageSrc = image.startsWith('/uploads/') || image.startsWith('http')
|
|
? image
|
|
: `/uploads/${image}`;
|
|
|
|
return `
|
|
<div class="item-image-wrapper">
|
|
<img src="{{ url_for('uploaded_file', filename=img) }}"
|
|
alt="Buchcover"
|
|
class="item-image"
|
|
loading="lazy"
|
|
onload="console.log('Bild geladen:', '${imageSrc}')"
|
|
onerror="console.error('FEHLER beim Laden des Bildes im DOM:', '${imageSrc}')">
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const mediaHtml = `<div class="detail-gallery-container">${imagesHtml}</div>`;
|
|
|
|
const h2Tag = detailContent.querySelector('h2');
|
|
if (h2Tag) {
|
|
h2Tag.insertAdjacentHTML('afterend', mediaHtml);
|
|
} else {
|
|
detailContent.insertAdjacentHTML('afterbegin', mediaHtml);
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching item detail:', error);
|
|
detailContent.innerHTML = '<p style="color: red;">Fehler beim Laden der Artikeldetails.</p>';
|
|
});
|
|
}
|
|
|
|
// Schließt das Modal über den 'x'-Button
|
|
function closeDetailModal() {
|
|
document.getElementById('detailModal').style.display = 'none';
|
|
}
|
|
|
|
// Closes the modal if the user clicks the dark background overlay
|
|
window.onclick = function(event) {
|
|
const modal = document.getElementById('detailModal');
|
|
// If the click happened directly on the dark overlay (not the white box)
|
|
if (event.target === modal) {
|
|
closeDetailModal();
|
|
}
|
|
};
|
|
|
|
// =========================================================================
|
|
// 5. EVENT LISTENERS INITIALIZATION & ON-LOAD INITIALIZER
|
|
// =========================================================================
|
|
function wireScannerUi() {
|
|
const toggleBtn = document.getElementById('toggleScannerBtn');
|
|
const resetBtn = document.getElementById('resetCardBtn');
|
|
const modeSelect = document.getElementById('scanModeSelect');
|
|
const keyboardToggle = document.getElementById('keyboardScannerToggle');
|
|
|
|
if (toggleBtn) {
|
|
toggleBtn.addEventListener('click', async () => {
|
|
if (scannerRunning) {
|
|
await stopScanner();
|
|
} else {
|
|
await startScanner(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (resetBtn) {
|
|
resetBtn.addEventListener('click', () => {
|
|
setActiveStudentCard('');
|
|
setScanStatus('Ausweis zurückgesetzt. Bitte neu scannen.', 'warn');
|
|
});
|
|
}
|
|
|
|
if (modeSelect) {
|
|
modeSelect.addEventListener('change', () => {
|
|
if (modeSelect.value === 'quick_toggle' && !activeStudentCardId) {
|
|
setScanStatus('Schnellmodus: zuerst Bibliotheksausweis scannen.', 'warn');
|
|
} else if (modeSelect.value === 'card_only') {
|
|
setScanStatus('Nur Ausweis-Modus aktiv.', 'warn');
|
|
}
|
|
});
|
|
}
|
|
|
|
if (keyboardToggle) {
|
|
keyboardToggle.addEventListener('change', () => {
|
|
keyboardScannerEnabled = !!keyboardToggle.checked;
|
|
if (keyboardScannerEnabled) {
|
|
document.addEventListener('keydown', keyboardScanKeydownHandler);
|
|
setScanStatus('Physischer Scanner aktiv (Schnellmodus empfohlen).', 'ok');
|
|
} else {
|
|
document.removeEventListener('keydown', keyboardScanKeydownHandler);
|
|
setScanStatus('Physischer Scanner deaktiviert.', 'warn');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Run when DOM structure is entirely ready
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
wireScannerUi(); // Setup scanner control buttons
|
|
loadLibraryItems(); // Fetch your database items right away!
|
|
|
|
// Safely connect standard Filters and Search inputs inside DOMContentLoaded
|
|
const filterToggleBtn = document.getElementById('filterToggleBtn');
|
|
if (filterToggleBtn) {
|
|
filterToggleBtn.addEventListener('click', () => {
|
|
filterPanelOpen = !filterPanelOpen;
|
|
document.getElementById('filterPanel').classList.toggle('open');
|
|
filterToggleBtn.classList.toggle('active');
|
|
});
|
|
}
|
|
|
|
const applyFilterBtn = document.getElementById('applyFilterBtn');
|
|
if (applyFilterBtn) {
|
|
applyFilterBtn.addEventListener('click', () => {
|
|
activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
|
|
activeFilters.type = document.getElementById('filterType').value;
|
|
activeFilters.status = document.getElementById('filterStatus').value;
|
|
applyFiltersAndSearch(true);
|
|
});
|
|
}
|
|
|
|
const clearFilterBtn = document.getElementById('clearFilterBtn');
|
|
if (clearFilterBtn) {
|
|
clearFilterBtn.addEventListener('click', () => {
|
|
document.getElementById('filterISBN').value = '';
|
|
document.getElementById('filterType').value = '';
|
|
document.getElementById('filterStatus').value = '';
|
|
activeFilters = {isbn: '', type: '', status: ''};
|
|
applyFiltersAndSearch(true);
|
|
});
|
|
}
|
|
|
|
const librarySearch = document.getElementById('librarySearch');
|
|
if (librarySearch) {
|
|
librarySearch.addEventListener('input', (e) => {
|
|
currentSearchTerm = (e.target.value || '').toLowerCase();
|
|
applyFiltersAndSearch(true);
|
|
});
|
|
}
|
|
|
|
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
|
if (loadMoreBtn) {
|
|
loadMoreBtn.addEventListener('click', () => {
|
|
renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length);
|
|
renderItems();
|
|
});
|
|
}
|
|
|
|
const manualActionBtn = document.getElementById('manualActionBtn');
|
|
const manualItemCode = document.getElementById('manualItemCode');
|
|
|
|
if (manualActionBtn && manualItemCode) {
|
|
manualActionBtn.addEventListener('click', () => {
|
|
const code = (manualItemCode.value || '').trim();
|
|
if (!code) {
|
|
alert('Bitte einen Code eingeben.');
|
|
return;
|
|
}
|
|
|
|
// Leitet den manuellen Code an die zentrale Logik weiter,
|
|
// die prüft, welcher Modus im Dropdown aktiv ist.
|
|
handleScanSuccess(code);
|
|
|
|
// Feld nach Eingabe leeren, um direkt den nächsten Code bereitzuhaben
|
|
manualItemCode.value = '';
|
|
});
|
|
|
|
// Optional: Auch auf "Enter" im Textfeld reagieren
|
|
manualItemCode.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
manualActionBtn.click();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
async function fetchLibraryGroupMembers(seriesGroupId) {
|
|
if (!seriesGroupId) return [];
|
|
try {
|
|
const response = await fetch(`/api/library_group/${encodeURIComponent(seriesGroupId)}`);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const payload = await response.json();
|
|
return Array.isArray(payload.items) ? payload.items : [];
|
|
} catch (error) {
|
|
console.warn('Falling back to loaded library items for group editing:', error);
|
|
return (libraryItems || []).filter(item => item.SeriesGroupId === seriesGroupId);
|
|
}
|
|
}
|
|
|
|
function openEditLibraryItem(itemId) {
|
|
window.location.href = `/item_edit/${itemId}`;
|
|
}
|
|
</script>
|
|
{% endblock %} |