Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60ec21f34e | |||
| 57f6d2f8e7 | |||
| 53575c1876 | |||
| 0f372f5056 | |||
| c078de0076 | |||
| daccf3334b | |||
| ef605e080b | |||
| 1755ec222d | |||
| 06ab3e1f4d | |||
| a2c79c80bc | |||
| 7ac66ae3ba |
Binary file not shown.
+616
-59
@@ -33,7 +33,6 @@ import ausleihung as au
|
||||
import audit_log as al
|
||||
import datetime
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from pymongo import MongoClient
|
||||
from bson.objectid import ObjectId
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
import requests
|
||||
@@ -59,6 +58,7 @@ import subprocess
|
||||
# Set base directory and centralized settings
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
|
||||
|
||||
app = Flask(__name__, static_folder='static') # Correctly set static folder
|
||||
@@ -895,6 +895,394 @@ def normalize_and_validate_isbn(isbn_raw):
|
||||
return ''
|
||||
|
||||
|
||||
def _normalize_excel_header(value):
|
||||
"""Normalize Excel header labels for robust auto-mapping."""
|
||||
if value is None:
|
||||
return ''
|
||||
text = str(value).strip().lower()
|
||||
text = text.replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace('ß', 'ss')
|
||||
text = re.sub(r'[^a-z0-9]+', '_', text)
|
||||
text = re.sub(r'_+', '_', text).strip('_')
|
||||
return text
|
||||
|
||||
|
||||
def _excel_bool(value, default=True):
|
||||
"""Parse a flexible Excel bool-like cell value."""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
text = str(value).strip().lower()
|
||||
if text in {'1', 'true', 'yes', 'y', 'ja', 'j', 'x'}:
|
||||
return True
|
||||
if text in {'0', 'false', 'no', 'n', 'nein'}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _excel_int(value):
|
||||
"""Parse int-like values from Excel cells."""
|
||||
if value is None or str(value).strip() == '':
|
||||
return None
|
||||
try:
|
||||
return int(float(str(value).replace(',', '.')))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _excel_float(value):
|
||||
"""Parse float-like values from Excel cells."""
|
||||
if value is None or str(value).strip() == '':
|
||||
return None
|
||||
try:
|
||||
return float(str(value).replace(',', '.'))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _excel_list(value):
|
||||
"""Parse list-like filter values from comma/semicolon/newline separated strings."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [sanitize_form_value(v) for v in value if str(v).strip()]
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return []
|
||||
parts = re.split(r'[;,\n\|]+', text)
|
||||
cleaned = [sanitize_form_value(p.strip()) for p in parts if p and p.strip()]
|
||||
# Deduplicate while preserving order.
|
||||
seen = set()
|
||||
unique = []
|
||||
for entry in cleaned:
|
||||
if entry not in seen:
|
||||
unique.append(entry)
|
||||
seen.add(entry)
|
||||
return unique
|
||||
|
||||
|
||||
def _upload_excel_items(scope='inventory'):
|
||||
"""Bulk import inventory/library items from Excel with validation-first workflow."""
|
||||
if 'username' not in session:
|
||||
flash('Nicht angemeldet.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if not us.check_admin(session['username']):
|
||||
flash('Administratorrechte erforderlich.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
|
||||
is_library_scope = scope == 'library'
|
||||
file_field = 'library_excel' if is_library_scope else 'inventory_excel'
|
||||
fallback_route = 'library_admin' if is_library_scope else 'upload_admin'
|
||||
|
||||
if is_library_scope:
|
||||
if not cfg.LIBRARY_MODULE_ENABLED:
|
||||
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
excel_file = request.files.get(file_field)
|
||||
if not excel_file or not excel_file.filename:
|
||||
flash('Bitte eine Excel-Datei auswählen.', 'error')
|
||||
return redirect(url_for(fallback_route))
|
||||
|
||||
filename_lower = excel_file.filename.lower()
|
||||
if not filename_lower.endswith('.xlsx'):
|
||||
flash('Nur .xlsx Dateien werden unterstützt.', 'error')
|
||||
return redirect(url_for(fallback_route))
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except Exception:
|
||||
flash('Excel-Import benötigt das Paket openpyxl. Bitte Abhängigkeiten aktualisieren.', 'error')
|
||||
return redirect(url_for(fallback_route))
|
||||
|
||||
try:
|
||||
workbook = load_workbook(excel_file, data_only=True, read_only=True)
|
||||
sheet = workbook.active
|
||||
except Exception as exc:
|
||||
flash(f'Excel-Datei konnte nicht gelesen werden: {exc}', 'error')
|
||||
return redirect(url_for(fallback_route))
|
||||
|
||||
header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
|
||||
if not header_row:
|
||||
flash('Die Excel-Datei enthält keine Kopfzeile.', 'error')
|
||||
return redirect(url_for(fallback_route))
|
||||
|
||||
header_map = {}
|
||||
for idx, raw_header in enumerate(header_row):
|
||||
normalized = _normalize_excel_header(raw_header)
|
||||
if normalized:
|
||||
header_map[normalized] = idx
|
||||
|
||||
synonyms = {
|
||||
'name': ['name', 'titel', 'artikel', 'item', 'bezeichnung'],
|
||||
'ort': ['ort', 'location', 'standort', 'platz'],
|
||||
'beschreibung': ['beschreibung', 'description', 'desc', 'details'],
|
||||
'filter1': ['filter1', 'filter', 'fach', 'unterrichtsfach', 'kategorie1', 'category1'],
|
||||
'filter2': ['filter2', 'jahrgang', 'jahrgangsstufe', 'klasse', 'kategorie2', 'category2'],
|
||||
'filter3': ['filter3', 'bereich', 'gruppe', 'typ', 'kategorie3', 'category3'],
|
||||
'anschaffungsjahr': ['anschaffungsjahr', 'jahr', 'year'],
|
||||
'anschaffungskosten': ['anschaffungskosten', 'kosten', 'cost', 'preis', 'price'],
|
||||
'code_4': ['code_4', 'code4', 'code', 'inventarnummer', 'inventar_nr', 'id_code'],
|
||||
'reservierbar': ['reservierbar', 'reservable', 'bookable'],
|
||||
'anzahl': ['anzahl', 'menge', 'quantity', 'count'],
|
||||
'isbn': ['isbn'],
|
||||
'item_type': ['item_type', 'typ', 'type'],
|
||||
}
|
||||
|
||||
def col_index(key):
|
||||
for candidate in synonyms.get(key, []):
|
||||
normalized = _normalize_excel_header(candidate)
|
||||
if normalized in header_map:
|
||||
return header_map[normalized]
|
||||
return None
|
||||
|
||||
required_columns = {'name': col_index('name'), 'ort': col_index('ort'), 'beschreibung': col_index('beschreibung')}
|
||||
missing_required = [k for k, v in required_columns.items() if v is None]
|
||||
if missing_required:
|
||||
missing_label = ', '.join(missing_required)
|
||||
flash(f'Pflichtspalten fehlen in Excel: {missing_label}', 'error')
|
||||
return redirect(url_for(fallback_route))
|
||||
|
||||
mapped_indices = {
|
||||
'name': col_index('name'),
|
||||
'ort': col_index('ort'),
|
||||
'beschreibung': col_index('beschreibung'),
|
||||
'filter1': col_index('filter1'),
|
||||
'filter2': col_index('filter2'),
|
||||
'filter3': col_index('filter3'),
|
||||
'anschaffungsjahr': col_index('anschaffungsjahr'),
|
||||
'anschaffungskosten': col_index('anschaffungskosten'),
|
||||
'code_4': col_index('code_4'),
|
||||
'reservierbar': col_index('reservierbar'),
|
||||
'anzahl': col_index('anzahl'),
|
||||
'isbn': col_index('isbn'),
|
||||
'item_type': col_index('item_type'),
|
||||
}
|
||||
|
||||
validation_only = (request.form.get('excel_action') or '').strip().lower() == 'validate'
|
||||
|
||||
max_rows = 15000
|
||||
max_generated_items = 50000
|
||||
max_items_per_row = 1000
|
||||
|
||||
# Validation phase: parse + map + validate every row before any write.
|
||||
planned_rows = []
|
||||
validation_errors = []
|
||||
validation_warnings = []
|
||||
reserved_codes = set()
|
||||
known_locations = set(it.get_predefined_locations())
|
||||
|
||||
def is_code_available(candidate_code):
|
||||
return candidate_code not in reserved_codes and it.is_code_unique(candidate_code)
|
||||
|
||||
def resolve_unique_code(base_candidate):
|
||||
if is_code_available(base_candidate):
|
||||
reserved_codes.add(base_candidate)
|
||||
return base_candidate
|
||||
|
||||
suffix = 1
|
||||
while suffix <= 1000:
|
||||
alt = f"{base_candidate}-{suffix}"
|
||||
if is_code_available(alt):
|
||||
reserved_codes.add(alt)
|
||||
return alt
|
||||
suffix += 1
|
||||
return None
|
||||
|
||||
processed_rows = 0
|
||||
planned_item_total = 0
|
||||
row_limit_exceeded = False
|
||||
|
||||
for row_number, row_values in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
|
||||
processed_rows += 1
|
||||
if processed_rows > max_rows:
|
||||
row_limit_exceeded = True
|
||||
break
|
||||
|
||||
def val(key):
|
||||
idx = mapped_indices.get(key)
|
||||
if idx is None or idx >= len(row_values):
|
||||
return None
|
||||
return row_values[idx]
|
||||
|
||||
name = sanitize_form_value(val('name'))
|
||||
ort = sanitize_form_value(val('ort'))
|
||||
beschreibung = sanitize_form_value(val('beschreibung'))
|
||||
|
||||
if not name and not ort and not beschreibung:
|
||||
# Fully empty row.
|
||||
continue
|
||||
|
||||
row_errors = []
|
||||
|
||||
if not name or not ort or not beschreibung:
|
||||
row_errors.append('Pflichtfeld Name/Ort/Beschreibung fehlt')
|
||||
|
||||
if ort and ort not in known_locations:
|
||||
validation_warnings.append((row_number, f'Ort "{ort}" wird neu angelegt'))
|
||||
|
||||
filter1 = expand_filter_selection(_excel_list(val('filter1')), 1)
|
||||
filter2 = expand_filter_selection(_excel_list(val('filter2')), 2)
|
||||
filter3 = _excel_list(val('filter3'))
|
||||
|
||||
anschaffungsjahr = _excel_int(val('anschaffungsjahr'))
|
||||
raw_year = val('anschaffungsjahr')
|
||||
if raw_year not in (None, '') and anschaffungsjahr is None:
|
||||
row_errors.append('Anschaffungsjahr ist kein gültiger Integer')
|
||||
|
||||
anschaffungskosten = _excel_float(val('anschaffungskosten'))
|
||||
raw_cost = val('anschaffungskosten')
|
||||
if raw_cost not in (None, '') and anschaffungskosten is None:
|
||||
row_errors.append('Anschaffungskosten sind keine gültige Zahl')
|
||||
|
||||
reservierbar = _excel_bool(val('reservierbar'), default=True)
|
||||
|
||||
count = _excel_int(val('anzahl')) or 1
|
||||
raw_count = val('anzahl')
|
||||
if raw_count not in (None, '') and _excel_int(raw_count) is None:
|
||||
row_errors.append('Anzahl ist kein gültiger Integer')
|
||||
count = max(1, min(count, max_items_per_row))
|
||||
|
||||
raw_code = val('code_4')
|
||||
base_code = sanitize_form_value(str(raw_code).strip()) if raw_code is not None and str(raw_code).strip() else None
|
||||
|
||||
item_isbn = ''
|
||||
raw_isbn = val('isbn')
|
||||
if raw_isbn:
|
||||
item_isbn = normalize_and_validate_isbn(str(raw_isbn))
|
||||
if not item_isbn:
|
||||
row_errors.append('ISBN ist ungültig (erwartet ISBN-10/13)')
|
||||
if is_library_scope and not item_isbn:
|
||||
row_errors.append('Für den Bibliotheksimport ist eine gültige ISBN erforderlich')
|
||||
|
||||
raw_item_type = sanitize_form_value(str(val('item_type')).strip().lower()) if val('item_type') else ''
|
||||
item_type = 'book' if is_library_scope else (raw_item_type or ('book' if item_isbn else 'general'))
|
||||
|
||||
planned_codes = []
|
||||
if base_code:
|
||||
for position in range(1, count + 1):
|
||||
candidate = base_code if position == 1 else f"{base_code}-{position}"
|
||||
code = resolve_unique_code(candidate)
|
||||
if not code:
|
||||
row_errors.append(f'Code konnte nicht eindeutig erzeugt werden ({candidate})')
|
||||
break
|
||||
planned_codes.append(code)
|
||||
else:
|
||||
planned_codes = [None] * count
|
||||
|
||||
if row_errors:
|
||||
validation_errors.append((row_number, '; '.join(row_errors)))
|
||||
continue
|
||||
|
||||
planned_rows.append({
|
||||
'row_number': row_number,
|
||||
'name': name,
|
||||
'ort': ort,
|
||||
'beschreibung': beschreibung,
|
||||
'filter1': filter1,
|
||||
'filter2': filter2,
|
||||
'filter3': filter3,
|
||||
'anschaffungsjahr': anschaffungsjahr,
|
||||
'anschaffungskosten': anschaffungskosten,
|
||||
'reservierbar': reservierbar,
|
||||
'count': count,
|
||||
'codes': planned_codes,
|
||||
'isbn': item_isbn,
|
||||
'item_type': item_type,
|
||||
})
|
||||
planned_item_total += count
|
||||
|
||||
if planned_item_total > max_generated_items:
|
||||
row_limit_exceeded = True
|
||||
break
|
||||
|
||||
if row_limit_exceeded:
|
||||
flash(
|
||||
f'Importgrenze überschritten. Maximal erlaubt: {max_rows} Zeilen und {max_generated_items} erzeugte Artikel pro Datei.',
|
||||
'error'
|
||||
)
|
||||
return redirect(url_for(fallback_route))
|
||||
|
||||
if validation_errors:
|
||||
details = '; '.join([f'Zeile {n}: {msg}' for n, msg in validation_errors[:15]])
|
||||
flash(f'Validierung fehlgeschlagen ({len(validation_errors)} Zeilen). {details}', 'error')
|
||||
return redirect(url_for(fallback_route))
|
||||
|
||||
if validation_only:
|
||||
warning_text = ''
|
||||
if validation_warnings:
|
||||
warning_details = '; '.join([f'Zeile {n}: {msg}' for n, msg in validation_warnings[:10]])
|
||||
warning_text = f' Hinweise: {warning_details}'
|
||||
planned_count = sum(row['count'] for row in planned_rows)
|
||||
flash(f'Validierung erfolgreich: {len(planned_rows)} Zeilen geprüft, {planned_count} Artikel würden importiert.{warning_text}', 'success')
|
||||
return redirect(url_for(fallback_route))
|
||||
|
||||
# Import phase: only runs after successful validation.
|
||||
created_total = 0
|
||||
import_errors = []
|
||||
for row in planned_rows:
|
||||
if row['ort'] and row['ort'] not in known_locations:
|
||||
it.add_predefined_location(row['ort'])
|
||||
known_locations.add(row['ort'])
|
||||
|
||||
series_group_id = str(uuid.uuid4()) if row['count'] > 1 else None
|
||||
row_created_ids = []
|
||||
|
||||
for position in range(1, row['count'] + 1):
|
||||
parent_item_id = str(row_created_ids[0]) if row_created_ids else None
|
||||
code = row['codes'][position - 1] if position - 1 < len(row['codes']) else None
|
||||
item_id = it.add_item(
|
||||
name=row['name'],
|
||||
ort=row['ort'],
|
||||
beschreibung=row['beschreibung'],
|
||||
images=[],
|
||||
filter=row['filter1'],
|
||||
filter2=row['filter2'],
|
||||
filter3=row['filter3'],
|
||||
ansch_jahr=row['anschaffungsjahr'],
|
||||
ansch_kost=row['anschaffungskosten'],
|
||||
code_4=code,
|
||||
reservierbar=row['reservierbar'],
|
||||
series_group_id=series_group_id,
|
||||
series_count=row['count'],
|
||||
series_position=position,
|
||||
is_grouped_sub_item=(position > 1),
|
||||
parent_item_id=parent_item_id,
|
||||
isbn=row['isbn'],
|
||||
item_type=row['item_type'],
|
||||
)
|
||||
if not item_id:
|
||||
import_errors.append((row['row_number'], 'Fehler beim Anlegen des Artikels'))
|
||||
break
|
||||
row_created_ids.append(item_id)
|
||||
|
||||
created_total += len(row_created_ids)
|
||||
|
||||
if created_total == 0:
|
||||
details = '; '.join([f'Zeile {n}: {msg}' for n, msg in import_errors[:10]])
|
||||
flash(f'Keine Artikel importiert. {details}', 'error')
|
||||
return redirect(url_for(fallback_route))
|
||||
|
||||
if import_errors:
|
||||
flash(f'Excel-Import abgeschlossen: {created_total} Artikel importiert, {len(import_errors)} Zeilen fehlgeschlagen.', 'warning')
|
||||
else:
|
||||
flash(f'Excel-Import erfolgreich: {created_total} Artikel importiert.', 'success')
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
|
||||
@app.route('/upload_inventory_excel', methods=['POST'])
|
||||
def upload_inventory_excel():
|
||||
"""Bulk import for non-library inventory."""
|
||||
return _upload_excel_items(scope='inventory')
|
||||
|
||||
|
||||
@app.route('/upload_library_excel', methods=['POST'])
|
||||
def upload_library_excel():
|
||||
"""Bulk import dedicated to library items (ISBN required)."""
|
||||
return _upload_excel_items(scope='library')
|
||||
|
||||
|
||||
@app.route('/uploads/<filename>')
|
||||
def uploaded_file(filename):
|
||||
"""
|
||||
@@ -1354,21 +1742,62 @@ def library_loans_admin():
|
||||
@app.route('/api/library_items')
|
||||
def api_library_items():
|
||||
"""
|
||||
API endpoint to fetch all library items (books, CDs, DVDs, media).
|
||||
Returns JSON array suitable for table view.
|
||||
API endpoint to fetch library items (books, CDs, DVDs, media).
|
||||
Supports pagination via query params: offset, limit.
|
||||
"""
|
||||
if 'username' not in session:
|
||||
return jsonify([]), 401
|
||||
return jsonify({'items': []}), 401
|
||||
|
||||
offset_raw = request.args.get('offset', '0')
|
||||
limit_raw = request.args.get('limit', '120')
|
||||
|
||||
try:
|
||||
offset = max(0, int(offset_raw))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
|
||||
try:
|
||||
limit = int(limit_raw)
|
||||
except (TypeError, ValueError):
|
||||
limit = 120
|
||||
limit = min(max(limit, 1), 500)
|
||||
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
items_db = db['items']
|
||||
ausleihungen_db = db['ausleihungen']
|
||||
|
||||
query = {
|
||||
'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']},
|
||||
'Deleted': {'$ne': True}
|
||||
}
|
||||
|
||||
projection = {
|
||||
'Name': 1,
|
||||
'Autor': 1,
|
||||
'Author': 1,
|
||||
'ISBN': 1,
|
||||
'Code_4': 1,
|
||||
'Code4': 1,
|
||||
'ItemType': 1,
|
||||
'Verfuegbar': 1,
|
||||
'Condition': 1,
|
||||
'HasDamage': 1,
|
||||
'User': 1,
|
||||
'Ort': 1,
|
||||
'Beschreibung': 1,
|
||||
'Image': 1
|
||||
}
|
||||
|
||||
total_count = items_db.count_documents(query)
|
||||
|
||||
library_items = list(items_db.find({
|
||||
'ItemType': {'$in': ['book', 'cd', 'dvd', 'media']}
|
||||
}))
|
||||
library_items = list(
|
||||
items_db.find(query, projection)
|
||||
.sort([('Name', 1), ('_id', 1)])
|
||||
.skip(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
item_ids = [str(item.get('_id')) for item in library_items if item.get('_id')]
|
||||
active_records = []
|
||||
@@ -1394,6 +1823,8 @@ def api_library_items():
|
||||
for item in library_items:
|
||||
item_id = str(item['_id'])
|
||||
item['_id'] = item_id
|
||||
if item.get('Code4') in (None, '') and item.get('Code_4') not in (None, ''):
|
||||
item['Code4'] = item.get('Code_4')
|
||||
|
||||
condition_value = str(item.get('Condition', '')).strip().lower()
|
||||
has_damage = bool(item.get('HasDamage')) or condition_value == 'destroyed'
|
||||
@@ -1407,8 +1838,16 @@ def api_library_items():
|
||||
item['LibraryDisplayStatus'] = 'available'
|
||||
|
||||
item['BorrowedBy'] = active_user_by_item.get(item_id) or item.get('User', '')
|
||||
|
||||
return jsonify(library_items), 200
|
||||
|
||||
count = len(library_items)
|
||||
return jsonify({
|
||||
'items': library_items,
|
||||
'offset': offset,
|
||||
'limit': limit,
|
||||
'count': count,
|
||||
'total': total_count,
|
||||
'has_more': (offset + count) < total_count
|
||||
}), 200
|
||||
except Exception as e:
|
||||
print(f"Error fetching library items: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -2494,6 +2933,7 @@ def logout():
|
||||
@app.route('/get_items', methods=['GET'])
|
||||
def get_items():
|
||||
"""Return items plus merged favorites (session + DB) and per-item favorite flag."""
|
||||
client = None
|
||||
try:
|
||||
username = session.get('username')
|
||||
# Merge DB favorites into session if logged in
|
||||
@@ -2507,14 +2947,40 @@ def get_items():
|
||||
app.logger.warning(f"Could not merge DB favorites: {fav_err}")
|
||||
favorites = set(session.get('favorites', []))
|
||||
|
||||
available_only = str(request.args.get('available_only', '')).strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
offset_raw = request.args.get('offset')
|
||||
limit_raw = request.args.get('limit')
|
||||
pagination_requested = offset_raw is not None or limit_raw is not None
|
||||
|
||||
if pagination_requested:
|
||||
try:
|
||||
offset = max(0, int(offset_raw or '0'))
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
try:
|
||||
limit = int(limit_raw or '120')
|
||||
except (TypeError, ValueError):
|
||||
limit = 120
|
||||
limit = min(max(limit, 1), 500)
|
||||
else:
|
||||
offset = 0
|
||||
limit = None
|
||||
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
items_col = db['items']
|
||||
items_cur = items_col.find({
|
||||
base_query = {
|
||||
'IsGroupedSubItem': {'$ne': True},
|
||||
'ItemType': {'$nin': LIBRARY_ITEM_TYPES},
|
||||
'Deleted': {'$ne': True},
|
||||
})
|
||||
}
|
||||
|
||||
total_count = items_col.count_documents(base_query)
|
||||
|
||||
items_cur = items_col.find(base_query).sort([('Name', 1), ('_id', 1)])
|
||||
if pagination_requested:
|
||||
items_cur = items_cur.skip(offset).limit(limit)
|
||||
|
||||
items = []
|
||||
for itm in items_cur:
|
||||
item_id_str = str(itm['_id'])
|
||||
@@ -2549,11 +3015,26 @@ def get_items():
|
||||
itm['GroupedAllCodes'] = grouped_all_codes
|
||||
if grouped_count > 1:
|
||||
itm['Verfuegbar'] = len(available_units) > 0
|
||||
if available_only and not itm.get('Verfuegbar', False):
|
||||
continue
|
||||
itm['is_favorite'] = item_id_str in favorites
|
||||
items.append(itm)
|
||||
return jsonify({'items': items, 'favorites': list(favorites)})
|
||||
|
||||
count = len(items)
|
||||
return jsonify({
|
||||
'items': items,
|
||||
'favorites': list(favorites),
|
||||
'offset': offset,
|
||||
'limit': limit if limit is not None else count,
|
||||
'count': count,
|
||||
'total': total_count,
|
||||
'has_more': pagination_requested and ((offset + count) < total_count)
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'items': [], 'error': str(e)}), 500
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
|
||||
@app.route('/get_item/<id>')
|
||||
@@ -3760,6 +4241,79 @@ def duplicate_item():
|
||||
return jsonify({'success': False, 'message': 'Serverfehler beim Duplizieren'}), 500
|
||||
|
||||
|
||||
def _soft_delete_item_groups(db, root_item_ids, username):
|
||||
"""Soft-delete one or more item groups and their borrow records."""
|
||||
now = datetime.datetime.now()
|
||||
unique_group_item_ids = []
|
||||
seen_ids = set()
|
||||
|
||||
for root_item_id in root_item_ids:
|
||||
for group_item_id in it.get_group_item_ids(root_item_id):
|
||||
if group_item_id not in seen_ids:
|
||||
seen_ids.add(group_item_id)
|
||||
unique_group_item_ids.append(group_item_id)
|
||||
|
||||
if not unique_group_item_ids:
|
||||
return {
|
||||
'success': False,
|
||||
'message': 'Element nicht gefunden.',
|
||||
'soft_deleted_items': 0,
|
||||
'soft_deleted_borrows': 0,
|
||||
'group_item_ids': [],
|
||||
}
|
||||
|
||||
delete_success = True
|
||||
soft_deleted_items = 0
|
||||
|
||||
for group_item_id in unique_group_item_ids:
|
||||
result = db['items'].update_one(
|
||||
{'_id': ObjectId(group_item_id), 'Deleted': {'$ne': True}},
|
||||
{'$set': {
|
||||
'Deleted': True,
|
||||
'DeletedAt': now,
|
||||
'DeletedBy': username,
|
||||
'LastUpdated': now,
|
||||
'Verfuegbar': False,
|
||||
}}
|
||||
)
|
||||
if result.modified_count > 0:
|
||||
soft_deleted_items += 1
|
||||
elif result.matched_count == 0:
|
||||
delete_success = False
|
||||
|
||||
borrow_result = db['ausleihungen'].update_many(
|
||||
{
|
||||
'Item': {'$in': unique_group_item_ids},
|
||||
'Status': {'$ne': 'deleted'}
|
||||
},
|
||||
{'$set': {
|
||||
'Status': 'deleted',
|
||||
'DeletedAt': now,
|
||||
'DeletedBy': username,
|
||||
'LastUpdated': now,
|
||||
}}
|
||||
)
|
||||
|
||||
_append_audit_event(
|
||||
db,
|
||||
'inventory_item_soft_deleted',
|
||||
{
|
||||
'root_item_ids': root_item_ids,
|
||||
'group_item_ids': unique_group_item_ids,
|
||||
'soft_deleted_items': soft_deleted_items,
|
||||
'soft_deleted_borrow_records': borrow_result.modified_count,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
'success': delete_success,
|
||||
'soft_deleted_items': soft_deleted_items,
|
||||
'soft_deleted_borrows': borrow_result.modified_count,
|
||||
'group_item_ids': unique_group_item_ids,
|
||||
'message': 'OK' if delete_success else 'Fehler beim revisionssicheren Deaktivieren des Elements.',
|
||||
}
|
||||
|
||||
|
||||
@app.route('/delete_item/<id>', methods=['POST', 'GET'])
|
||||
def delete_item(id):
|
||||
"""
|
||||
@@ -3794,57 +4348,14 @@ def delete_item(id):
|
||||
flash('Element nicht gefunden.', 'error')
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
# GoBD hardening: keep files and records immutable, use soft-delete markers only.
|
||||
delete_success = True
|
||||
soft_deleted_items = 0
|
||||
soft_deleted_borrows = 0
|
||||
now = datetime.datetime.now()
|
||||
|
||||
client = None
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
|
||||
for group_item_id in group_item_ids:
|
||||
result = db['items'].update_one(
|
||||
{'_id': ObjectId(group_item_id), 'Deleted': {'$ne': True}},
|
||||
{'$set': {
|
||||
'Deleted': True,
|
||||
'DeletedAt': now,
|
||||
'DeletedBy': session.get('username', ''),
|
||||
'LastUpdated': now,
|
||||
'Verfuegbar': False,
|
||||
}}
|
||||
)
|
||||
if result.modified_count > 0:
|
||||
soft_deleted_items += 1
|
||||
elif result.matched_count == 0:
|
||||
delete_success = False
|
||||
|
||||
borrow_result = db['ausleihungen'].update_many(
|
||||
{
|
||||
'Item': {'$in': group_item_ids},
|
||||
'Status': {'$ne': 'deleted'}
|
||||
},
|
||||
{'$set': {
|
||||
'Status': 'deleted',
|
||||
'DeletedAt': now,
|
||||
'DeletedBy': session.get('username', ''),
|
||||
'LastUpdated': now,
|
||||
}}
|
||||
)
|
||||
soft_deleted_borrows = borrow_result.modified_count
|
||||
|
||||
_append_audit_event(
|
||||
db,
|
||||
'inventory_item_soft_deleted',
|
||||
{
|
||||
'root_item_id': id,
|
||||
'group_item_ids': group_item_ids,
|
||||
'soft_deleted_items': soft_deleted_items,
|
||||
'soft_deleted_borrow_records': soft_deleted_borrows,
|
||||
}
|
||||
)
|
||||
delete_result = _soft_delete_item_groups(db, [id], session.get('username', ''))
|
||||
delete_success = bool(delete_result.get('success'))
|
||||
soft_deleted_items = int(delete_result.get('soft_deleted_items', 0))
|
||||
soft_deleted_borrows = int(delete_result.get('soft_deleted_borrows', 0))
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error during soft-delete for item group {id}: {str(e)}")
|
||||
delete_success = False
|
||||
@@ -3860,6 +4371,52 @@ def delete_item(id):
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
|
||||
@app.route('/bulk_delete_items', methods=['POST'])
|
||||
def bulk_delete_items():
|
||||
"""Soft-delete multiple selected item groups in one request."""
|
||||
if 'username' not in session:
|
||||
return jsonify({'success': False, 'message': 'Nicht angemeldet'}), 401
|
||||
if not us.check_admin(session['username']):
|
||||
return jsonify({'success': False, 'message': 'Administratorrechte erforderlich'}), 403
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
item_ids = payload.get('item_ids') or request.form.getlist('item_ids')
|
||||
if isinstance(item_ids, str):
|
||||
item_ids = [item_ids]
|
||||
item_ids = [str(item_id).strip() for item_id in item_ids if str(item_id).strip()]
|
||||
|
||||
if not item_ids:
|
||||
return jsonify({'success': False, 'message': 'Keine Elemente ausgewählt.'}), 400
|
||||
|
||||
client = None
|
||||
try:
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
|
||||
result = _soft_delete_item_groups(db, item_ids, session.get('username', ''))
|
||||
if not result.get('success'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': result.get('message', 'Fehler beim Löschen.'),
|
||||
'deleted_items': result.get('soft_deleted_items', 0),
|
||||
'deleted_borrows': result.get('soft_deleted_borrows', 0),
|
||||
}), 400
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f"{result.get('soft_deleted_items', 0)} Elemente revisionssicher deaktiviert.",
|
||||
'deleted_items': result.get('soft_deleted_items', 0),
|
||||
'deleted_borrows': result.get('soft_deleted_borrows', 0),
|
||||
'group_item_ids': result.get('group_item_ids', []),
|
||||
})
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error during bulk delete: {str(e)}")
|
||||
return jsonify({'success': False, 'message': 'Fehler beim Sammellöschen.'}), 500
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
|
||||
@app.route('/edit_item/<id>', methods=['POST'])
|
||||
def edit_item(id):
|
||||
"""
|
||||
|
||||
+1
-1
@@ -25,7 +25,6 @@ Sammlungsstruktur:
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
from pymongo import MongoClient
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
import pytz
|
||||
@@ -34,6 +33,7 @@ import os
|
||||
import json
|
||||
import shutil
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
|
||||
# Add this helper function after imports
|
||||
def ensure_timezone_aware(dt):
|
||||
|
||||
+1
-1
@@ -26,10 +26,10 @@ Collection Structure:
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
from pymongo import MongoClient
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
|
||||
|
||||
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
|
||||
|
||||
@@ -10,4 +10,5 @@ python-dateutil
|
||||
pytz
|
||||
requests
|
||||
reportlab
|
||||
python-barcode
|
||||
python-barcode
|
||||
openpyxl
|
||||
@@ -12,6 +12,7 @@ defaults for the web application and helper modules.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from pymongo import MongoClient as _PyMongoClient
|
||||
|
||||
# Base directory of this Web package
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -173,3 +174,21 @@ if not os.path.isabs(BACKUP_FOLDER):
|
||||
BACKUP_FOLDER = os.path.join(PROJECT_ROOT, BACKUP_FOLDER)
|
||||
if not os.path.isabs(LOGS_FOLDER):
|
||||
LOGS_FOLDER = os.path.join(PROJECT_ROOT, LOGS_FOLDER)
|
||||
|
||||
|
||||
def MongoClient(*args, **kwargs):
|
||||
"""Return a lightweight MongoDB client configured from this settings module."""
|
||||
client_kwargs = {
|
||||
'maxPoolSize': 10,
|
||||
'minPoolSize': 0,
|
||||
'connectTimeoutMS': 5000,
|
||||
'serverSelectionTimeoutMS': 5000,
|
||||
'retryWrites': True,
|
||||
}
|
||||
client_kwargs.update(kwargs)
|
||||
|
||||
# Preserve caller-provided positional host/port arguments.
|
||||
# If none are passed, use configured defaults.
|
||||
if args:
|
||||
return _PyMongoClient(*args, **client_kwargs)
|
||||
return _PyMongoClient(MONGODB_HOST, MONGODB_PORT, **client_kwargs)
|
||||
|
||||
@@ -290,6 +290,20 @@
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.library-load-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.library-load-hint {
|
||||
font-size: 0.9em;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal {
|
||||
display: none;
|
||||
@@ -501,6 +515,10 @@
|
||||
<!-- 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 -->
|
||||
@@ -523,6 +541,24 @@
|
||||
// State
|
||||
let libraryItems = [];
|
||||
let filteredItems = [];
|
||||
let visibleItems = [];
|
||||
let currentSearchTerm = '';
|
||||
let activeFilters = {
|
||||
author: '',
|
||||
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;
|
||||
let scannerInstance = null;
|
||||
let scannerRunning = false;
|
||||
@@ -531,32 +567,124 @@
|
||||
let lastScanAt = 0;
|
||||
const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1');
|
||||
|
||||
// Load items
|
||||
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();
|
||||
}
|
||||
|
||||
// Load items progressively: first page immediately, rest in background
|
||||
async function loadLibraryItems() {
|
||||
if (pagingState.loading) return;
|
||||
pagingState.loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/library_items');
|
||||
if (!response.ok) throw new Error('Failed to load library items');
|
||||
libraryItems = await response.json();
|
||||
displayItems(libraryItems);
|
||||
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="6" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
|
||||
} finally {
|
||||
pagingState.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Display items in table
|
||||
function displayItems(items) {
|
||||
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);
|
||||
|
||||
// Keep the current view reactive while avoiding a full rerender burst.
|
||||
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 author = activeFilters.author;
|
||||
const isbn = activeFilters.isbn;
|
||||
const type = activeFilters.type;
|
||||
const status = activeFilters.status;
|
||||
|
||||
return items.filter(item => {
|
||||
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
|
||||
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 authorMatch && isbnMatch && typeMatch && statusMatch;
|
||||
});
|
||||
}
|
||||
|
||||
function applySearch(items) {
|
||||
if (!currentSearchTerm) return items;
|
||||
return items.filter(item =>
|
||||
(item.Name || '').toLowerCase().includes(currentSearchTerm) ||
|
||||
(item.Autor || item.Author || '').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();
|
||||
}
|
||||
|
||||
// Display current view with incremental rendering
|
||||
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 (items.length === 0) {
|
||||
if (visibleItems.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
emptyState.style.display = 'block';
|
||||
loadMoreRow.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
tbody.innerHTML = items.map(item => `
|
||||
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');
|
||||
@@ -567,7 +695,7 @@
|
||||
<tr>
|
||||
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
|
||||
<td>${escapeHtml(item.Autor || item.Author || '-')}</td>
|
||||
<td>${escapeHtml(item.ISBN || item.Code4 || '-')}</td>
|
||||
<td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td>
|
||||
<td>${getItemTypeLabel(item.ItemType || 'book')}</td>
|
||||
<td>
|
||||
<span class="table-status ${statusClass}">
|
||||
@@ -585,6 +713,18 @@
|
||||
`;
|
||||
})()}
|
||||
`).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;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter toggle
|
||||
@@ -598,21 +738,11 @@
|
||||
|
||||
// Apply filters
|
||||
document.getElementById('applyFilterBtn').addEventListener('click', () => {
|
||||
const author = document.getElementById('filterAuthor').value.toLowerCase();
|
||||
const isbn = document.getElementById('filterISBN').value.toLowerCase();
|
||||
const type = document.getElementById('filterType').value;
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
|
||||
filteredItems = libraryItems.filter(item => {
|
||||
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
|
||||
const isbnMatch = !isbn || (item.ISBN || 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 authorMatch && isbnMatch && typeMatch && statusMatch;
|
||||
});
|
||||
|
||||
displayItems(filteredItems);
|
||||
activeFilters.author = document.getElementById('filterAuthor').value.toLowerCase();
|
||||
activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
|
||||
activeFilters.type = document.getElementById('filterType').value;
|
||||
activeFilters.status = document.getElementById('filterStatus').value;
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
|
||||
// Clear filters
|
||||
@@ -621,19 +751,19 @@
|
||||
document.getElementById('filterISBN').value = '';
|
||||
document.getElementById('filterType').value = '';
|
||||
document.getElementById('filterStatus').value = '';
|
||||
displayItems(libraryItems);
|
||||
activeFilters = { author: '', isbn: '', type: '', status: '' };
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
|
||||
// Search input
|
||||
document.getElementById('librarySearch').addEventListener('input', (e) => {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
const toSearch = filteredItems.length > 0 ? filteredItems : libraryItems;
|
||||
const results = toSearch.filter(item =>
|
||||
(item.Name || '').toLowerCase().includes(searchTerm) ||
|
||||
(item.Autor || item.Author || '').toLowerCase().includes(searchTerm) ||
|
||||
(item.ISBN || item.Code4 || '').toLowerCase().includes(searchTerm)
|
||||
);
|
||||
displayItems(results);
|
||||
currentSearchTerm = (e.target.value || '').toLowerCase();
|
||||
applyFiltersAndSearch(true);
|
||||
});
|
||||
|
||||
document.getElementById('loadMoreBtn').addEventListener('click', () => {
|
||||
renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length);
|
||||
renderItems();
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
|
||||
+118
-9
@@ -724,8 +724,34 @@
|
||||
// Add this line to define allItems globally
|
||||
let allItems = [];
|
||||
|
||||
function loadItems() {
|
||||
fetch("{{ url_for('get_items') }}")
|
||||
const MAIN_ITEMS_PAGE_SIZE = 120;
|
||||
let mainItemsNextOffset = 0;
|
||||
let mainItemsHasMore = false;
|
||||
let mainItemsLoadingMore = false;
|
||||
let mainItemsObserver = null;
|
||||
let mainItemsSentinel = null;
|
||||
let mainItemsScrollPrefetchBound = false;
|
||||
|
||||
function maybePrefetchMainItems() {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (!itemsContainer || !mainItemsHasMore || mainItemsLoadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
|
||||
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
|
||||
if (distanceToEnd > prefetchThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainItemsLoadingMore = true;
|
||||
loadItems(mainItemsNextOffset, true).finally(() => {
|
||||
mainItemsLoadingMore = false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadItems(offset = 0, append = false) {
|
||||
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
@@ -735,27 +761,39 @@
|
||||
const filter3Values = new Set();
|
||||
|
||||
// Store all items data globally for filter rebuilding
|
||||
allItems = data.items || [];
|
||||
const pageItems = data.items || [];
|
||||
allItems = append ? allItems.concat(pageItems) : pageItems;
|
||||
|
||||
// Clear the container first
|
||||
itemsContainer.innerHTML = '';
|
||||
if (!append) {
|
||||
itemsContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
if (!data.items || data.items.length === 0) {
|
||||
if (!append && (!pageItems || pageItems.length === 0)) {
|
||||
itemsContainer.innerHTML = '<div class="no-items-message">Keine Objekte gefunden</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort items by name in ascending order (A-Z)
|
||||
data.items.sort((a, b) => {
|
||||
pageItems.sort((a, b) => {
|
||||
const nameA = a.Name ? a.Name.toLowerCase() : '';
|
||||
const nameB = b.Name ? b.Name.toLowerCase() : '';
|
||||
return nameA.localeCompare(nameB); // Ascending order
|
||||
});
|
||||
|
||||
allItems.forEach(itemForFilters => {
|
||||
const f1 = Array.isArray(itemForFilters.Filter) ? itemForFilters.Filter : (itemForFilters.Filter ? [itemForFilters.Filter] : []);
|
||||
const f2 = Array.isArray(itemForFilters.Filter2) ? itemForFilters.Filter2 : (itemForFilters.Filter2 ? [itemForFilters.Filter2] : []);
|
||||
const f3 = Array.isArray(itemForFilters.Filter3) ? itemForFilters.Filter3 : (itemForFilters.Filter3 ? [itemForFilters.Filter3] : []);
|
||||
f1.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter1Values.add(value); });
|
||||
f2.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter2Values.add(value); });
|
||||
f3.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter3Values.add(value); });
|
||||
});
|
||||
|
||||
const favoriteIds = new Set(data.favorites || []);
|
||||
window.currentFavorites = favoriteIds;
|
||||
try { sessionStorage.setItem('favoritesCache', JSON.stringify(Array.from(favoriteIds))); } catch(e){}
|
||||
data.items.forEach(item => {
|
||||
pageItems.forEach(item => {
|
||||
try {
|
||||
const card = document.createElement('div');
|
||||
card.classList.add('item-card');
|
||||
@@ -995,8 +1033,10 @@
|
||||
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
|
||||
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
|
||||
|
||||
// Start display from leftmost position (first card, which is now Z)
|
||||
itemsContainer.scrollLeft = 0;
|
||||
// Start display from leftmost position on full reload only.
|
||||
if (!append) {
|
||||
itemsContainer.scrollLeft = 0;
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
applyFilters();
|
||||
@@ -1011,15 +1051,84 @@
|
||||
if (activeFilters.filter1.length > 0 || activeFilters.filter2.length > 0) {
|
||||
rebuildFilter3Options();
|
||||
}
|
||||
|
||||
mainItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
|
||||
mainItemsHasMore = Boolean(data.has_more);
|
||||
setupMainItemsLazyLoading();
|
||||
maybePrefetchMainItems();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching items:', error);
|
||||
if (append) {
|
||||
return;
|
||||
}
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
itemsContainer.innerHTML =
|
||||
'<div class="error-message">Fehler beim Laden der Objekte. Bitte versuchen Sie es später erneut.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function ensureMainItemsSentinel() {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (!itemsContainer) return null;
|
||||
|
||||
if (!mainItemsSentinel) {
|
||||
mainItemsSentinel = document.createElement('div');
|
||||
mainItemsSentinel.id = 'main-items-sentinel';
|
||||
mainItemsSentinel.style.flex = '0 0 1px';
|
||||
mainItemsSentinel.style.width = '1px';
|
||||
mainItemsSentinel.style.minWidth = '1px';
|
||||
mainItemsSentinel.style.height = '1px';
|
||||
mainItemsSentinel.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
if (!mainItemsSentinel.parentNode) {
|
||||
itemsContainer.appendChild(mainItemsSentinel);
|
||||
} else {
|
||||
itemsContainer.appendChild(mainItemsSentinel);
|
||||
}
|
||||
|
||||
return itemsContainer;
|
||||
}
|
||||
|
||||
function setupMainItemsLazyLoading() {
|
||||
const itemsContainer = ensureMainItemsSentinel();
|
||||
if (!itemsContainer) return;
|
||||
|
||||
if (mainItemsObserver) {
|
||||
mainItemsObserver.disconnect();
|
||||
mainItemsObserver = null;
|
||||
}
|
||||
|
||||
if (!mainItemsScrollPrefetchBound) {
|
||||
itemsContainer.addEventListener('scroll', maybePrefetchMainItems, { passive: true });
|
||||
mainItemsScrollPrefetchBound = true;
|
||||
}
|
||||
|
||||
if (!mainItemsHasMore) return;
|
||||
|
||||
mainItemsObserver = new IntersectionObserver(entries => {
|
||||
if (!entries.some(entry => entry.isIntersecting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainItemsLoadingMore || !mainItemsHasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainItemsLoadingMore = true;
|
||||
loadItems(mainItemsNextOffset, true).finally(() => {
|
||||
mainItemsLoadingMore = false;
|
||||
});
|
||||
}, {
|
||||
root: itemsContainer,
|
||||
threshold: 0.15,
|
||||
rootMargin: '0px 900px 0px 0px'
|
||||
});
|
||||
|
||||
mainItemsObserver.observe(mainItemsSentinel);
|
||||
}
|
||||
|
||||
function searchByCode() {
|
||||
const searchInput = document.getElementById('code-search');
|
||||
const rawSearchTerm = searchInput.value;
|
||||
|
||||
@@ -26,6 +26,106 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bulk-delete-inline-wrap {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.bulk-delete-drawer-toggle {
|
||||
position: static;
|
||||
transform: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.bulk-delete-drawer-toggle .drawer-icon {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.bulk-delete-drawer-toggle:hover {
|
||||
box-shadow: 0 10px 20px rgba(13, 110, 253, 0.3);
|
||||
}
|
||||
|
||||
.bulk-delete-drawer-toggle:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.bulk-delete-drawer {
|
||||
display: none;
|
||||
width: 100%;
|
||||
max-width: 980px;
|
||||
margin: 0 auto 18px;
|
||||
border: 1px solid #dbe3ee;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.bulk-delete-drawer.open {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bulk-delete-content {
|
||||
flex: 1 1 380px;
|
||||
}
|
||||
|
||||
.bulk-delete-drawer strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.bulk-delete-summary {
|
||||
color: #334155;
|
||||
font-size: 0.92rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bulk-delete-drawer small {
|
||||
color: #64748b;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.bulk-delete-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
align-items: center;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.bulk-delete-actions button {
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bulk-delete-actions button:hover {
|
||||
background: #eef4ff;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.bulk-delete-actions .danger {
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
border-color: #c82333;
|
||||
}
|
||||
|
||||
.bulk-delete-actions .danger:hover {
|
||||
background: #c82333;
|
||||
border-color: #b21f2d;
|
||||
}
|
||||
|
||||
/* Filter styles */
|
||||
.filter-container {
|
||||
display: flex;
|
||||
@@ -237,6 +337,12 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-card.bulk-selected {
|
||||
border-color: #dc3545;
|
||||
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.18), 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
background: #fffdfd;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||
@@ -257,6 +363,45 @@
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.bulk-delete-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bulk-delete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bulk-delete-toggle input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #dc3545;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bulk-delete-drawer.open {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bulk-delete-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bulk-delete-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.item-card .item-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
@@ -2109,9 +2254,26 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<button id="toggle-favorites-view" class="view-toggle-btn" title="Nur Merkliste anzeigen">
|
||||
<span id="favorites-view-icon">🔖</span>
|
||||
</button>
|
||||
<div class="bulk-delete-inline-wrap">
|
||||
<button type="button" id="bulk-delete-drawer-toggle" class="view-toggle-btn bulk-delete-drawer-toggle" aria-expanded="false" title="Massenlöschung" onclick="toggleBulkDeleteDrawer()">
|
||||
<span class="drawer-icon">⚙</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</h1>
|
||||
<div id="items-indicator" class="items-indicator">Objekte im System: 0</div>
|
||||
<div id="bulk-delete-drawer" class="bulk-delete-drawer" aria-hidden="true">
|
||||
<div class="bulk-delete-content">
|
||||
<strong>Massenlöschung nach Kriterien</strong>
|
||||
<div class="bulk-delete-summary" id="bulk-delete-summary">0 Elemente ausgewählt</div>
|
||||
<small>Filter zuerst setzen, dann alle sichtbaren Elemente markieren oder einzelne Karten per Checkbox auswählen.</small>
|
||||
</div>
|
||||
<div class="bulk-delete-actions">
|
||||
<button type="button" onclick="selectVisibleItems()">Alle sichtbaren auswählen</button>
|
||||
<button type="button" onclick="clearBulkSelection()">Auswahl leeren</button>
|
||||
<button type="button" id="bulk-delete-button" class="danger" onclick="deleteSelectedItems()" disabled>Auswahl löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-container">
|
||||
<div class="filter-group">
|
||||
<div class="filter-header">
|
||||
@@ -2492,6 +2654,8 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
let descSearchIds = null; // Set of matching IDs or null when disabled
|
||||
let currentUsername = '';
|
||||
let allItems = []; // Cache for all items
|
||||
let bulkDeleteSelection = new Set();
|
||||
let bulkDeleteDrawerOpen = false;
|
||||
const studentCardsModuleEnabled = {{ 'true' if student_cards_module_enabled else 'false' }};
|
||||
const studentDefaultBorrowDays = {{ student_default_borrow_days|default(14) }};
|
||||
const studentMaxBorrowDays = {{ student_max_borrow_days|default(365) }};
|
||||
@@ -2822,6 +2986,134 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
filter3: []
|
||||
};
|
||||
}
|
||||
|
||||
function updateBulkDeleteSummary() {
|
||||
const summary = document.getElementById('bulk-delete-summary');
|
||||
const button = document.getElementById('bulk-delete-button');
|
||||
const count = bulkDeleteSelection.size;
|
||||
|
||||
if (summary) {
|
||||
summary.textContent = `${count} ${count === 1 ? 'Element' : 'Elemente'} ausgewählt`;
|
||||
}
|
||||
if (button) {
|
||||
button.disabled = count === 0;
|
||||
button.textContent = count === 0 ? 'Auswahl löschen' : `Auswahl löschen (${count})`;
|
||||
}
|
||||
}
|
||||
|
||||
function setBulkDeleteDrawer(open) {
|
||||
bulkDeleteDrawerOpen = Boolean(open);
|
||||
const drawer = document.getElementById('bulk-delete-drawer');
|
||||
const toggle = document.getElementById('bulk-delete-drawer-toggle');
|
||||
if (drawer) {
|
||||
drawer.classList.toggle('open', bulkDeleteDrawerOpen);
|
||||
drawer.setAttribute('aria-hidden', bulkDeleteDrawerOpen ? 'false' : 'true');
|
||||
}
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', bulkDeleteDrawerOpen ? 'true' : 'false');
|
||||
toggle.innerHTML = bulkDeleteDrawerOpen ? '<span class="drawer-icon">✕</span>' : '<span class="drawer-icon">⚙</span>';
|
||||
toggle.title = bulkDeleteDrawerOpen ? 'Massenlöschung schließen' : 'Massenlöschung öffnen';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBulkDeleteDrawer() {
|
||||
setBulkDeleteDrawer(!bulkDeleteDrawerOpen);
|
||||
}
|
||||
|
||||
function setBulkDeleteSelection(itemId, checked) {
|
||||
const normalizedId = String(itemId || '').trim();
|
||||
if (!normalizedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (checked) {
|
||||
bulkDeleteSelection.add(normalizedId);
|
||||
} else {
|
||||
bulkDeleteSelection.delete(normalizedId);
|
||||
}
|
||||
|
||||
document.querySelectorAll(`.item-card[data-item-id='${normalizedId}']`).forEach(card => {
|
||||
card.classList.toggle('bulk-selected', checked);
|
||||
const checkbox = card.querySelector('.bulk-delete-checkbox');
|
||||
if (checkbox && checkbox.checked !== checked) {
|
||||
checkbox.checked = checked;
|
||||
}
|
||||
});
|
||||
|
||||
updateBulkDeleteSummary();
|
||||
}
|
||||
|
||||
function selectVisibleItems() {
|
||||
document.querySelectorAll('.item-card').forEach(card => {
|
||||
if (card.style.display === 'none') {
|
||||
return;
|
||||
}
|
||||
const itemId = String(card.dataset.itemId || '').trim();
|
||||
if (!itemId) {
|
||||
return;
|
||||
}
|
||||
bulkDeleteSelection.add(itemId);
|
||||
card.classList.add('bulk-selected');
|
||||
const checkbox = card.querySelector('.bulk-delete-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
updateBulkDeleteSummary();
|
||||
}
|
||||
|
||||
function clearBulkSelection() {
|
||||
bulkDeleteSelection.clear();
|
||||
document.querySelectorAll('.item-card.bulk-selected').forEach(card => {
|
||||
card.classList.remove('bulk-selected');
|
||||
const checkbox = card.querySelector('.bulk-delete-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
});
|
||||
|
||||
updateBulkDeleteSummary();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape' && bulkDeleteDrawerOpen) {
|
||||
setBulkDeleteDrawer(false);
|
||||
}
|
||||
});
|
||||
|
||||
function deleteSelectedItems() {
|
||||
const selectedIds = [...bulkDeleteSelection];
|
||||
if (selectedIds.length === 0) {
|
||||
alert('Bitte zuerst mindestens ein Element auswählen.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Wirklich ${selectedIds.length} ausgewählte Elemente revisionssicher deaktivieren?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ url_for('bulk_delete_items') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ item_ids: selectedIds })
|
||||
})
|
||||
.then(async response => {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Fehler beim Sammellöschen.');
|
||||
}
|
||||
clearBulkSelection();
|
||||
alert(data.message || 'Auswahl gelöscht.');
|
||||
loadItems();
|
||||
})
|
||||
.catch(error => {
|
||||
alert(error.message || 'Fehler beim Sammellöschen.');
|
||||
});
|
||||
}
|
||||
|
||||
// Load predefined filter values for dropdowns
|
||||
function loadPredefinedFilterValues(filterNumber) {
|
||||
@@ -2963,8 +3255,34 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
});
|
||||
|
||||
// Function to load items from server
|
||||
function loadItems() {
|
||||
fetch("{{ url_for('get_items') }}")
|
||||
const MAIN_ADMIN_ITEMS_PAGE_SIZE = 120;
|
||||
let mainAdminItemsNextOffset = 0;
|
||||
let mainAdminItemsHasMore = false;
|
||||
let mainAdminItemsLoadingMore = false;
|
||||
let mainAdminItemsObserver = null;
|
||||
let mainAdminItemsSentinel = null;
|
||||
let mainAdminItemsScrollPrefetchBound = false;
|
||||
|
||||
function maybePrefetchMainAdminItems() {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (!itemsContainer || !mainAdminItemsHasMore || mainAdminItemsLoadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceToEnd = itemsContainer.scrollWidth - (itemsContainer.scrollLeft + itemsContainer.clientWidth);
|
||||
const prefetchThreshold = Math.max(260, itemsContainer.clientWidth * 1.4);
|
||||
if (distanceToEnd > prefetchThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainAdminItemsLoadingMore = true;
|
||||
loadItems(mainAdminItemsNextOffset, true).finally(() => {
|
||||
mainAdminItemsLoadingMore = false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadItems(offset = 0, append = false) {
|
||||
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
@@ -2974,9 +3292,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const filter1Values = new Set();
|
||||
const filter2Values = new Set();
|
||||
const filter3Values = new Set();
|
||||
allItems = data.items || [];
|
||||
itemsContainer.innerHTML = '';
|
||||
if (!data.items || data.items.length === 0) {
|
||||
const pageItems = data.items || [];
|
||||
allItems = append ? allItems.concat(pageItems) : pageItems;
|
||||
if (!append) {
|
||||
itemsContainer.innerHTML = '';
|
||||
}
|
||||
if (!append && (!pageItems || pageItems.length === 0)) {
|
||||
itemsContainer.innerHTML = `
|
||||
<div class="no-items-message">
|
||||
<div>Keine Objekte gefunden</div>
|
||||
@@ -2989,15 +3310,27 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
return;
|
||||
}
|
||||
if (itemsIndicator) {
|
||||
itemsIndicator.textContent = `Objekte im System: ${data.items.length}`;
|
||||
const shownCount = allItems.length;
|
||||
const totalCount = Number(data.total || shownCount);
|
||||
itemsIndicator.textContent = `Objekte im System: ${shownCount}${data.has_more ? ` / ${totalCount} (lädt...)` : ''}`;
|
||||
}
|
||||
data.items.sort((a, b) => {
|
||||
pageItems.sort((a, b) => {
|
||||
const nameA = a.Name ? a.Name.toLowerCase() : '';
|
||||
const nameB = a.Name ? a.Name.toLowerCase() : '';
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
allItems.forEach(itemForFilters => {
|
||||
const f1 = Array.isArray(itemForFilters.Filter) ? itemForFilters.Filter : (itemForFilters.Filter ? [itemForFilters.Filter] : []);
|
||||
const f2 = Array.isArray(itemForFilters.Filter2) ? itemForFilters.Filter2 : (itemForFilters.Filter2 ? [itemForFilters.Filter2] : []);
|
||||
const f3 = Array.isArray(itemForFilters.Filter3) ? itemForFilters.Filter3 : (itemForFilters.Filter3 ? [itemForFilters.Filter3] : []);
|
||||
f1.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter1Values.add(value); });
|
||||
f2.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter2Values.add(value); });
|
||||
f3.forEach(value => { if (value && typeof value === 'string' && value.trim() !== '') filter3Values.add(value); });
|
||||
});
|
||||
|
||||
const favoriteIds = new Set(data.favorites || []);
|
||||
data.items.forEach(item => {
|
||||
pageItems.forEach(item => {
|
||||
try {
|
||||
const card = document.createElement('div');
|
||||
card.classList.add('item-card');
|
||||
@@ -3148,6 +3481,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
${appointmentBadge}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="bulk-delete-row">
|
||||
<label class="bulk-delete-toggle">
|
||||
<input type="checkbox" class="bulk-delete-checkbox" data-item-id="${item._id}" ${bulkDeleteSelection.has(String(item._id)) ? 'checked' : ''} onchange="setBulkDeleteSelection('${item._id}', this.checked)">
|
||||
Für Löschung markieren
|
||||
</label>
|
||||
</div>
|
||||
${isAvailableForBorrow && !item.BlockedNow ?
|
||||
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}">
|
||||
${isGroupedItem ? `
|
||||
@@ -3223,8 +3562,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
|
||||
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
|
||||
|
||||
itemsContainer.scrollLeft = 0;
|
||||
if (!append) {
|
||||
itemsContainer.scrollLeft = 0;
|
||||
}
|
||||
applyFilters();
|
||||
updateBulkDeleteSummary();
|
||||
|
||||
// Setup proper image display for all cards
|
||||
setupCardImagesDisplay();
|
||||
@@ -3237,9 +3579,17 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
rebuildFilter3Options();
|
||||
}
|
||||
/* favorites render count removed */
|
||||
|
||||
mainAdminItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
|
||||
mainAdminItemsHasMore = Boolean(data.has_more);
|
||||
setupMainAdminItemsLazyLoading();
|
||||
maybePrefetchMainAdminItems();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching items:', error);
|
||||
if (append) {
|
||||
return;
|
||||
}
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
const itemsIndicator = document.getElementById('items-indicator');
|
||||
itemsContainer.innerHTML =
|
||||
@@ -3250,6 +3600,62 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
});
|
||||
}
|
||||
|
||||
function ensureMainAdminItemsSentinel() {
|
||||
const itemsContainer = document.querySelector('#items-container');
|
||||
if (!itemsContainer) return null;
|
||||
|
||||
if (!mainAdminItemsSentinel) {
|
||||
mainAdminItemsSentinel = document.createElement('div');
|
||||
mainAdminItemsSentinel.id = 'main-admin-items-sentinel';
|
||||
mainAdminItemsSentinel.style.flex = '0 0 1px';
|
||||
mainAdminItemsSentinel.style.width = '1px';
|
||||
mainAdminItemsSentinel.style.minWidth = '1px';
|
||||
mainAdminItemsSentinel.style.height = '1px';
|
||||
mainAdminItemsSentinel.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
itemsContainer.appendChild(mainAdminItemsSentinel);
|
||||
return itemsContainer;
|
||||
}
|
||||
|
||||
function setupMainAdminItemsLazyLoading() {
|
||||
const itemsContainer = ensureMainAdminItemsSentinel();
|
||||
if (!itemsContainer) return;
|
||||
|
||||
if (mainAdminItemsObserver) {
|
||||
mainAdminItemsObserver.disconnect();
|
||||
mainAdminItemsObserver = null;
|
||||
}
|
||||
|
||||
if (!mainAdminItemsScrollPrefetchBound) {
|
||||
itemsContainer.addEventListener('scroll', maybePrefetchMainAdminItems, { passive: true });
|
||||
mainAdminItemsScrollPrefetchBound = true;
|
||||
}
|
||||
|
||||
if (!mainAdminItemsHasMore) return;
|
||||
|
||||
mainAdminItemsObserver = new IntersectionObserver(entries => {
|
||||
if (!entries.some(entry => entry.isIntersecting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainAdminItemsLoadingMore || !mainAdminItemsHasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainAdminItemsLoadingMore = true;
|
||||
loadItems(mainAdminItemsNextOffset, true).finally(() => {
|
||||
mainAdminItemsLoadingMore = false;
|
||||
});
|
||||
}, {
|
||||
root: itemsContainer,
|
||||
threshold: 0.15,
|
||||
rootMargin: '0px 900px 0px 0px'
|
||||
});
|
||||
|
||||
mainAdminItemsObserver.observe(mainAdminItemsSentinel);
|
||||
}
|
||||
|
||||
function searchByCode() {
|
||||
const searchInput = document.getElementById('code-search');
|
||||
const rawSearchTerm = searchInput.value;
|
||||
|
||||
@@ -710,6 +710,28 @@
|
||||
|
||||
<div class="upload-container">
|
||||
<a href="{{ url_for(back_target|default('home_admin')) }}" class="nav-back-button">← Zurück zur Artikelübersicht</a>
|
||||
|
||||
{% if show_library_features %}
|
||||
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
|
||||
<h3 style="margin:0 0 8px 0;">Excel-Import Bibliothek (Mehrere Bücher)</h3>
|
||||
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p>
|
||||
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||||
<input type="file" name="library_excel" accept=".xlsx" required>
|
||||
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
|
||||
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Bibliothek importieren</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
|
||||
<h3 style="margin:0 0 8px 0;">Excel-Import Inventar (Mehrere Artikel)</h3>
|
||||
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p>
|
||||
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||||
<input type="file" name="inventory_excel" accept=".xlsx" required>
|
||||
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
|
||||
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="upload-form">
|
||||
<h1>{{ page_title|default('Artikel hochladen') }}</h1>
|
||||
|
||||
+1
-1
@@ -10,10 +10,10 @@ Provides methods for creating, validating, and retrieving user information.
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
from pymongo import MongoClient
|
||||
import hashlib
|
||||
from bson.objectid import ObjectId
|
||||
import settings as cfg
|
||||
from settings import MongoClient
|
||||
|
||||
|
||||
def normalize_student_card_id(card_id):
|
||||
|
||||
+2
-1
@@ -9,4 +9,5 @@ python-dateutil
|
||||
pytz
|
||||
requests
|
||||
reportlab
|
||||
python-barcode
|
||||
python-barcode
|
||||
openpyxl
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user