Compare commits

...

7 Commits

Author SHA1 Message Date
Aiirondev_dev f0b112b34c Klass implementation
Release Inventarsystem / release-docker (push) Successful in 2m19s
2026-08-21 13:20:34 +02:00
Aiirondev_dev 01637c31ee feat(library): add class filtering and sorting to library loans admin view
Release Inventarsystem / release-docker (push) Successful in 2m17s
- Add class filter dropdown and class column to borrowings table
- Implement interactive client-side column sorting for tables
- Update backend route to resolve and map user class information
2026-08-21 12:32:53 +02:00
Aiirondev_dev 52b2628adb Miss of an damage_unset
Release Inventarsystem / release-docker (push) Successful in 2m15s
2026-08-21 12:08:21 +02:00
Aiirondev_dev a51c4c4cbe fix: resolve item repair logic and clear mongo damage flags
Release Inventarsystem / release-docker (push) Successful in 2m20s
- Fixed incorrect form field key (`action_type` -> `action`) in admin route.
- Switched to MongoDB `$unset` to completely remove damage fields (`HasDamage`, `DamageText`, etc.) from the items collection instead of just setting them to false.
- Added cleanup logic to `$unset` damage flags from related active loans in the `borrowings` collection.
- Ensures repaired/replaced items correctly disappear from the damaged items UI.
2026-08-21 11:59:50 +02:00
Aiirondev_dev e4b27c2d62 Fix of a naming error in the repair resolver
Release Inventarsystem / release-docker (push) Successful in 2m17s
2026-08-21 11:48:09 +02:00
Aiirondev_dev bf85d406ac Fix of a naming error
Release Inventarsystem / release-docker (push) Successful in 3m11s
2026-08-21 11:02:21 +02:00
Aiirondev_dev 06e0c6dfb7 Fix of an redirect
Release Inventarsystem / release-docker (push) Successful in 3m8s
2026-08-20 23:12:43 +02:00
2 changed files with 242 additions and 53 deletions
+123 -33
View File
@@ -3312,13 +3312,13 @@ def library_view():
def library_loans_admin():
"""Admin overview for library borrowings and damaged library items."""
if 'username' not in session:
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!','error')
return redirect(url_for('login'))
current_permissions = us.get_effective_permissions(session['username'])
if not current_permissions['pages'].get('library_loans_admin', False):
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!','error')
return redirect(url_for('library_view'))
if not cfg.MODULES.is_enabled('library'):
@@ -3336,16 +3336,28 @@ def library_loans_admin():
def fmt_money(value):
return _format_money_value(value)
def safe_decrypt(val):
"""Helper to decrypt strings that start with enc:: or standard encrypted strings."""
if not val or not isinstance(val, str):
return val or ''
try:
return decrypt_text(val)
except Exception:
return val
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
ausleihungen_col = db['ausleihungen']
users_col = db['users']
student_cards_col = db['student_cards']
library_items = list(items_col.find(
{'ItemType': {'$in': LIBRARY_ITEM_TYPES}, 'Deleted': {'$ne': True}},
{'Name': 1, 'Code_4': 1, 'Anschaffungskosten': 1, 'Condition': 1, 'HasDamage': 1, 'DamageReports': 1, 'Verfuegbar': 1, 'User': 1, 'ItemType': 1, 'Author': 1, 'ISBN': 1}
{'Name': 1, 'Code_4': 1, 'Anschaffungskosten': 1, 'Condition': 1, 'HasDamage': 1, 'DamageReports': 1,
'Verfuegbar': 1, 'User': 1, 'ItemType': 1, 'Author': 1, 'ISBN': 1}
))
item_map = {str(item['_id']): item for item in library_items if item.get('_id')}
item_ids = list(item_map.keys())
@@ -3354,27 +3366,78 @@ def library_loans_admin():
if item_ids:
active_records = list(ausleihungen_col.find(
{'Item': {'$in': item_ids}, 'Status': {'$in': ['active', 'planned', 'completed']}},
{'User': 1, 'Item': 1, 'Status': 1, 'Start': 1, 'End': 1, 'Period': 1, 'Notes': 1, 'InvoiceData': 1}
{'User': 1, 'Item': 1, 'Status': 1, 'Start': 1, 'End': 1, 'Period': 1, 'Notes': 1, 'InvoiceData': 1,
'Klasse': 1, 'klasse': 1, 'Class': 1, 'school_class': 1}
).sort('Start', -1))
class_map = {}
student_cards = list(student_cards_col.find({}, {'SchülerName': 1, 'Klasse': 1, 'AusweisId': 1}))
for sc in student_cards:
dec_cls = safe_decrypt(sc.get('Klasse'))
if not dec_cls:
continue
dec_name = safe_decrypt(sc.get('SchülerName'))
raw_name = sc.get('SchülerName')
ausweis_id = sc.get('AusweisId')
if dec_name:
class_map[dec_name.strip().lower()] = dec_cls
class_map[dec_name] = dec_cls
if raw_name:
class_map[raw_name] = dec_cls
if ausweis_id:
class_map[str(ausweis_id).strip().lower()] = dec_cls
users = list(users_col.find({}, {'Username': 1, 'Name': 1, 'User': 1, 'Klasse': 1, 'klasse': 1, 'Class': 1,
'school_class': 1}))
for u in users:
raw_cls = u.get('Klasse') or u.get('klasse') or u.get('Class') or u.get('school_class') or ''
dec_cls = safe_decrypt(raw_cls)
if not dec_cls:
continue
for key in [u.get('Username'), u.get('Name'), u.get('User')]:
if key:
dec_key = safe_decrypt(key)
class_map[key] = dec_cls
class_map[dec_key] = dec_cls
class_map[dec_key.strip().lower()] = dec_cls
active_item_ids = set()
loan_entries = []
for record in active_records:
item_id = str(record.get('Item') or '')
item_doc = item_map.get(item_id)
if item_id and record.get('Status') == 'active':
active_item_ids.add(item_id)
item_doc = item_map.get(item_id)
if not item_doc:
continue
invoice_data = record.get('InvoiceData') or {}
condition_value = str(item_doc.get('Condition', '')).strip().lower()
item_has_damage = bool(item_doc.get('HasDamage')) or condition_value == 'destroyed' or bool(item_doc.get('DamageReports'))
item_has_damage = bool(item_doc.get('HasDamage')) or condition_value == 'destroyed' or bool(
item_doc.get('DamageReports'))
damage_reports = item_doc.get('DamageReports', []) or []
raw_user = record.get('User', '')
decrypted_user = decrypt_text(raw_user) if raw_user else ''
decrypted_user = safe_decrypt(raw_user)
direct_class = (
safe_decrypt(record.get('Klasse'))
or safe_decrypt(record.get('klasse'))
or safe_decrypt(record.get('Class'))
or safe_decrypt(record.get('school_class'))
)
user_class = (
direct_class
or class_map.get(decrypted_user)
or class_map.get(decrypted_user.strip().lower())
or class_map.get(raw_user)
or ''
)
loan_entries.append({
'id': str(record.get('_id')),
@@ -3384,7 +3447,8 @@ def library_loans_admin():
'item_author': item_doc.get('Author', ''),
'item_isbn': item_doc.get('ISBN', ''),
'item_cost_raw': item_doc.get('Anschaffungskosten', ''),
'user': decrypted_user,
'user': decrypted_user,
'klasse': user_class,
'status': record.get('Status', ''),
'start': fmt_dt(record.get('Start')),
'end': fmt_dt(record.get('End')),
@@ -9132,23 +9196,25 @@ def admin_resolve_repair(item_id):
flash('Ihnen fehlen die nötigen Berechtigungen für diese Aktion.', 'error')
return redirect(url_for('home_admin'))
action = request.form.get('action_type', 'repair')
action = request.form.get('action', 'repair')
new_code_4 = request.form.get('new_code_4', '')
current_user = session.get('username', 'admin')
success, message = resolve_repaired_item(item_id, action, new_code_4, current_user)
success, message = resolve_repaired_item_funct(item_id, action, new_code_4, current_user)
if success:
flash(message, 'success')
else:
flash(message, 'error')
return redirect(url_for('library_inventory_admin'))
return redirect(url_for('library_loans_admin'))
def resolve_repaired_item(item_id, action, new_code_4="", current_user="admin"):
def resolve_repaired_item_funct(item_id, action, new_code_4="", current_user="admin"):
"""
Verarbeitet Items aus der Reparatur und aktualisiert die Serien-Counts
sowie Parent/Child-Abhängigkeiten beim Löschen korrekt.
Entfernt Schadens-Flags komplett aus Items und zugehörigen Ausleihen.
"""
if not ObjectId.is_valid(item_id):
return False, "Ungültige Item-ID."
@@ -9158,12 +9224,27 @@ def resolve_repaired_item(item_id, action, new_code_4="", current_user="admin"):
db = client[cfg.MONGODB_DB]
items = db['items']
borrowings = db['borrowings'] if 'borrowings' in db.list_collection_names() else None
item = items.find_one({'_id': ObjectId(item_id)})
if not item:
return False, "Item nicht in der Datenbank gefunden."
series_group_id = item.get('SeriesGroupId')
damage_unset = {
'HasDamage': "",
'has_damage': "",
'DamageCount': "",
'damage_count': "",
'DamageText': "",
'damage_text': "",
'is_damaged': "",
'IsDamaged': "",
'Condition': "",
'DamageReports': ""
}
# --- OPTION 1: DELETE ---
if action == 'delete':
is_parent = not item.get('IsGroupedSubItem')
@@ -9178,7 +9259,6 @@ def resolve_repaired_item(item_id, action, new_code_4="", current_user="admin"):
if sibling:
new_parent_id_str = str(sibling['_id'])
# 1. Mache das Geschwister-Item zum neuen Parent
items.update_one(
{'_id': sibling['_id']},
{'$set': {
@@ -9187,7 +9267,6 @@ def resolve_repaired_item(item_id, action, new_code_4="", current_user="admin"):
}}
)
# 2. Leite alle anderen verbleibenden Sub-Items auf den neuen Parent um
items.update_many(
{
'SeriesGroupId': series_group_id,
@@ -9196,17 +9275,8 @@ def resolve_repaired_item(item_id, action, new_code_4="", current_user="admin"):
{'$set': {'ParentItemId': new_parent_id_str}}
)
# Physisch aus der Datenbank löschen (Hard Delete)
items.delete_one({'_id': ObjectId(item_id)})
# ALTERNATIVE (Soft Delete have to see if it makes sense):
# now = datetime.datetime.now()
# items.update_one(
# {'_id': ObjectId(item_id)},
# {'$set': {'Deleted': True, 'DeletedAt': now, 'DeletedBy': current_user}}
# )
# SeriesCount bei ALLEN verbleibenden Items dieser Serie um 1 reduzieren
if series_group_id:
items.update_many(
{'SeriesGroupId': series_group_id},
@@ -9219,23 +9289,43 @@ def resolve_repaired_item(item_id, action, new_code_4="", current_user="admin"):
elif action == 'replace':
items.update_one(
{'_id': ObjectId(item_id)},
{'$set': {
'Code_4': str(new_code_4).strip(),
'Verfuegbar': True,
'LastUpdated': datetime.datetime.now()
}}
{
'$set': {
'Code_4': str(new_code_4).strip(),
'Verfuegbar': True,
'LastUpdated': datetime.datetime.now()
},
'$unset': damage_unset
}
)
if borrowings is not None:
borrowings.update_many(
{'$or': [{'item_id': str(item_id)}, {'item_id': ObjectId(item_id)}]},
{'$unset': damage_unset}
)
return True, f"Item wurde ersetzt. Neuer Code 4 zugewiesen: {new_code_4}."
# --- OPTION 3: JUST REPAIR ---
elif action == 'repair':
items.update_one(
{'_id': ObjectId(item_id)},
{'$set': {
'Verfuegbar': True,
'LastUpdated': datetime.datetime.now()
}}
{
'$set': {
'Verfuegbar': True,
'LastUpdated': datetime.datetime.now()
},
'$unset': damage_unset
}
)
if borrowings is not None:
borrowings.update_many(
{'$or': [{'item_id': str(item_id)}, {'item_id': ObjectId(item_id)}]},
{'$unset': damage_unset}
)
return True, "Item wurde repariert und ist wieder regulär verfügbar."
else:
+119 -20
View File
@@ -72,9 +72,10 @@
color: #1f2937;
}
/* Angepasst für 4 Filter-Felder statt 3 */
.filter-bar {
display: grid;
grid-template-columns: 1fr 220px 220px;
grid-template-columns: 1fr 140px 180px 180px;
gap: 10px;
margin-bottom: 16px;
}
@@ -121,6 +122,7 @@
letter-spacing: 0.04em;
color: #64748b;
background: var(--ui-surface-soft);
user-select: none; /* Verhindert Textmarkierung beim Klicken */
}
.library-table tr:hover td {
@@ -149,6 +151,7 @@
.badge-open { background: #fee2e2; color: #991b1b; }
.badge-paid { background: #dcfce7; color: #166534; }
.badge-damaged { background: #fee2e2; color: #991b1b; }
.badge-class { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; } /* Neues Badge für Klasse */
.row-actions {
display: flex;
@@ -222,15 +225,30 @@
</div>
<div class="filter-bar">
<input id="library-search" type="text" placeholder="Nach Element, Benutzer, Ausweis oder Rechnung suchen...">
<input id="library-search" type="text" placeholder="Nach Element, Benutzer, Klasse, Ausweis oder Rechnung suchen...">
<!-- NEU: Klassen-Filter -->
<select id="class-filter">
<option value="all">Alle Klassen</option>
{% set classes = [] %}
{% for e in loan_entries %}
{% if e.klasse and e.klasse not in classes %}
{% set _ = classes.append(e.klasse) %}
{% endif %}
{% endfor %}
{% for c in classes|sort %}
<option value="{{ c|lower }}">{{ c }}</option>
{% endfor %}
</select>
<select id="loan-status-filter">
<option value="">Alle Ausleihen</option>
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="planned">Geplant</option>
<option value="completed">Abgeschlossen</option>
</select>
<select id="damage-filter">
<option value="all">Alle Einträge</option>
<option value="all">Alle Zustände</option>
<option value="damage">Nur defekt</option>
<option value="clean">Nur ohne Schaden</option>
</select>
@@ -242,18 +260,31 @@
<table class="library-table" id="loans-table">
<thead>
<tr>
<th>Status</th>
<th>Element</th>
<th>Benutzer</th>
<th>Zeit</th>
<th>Rechnung</th>
<th>Schaden</th>
<!-- NEU: onClick Handler zum Sortieren hinzugefügt -->
<th style="cursor: pointer;" onclick="sortTable('loans-table', 0)">Status ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 1)">Element ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 2)">Benutzer ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 3)">Klasse ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 4)">Zeit ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 5)">Rechnung ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 6)">Schaden ↕</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<!-- NEU: data-klasse hinzugefügt -->
{% for e in loan_entries %}
<tr class="loan-row" data-borrow-id="{{ e.id }}" data-item-id="{{ e.item_id }}" data-item-name="{{ e.item_name }}" data-item-code="{{ e.item_code }}" data-item-cost="{{ e.item_cost_raw }}" data-user-name="{{ e.user }}" data-search="{{ (e.item_name ~ ' ' ~ e.item_code ~ ' ' ~ e.user ~ ' ' ~ e.invoice_number ~ ' ' ~ e.item_author ~ ' ' ~ e.item_isbn)|lower }}" data-status="{{ e.status }}" data-has-damage="{{ '1' if e.has_damage else '0' }}">
<tr class="loan-row"
data-borrow-id="{{ e.id }}"
data-item-id="{{ e.item_id }}"
data-item-name="{{ e.item_name }}"
data-item-code="{{ e.item_code }}"
data-item-cost="{{ e.item_cost_raw }}"
data-user-name="{{ e.user }}"
data-klasse="{{ (e.klasse|default(''))|lower }}"
data-search="{{ (e.item_name ~ ' ' ~ e.item_code ~ ' ' ~ e.user ~ ' ' ~ (e.klasse|default('')) ~ ' ' ~ e.invoice_number ~ ' ' ~ e.item_author ~ ' ' ~ e.item_isbn)|lower }}"
data-status="{{ e.status }}"
data-has-damage="{{ '1' if e.has_damage else '0' }}">
<td>
{% if e.status == 'active' %}
<span class="badge-pill badge-active">Aktiv</span>
@@ -272,11 +303,19 @@
</div>
</td>
<td>
<div>{{ e.user }}</div>
<div class="muted">{{ e.start }}{% if e.end %} bis {{ e.end }}{% endif %}</div>
<div><strong>{{ e.user }}</strong></div>
</td>
<!-- NEU: Klasse Spalte -->
<td>
{% if e.klasse %}
<span class="badge-pill badge-class">{{ e.klasse }}</span>
{% else %}
<span class="muted"></span>
{% endif %}
</td>
<td>
<div>{{ e.period or '—' }}</div>
<div class="muted">{{ e.start }}{% if e.end %} bis {{ e.end }}{% endif %}</div>
{% if e.notes %}<div class="muted">{{ e.notes }}</div>{% endif %}
</td>
<td>
@@ -355,10 +394,11 @@
<table class="library-table" id="damaged-table">
<thead>
<tr>
<th>Element</th>
<th>Code</th>
<th>Schaden</th>
<th>Status</th>
<!-- NEU: onClick Handler zum Sortieren hinzugefügt -->
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 0)">Element ↕</th>
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 1)">Code ↕</th>
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 2)">Schaden ↕</th>
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 3)">Status ↕</th>
<th>Aktion</th>
</tr>
</thead>
@@ -489,6 +529,7 @@
<script>
(function() {
const searchInput = document.getElementById('library-search');
const classFilter = document.getElementById('class-filter'); // NEU: Klassenfilter hinzugefügt
const statusFilter = document.getElementById('loan-status-filter');
const damageFilter = document.getElementById('damage-filter');
const loanRows = Array.from(document.querySelectorAll('.loan-row'));
@@ -505,6 +546,51 @@
const damageInvoiceReason = document.getElementById('damage-invoice-reason');
const damageInvoiceReplaceBtn = document.getElementById('damage-invoice-replace-btn');
// NEU: Globale Sortier-Richtungsobjekte
let sortDirections = {};
// NEU: Sortier-Funktion
window.sortTable = function(tableId, columnIndex) {
const table = document.getElementById(tableId);
const tbody = table.tBodies[0];
const rows = Array.from(tbody.querySelectorAll("tr"));
if (rows.length === 0) return;
const sortKey = tableId + "-" + columnIndex;
if (!(sortKey in sortDirections)) {
sortDirections[sortKey] = true;
} else {
sortDirections[sortKey] = !sortDirections[sortKey];
}
const isAscending = sortDirections[sortKey];
const multiplier = isAscending ? 1 : -1;
// Pfeil im Header aktualisieren
const headers = table.querySelectorAll("th");
headers.forEach(th => {
if(th.innerHTML.includes('↕') || th.innerHTML.includes('▲') || th.innerHTML.includes('▼')) {
th.innerHTML = th.innerHTML.replace(/[↕▲▼]/g, '↕');
}
});
const currentTh = headers[columnIndex];
if (currentTh) {
currentTh.innerHTML = currentTh.innerHTML.replace('↕', isAscending ? '▲' : '▼');
}
// Zeilen sortieren
rows.sort((a, b) => {
const cellA = a.cells[columnIndex].textContent.trim();
const cellB = b.cells[columnIndex].textContent.trim();
return cellA.localeCompare(cellB, 'de', { numeric: true, sensitivity: 'base' }) * multiplier;
});
// Neu einfügen
rows.forEach(row => tbody.appendChild(row));
};
// Funktionen für das Reparatur-Modal
window.openRepairModal = function(itemId, currentCode) {
const modal = document.getElementById('repair-action-modal');
@@ -512,7 +598,6 @@
const codeContainer = document.getElementById('new-code-container');
const replaceBtn = document.getElementById('submit-replace-btn');
// Setze die Route im Formular (passe hier den Endpunkt an deine Backend-Route an, z.B. /admin/items/ID/resolve_repair)
form.action = `/admin/items/${itemId}/resolve_repair`;
document.getElementById('repair-action-input').value = 'repair';
@@ -639,20 +724,26 @@
});
}
// NEU: Kombinierte Filter-Funktion (Suche + Status + Schaden + Klasse)
function applyFilters() {
const search = (searchInput.value || '').trim().toLowerCase();
const status = statusFilter.value;
const damage = damageFilter.value;
const klasse = classFilter.value; // NEU
let visibleLoans = 0;
loanRows.forEach(row => {
const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || '';
const rowKlasse = row.dataset.klasse || ''; // NEU
const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status;
const classMatch = klasse === 'all' || rowKlasse === klasse; // NEU
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && damageMatch;
const show = searchMatch && statusMatch && classMatch && damageMatch; // NEU
row.style.display = show ? '' : 'none';
if (show) visibleLoans++;
});
@@ -663,10 +754,17 @@
const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || '';
const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status || status === '';
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && damageMatch;
// Defekte-Medien-Tabelle hat keine verknüpfte "Klasse", deshalb blenden wir sie nur bei Klassen-Filter "all" ein,
// oder wenn gar nicht nach Klasse gefiltert wird, damit sie nicht verschwindet.
// Falls sie bei aktiver Klassensuche komplett verschwinden soll, passe die Bedingung an:
const classMatch = klasse === 'all';
const show = searchMatch && statusMatch && damageMatch && classMatch;
row.style.display = show ? '' : 'none';
if (show) visibleDamaged++;
});
@@ -674,6 +772,7 @@
}
searchInput.addEventListener('input', applyFilters);
classFilter.addEventListener('change', applyFilters); // NEU
statusFilter.addEventListener('change', applyFilters);
damageFilter.addEventListener('change', applyFilters);
applyFilters();