Compare commits

..

6 Commits

11 changed files with 836 additions and 49 deletions
Binary file not shown.
+511 -47
View File
@@ -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):
"""
@@ -3760,6 +4148,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 +4255,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 +4278,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):
"""
+2 -1
View File
@@ -10,4 +10,5 @@ python-dateutil
pytz
requests
reportlab
python-barcode
python-barcode
openpyxl
+299
View File
@@ -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) {
@@ -3148,6 +3440,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 ? `
@@ -3225,6 +3523,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
itemsContainer.scrollLeft = 0;
applyFilters();
updateBulkDeleteSummary();
// Setup proper image display for all cards
setupCardImagesDisplay();
+22
View File
@@ -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>
+2 -1
View File
@@ -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.