Compare commits

..

33 Commits

Author SHA1 Message Date
Aiirondev_dev 5afcaa5e19 Add damage report link to notification details 2026-04-13 21:19:32 +02:00
Aiirondev_dev 3aa44b64b8 Add notification system with unread count and in-app alerts 2026-04-13 21:05:34 +02:00
Aiirondev_dev 86d8f19313 Implement notification system with user and admin views, including unread count and reminder functionality for damaged items 2026-04-13 20:29:50 +02:00
Maximilian G. e74798f252 Update support status for version 3.0.x 2026-04-13 12:10:03 +02:00
Aiirondev_dev 31c3d700a8 Update terminology from "Schülerausweise" to "Bibliotheksausweis" across admin and print templates for consistency 2026-04-12 21:31:11 +02:00
Aiirondev_dev a3c114e4b5 Refactor bulk delete functionality to toggle visibility of checkboxes in admin view 2026-04-12 21:21:50 +02:00
Aiirondev_dev 170a2a53ab Reduce Gunicorn worker count and adjust timeout settings for optimized performance 2026-04-12 20:57:41 +02:00
Aiirondev_dev a8ac08d5b0 Implement light mode for item loading to optimize initial data fetch 2026-04-12 20:36:13 +02:00
Aiirondev_dev 05c4c73635 Add loading indicator for main items in admin view to enhance user experience 2026-04-12 20:21:47 +02:00
Aiirondev_dev 76dd00831e Enhance item count display logic in admin view for improved accuracy 2026-04-12 20:02:19 +02:00
Aiirondev_dev 205a56ff24 Update filter display function calls for improved clarity in UI updates 2026-04-12 19:47:56 +02:00
Aiirondev_dev 60ec21f34e Implement lazy loading and prefetching for main admin items to enhance performance 2026-04-12 19:30:20 +02:00
Aiirondev_dev 57f6d2f8e7 Refactor MongoClient function to enhance configuration handling and preserve caller arguments 2026-04-12 19:18:30 +02:00
Aiirondev_dev 53575c1876 Refactor MongoDB client initialization for improved performance and simplicity 2026-04-12 17:50:24 +02:00
Aiirondev_dev 0f372f5056 Refactor MongoDB client imports and implement lazy loading for main items in UI 2026-04-12 17:36:49 +02:00
Aiirondev_dev c078de0076 Enhance library items API with pagination support and improve loading mechanism in UI 2026-04-12 17:22:49 +02:00
Aiirondev_dev daccf3334b Refactor bulk delete drawer for improved layout and accessibility 2026-04-10 23:20:20 +02:00
Aiirondev_dev ef605e080b Refactor bulk delete drawer for improved UI and functionality 2026-04-10 23:14:27 +02:00
Aiirondev_dev 1755ec222d Enhance bulk delete drawer UI with improved styles and button text 2026-04-10 23:08:02 +02:00
Aiirondev_dev 06ab3e1f4d Implement bulk delete drawer functionality with UI enhancements 2026-04-10 22:38:24 +02:00
Aiirondev_dev a2c79c80bc Implement bulk delete functionality for item groups with UI enhancements 2026-04-10 22:32:07 +02:00
Aiirondev_dev 7ac66ae3ba Refactor code structure for improved readability and maintainability 2026-04-10 22:08:25 +02:00
Aiirondev_dev 444a1df1aa Add audit review export functionality in markdown and JSON formats 2026-04-10 21:22:32 +02:00
Aiirondev_dev 0581b5c5e9 Add standalone audit event logging for borrowing and returning actions 2026-04-10 20:51:01 +02:00
Aiirondev_dev 2faf284a0e Implement tamper-evident audit logging and invoice correction features
- Introduced a new audit logging system that creates a tamper-evident chain of events in MongoDB.
- Added functions for appending audit events, ensuring index integrity, and verifying the audit chain.
- Implemented soft-delete functionality for inventory and borrowing records to comply with GoBD regulations.
- Added UI components for displaying invoice corrections and audit dashboard, including mismatch reporting.
- Created a CLI utility for verifying the integrity of the audit chain.
- Enhanced invoice management by preventing overwrites and allowing correction entries with reasons and optional deltas.
2026-04-10 20:32:56 +02:00
Aiirondev_dev 025e03f9ce Enhance image handling in admin template: add dynamic input management and improve UI for existing images 2026-04-10 17:23:56 +02:00
Aiirondev_dev a96e103733 Add bulk selection functionality for filter options in admin templates 2026-04-10 17:13:09 +02:00
Aiirondev_dev b636d06908 Add function to ensure runtime config JSON is created and backup unexpected directories 2026-04-10 16:42:36 +02:00
Aiirondev_dev a85461711c Add config.json handling for Docker setup and update script 2026-04-10 16:35:49 +02:00
Aiirondev_dev bf0a50ad57 Add module detection for dynamic navbar rendering 2026-04-10 16:22:03 +02:00
Aiirondev_dev 9212ad04f5 Add asset versioning for cache-busting in templates and configuration 2026-04-10 16:10:30 +02:00
Aiirondev_dev 1931e07013 Add function to refresh runtime scripts from main repository and update image tags 2026-04-10 16:02:18 +02:00
Maximilian G. c81aa882e9 Add badges for CI and coding activity
Added badges for GitHub Actions and Wakatime to README.
2026-04-10 15:57:44 +02:00
37 changed files with 3901 additions and 237 deletions
+2
View File
@@ -203,6 +203,7 @@ jobs:
expose: expose:
- "8000" - "8000"
volumes: volumes:
- ./config.json:/app/config.json:ro
- app_uploads:/app/Web/uploads - app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails - app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews - app_previews:/app/Web/previews
@@ -225,6 +226,7 @@ jobs:
cp stop.sh release-bundle/stop.sh cp stop.sh release-bundle/stop.sh
cp restart.sh release-bundle/restart.sh cp restart.sh release-bundle/restart.sh
cp backup.sh release-bundle/backup.sh cp backup.sh release-bundle/backup.sh
cp config.json release-bundle/config.json
cp update.sh release-bundle/update.sh cp update.sh release-bundle/update.sh
cp init-admin.sh release-bundle/init-admin.sh cp init-admin.sh release-bundle/init-admin.sh
+1 -1
View File
@@ -32,4 +32,4 @@ RUN if [ "$NUITKA_BUILD" = "1" ]; then \
WORKDIR /app/Web WORKDIR /app/Web
EXPOSE 8000 EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"] CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
+180
View File
@@ -0,0 +1,180 @@
# GoBD Hardening Change Report (2026-04-10)
## Scope
This change set implements core hardening measures for GoBD-oriented operation:
1. Soft-delete instead of hard-delete for inventory and borrowing records.
2. Tamper-evident audit log chain for critical accounting/inventory events.
3. Invoice immutability model with correction entries instead of overwrite.
## Implemented Changes
### 1) Tamper-evident audit chain
- New module: `Web/audit_log.py`
- Audit entries are chained by hash (`prev_hash` -> `entry_hash`) with monotonic `chain_index`.
- Canonical JSON serialization is used to make hashing deterministic.
Current fields per audit event:
- `event_type`
- `actor`
- `source`
- `ip`
- `payload`
- `timestamp`
- `created_at`
- `prev_hash`
- `entry_hash`
- `chain_index`
Integrated event writes in:
- Item soft-delete flow
- Invoice creation
- Invoice paid marking
- Invoice finalize+repair
- Invoice correction entry creation
### 1b) Audit operational controls (phase 2)
- Added index management in `Web/audit_log.py`:
- Unique index on `chain_index`
- Additional indexes on `created_at` and `event_type`
- Added chain verification function in `Web/audit_log.py`.
- Added CLI verification tool: `Web/verify_audit_chain.py`.
- Added admin verification endpoint:
- `GET /admin/audit/verify`
- Returns chain integrity result (`200` if valid, `409` on mismatch).
- Added lazy index provisioning helper invoked in admin borrowing views.
### 2) Soft-delete conversion
#### Inventory items
- Deletion endpoint now marks records logically deleted:
- `Deleted: true`
- `DeletedAt`
- `DeletedBy`
- `LastUpdated`
- `Verfuegbar: false`
- Item-linked borrow records are also logically deleted (`Status: deleted`) instead of physically removed.
- Image files are no longer physically deleted in this flow.
#### Borrow records
- `remove_ausleihung` changed to set `Status: deleted` + timestamps.
- Retrieval helpers now exclude deleted records by default.
#### Item reads
- Item helper queries now exclude `Deleted: true` records.
- Grouped item lookups and appointment queries also exclude deleted records.
- Code uniqueness checks ignore deleted records, allowing controlled code reuse.
### 3) Invoice immutability and correction flow
- Invoice creation now blocks overwrite if an invoice already exists for the borrow record.
- New lock marker on invoice creation/update path: `InvoiceLocked: true`.
- New correction endpoint:
- `POST /admin/borrowings/<borrow_id>/invoice/correction`
- Appends entries to `InvoiceCorrections`
- Does not mutate existing `InvoiceData` body
- Requires correction reason
- Supports optional amount delta
### 3b) UI integration for correction flow (phase 2)
- Added correction action forms in:
- `Web/templates/admin_borrowings.html`
- `Web/templates/library_borrowings_admin.html`
- Added correction count display (`invoice_corrections_count`) in both admin tables.
## Detailed File-Level Review
### `Web/audit_log.py`
- Introduces chain-based audit persistence.
- Uses last chain entry to calculate next `chain_index` and hash.
- Adds explicit index provisioning and full chain verification routine.
- Tradeoff: application-level sequencing is improved by unique index, but concurrent peak writes may still require retry logic around duplicate key conflicts.
### `Web/verify_audit_chain.py`
- New CLI operational tool for manual/cron verification.
- Returns non-zero exit code on chain mismatch.
### `Web/app.py`
- Added `_append_audit_event(...)` helper and integrated it at critical event boundaries.
- Inventory API routes now hide soft-deleted items.
- `delete_item` changed from destructive deletion to soft-delete semantics.
- Invoice route now rejects overwrite and requires correction route for changes.
- Added correction route with immutable invoice core.
- Added admin audit verification route and lazy audit index initialization helper.
Behavioral impact:
- Deleted items no longer disappear from DB; they are hidden from normal views.
- Existing UI actions for delete continue to work, but now preserve evidence.
- Invoice re-creation attempts now return warning and redirect.
### `Web/items.py`
- Introduced `_active_record_query(...)` and applied it across item retrieval APIs.
- Converted `remove_item` to soft-delete update.
- Updated maintenance reset (`unstuck_item`) to status updates instead of `delete_many`.
Behavioral impact:
- Item-level DB history is preserved.
- Legacy scripts relying on hard delete semantics may need adaptation.
### `Web/ausleihung.py`
- Converted `remove_ausleihung` to soft-delete by status.
- Default retrieval paths now exclude deleted records.
Behavioral impact:
- Borrowing history remains in DB for traceability.
## Validation Performed
- Static diagnostics reported no errors in modified files:
- `Web/app.py`
- `Web/items.py`
- `Web/ausleihung.py`
- `Web/audit_log.py`
- Additional phase-2 checks:
- `python3 -m py_compile Web/app.py Web/audit_log.py Web/verify_audit_chain.py`
- No syntax errors
- Template diagnostics:
- `Web/templates/admin_borrowings.html` no errors
- `Web/templates/library_borrowings_admin.html` no errors
## Residual Risks / Open Points
1. Concurrency hardening for audit chain:
- Current chain append may produce collisions under parallel writes.
- Recommendation: transaction or optimistic retry with unique index on `chain_index`.
2. Broader query coverage:
- Some direct Mongo queries outside helper paths may still need explicit `Deleted != true` filters.
3. Formal GoBD process requirements beyond code:
- Verfahrensdokumentation must be updated.
- WORM/immutable storage for exported archives should be enforced externally.
- Operational controls (RBAC review, periodic reconciliation, restore drills) should be documented.
## Recommended Next Steps
1. Add retry strategy for audit write conflicts:
- Retry `append_audit_event` on duplicate `chain_index` key errors.
2. Add admin UI page for chain status and recent audit events:
- Human-readable inspection view on top of `/admin/audit/verify`.
3. Add policy enforcement tests:
- Ensure invoice overwrite is blocked.
- Ensure soft-deleted records are hidden in APIs.
- Ensure deletion endpoints never call physical delete operators.
4. Infrastructure-level immutability:
- Push periodic audit snapshots and invoice archives to immutable/WORM storage.
+2
View File
@@ -1,5 +1,7 @@
# Inventarsystem # Inventarsystem
[![https://github.com/AIIrondev/legendary-octo-garbanzo](https://github.com/AIIrondev/legendary-octo-garbanzo/actions/workflows/release-docker.yml/badge.svg)](https://github.com/AIIrondev/legendary-octo-garbanzo/actions/workflows/release-docker.yml)
[![wakatime](https://wakatime.com/badge/user/30b8509f-5e17-4d16-b6b8-3ca0f3f936d3/project/8a380b7f-389f-4a7e-8877-0fe9e1a4c243.svg)](https://wakatime.com/badge/user/30b8509f-5e17-4d16-b6b8-3ca0f3f936d3/project/8a380b7f-389f-4a7e-8877-0fe9e1a4c243) [![wakatime](https://wakatime.com/badge/user/30b8509f-5e17-4d16-b6b8-3ca0f3f936d3/project/8a380b7f-389f-4a7e-8877-0fe9e1a4c243.svg)](https://wakatime.com/badge/user/30b8509f-5e17-4d16-b6b8-3ca0f3f936d3/project/8a380b7f-389f-4a7e-8877-0fe9e1a4c243)
**Aktuelle Version: 3.2.1** **Aktuelle Version: 3.2.1**
+2 -1
View File
@@ -6,9 +6,10 @@ The latest version will allways be supported the rest are old version that are n
| Version | Supported | | Version | Supported |
| ------- | ------------------ | | ------- | ------------------ |
| 0.2.17 | ✅ |
| 3.2.x | :white_check_mark: | | 3.2.x | :white_check_mark: |
| 3.1.x | :x: | | 3.1.x | :x: |
| 3.0.x | :white_check_mark: | | 3.0.x | :x: |
| 2.6.x | :x: | | 2.6.x | :x: |
| 2.4.x | :x: | | 2.4.x | :x: |
| 1.8.x | :x: | | 1.8.x | :x: |
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1753 -52
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
"""
Tamper-evident audit logging helpers.
The audit chain stores each entry with a hash of the previous entry.
Any mutation in history breaks the chain verification.
"""
import datetime
import hashlib
import json
import random
import time
from pymongo.errors import DuplicateKeyError
def _stable_json(value):
"""Serialize dictionaries in a stable way for deterministic hashing."""
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
def _entry_hash(prev_hash, payload):
"""Build the chained entry hash from previous hash + canonical payload."""
base = f"{prev_hash}|{_stable_json(payload)}"
return hashlib.sha256(base.encode("utf-8")).hexdigest()
def append_audit_event(db, event_type, actor, payload, request_ip=None, source="web", max_retries=5):
"""
Append an audit event to a tamper-evident chain.
Args:
db: MongoDB database handle.
event_type (str): Event category.
actor (str): User/system who performed the action.
payload (dict): Event details.
request_ip (str, optional): Request origin.
source (str): Source subsystem.
Returns:
dict: Inserted audit entry.
"""
logs = db["audit_log"]
attempts = 0
while attempts <= max_retries:
previous = logs.find_one(sort=[("chain_index", -1)])
prev_hash = previous.get("entry_hash", "") if previous else ""
chain_index = int(previous.get("chain_index", 0)) + 1 if previous else 1
timestamp = datetime.datetime.utcnow()
entry_payload = {
"event_type": event_type,
"actor": actor or "system",
"source": source,
"ip": request_ip or "",
"payload": payload or {},
"timestamp": timestamp.isoformat() + "Z",
}
entry_hash = _entry_hash(prev_hash, entry_payload)
entry = {
**entry_payload,
"created_at": timestamp,
"prev_hash": prev_hash,
"entry_hash": entry_hash,
"chain_index": chain_index,
}
try:
logs.insert_one(entry)
return entry
except DuplicateKeyError:
attempts += 1
if attempts > max_retries:
raise
# Exponential backoff with jitter to avoid retry storms.
delay = min(0.25, (0.005 * (2 ** attempts)) + random.random() * 0.01)
time.sleep(delay)
def ensure_audit_indexes(db):
"""Create indexes required for fast and safe audit operations."""
logs = db["audit_log"]
logs.create_index("chain_index", unique=True, name="audit_chain_index_unique")
logs.create_index("created_at", name="audit_created_at_idx")
logs.create_index("event_type", name="audit_event_type_idx")
def verify_audit_chain(db):
"""Verify hash chain integrity across all stored audit entries."""
logs = db["audit_log"]
entries = list(logs.find({}, {"_id": 1, "event_type": 1, "actor": 1, "source": 1, "ip": 1, "payload": 1, "timestamp": 1, "prev_hash": 1, "entry_hash": 1, "chain_index": 1}).sort("chain_index", 1))
previous_hash = ""
previous_index = 0
mismatches = []
for entry in entries:
chain_index = int(entry.get("chain_index", 0))
prev_hash = entry.get("prev_hash", "")
entry_hash = entry.get("entry_hash", "")
payload = {
"event_type": entry.get("event_type", ""),
"actor": entry.get("actor", ""),
"source": entry.get("source", ""),
"ip": entry.get("ip", ""),
"payload": entry.get("payload", {}),
"timestamp": entry.get("timestamp", ""),
}
expected_hash = _entry_hash(previous_hash, payload)
if chain_index != previous_index + 1:
mismatches.append({
"chain_index": chain_index,
"error": "chain_index_gap",
"expected": previous_index + 1,
"found": chain_index,
})
if prev_hash != previous_hash:
mismatches.append({
"chain_index": chain_index,
"error": "prev_hash_mismatch",
"expected": previous_hash,
"found": prev_hash,
})
if entry_hash != expected_hash:
mismatches.append({
"chain_index": chain_index,
"error": "entry_hash_mismatch",
"expected": expected_hash,
"found": entry_hash,
})
previous_hash = entry_hash
previous_index = chain_index
return {
"ok": len(mismatches) == 0,
"count": len(entries),
"last_chain_index": previous_index,
"last_hash": previous_hash,
"mismatches": mismatches,
}
+20 -12
View File
@@ -25,7 +25,6 @@ Sammlungsstruktur:
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev For commercial licensing inquiries: https://github.com/AIIrondev
''' '''
from pymongo import MongoClient
from bson.objectid import ObjectId from bson.objectid import ObjectId
import datetime import datetime
import pytz import pytz
@@ -34,6 +33,7 @@ import os
import json import json
import shutil import shutil
import settings as cfg import settings as cfg
from settings import MongoClient
# Add this helper function after imports # Add this helper function after imports
def ensure_timezone_aware(dt): def ensure_timezone_aware(dt):
@@ -365,8 +365,7 @@ def cancel_ausleihung(id):
def remove_ausleihung(id): def remove_ausleihung(id):
""" """
Entfernt einen Ausleihungsdatensatz aus der Datenbank. Markiert einen Ausleihungsdatensatz als gelöscht (Soft-Delete).
Hinweis: Normalerweise ist es besser, Datensätze zu markieren als sie zu löschen.
Args: Args:
id (str): ID des zu entfernenden Ausleihungsdatensatzes id (str): ID des zu entfernenden Ausleihungsdatensatzes
@@ -378,9 +377,17 @@ def remove_ausleihung(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen'] ausleihungen = db['ausleihungen']
result = ausleihungen.delete_one({'_id': ObjectId(id)}) now = datetime.datetime.now()
result = ausleihungen.update_one(
{'_id': ObjectId(id), 'Status': {'$ne': 'deleted'}},
{'$set': {
'Status': 'deleted',
'DeletedAt': now,
'LastUpdated': now,
}}
)
client.close() client.close()
return result.deleted_count > 0 return result.modified_count > 0
except Exception as e: except Exception as e:
# print(f"Error removing ausleihung: {e}") # Log the error # print(f"Error removing ausleihung: {e}") # Log the error
return False return False
@@ -429,14 +436,15 @@ def get_ausleihungen(status=None, start=None, end=None, date_filter='overlap'):
collection = db['ausleihungen'] collection = db['ausleihungen']
# Query erstellen # Query erstellen
query = {} query = {'Status': {'$ne': 'deleted'}}
# Status-Filter hinzufügen # Status-Filter hinzufügen
if status is not None: if status is not None:
if isinstance(status, list): if isinstance(status, list):
query['Status'] = {'$in': status} allowed_status = [s for s in status if s != 'deleted']
query['Status'] = {'$in': allowed_status}
else: else:
query['Status'] = status query['Status'] = status if status != 'deleted' else '__blocked_deleted_status__'
# Datum parsen, wenn als String angegeben # Datum parsen, wenn als String angegeben
if start is not None and isinstance(start, str): if start is not None and isinstance(start, str):
@@ -561,7 +569,7 @@ def get_ausleihung_by_user(user_id, status=None, use_client_side_verification=Tr
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen'] ausleihungen = db['ausleihungen']
query = {'User': user_id} query = {'User': user_id, 'Status': {'$ne': 'deleted'}}
# Wenn clientseitige Verifikation verwendet wird, holen wir ALLE Ausleihungen # Wenn clientseitige Verifikation verwendet wird, holen wir ALLE Ausleihungen
# und filtern später clientseitig # und filtern später clientseitig
@@ -610,12 +618,12 @@ def get_ausleihung_by_user(user_id, status=None, use_client_side_verification=Tr
# Status-Matching # Status-Matching
if isinstance(status, list): if isinstance(status, list):
if current_status in status: if current_status in status and current_status != 'deleted':
# Status aktualisieren und zur Ergebnismenge hinzufügen # Status aktualisieren und zur Ergebnismenge hinzufügen
ausleihung['VerifiedStatus'] = current_status ausleihung['VerifiedStatus'] = current_status
filtered_results.append(ausleihung) filtered_results.append(ausleihung)
else: else:
if current_status == status: if current_status == status and current_status != 'deleted':
# Status aktualisieren und zur Ergebnismenge hinzufügen # Status aktualisieren und zur Ergebnismenge hinzufügen
ausleihung['VerifiedStatus'] = current_status ausleihung['VerifiedStatus'] = current_status
filtered_results.append(ausleihung) filtered_results.append(ausleihung)
@@ -644,7 +652,7 @@ def get_ausleihung_by_item(item_id, status=None, include_history=False):
ausleihungen = db['ausleihungen'] ausleihungen = db['ausleihungen']
# Build query # Build query
query = {'Item': item_id} query = {'Item': item_id, 'Status': {'$ne': 'deleted'}}
if status and not include_history: if status and not include_history:
query['Status'] = status query['Status'] = status
+46 -23
View File
@@ -26,10 +26,10 @@ Collection Structure:
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev For commercial licensing inquiries: https://github.com/AIIrondev
''' '''
from pymongo import MongoClient
from bson.objectid import ObjectId from bson.objectid import ObjectId
import datetime import datetime
import settings as cfg import settings as cfg
from settings import MongoClient
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media') LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
@@ -43,6 +43,14 @@ def _non_library_query(extra_query=None):
return base_query return base_query
def _active_record_query(extra_query=None):
"""Build a query that excludes logically deleted records."""
base_query = {'Deleted': {'$ne': True}}
if extra_query:
base_query.update(extra_query)
return base_query
# === ITEM MANAGEMENT === # === ITEM MANAGEMENT ===
def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, filter3=None, def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, filter3=None,
@@ -118,7 +126,7 @@ def add_item(name, ort, beschreibung, images=None, filter=None, filter2=None, fi
def remove_item(id): def remove_item(id):
""" """
Remove an item from the inventory. Soft-delete an item from the inventory.
Args: Args:
id (str): ID of the item to remove id (str): ID of the item to remove
@@ -130,9 +138,17 @@ def remove_item(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
items = db['items'] items = db['items']
result = items.delete_one({'_id': ObjectId(id)}) result = items.update_one(
{'_id': ObjectId(id), 'Deleted': {'$ne': True}},
{'$set': {
'Deleted': True,
'DeletedAt': datetime.datetime.now(),
'LastUpdated': datetime.datetime.now(),
'Verfuegbar': False,
}}
)
client.close() client.close()
return result.deleted_count > 0 return result.modified_count > 0
except Exception as e: except Exception as e:
print(f"Error removing item: {e}") print(f"Error removing item: {e}")
return False return False
@@ -155,7 +171,7 @@ def get_group_item_ids(id):
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
items = db['items'] items = db['items']
base_item = items.find_one({'_id': ObjectId(id)}) base_item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
if not base_item: if not base_item:
client.close() client.close()
return [] return []
@@ -165,7 +181,7 @@ def get_group_item_ids(id):
# Prefer SeriesGroupId because it represents the full logical group. # Prefer SeriesGroupId because it represents the full logical group.
series_group_id = base_item.get('SeriesGroupId') series_group_id = base_item.get('SeriesGroupId')
if series_group_id: if series_group_id:
for group_item in items.find({'SeriesGroupId': series_group_id}, {'_id': 1}): for group_item in items.find(_active_record_query({'SeriesGroupId': series_group_id}), {'_id': 1}):
resolved_ids.add(str(group_item['_id'])) resolved_ids.add(str(group_item['_id']))
else: else:
resolved_ids.add(str(base_item['_id'])) resolved_ids.add(str(base_item['_id']))
@@ -173,10 +189,10 @@ def get_group_item_ids(id):
parent_item_id = base_item.get('ParentItemId') parent_item_id = base_item.get('ParentItemId')
if parent_item_id: if parent_item_id:
resolved_ids.add(str(parent_item_id)) resolved_ids.add(str(parent_item_id))
for sibling in items.find({'ParentItemId': str(parent_item_id), 'IsGroupedSubItem': True}, {'_id': 1}): for sibling in items.find(_active_record_query({'ParentItemId': str(parent_item_id), 'IsGroupedSubItem': True}), {'_id': 1}):
resolved_ids.add(str(sibling['_id'])) resolved_ids.add(str(sibling['_id']))
else: else:
for child in items.find({'ParentItemId': str(base_item['_id']), 'IsGroupedSubItem': True}, {'_id': 1}): for child in items.find(_active_record_query({'ParentItemId': str(base_item['_id']), 'IsGroupedSubItem': True}), {'_id': 1}):
resolved_ids.add(str(child['_id'])) resolved_ids.add(str(child['_id']))
client.close() client.close()
@@ -342,7 +358,7 @@ def is_code_unique(code_4, exclude_id=None):
items = db['items'] items = db['items']
# Build query to find items with this code # Build query to find items with this code
query = {'Code_4': code_4} query = {'Code_4': code_4, 'Deleted': {'$ne': True}}
# If we're editing an item, exclude it from the uniqueness check # If we're editing an item, exclude it from the uniqueness check
if exclude_id: if exclude_id:
@@ -368,7 +384,7 @@ def get_items():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
items = db['items'] items = db['items']
items_return = items.find(_non_library_query()) items_return = items.find(_active_record_query(_non_library_query()))
items_list = [] items_list = []
for item in items_return: for item in items_return:
item['_id'] = str(item['_id']) item['_id'] = str(item['_id'])
@@ -391,7 +407,7 @@ def get_available_items():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
items = db['items'] items = db['items']
items_return = items.find(_non_library_query({'Verfuegbar': True})) items_return = items.find(_active_record_query(_non_library_query({'Verfuegbar': True})))
items_list = [] items_list = []
for item in items_return: for item in items_return:
item['_id'] = str(item['_id']) item['_id'] = str(item['_id'])
@@ -414,7 +430,7 @@ def get_borrowed_items():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
items = db['items'] items = db['items']
items_return = items.find(_non_library_query({'Verfuegbar': False})) items_return = items.find(_active_record_query(_non_library_query({'Verfuegbar': False})))
items_list = [] items_list = []
for item in items_return: for item in items_return:
item['_id'] = str(item['_id']) item['_id'] = str(item['_id'])
@@ -440,7 +456,7 @@ def get_item(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
items = db['items'] items = db['items']
item = items.find_one({'_id': ObjectId(id)}) item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
client.close() client.close()
return item return item
except Exception as e: except Exception as e:
@@ -462,7 +478,7 @@ def get_item_by_name(name):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
items = db['items'] items = db['items']
item = items.find_one({'Name': name}) item = items.find_one(_active_record_query({'Name': name}))
client.close() client.close()
return item return item
except Exception as e: except Exception as e:
@@ -486,13 +502,13 @@ def get_items_by_filter(filter_value):
items = db['items'] items = db['items']
# Use $or to find matches in any filter field # Use $or to find matches in any filter field
query = _non_library_query({ query = _active_record_query(_non_library_query({
'$or': [ '$or': [
{'Filter': filter_value}, {'Filter': filter_value},
{'Filter2': filter_value}, {'Filter2': filter_value},
{'Filter3': filter_value} {'Filter3': filter_value}
] ]
}) }))
results = list(items.find(query)) results = list(items.find(query))
client.close() client.close()
@@ -518,7 +534,7 @@ def get_filters():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
items = db['items'] items = db['items']
non_library = _non_library_query() non_library = _active_record_query(_non_library_query())
filters = items.distinct('Filter', non_library) filters = items.distinct('Filter', non_library)
filters2 = items.distinct('Filter2', non_library) filters2 = items.distinct('Filter2', non_library)
filters3 = items.distinct('Filter3', non_library) filters3 = items.distinct('Filter3', non_library)
@@ -550,7 +566,7 @@ def get_primary_filters():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter', _non_library_query()) if f] filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters return filters
except Exception as e: except Exception as e:
@@ -569,7 +585,7 @@ def get_secondary_filters():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter2', _non_library_query()) if f] filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters return filters
except Exception as e: except Exception as e:
@@ -588,7 +604,7 @@ def get_tertiary_filters():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
items = db['items'] items = db['items']
filters = [f for f in items.distinct('Filter3', _non_library_query()) if f] filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f]
client.close() client.close()
return filters return filters
except Exception as e: except Exception as e:
@@ -610,7 +626,7 @@ def get_item_by_code_4(code_4):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
items = db['items'] items = db['items']
results = list(items.find(_non_library_query({"Code_4": code_4}))) results = list(items.find(_active_record_query(_non_library_query({"Code_4": code_4}))))
# Convert ObjectId to string # Convert ObjectId to string
for item in results: for item in results:
@@ -640,7 +656,14 @@ def unstuck_item(id):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT) client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen'] ausleihungen = db['ausleihungen']
result = ausleihungen.delete_many({'Item': id}) result = ausleihungen.update_many(
{'Item': id, 'Status': {'$nin': ['cancelled', 'deleted']}},
{'$set': {
'Status': 'cancelled',
'CancelledReason': 'unstuck_reset',
'LastUpdated': datetime.datetime.now()
}}
)
# Also reset the item status # Also reset the item status
items = db['items'] items = db['items']
@@ -980,7 +1003,7 @@ def get_items_with_appointments():
db = client[cfg.MONGODB_DB] db = client[cfg.MONGODB_DB]
items = db['items'] items = db['items']
items_return = items.find({'NextAppointment': {'$exists': True}}) items_return = items.find({'NextAppointment': {'$exists': True}, 'Deleted': {'$ne': True}})
items_list = [] items_list = []
for item in items_return: for item in items_return:
item['_id'] = str(item['_id']) item['_id'] = str(item['_id'])
+1
View File
@@ -11,3 +11,4 @@ pytz
requests requests
reportlab reportlab
python-barcode python-barcode
openpyxl
+19
View File
@@ -12,6 +12,7 @@ defaults for the web application and helper modules.
""" """
import os import os
import json import json
from pymongo import MongoClient as _PyMongoClient
# Base directory of this Web package # Base directory of this Web package
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 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) BACKUP_FOLDER = os.path.join(PROJECT_ROOT, BACKUP_FOLDER)
if not os.path.isabs(LOGS_FOLDER): if not os.path.isabs(LOGS_FOLDER):
LOGS_FOLDER = os.path.join(PROJECT_ROOT, 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)
+94
View File
@@ -0,0 +1,94 @@
{% extends 'base.html' %}
{% block title %}Audit Dashboard - {{ APP_VERSION }}{% endblock %}
{% block content %}
<div class="container" style="max-width:1400px; margin:0 auto; padding:18px;">
<h1>Audit Dashboard</h1>
<p>Integritätsstatus der Audit-Chain und letzte Audit-Ereignisse (max. 200 Einträge).</p>
<div style="display:flex; gap:10px; flex-wrap:wrap; margin:10px 0 18px;">
<a class="btn btn-primary" href="{{ url_for('admin_audit_export', format='md') }}">Audit Review exportieren (Markdown)</a>
<a class="btn btn-outline-secondary" href="{{ url_for('admin_audit_export', format='json') }}">Audit Review exportieren (JSON)</a>
</div>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
<div style="font-size:0.85rem; color:#64748b;">Chain Status</div>
{% if verify_result.ok %}
<div style="font-size:1.35rem; font-weight:700; color:#166534;">OK</div>
{% else %}
<div style="font-size:1.35rem; font-weight:700; color:#991b1b;">FEHLER</div>
{% endif %}
</div>
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
<div style="font-size:0.85rem; color:#64748b;">Einträge</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.count }}</div>
</div>
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
<div style="font-size:0.85rem; color:#64748b;">Letzter Index</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.last_chain_index }}</div>
</div>
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background:#fff;">
<div style="font-size:0.85rem; color:#64748b;">Mismatch Count</div>
<div style="font-size:1.35rem; font-weight:700;">{{ verify_result.mismatches|length }}</div>
</div>
</div>
{% if verify_result.mismatches %}
<div style="margin-bottom:20px; border:1px solid #fecaca; background:#fff7f7; border-radius:10px; padding:12px;">
<h3 style="margin-top:0; color:#991b1b;">Integritätsabweichungen</h3>
<div style="max-height:280px; overflow:auto;">
<table class="table" style="width:100%; border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Index</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Typ</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Expected</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Found</th>
</tr>
</thead>
<tbody>
{% for m in verify_result.mismatches %}
<tr>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ m.chain_index }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ m.error }}</td>
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace;">{{ m.expected }}</td>
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace;">{{ m.found }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div style="border:1px solid #e2e8f0; border-radius:10px; background:#fff; padding:12px;">
<h3 style="margin-top:0;">Letzte Audit-Ereignisse</h3>
<div style="max-height:560px; overflow:auto;">
<table class="table" style="width:100%; border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Index</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Zeit</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Event</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Actor</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Hash</th>
<th style="text-align:left; padding:8px; border-bottom:1px solid #ddd;">Payload</th>
</tr>
</thead>
<tbody>
{% for row in audit_rows %}
<tr>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.chain_index }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.timestamp or row.created_at }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.event_type }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;">{{ row.actor }}</td>
<td style="padding:8px; border-bottom:1px solid #eee; font-family:monospace; font-size:0.8rem;">{{ row.entry_hash }}</td>
<td style="padding:8px; border-bottom:1px solid #eee;"><pre style="margin:0; white-space:pre-wrap; font-size:0.8rem;">{{ row.payload }}</pre></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+10
View File
@@ -100,6 +100,9 @@
{% if e.invoice_number %} {% if e.invoice_number %}
<div style="font-weight:600;">{{ e.invoice_number }}</div> <div style="font-weight:600;">{{ e.invoice_number }}</div>
<div style="font-size:0.85rem; color:#666;">{{ e.invoice_amount }}</div> <div style="font-size:0.85rem; color:#666;">{{ e.invoice_amount }}</div>
{% if e.invoice_corrections_count %}
<div style="font-size:0.78rem; color:#7c2d12; margin-top:4px;">{{ e.invoice_corrections_count }} Korrektur(en)</div>
{% endif %}
{% if e.invoice_paid %} {% if e.invoice_paid %}
<div style="display:inline-block; margin-top:6px; padding:2px 8px; border-radius:999px; background:#dcfce7; color:#166534; font-size:0.75rem; font-weight:700;">Bezahlt</div> <div style="display:inline-block; margin-top:6px; padding:2px 8px; border-radius:999px; background:#dcfce7; color:#166534; font-size:0.75rem; font-weight:700;">Bezahlt</div>
{% if e.invoice_paid_at %} {% if e.invoice_paid_at %}
@@ -129,6 +132,13 @@
</button> </button>
</form> </form>
{% endif %} {% endif %}
{% if e.invoice_number %}
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');">
<input type="text" name="correction_reason" placeholder="Korrekturgrund" required style="padding:6px; border:1px solid #ddd; border-radius:6px; min-width:160px;">
<input type="text" name="amount_delta" placeholder="Delta (optional)" style="padding:6px; border:1px solid #ddd; border-radius:6px; width:120px;">
<button type="submit" class="btn btn-outline-danger">Korrektur</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');"> <form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-warning">Zurücksetzen</button> <button type="submit" class="btn btn-warning">Zurücksetzen</button>
</form> </form>
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}Defekte Items verwalten{% endblock %}
{% block content %}
<div class="container" style="max-width: 1250px; margin: 18px auto 32px;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap; margin-bottom:16px;">
<div>
<h1 style="margin:0;">Defekte Items</h1>
<p style="margin:6px 0 0; color:#64748b;">Eigenes Verwaltungsfenster fuer gemeldete Defekte mit schneller Reparatur-Funktion.</p>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="btn btn-outline-secondary" href="{{ url_for('admin_borrowings') }}">Ausleihen</a>
{% if library_module_enabled %}
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Bibliothek</a>
{% endif %}
</div>
</div>
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:14px; margin-bottom:12px;">
<input id="damage-search" type="text" placeholder="Suche nach Name, Code, Typ, Benutzer oder Schaden..." style="width:100%; padding:10px 12px; border:1px solid #d1d5db; border-radius:10px;">
</div>
<div style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:0; overflow:hidden;">
<table id="damage-table" style="width:100%; border-collapse:collapse;">
<thead>
<tr style="background:#f8fafc;">
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Item</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Status</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Letzte Meldung</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Ausleihe</th>
<th style="text-align:left; padding:11px; border-bottom:1px solid #e5e7eb;">Aktion</th>
</tr>
</thead>
<tbody>
{% for item in damaged_items %}
<tr class="damage-row" data-search="{{ (item.name ~ ' ' ~ item.code ~ ' ' ~ item.item_type ~ ' ' ~ item.borrow_user ~ ' ' ~ item.latest_damage_description)|lower }}">
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
<div style="font-weight:700;">{{ item.name }}</div>
<div style="font-size:0.86rem; color:#64748b;">{{ item.code or '-' }} | {{ item.item_type or '-' }}</div>
{% if item.author %}<div style="font-size:0.82rem; color:#64748b;">{{ item.author }}</div>{% endif %}
{% if item.isbn %}<div style="font-size:0.82rem; color:#64748b;">ISBN: {{ item.isbn }}</div>{% endif %}
</td>
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#fee2e2; color:#991b1b; font-size:0.75rem; font-weight:700;">Defekt</div>
<div style="font-size:0.84rem; color:#475569; margin-top:6px;">Meldungen: {{ item.damage_count }}</div>
<div style="font-size:0.84rem; color:#475569;">Condition: {{ item.condition or '-' }}</div>
<div style="font-size:0.84rem; color:#475569;">Verfuegbar: {{ 'Ja' if item.available else 'Nein' }}</div>
</td>
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
<div style="font-size:0.9rem; color:#1f2937;">{{ item.latest_damage_description or '-' }}</div>
<div style="font-size:0.82rem; color:#64748b; margin-top:4px;">
Gemeldet von {{ item.latest_damage_by or '-' }}
{% if item.latest_damage_at %} am {{ item.latest_damage_at.strftime('%d.%m.%Y %H:%M') }}{% endif %}
</div>
</td>
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
{% if item.active_borrow %}
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#dbeafe; color:#1d4ed8; font-size:0.75rem; font-weight:700;">Aktiv/Geplant</div>
<div style="font-size:0.84rem; color:#475569; margin-top:6px;">User: {{ item.active_borrow.User or item.borrow_user or '-' }}</div>
{% if item.active_borrow.End %}
<div style="font-size:0.84rem; color:#475569;">Ende: {{ item.active_borrow.End.strftime('%d.%m.%Y %H:%M') }}</div>
{% endif %}
{% else %}
<div style="display:inline-block; padding:3px 9px; border-radius:999px; background:#dcfce7; color:#166534; font-size:0.75rem; font-weight:700;">Keine offene Ausleihe</div>
{% endif %}
</td>
<td style="padding:11px; border-bottom:1px solid #eef2f7; vertical-align:top;">
<button class="btn btn-success btn-sm" onclick="repairDamage('{{ item.id }}', this)">Als repariert markieren</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not damaged_items %}
<div style="padding:24px; color:#64748b; text-align:center;">Keine defekten Items vorhanden.</div>
{% endif %}
</div>
</div>
<script>
(function() {
const searchInput = document.getElementById('damage-search');
if (searchInput) {
searchInput.addEventListener('input', function() {
const q = (searchInput.value || '').toLowerCase();
document.querySelectorAll('.damage-row').forEach(function(row) {
const text = row.getAttribute('data-search') || '';
row.style.display = text.includes(q) ? '' : 'none';
});
});
}
})();
function repairDamage(itemId, button) {
if (!itemId) return;
if (!confirm('Alle offenen Defektmeldungen fuer dieses Item als repariert markieren?')) {
return;
}
button.disabled = true;
fetch('/mark_damage_repaired/' + itemId, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (!data.success) {
throw new Error(data.message || 'Reparatur fehlgeschlagen');
}
window.location.reload();
})
.catch(function(err) {
button.disabled = false;
alert(err.message || 'Fehler beim Markieren als repariert.');
});
}
</script>
{% endblock %}
+198 -71
View File
@@ -7,7 +7,7 @@
For commercial licensing inquiries: https://github.com/AIIrondev For commercial licensing inquiries: https://github.com/AIIrondev
--> -->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-module="inventory"> <html lang="en" data-module="{{ CURRENT_MODULE }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
@@ -20,8 +20,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css', v=APP_VERSION) }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css', v=ASSET_VERSION) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/planned_appointments.css', v=APP_VERSION) }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/planned_appointments.css', v=ASSET_VERSION) }}">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}"> <link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<style> <style>
@@ -369,7 +369,7 @@
<span class="module-label">Module:</span> <span class="module-label">Module:</span>
<div class="module-tabs"> <div class="module-tabs">
<a href="{{ url_for('home') }}" <a href="{{ url_for('home') }}"
class="module-tab" class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}"
id="inventoryModule" id="inventoryModule"
data-module="inventory"> data-module="inventory">
📦 Inventarsystem 📦 Inventarsystem
@@ -377,7 +377,7 @@
{% if library_module_enabled %} {% if library_module_enabled %}
<div class="module-separator"></div> <div class="module-separator"></div>
<a href="{{ url_for('library_view') }}" <a href="{{ url_for('library_view') }}"
class="module-tab" class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}"
id="libraryModule" id="libraryModule"
data-module="library"> data-module="library">
📚 Bibliothek 📚 Bibliothek
@@ -386,48 +386,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if CURRENT_MODULE != 'library' %}
<script>
(function() {
// Detect current module based on URL and update data-module attribute
const currentPath = window.location.pathname;
const htmlTag = document.documentElement;
// Detect module and pages
const isLibraryModule = currentPath.includes('/library') ||
currentPath.includes('/library_admin') ||
currentPath.includes('/library_loans') ||
currentPath.includes('/student_cards');
const currentModule = isLibraryModule ? 'library' : 'inventory';
htmlTag.setAttribute('data-module', currentModule);
// Update module selector tabs
const invModule = document.getElementById('inventoryModule');
const libModule = document.getElementById('libraryModule');
if (currentModule === 'library') {
if (libModule) libModule.classList.add('active');
if (invModule) invModule.classList.remove('active');
} else {
if (invModule) invModule.classList.add('active');
if (libModule) libModule.classList.remove('active');
}
// Show/hide appropriate navbar based on current module
const invNavbar = document.getElementById('inventoryNavbar');
const libNavbar = document.getElementById('libraryNavbar');
if (currentModule === 'library') {
if (invNavbar) invNavbar.style.display = 'none';
if (libNavbar) libNavbar.style.display = '';
} else {
if (invNavbar) invNavbar.style.display = '';
if (libNavbar) libNavbar.style.display = 'none';
}
})();
</script>
<nav class="navbar navbar-expand-lg navbar-dark" id="inventoryNavbar"> <nav class="navbar navbar-expand-lg navbar-dark" id="inventoryNavbar">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('home') }}" data-icon="📦">Inventarsystem</a> <a class="navbar-brand" href="{{ url_for('home') }}" data-icon="📦">Inventarsystem</a>
@@ -459,6 +418,8 @@
<li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li> <li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li> <li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li> <li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
<li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li> <li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">System</h6></li> <li><h6 class="dropdown-header">System</h6></li>
@@ -475,11 +436,14 @@
<div class="d-flex"> <div class="d-flex">
{% if 'username' in session %} {% if 'username' in session %}
<span class="navbar-text text-light me-3">{{ session['username'] }}</span> <span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2"> <div class="dropdown me-2 user-menu-wrap">
<button class="btn btn-secondary dropdown-toggle" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false"> <button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
👤 👤
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li> <li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li> <li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
@@ -490,9 +454,10 @@
</div> </div>
</div> </div>
</nav> </nav>
{% endif %}
{% if library_module_enabled %} {% if library_module_enabled and CURRENT_MODULE == 'library' %}
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar" style="display: none;"> <nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('library_view') }}" data-icon="📚">Bibliothek</a> <a class="navbar-brand" href="{{ url_for('library_view') }}" data-icon="📚">Bibliothek</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#libraryNavContent"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#libraryNavContent">
@@ -521,8 +486,9 @@
{% if 'username' in session and (session.get('admin', False) or is_admin) %} {% if 'username' in session and (session.get('admin', False) or is_admin) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li> <li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li> <li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
{% if student_cards_module_enabled %} {% if student_cards_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Schülerausweise</a></li> <li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
{% endif %} {% endif %}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">System</h6></li> <li><h6 class="dropdown-header">System</h6></li>
@@ -539,11 +505,14 @@
<div class="d-flex"> <div class="d-flex">
{% if 'username' in session %} {% if 'username' in session %}
<span class="navbar-text text-light me-3">{{ session['username'] }}</span> <span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2"> <div class="dropdown me-2 user-menu-wrap">
<button class="btn btn-secondary dropdown-toggle" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false"> <button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
👤 👤
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libUserMenuDropdown"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libUserMenuDropdown">
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li> <li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li> <li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
@@ -555,23 +524,6 @@
</div> </div>
</nav> </nav>
{% endif %} {% endif %}
<script>
(function() {
// Show/hide appropriate navbar based on current module
const currentPath = window.location.pathname;
const invNavbar = document.getElementById('inventoryNavbar');
const libNavbar = document.getElementById('libraryNavbar');
if (currentPath.includes('/library')) {
if (invNavbar) invNavbar.style.display = 'none';
if (libNavbar) libNavbar.style.display = '';
} else {
if (invNavbar) invNavbar.style.display = '';
if (libNavbar) libNavbar.style.display = 'none';
}
})();
</script>
<div class="container"> <div class="container">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
@@ -653,6 +605,73 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.notif-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
border-radius: 999px;
background: #dc2626;
color: #fff;
font-size: 0.72rem;
font-weight: 800;
padding: 0 6px;
margin-left: 8px;
line-height: 1;
}
.user-menu-wrap {
position: relative;
}
.user-menu-btn {
position: relative;
}
.user-notification-dot {
position: absolute;
top: 6px;
right: 10px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #2563eb;
border: 2px solid #ffffff;
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.35);
display: none;
}
.user-notification-dot.visible {
display: block;
}
.notification-toast {
position: fixed;
right: 16px;
bottom: 18px;
z-index: 2300;
max-width: 360px;
background: #0f172a;
color: #fff;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.35);
box-shadow: 0 18px 35px rgba(2, 6, 23, 0.4);
padding: 12px 14px;
font-size: 0.9rem;
line-height: 1.35;
display: none;
}
.notification-toast strong {
display: block;
margin-bottom: 2px;
}
.notification-toast.show {
display: block;
}
</style> </style>
<div id="cookie-banner" role="dialog" aria-live="polite" aria-label="Cookie-Hinweis"> <div id="cookie-banner" role="dialog" aria-live="polite" aria-label="Cookie-Hinweis">
<div class="cb-inner"> <div class="cb-inner">
@@ -680,6 +699,11 @@
</div> </div>
</div> </div>
<div id="notification-toast" class="notification-toast" role="status" aria-live="polite">
<strong id="notification-toast-title">Neue Benachrichtigung</strong>
<span id="notification-toast-message"></span>
</div>
<script> <script>
(function(){ (function(){
function getCookie(name){ function getCookie(name){
@@ -713,8 +737,111 @@
const username = {{ (session['username'] if 'username' in session else '')|tojson }}; const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }}; const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }};
const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }}; const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }};
const notificationsPagePath = {{ url_for('notifications_view')|tojson }};
const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null; const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null;
const onboardingOverlay = document.getElementById('onboarding-overlay'); const onboardingOverlay = document.getElementById('onboarding-overlay');
const notificationButtons = Array.from(document.querySelectorAll('[data-notification-button="true"]'));
const notificationToast = document.getElementById('notification-toast');
const notificationToastTitle = document.getElementById('notification-toast-title');
const notificationToastMessage = document.getElementById('notification-toast-message');
let lastUnreadCount = Number({{ unread_notification_count|default(0)|int }});
function updateNotificationDots(unreadCount) {
const hasUnread = Number(unreadCount) > 0;
notificationButtons.forEach(function(btn) {
const dot = btn.querySelector('.user-notification-dot');
if (!dot) {
return;
}
if (hasUnread) {
dot.classList.add('visible');
} else {
dot.classList.remove('visible');
}
});
}
function showInAppNotification(title, message) {
if (!notificationToast || !notificationToastTitle || !notificationToastMessage) {
return;
}
notificationToastTitle.textContent = title || 'Neue Benachrichtigung';
notificationToastMessage.textContent = message || '';
notificationToast.classList.add('show');
window.setTimeout(function() {
notificationToast.classList.remove('show');
}, 5000);
}
function showBrowserNotification(title, message) {
if (!('Notification' in window)) {
showInAppNotification(title, message);
return;
}
if (Notification.permission === 'granted') {
new Notification(title || 'Neue Benachrichtigung', {
body: message || '',
});
return;
}
if (Notification.permission === 'default') {
Notification.requestPermission().then(function(permission) {
if (permission === 'granted') {
new Notification(title || 'Neue Benachrichtigung', {
body: message || '',
});
} else {
showInAppNotification(title, message);
}
}).catch(function() {
showInAppNotification(title, message);
});
return;
}
showInAppNotification(title, message);
}
function pollNotificationStatus() {
if (!username) {
return;
}
fetch('/notifications/unread_status', {
method: 'GET',
headers: {
'Accept': 'application/json'
}
})
.then(function(response) {
if (!response.ok) {
throw new Error('status request failed');
}
return response.json();
})
.then(function(data) {
if (!data || !data.ok) {
return;
}
const unreadCount = Number(data.unread_count || 0);
updateNotificationDots(unreadCount);
if (unreadCount > lastUnreadCount && window.location.pathname !== notificationsPagePath) {
const latest = data.latest_unread || {};
showBrowserNotification(latest.title || 'Neue Nachricht', latest.message || 'Es gibt neue Benachrichtigungen.');
}
lastUnreadCount = unreadCount;
})
.catch(function() {
// Silent fail; polling retries automatically.
});
}
updateNotificationDots(lastUnreadCount);
if (username) {
window.setInterval(pollNotificationStatus, 30000);
}
function showOnboarding(){ function showOnboarding(){
if (onboardingOverlay) { if (onboardingOverlay) {
@@ -282,6 +282,9 @@
{% if e.invoice_number %} {% if e.invoice_number %}
<div class="mono">{{ e.invoice_number }}</div> <div class="mono">{{ e.invoice_number }}</div>
<div class="muted">{{ e.invoice_amount }}</div> <div class="muted">{{ e.invoice_amount }}</div>
{% if e.invoice_corrections_count %}
<div class="muted" style="color:#7c2d12;">{{ e.invoice_corrections_count }} Korrektur(en)</div>
{% endif %}
<div style="margin-top:6px;"> <div style="margin-top:6px;">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a> <a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a>
</div> </div>
@@ -326,6 +329,14 @@
</form> </form>
{% endif %} {% endif %}
{% if e.invoice_number %}
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');">
<input type="text" name="correction_reason" placeholder="Korrekturgrund" required style="padding:6px; border:1px solid #ddd; border-radius:6px; min-width:140px;">
<input type="text" name="amount_delta" placeholder="Delta optional" style="padding:6px; border:1px solid #ddd; border-radius:6px; width:120px;">
<button type="submit" class="btn btn-outline-danger btn-sm">Korrektur</button>
</form>
{% endif %}
{% if e.status in ['active', 'planned'] %} {% if e.status in ['active', 'planned'] %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');"> <form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-warning btn-sm">Zurücksetzen</button> <button type="submit" class="btn btn-warning btn-sm">Zurücksetzen</button>
+164 -34
View File
@@ -290,6 +290,20 @@
color: #666; 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 styles */
.modal { .modal {
display: none; display: none;
@@ -501,6 +515,10 @@
<!-- Items will be loaded here --> <!-- Items will be loaded here -->
</tbody> </tbody>
</table> </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> </div>
<!-- Empty state --> <!-- Empty state -->
@@ -523,6 +541,24 @@
// State // State
let libraryItems = []; let libraryItems = [];
let filteredItems = []; 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 filterPanelOpen = false;
let scannerInstance = null; let scannerInstance = null;
let scannerRunning = false; let scannerRunning = false;
@@ -531,32 +567,124 @@
let lastScanAt = 0; let lastScanAt = 0;
const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1'); 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() { async function loadLibraryItems() {
if (pagingState.loading) return;
pagingState.loading = true;
try { try {
const response = await fetch('/api/library_items'); const firstPage = await fetchLibraryPage(0, API_PAGE_SIZE);
if (!response.ok) throw new Error('Failed to load library items'); libraryItems = firstPage.items || [];
libraryItems = await response.json(); pagingState.offset = (firstPage.offset || 0) + (firstPage.count || libraryItems.length);
displayItems(libraryItems); pagingState.total = firstPage.total || libraryItems.length;
pagingState.hasMore = Boolean(firstPage.has_more);
applyFiltersAndSearch(true);
if (pagingState.hasMore) {
loadRemainingLibraryItemsInBackground();
}
} catch (error) { } catch (error) {
console.error('Error loading library items:', 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>'; 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 async function loadRemainingLibraryItemsInBackground() {
function displayItems(items) { 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 tbody = document.getElementById('itemsTableBody');
const emptyState = document.getElementById('emptyState'); 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 = ''; tbody.innerHTML = '';
emptyState.style.display = 'block'; emptyState.style.display = 'block';
loadMoreRow.style.display = 'none';
return; return;
} }
emptyState.style.display = 'none'; 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 statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available'); const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available');
@@ -567,7 +695,7 @@
<tr> <tr>
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td> <td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
<td>${escapeHtml(item.Autor || item.Author || '-')}</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>${getItemTypeLabel(item.ItemType || 'book')}</td>
<td> <td>
<span class="table-status ${statusClass}"> <span class="table-status ${statusClass}">
@@ -585,6 +713,18 @@
`; `;
})()} })()}
`).join(''); `).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 // Filter toggle
@@ -598,21 +738,11 @@
// Apply filters // Apply filters
document.getElementById('applyFilterBtn').addEventListener('click', () => { document.getElementById('applyFilterBtn').addEventListener('click', () => {
const author = document.getElementById('filterAuthor').value.toLowerCase(); activeFilters.author = document.getElementById('filterAuthor').value.toLowerCase();
const isbn = document.getElementById('filterISBN').value.toLowerCase(); activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
const type = document.getElementById('filterType').value; activeFilters.type = document.getElementById('filterType').value;
const status = document.getElementById('filterStatus').value; activeFilters.status = document.getElementById('filterStatus').value;
applyFiltersAndSearch(true);
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);
}); });
// Clear filters // Clear filters
@@ -621,19 +751,19 @@
document.getElementById('filterISBN').value = ''; document.getElementById('filterISBN').value = '';
document.getElementById('filterType').value = ''; document.getElementById('filterType').value = '';
document.getElementById('filterStatus').value = ''; document.getElementById('filterStatus').value = '';
displayItems(libraryItems); activeFilters = { author: '', isbn: '', type: '', status: '' };
applyFiltersAndSearch(true);
}); });
// Search input // Search input
document.getElementById('librarySearch').addEventListener('input', (e) => { document.getElementById('librarySearch').addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase(); currentSearchTerm = (e.target.value || '').toLowerCase();
const toSearch = filteredItems.length > 0 ? filteredItems : libraryItems; applyFiltersAndSearch(true);
const results = toSearch.filter(item => });
(item.Name || '').toLowerCase().includes(searchTerm) ||
(item.Autor || item.Author || '').toLowerCase().includes(searchTerm) || document.getElementById('loadMoreBtn').addEventListener('click', () => {
(item.ISBN || item.Code4 || '').toLowerCase().includes(searchTerm) renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length);
); renderItems();
displayItems(results);
}); });
// Helper functions // Helper functions
+276 -9
View File
@@ -724,8 +724,75 @@
// Add this line to define allItems globally // Add this line to define allItems globally
let allItems = []; let allItems = [];
function loadItems() { const MAIN_ITEMS_PAGE_SIZE = 120;
fetch("{{ url_for('get_items') }}") let mainItemsNextOffset = 0;
let mainItemsHasMore = false;
let mainItemsLoadingMore = false;
let mainItemsLightMode = true; // Track if we're in light mode to gradually request full data
let mainItemsObserver = null;
let mainItemsSentinel = null;
let mainItemsLoadingIndicator = null;
let mainItemsScrollPrefetchBound = false;
function ensureMainItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
if (!mainItemsLoadingIndicator) {
mainItemsLoadingIndicator = document.createElement('div');
mainItemsLoadingIndicator.id = 'main-items-loading-indicator';
mainItemsLoadingIndicator.className = 'items-loading-indicator';
mainItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
`;
}
itemsContainer.appendChild(mainItemsLoadingIndicator);
return itemsContainer;
}
function updateMainItemsLoadingIndicator() {
const itemsContainer = ensureMainItemsLoadingIndicator();
if (!itemsContainer || !mainItemsLoadingIndicator) return;
const shouldShow = mainItemsHasMore || mainItemsLoadingMore;
mainItemsLoadingIndicator.style.display = shouldShow ? 'flex' : 'none';
mainItemsLoadingIndicator.classList.toggle('is-loading', mainItemsLoadingMore);
const label = mainItemsLoadingIndicator.querySelector('.loading-label');
if (label) {
label.textContent = mainItemsLoadingMore
? 'Weitere Objekte werden geladen...'
: 'Weitere Objekte werden vorbereitet...';
}
}
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;
updateMainItemsLoadingIndicator();
loadItems(mainItemsNextOffset, true).finally(() => {
mainItemsLoadingMore = false;
updateMainItemsLoadingIndicator();
});
}
function loadItems(offset = 0, append = false) {
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
// Erste Page: light_mode wird automatisch enablet
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
const itemsContainer = document.querySelector('#items-container'); const itemsContainer = document.querySelector('#items-container');
@@ -735,27 +802,39 @@
const filter3Values = new Set(); const filter3Values = new Set();
// Store all items data globally for filter rebuilding // 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 // 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>'; itemsContainer.innerHTML = '<div class="no-items-message">Keine Objekte gefunden</div>';
return; return;
} }
// Sort items by name in ascending order (A-Z) // 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 nameA = a.Name ? a.Name.toLowerCase() : '';
const nameB = b.Name ? b.Name.toLowerCase() : ''; const nameB = b.Name ? b.Name.toLowerCase() : '';
return nameA.localeCompare(nameB); // Ascending order 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 || []); const favoriteIds = new Set(data.favorites || []);
window.currentFavorites = favoriteIds; window.currentFavorites = favoriteIds;
try { sessionStorage.setItem('favoritesCache', JSON.stringify(Array.from(favoriteIds))); } catch(e){} try { sessionStorage.setItem('favoritesCache', JSON.stringify(Array.from(favoriteIds))); } catch(e){}
data.items.forEach(item => { pageItems.forEach(item => {
try { try {
const card = document.createElement('div'); const card = document.createElement('div');
card.classList.add('item-card'); card.classList.add('item-card');
@@ -995,8 +1074,10 @@
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2); populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3); populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
// Start display from leftmost position (first card, which is now Z) // Start display from leftmost position on full reload only.
itemsContainer.scrollLeft = 0; if (!append) {
itemsContainer.scrollLeft = 0;
}
// Apply filters // Apply filters
applyFilters(); applyFilters();
@@ -1011,15 +1092,93 @@
if (activeFilters.filter1.length > 0 || activeFilters.filter2.length > 0) { if (activeFilters.filter1.length > 0 || activeFilters.filter2.length > 0) {
rebuildFilter3Options(); rebuildFilter3Options();
} }
mainItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
mainItemsHasMore = Boolean(data.has_more);
updateMainItemsLoadingIndicator();
setupMainItemsLazyLoading();
maybePrefetchMainItems();
}) })
.catch(error => { .catch(error => {
console.error('Error fetching items:', error); console.error('Error fetching items:', error);
mainItemsLoadingMore = false;
mainItemsHasMore = false;
updateMainItemsLoadingIndicator();
if (append) {
return;
}
const itemsContainer = document.querySelector('#items-container'); const itemsContainer = document.querySelector('#items-container');
itemsContainer.innerHTML = itemsContainer.innerHTML =
'<div class="error-message">Fehler beim Laden der Objekte. Bitte versuchen Sie es später erneut.</div>'; '<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;
ensureMainItemsLoadingIndicator();
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;
updateMainItemsLoadingIndicator();
loadItems(mainItemsNextOffset, true).finally(() => {
mainItemsLoadingMore = false;
updateMainItemsLoadingIndicator();
});
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
});
mainItemsObserver.observe(mainItemsSentinel);
updateMainItemsLoadingIndicator();
}
function searchByCode() { function searchByCode() {
const searchInput = document.getElementById('code-search'); const searchInput = document.getElementById('code-search');
const rawSearchTerm = searchInput.value; const rawSearchTerm = searchInput.value;
@@ -1923,6 +2082,42 @@
applyFilters(); applyFilters();
} }
function bulkToggleFilterOptions(filterNum, shouldSelect) {
const filterKey = `filter${filterNum}`;
const checkboxes = Array.from(document.querySelectorAll(`#filter${filterNum}-options input[type="checkbox"]`));
if (checkboxes.length === 0) {
return;
}
const nextValues = new Set(activeFilters[filterKey]);
checkboxes.forEach(checkbox => {
const value = checkbox.value;
const group = checkbox.dataset.group || '';
const filterValue = group ? `${group}:${value}` : value;
checkbox.checked = shouldSelect;
if (shouldSelect) {
nextValues.add(filterValue);
} else {
nextValues.delete(filterValue);
}
});
activeFilters[filterKey] = Array.from(nextValues);
updateSelectedFilters(filterNum);
if (filterNum === 1) {
rebuildFilter2Options();
rebuildFilter3Options();
} else if (filterNum === 2) {
rebuildFilter3Options();
}
saveFilterState();
applyFilters();
}
function populateFilterOptions(containerId, filterValues, filterNum) { function populateFilterOptions(containerId, filterValues, filterNum) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (!container) { if (!container) {
@@ -1932,6 +2127,27 @@
container.innerHTML = ''; // Clear previous options container.innerHTML = ''; // Clear previous options
const bulkActions = document.createElement('div');
bulkActions.style.display = 'flex';
bulkActions.style.gap = '8px';
bulkActions.style.marginBottom = '10px';
const selectAllBtn = document.createElement('button');
selectAllBtn.type = 'button';
selectAllBtn.className = 'clear-filter';
selectAllBtn.textContent = 'Alle wählen';
selectAllBtn.onclick = () => bulkToggleFilterOptions(filterNum, true);
const clearAllBtn = document.createElement('button');
clearAllBtn.type = 'button';
clearAllBtn.className = 'clear-filter';
clearAllBtn.textContent = 'Alle abwählen';
clearAllBtn.onclick = () => bulkToggleFilterOptions(filterNum, false);
bulkActions.appendChild(selectAllBtn);
bulkActions.appendChild(clearAllBtn);
container.appendChild(bulkActions);
// First group values by category/prefix if they exist // First group values by category/prefix if they exist
const groupedValues = {}; const groupedValues = {};
@@ -2680,6 +2896,57 @@
scroll-behavior: smooth; scroll-behavior: smooth;
} }
.items-loading-indicator {
display: none;
flex: 0 0 220px;
min-width: 220px;
max-width: 220px;
height: 180px;
align-self: center;
margin-right: 8px;
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
color: #334155;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
scroll-snap-align: center;
}
.items-loading-indicator .loading-label {
font-size: 0.84rem;
text-align: center;
line-height: 1.3;
max-width: 170px;
}
.items-loading-indicator .loading-track {
width: 160px;
height: 8px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.items-loading-indicator .loading-fill {
width: 45%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #60a5fa, #2563eb);
transform: translateX(-100%);
}
.items-loading-indicator.is-loading .loading-fill {
animation: items-loader-slide 1.05s ease-in-out infinite;
}
@keyframes items-loader-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
}
.item-card { .item-card {
background-color: #fff; background-color: #fff;
border: 1px solid #ddd; border: 1px solid #ddd;
+631 -25
View File
@@ -26,6 +26,106 @@
font-weight: 600; 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 styles */
.filter-container { .filter-container {
display: flex; display: flex;
@@ -223,6 +323,57 @@
scroll-behavior: smooth; scroll-behavior: smooth;
} }
.items-loading-indicator {
display: none;
flex: 0 0 220px;
min-width: 220px;
max-width: 220px;
height: 180px;
align-self: center;
margin-right: 8px;
border: 1px solid #dbe3ee;
border-radius: 10px;
background: #f8fafc;
color: #334155;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
scroll-snap-align: center;
}
.items-loading-indicator .loading-label {
font-size: 0.84rem;
text-align: center;
line-height: 1.3;
max-width: 170px;
}
.items-loading-indicator .loading-track {
width: 160px;
height: 8px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.items-loading-indicator .loading-fill {
width: 45%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #60a5fa, #2563eb);
transform: translateX(-100%);
}
.items-loading-indicator.is-loading .loading-fill {
animation: items-loader-slide 1.05s ease-in-out infinite;
}
@keyframes items-loader-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(260%); }
}
.item-card { .item-card {
background-color: #fff; background-color: #fff;
border: 1px solid #ddd; border: 1px solid #ddd;
@@ -237,6 +388,12 @@
position: relative; 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 { .item-card:hover {
transform: translateY(-5px); transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
@@ -257,6 +414,49 @@
margin: 10px 0; margin: 10px 0;
} }
.bulk-delete-row {
display: none;
justify-content: flex-start;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
.bulk-delete-mode-active .bulk-delete-row {
display: flex;
}
.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 { .item-card .item-image {
width: 100%; width: 100%;
height: auto; height: auto;
@@ -2109,9 +2309,26 @@ document.addEventListener('DOMContentLoaded', ()=>{
<button id="toggle-favorites-view" class="view-toggle-btn" title="Nur Merkliste anzeigen"> <button id="toggle-favorites-view" class="view-toggle-btn" title="Nur Merkliste anzeigen">
<span id="favorites-view-icon">🔖</span> <span id="favorites-view-icon">🔖</span>
</button> </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> </div>
</h1> </h1>
<div id="items-indicator" class="items-indicator">Objekte im System: 0</div> <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-container">
<div class="filter-group"> <div class="filter-group">
<div class="filter-header"> <div class="filter-header">
@@ -2492,6 +2709,8 @@ document.addEventListener('DOMContentLoaded', ()=>{
let descSearchIds = null; // Set of matching IDs or null when disabled let descSearchIds = null; // Set of matching IDs or null when disabled
let currentUsername = ''; let currentUsername = '';
let allItems = []; // Cache for all items let allItems = []; // Cache for all items
let bulkDeleteSelection = new Set();
let bulkDeleteDrawerOpen = false;
const studentCardsModuleEnabled = {{ 'true' if student_cards_module_enabled else 'false' }}; const studentCardsModuleEnabled = {{ 'true' if student_cards_module_enabled else 'false' }};
const studentDefaultBorrowDays = {{ student_default_borrow_days|default(14) }}; const studentDefaultBorrowDays = {{ student_default_borrow_days|default(14) }};
const studentMaxBorrowDays = {{ student_max_borrow_days|default(365) }}; const studentMaxBorrowDays = {{ student_max_borrow_days|default(365) }};
@@ -2802,9 +3021,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
if (parsedFilters.filter3) activeFilters.filter3 = parsedFilters.filter3; if (parsedFilters.filter3) activeFilters.filter3 = parsedFilters.filter3;
// Update UI to show selected filters // Update UI to show selected filters
updateSelectedFilters(1); updateSelectedFiltersDisplay(1);
updateSelectedFilters(2); updateSelectedFiltersDisplay(2);
updateSelectedFilters(3); updateSelectedFiltersDisplay(3);
// We'll rebuild Filter 3 options after items are loaded // We'll rebuild Filter 3 options after items are loaded
} catch (e) { } catch (e) {
@@ -2823,6 +3042,139 @@ document.addEventListener('DOMContentLoaded', ()=>{
}; };
} }
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');
const itemsContainer = document.querySelector('#items-container');
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';
}
// Toggle checkbox visibility: show checkboxes only when bulk delete mode is active
if (itemsContainer) {
itemsContainer.classList.toggle('bulk-delete-mode-active', bulkDeleteDrawerOpen);
}
}
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 // Load predefined filter values for dropdowns
function loadPredefinedFilterValues(filterNumber) { function loadPredefinedFilterValues(filterNumber) {
fetch(`/get_predefined_filter_values/${filterNumber}`) fetch(`/get_predefined_filter_values/${filterNumber}`)
@@ -2848,6 +3200,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
select.removeChild(select.lastChild); select.removeChild(select.lastChild);
} }
const allOption = document.createElement('option');
allOption.value = '__ALL__';
allOption.textContent = '-- Alle auswählen --';
select.appendChild(allOption);
// Add new options - data.values contains the array // Add new options - data.values contains the array
data.values.forEach(filter => { data.values.forEach(filter => {
if (filter && filter.trim() !== '') { if (filter && filter.trim() !== '') {
@@ -2958,8 +3315,76 @@ document.addEventListener('DOMContentLoaded', ()=>{
}); });
// Function to load items from server // Function to load items from server
function loadItems() { const MAIN_ADMIN_ITEMS_PAGE_SIZE = 120;
fetch("{{ url_for('get_items') }}") let mainAdminItemsNextOffset = 0;
let mainAdminItemsHasMore = false;
let mainAdminItemsLoadingMore = false;
let mainAdminLightMode = true; // Track if we're in light mode to gradually request full data
let mainAdminItemsObserver = null;
let mainAdminItemsSentinel = null;
let mainAdminItemsLoadingIndicator = null;
let mainAdminItemsScrollPrefetchBound = false;
let totalItemsInSystem = 0;
function ensureMainAdminItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
if (!mainAdminItemsLoadingIndicator) {
mainAdminItemsLoadingIndicator = document.createElement('div');
mainAdminItemsLoadingIndicator.id = 'main-admin-items-loading-indicator';
mainAdminItemsLoadingIndicator.className = 'items-loading-indicator';
mainAdminItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
`;
}
itemsContainer.appendChild(mainAdminItemsLoadingIndicator);
return itemsContainer;
}
function updateMainAdminItemsLoadingIndicator() {
const itemsContainer = ensureMainAdminItemsLoadingIndicator();
if (!itemsContainer || !mainAdminItemsLoadingIndicator) return;
const shouldShow = mainAdminItemsHasMore || mainAdminItemsLoadingMore;
mainAdminItemsLoadingIndicator.style.display = shouldShow ? 'flex' : 'none';
mainAdminItemsLoadingIndicator.classList.toggle('is-loading', mainAdminItemsLoadingMore);
const label = mainAdminItemsLoadingIndicator.querySelector('.loading-label');
if (label) {
label.textContent = mainAdminItemsLoadingMore
? 'Weitere Objekte werden geladen...'
: 'Weitere Objekte werden vorbereitet...';
}
}
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;
updateMainAdminItemsLoadingIndicator();
loadItems(mainAdminItemsNextOffset, true).finally(() => {
mainAdminItemsLoadingMore = false;
updateMainAdminItemsLoadingIndicator();
});
}
function loadItems(offset = 0, append = false) {
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
// Erste Page: light_mode wird automatisch enablet
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
const itemsContainer = document.querySelector('#items-container'); const itemsContainer = document.querySelector('#items-container');
@@ -2969,9 +3394,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
const filter1Values = new Set(); const filter1Values = new Set();
const filter2Values = new Set(); const filter2Values = new Set();
const filter3Values = new Set(); const filter3Values = new Set();
allItems = data.items || []; const pageItems = data.items || [];
itemsContainer.innerHTML = ''; allItems = append ? allItems.concat(pageItems) : pageItems;
if (!data.items || data.items.length === 0) { totalItemsInSystem = Number(data.total || totalItemsInSystem || pageItems.length || 0);
if (!append) {
itemsContainer.innerHTML = '';
}
if (!append && (!pageItems || pageItems.length === 0)) {
itemsContainer.innerHTML = ` itemsContainer.innerHTML = `
<div class="no-items-message"> <div class="no-items-message">
<div>Keine Objekte gefunden</div> <div>Keine Objekte gefunden</div>
@@ -2984,15 +3413,25 @@ document.addEventListener('DOMContentLoaded', ()=>{
return; return;
} }
if (itemsIndicator) { if (itemsIndicator) {
itemsIndicator.textContent = `Objekte im System: ${data.items.length}`; itemsIndicator.textContent = `Objekte im System: ${totalItemsInSystem}${data.has_more ? ' (lädt...)' : ''}`;
} }
data.items.sort((a, b) => { pageItems.sort((a, b) => {
const nameA = a.Name ? a.Name.toLowerCase() : ''; const nameA = a.Name ? a.Name.toLowerCase() : '';
const nameB = a.Name ? a.Name.toLowerCase() : ''; const nameB = a.Name ? a.Name.toLowerCase() : '';
return nameA.localeCompare(nameB); 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 || []); const favoriteIds = new Set(data.favorites || []);
data.items.forEach(item => { pageItems.forEach(item => {
try { try {
const card = document.createElement('div'); const card = document.createElement('div');
card.classList.add('item-card'); card.classList.add('item-card');
@@ -3143,6 +3582,12 @@ document.addEventListener('DOMContentLoaded', ()=>{
${appointmentBadge} ${appointmentBadge}
</div> </div>
<div class="actions"> <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 ? ${isAvailableForBorrow && !item.BlockedNow ?
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}"> `<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}">
${isGroupedItem ? ` ${isGroupedItem ? `
@@ -3218,8 +3663,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2); populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3); populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
itemsContainer.scrollLeft = 0; if (!append) {
itemsContainer.scrollLeft = 0;
}
applyFilters(); applyFilters();
updateBulkDeleteSummary();
// Setup proper image display for all cards // Setup proper image display for all cards
setupCardImagesDisplay(); setupCardImagesDisplay();
@@ -3232,9 +3680,21 @@ document.addEventListener('DOMContentLoaded', ()=>{
rebuildFilter3Options(); rebuildFilter3Options();
} }
/* favorites render count removed */ /* favorites render count removed */
mainAdminItemsNextOffset = (data.offset || offset) + (data.count || pageItems.length);
mainAdminItemsHasMore = Boolean(data.has_more);
updateMainAdminItemsLoadingIndicator();
setupMainAdminItemsLazyLoading();
maybePrefetchMainAdminItems();
}) })
.catch(error => { .catch(error => {
console.error('Error fetching items:', error); console.error('Error fetching items:', error);
mainAdminItemsLoadingMore = false;
mainAdminItemsHasMore = false;
updateMainAdminItemsLoadingIndicator();
if (append) {
return;
}
const itemsContainer = document.querySelector('#items-container'); const itemsContainer = document.querySelector('#items-container');
const itemsIndicator = document.getElementById('items-indicator'); const itemsIndicator = document.getElementById('items-indicator');
itemsContainer.innerHTML = itemsContainer.innerHTML =
@@ -3245,6 +3705,67 @@ document.addEventListener('DOMContentLoaded', ()=>{
}); });
} }
function ensureMainAdminItemsSentinel() {
const itemsContainer = document.querySelector('#items-container');
if (!itemsContainer) return null;
ensureMainAdminItemsLoadingIndicator();
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;
updateMainAdminItemsLoadingIndicator();
loadItems(mainAdminItemsNextOffset, true).finally(() => {
mainAdminItemsLoadingMore = false;
updateMainAdminItemsLoadingIndicator();
});
}, {
root: itemsContainer,
threshold: 0.15,
rootMargin: '0px 900px 0px 0px'
});
mainAdminItemsObserver.observe(mainAdminItemsSentinel);
updateMainAdminItemsLoadingIndicator();
}
function searchByCode() { function searchByCode() {
const searchInput = document.getElementById('code-search'); const searchInput = document.getElementById('code-search');
const rawSearchTerm = searchInput.value; const rawSearchTerm = searchInput.value;
@@ -3339,7 +3860,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}); });
if (itemsIndicator) { if (itemsIndicator) {
itemsIndicator.textContent = `Objekte im System: ${allItems.length} | Angezeigt: ${visibleCount}`; itemsIndicator.textContent = `Objekte im System: ${totalItemsInSystem || allItems.length} | Angezeigt: ${visibleCount}`;
} }
if (isCodeSearch && visibleCount > 0 && (codeMatchCount > 0 || exactMatchCount > 0)) { if (isCodeSearch && visibleCount > 0 && (codeMatchCount > 0 || exactMatchCount > 0)) {
@@ -3528,7 +4049,11 @@ document.addEventListener('DOMContentLoaded', ()=>{
// Display existing images // Display existing images
const existingImagesContainer = document.getElementById('edit-existing-images'); const existingImagesContainer = document.getElementById('edit-existing-images');
const editForm = document.getElementById('edit-item-form');
existingImagesContainer.innerHTML = ''; existingImagesContainer.innerHTML = '';
if (editForm) {
editForm.querySelectorAll('input[name="existing_images"], input[name="removed_images"]').forEach(input => input.remove());
}
if (item.Images && Array.isArray(item.Images)) { if (item.Images && Array.isArray(item.Images)) {
item.Images.forEach((image, index) => { item.Images.forEach((image, index) => {
const isVideo = isVideoFile(image); const isVideo = isVideoFile(image);
@@ -3543,22 +4068,46 @@ document.addEventListener('DOMContentLoaded', ()=>{
image : image :
`{{ url_for('uploaded_file', filename='') }}${image}`); `{{ url_for('uploaded_file', filename='') }}${image}`);
const row = document.createElement('div');
row.style.display = 'flex';
row.style.gap = '8px';
row.style.alignItems = 'center';
if (isVideo) { if (isVideo) {
imageDiv.innerHTML = ` const video = document.createElement('video');
<div style="display:flex; gap:8px; align-items:center;"> video.src = imageSrc;
<video src="${imageSrc}" style="max-width:100px; max-height:100px; object-fit:contain;" controls></video> video.style.maxWidth = '100px';
<button type="button" class="delete-image-button" onclick="deleteExistingImage('${item._id}', ${index})">Löschen</button> video.style.maxHeight = '100px';
</div> video.style.objectFit = 'contain';
`; video.controls = true;
row.appendChild(video);
} else { } else {
imageDiv.innerHTML = ` const img = document.createElement('img');
<div style="display:flex; gap:8px; align-items:center;"> img.src = imageSrc;
<img src="${imageSrc}" style="max-width:100px; max-height:100px; object-fit:contain;" alt="Existierendes Bild ${index + 1}"> img.style.maxWidth = '100px';
<button type="button" class="delete-image-button" onclick="deleteExistingImage('${item._id}', ${index})">Löschen</button> img.style.maxHeight = '100px';
</div> img.style.objectFit = 'contain';
`; img.alt = `Existierendes Bild ${index + 1}`;
row.appendChild(img);
} }
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'delete-image-button';
deleteButton.textContent = 'Löschen';
deleteButton.addEventListener('click', () => removeExistingImage(image, deleteButton));
row.appendChild(deleteButton);
imageDiv.appendChild(row);
existingImagesContainer.appendChild(imageDiv); existingImagesContainer.appendChild(imageDiv);
if (editForm) {
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'existing_images';
hiddenInput.value = image;
editForm.appendChild(hiddenInput);
}
}); });
} }
@@ -4401,6 +4950,42 @@ document.addEventListener('DOMContentLoaded', ()=>{
applyFilters(); applyFilters();
} }
function bulkToggleFilterOptions(filterNum, shouldSelect) {
const filterKey = `filter${filterNum}`;
const checkboxes = Array.from(document.querySelectorAll(`#filter${filterNum}-options input[type="checkbox"]`));
if (checkboxes.length === 0) {
return;
}
const nextValues = new Set(activeFilters[filterKey]);
checkboxes.forEach(checkbox => {
const value = checkbox.value;
const group = checkbox.dataset.group || '';
const filterValue = group ? `${group}:${value}` : value;
checkbox.checked = shouldSelect;
if (shouldSelect) {
nextValues.add(filterValue);
} else {
nextValues.delete(filterValue);
}
});
activeFilters[filterKey] = Array.from(nextValues);
updateSelectedFiltersDisplay(filterNum);
if (filterNum === 1) {
rebuildFilter2Options();
rebuildFilter3Options();
} else if (filterNum === 2) {
rebuildFilter3Options();
}
saveFilterState();
applyFilters();
}
function populateFilterOptions(containerId, filterValues, filterNum) { function populateFilterOptions(containerId, filterValues, filterNum) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (!container) { if (!container) {
@@ -4409,6 +4994,27 @@ document.addEventListener('DOMContentLoaded', ()=>{
container.innerHTML = ''; container.innerHTML = '';
const bulkActions = document.createElement('div');
bulkActions.style.display = 'flex';
bulkActions.style.gap = '8px';
bulkActions.style.marginBottom = '10px';
const selectAllBtn = document.createElement('button');
selectAllBtn.type = 'button';
selectAllBtn.className = 'clear-filter';
selectAllBtn.textContent = 'Alle wählen';
selectAllBtn.onclick = () => bulkToggleFilterOptions(filterNum, true);
const clearAllBtn = document.createElement('button');
clearAllBtn.type = 'button';
clearAllBtn.className = 'clear-filter';
clearAllBtn.textContent = 'Alle abwählen';
clearAllBtn.onclick = () => bulkToggleFilterOptions(filterNum, false);
bulkActions.appendChild(selectAllBtn);
bulkActions.appendChild(clearAllBtn);
container.appendChild(bulkActions);
const groupedValues = {}; const groupedValues = {};
filterValues.forEach(value => { filterValues.forEach(value => {
+86
View File
@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}Benachrichtigungen{% endblock %}
{% block content %}
<div class="container" style="max-width: 1080px; margin: 18px auto 32px;">
<div style="display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:16px; flex-wrap:wrap;">
<div>
<h1 style="margin:0;">Benachrichtigungen</h1>
<p style="margin:6px 0 0; color:#64748b;">Rueckgabe-Erinnerungen und wichtige Hinweise fuer Benutzer und Verwaltung.</p>
</div>
<form method="post" action="{{ url_for('mark_all_notifications_read') }}">
<button class="btn btn-outline-secondary" type="submit">Alle als gelesen markieren</button>
</form>
</div>
<div style="display:grid; grid-template-columns:1fr; gap:14px;">
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
<h2 style="margin:0 0 12px; font-size:1.15rem;">Meine Benachrichtigungen</h2>
{% if user_notifications %}
<div style="display:grid; gap:10px;">
{% for n in user_notifications %}
<article style="border:1px solid #e5e7eb; border-left:5px solid {% if n.severity == 'danger' %}#dc2626{% elif n.severity == 'warning' %}#d97706{% else %}#2563eb{% endif %}; border-radius:10px; padding:12px 12px 10px; background:{% if n.is_read %}#f8fafc{% else %}#ffffff{% endif %};">
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap;">
<div>
<div style="font-weight:800; color:#0f172a;">{{ n.title }}</div>
<div style="font-size:0.9rem; color:#475569; margin-top:2px;">{{ n.message }}</div>
{% if n.type == 'damage_reported' %}
<div style="margin-top:8px;">
<a class="btn btn-sm btn-outline-danger" href="{{ url_for('admin_damaged_items') }}">Zu Defekte-Items Verwaltung</a>
</div>
{% endif %}
<div style="font-size:0.78rem; color:#64748b; margin-top:6px;">
{% if n.created_at %}{{ n.created_at.strftime('%d.%m.%Y %H:%M') }}{% else %}-{% endif %}
</div>
</div>
{% if not n.is_read %}
<form method="post" action="{{ url_for('mark_notification_read', notification_id=n.id) }}">
<button class="btn btn-sm btn-primary" type="submit">Als gelesen</button>
</form>
{% else %}
<span style="font-size:0.8rem; color:#16a34a; font-weight:700;">Gelesen</span>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p style="color:#64748b; margin:0;">Keine Benachrichtigungen vorhanden.</p>
{% endif %}
</section>
{% if is_admin_user %}
<section style="background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:16px;">
<h2 style="margin:0 0 12px; font-size:1.15rem;">Admin-Benachrichtigungen</h2>
{% if admin_notifications %}
<div style="display:grid; gap:10px;">
{% for n in admin_notifications %}
<article style="border:1px solid #e5e7eb; border-left:5px solid {% if n.severity == 'danger' %}#dc2626{% elif n.severity == 'warning' %}#d97706{% else %}#2563eb{% endif %}; border-radius:10px; padding:12px 12px 10px; background:{% if n.is_read %}#f8fafc{% else %}#ffffff{% endif %};">
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start; flex-wrap:wrap;">
<div>
<div style="font-weight:800; color:#0f172a;">{{ n.title }}</div>
<div style="font-size:0.9rem; color:#475569; margin-top:2px;">{{ n.message }}</div>
<div style="font-size:0.78rem; color:#64748b; margin-top:6px;">
{% if n.created_at %}{{ n.created_at.strftime('%d.%m.%Y %H:%M') }}{% else %}-{% endif %}
</div>
</div>
{% if not n.is_read %}
<form method="post" action="{{ url_for('mark_notification_read', notification_id=n.id) }}">
<button class="btn btn-sm btn-primary" type="submit">Als gelesen</button>
</form>
{% else %}
<span style="font-size:0.8rem; color:#16a34a; font-weight:700;">Gelesen</span>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p style="color:#64748b; margin:0;">Keine Admin-Benachrichtigungen vorhanden.</p>
{% endif %}
</section>
{% endif %}
</div>
</div>
{% endblock %}
@@ -124,12 +124,12 @@
<div class="container"> <div class="container">
<div class="icon">📇</div> <div class="icon">📇</div>
<h1>Schülerausweis-Download</h1> <h1>Schülerausweis-Download</h1>
<p>Generieren Sie eine PDF mit Barcodes aller Schülerausweise zum direkten Drucken</p> <p>Generieren Sie eine PDF mit Barcodes aller Bibliotheksausweise zum direkten Drucken</p>
<div class="info-box"> <div class="info-box">
<strong>📌 Was wird heruntergeladen?</strong> <strong>📌 Was wird heruntergeladen?</strong>
<p>Eine druckfertige PDF mit:</p> <p>Eine druckfertige PDF mit:</p>
<p>✓ Alle Schülerausweise</p> <p>✓ Alle Bibliotheksausweise</p>
<p>✓ Scanbare CODE128 Barcodes</p> <p>✓ Scanbare CODE128 Barcodes</p>
<p>✓ Optimiert für A4-Druck (2 Karten pro Seite)</p> <p>✓ Optimiert für A4-Druck (2 Karten pro Seite)</p>
<p>✓ Professionelle Qualität</p> <p>✓ Professionelle Qualität</p>
+3 -3
View File
@@ -8,7 +8,7 @@
--> -->
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Schülerausweise - Inventarsystem{% endblock %} {% block title %}Bibliotheksausweise - Inventarsystem{% endblock %}
{% block content %} {% block content %}
<style> <style>
@@ -229,7 +229,7 @@
<div class="container"> <div class="container">
<div class="student-card-header"> <div class="student-card-header">
<div> <div>
<h1>📚 Schülerausweise (Bibliotek)</h1> <h1>📚 Bibliotheksausweise (Bibliotek)</h1>
</div> </div>
<div class="export-buttons"> <div class="export-buttons">
<a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a> <a href="{{ url_for('student_card_barcode_download') }}" class="btn-print" style="background: #28a745;">📥 Alle Ausweise (PDF)</a>
@@ -342,7 +342,7 @@
</table> </table>
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
<p>Keine Schülerausweise registriert.</p> <p>Keine Bibliotheksausweise registriert.</p>
<p>Fügen Sie ein neues Ausweis oben hinzu.</p> <p>Fügen Sie ein neues Ausweis oben hinzu.</p>
</div> </div>
{% endif %} {% endif %}
+30
View File
@@ -711,6 +711,28 @@
<div class="upload-container"> <div class="upload-container">
<a href="{{ url_for(back_target|default('home_admin')) }}" class="nav-back-button">← Zurück zur Artikelübersicht</a> <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"> <div class="upload-form">
<h1>{{ page_title|default('Artikel hochladen') }}</h1> <h1>{{ page_title|default('Artikel hochladen') }}</h1>
<form method="POST" action="{{ url_for('upload_item') }}" enctype="multipart/form-data"> <form method="POST" action="{{ url_for('upload_item') }}" enctype="multipart/form-data">
@@ -1115,8 +1137,16 @@
select.removeChild(select.lastChild); select.removeChild(select.lastChild);
} }
const allOption = document.createElement('option');
allOption.value = '__ALL__';
allOption.textContent = '-- Alle auswählen --';
select.appendChild(allOption);
// Add new options - data.values contains the array // Add new options - data.values contains the array
data.values.forEach(value => { data.values.forEach(value => {
if (!value || String(value).trim() === '') {
return;
}
const option = document.createElement('option'); const option = document.createElement('option');
option.value = value; option.value = value;
option.textContent = value; option.textContent = value;
+1 -1
View File
@@ -10,10 +10,10 @@ Provides methods for creating, validating, and retrieving user information.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited. Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev For commercial licensing inquiries: https://github.com/AIIrondev
''' '''
from pymongo import MongoClient
import hashlib import hashlib
from bson.objectid import ObjectId from bson.objectid import ObjectId
import settings as cfg import settings as cfg
from settings import MongoClient
def normalize_student_card_id(card_id): def normalize_student_card_id(card_id):
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""CLI utility to verify the tamper-evident audit chain."""
import json
import sys
from pymongo import MongoClient
import settings as cfg
import audit_log as al
def main():
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
al.ensure_audit_indexes(db)
result = al.verify_audit_chain(db)
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
return 0 if result.get("ok") else 2
except Exception as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
return 1
finally:
if client:
client.close()
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -42,6 +42,7 @@ services:
expose: expose:
- "8000" - "8000"
volumes: volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web - ./Web:/app/Web
- app_uploads:/app/Web/uploads - app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails - app_thumbnails:/app/Web/thumbnails
+1
View File
@@ -10,3 +10,4 @@ pytz
requests requests
reportlab reportlab
python-barcode python-barcode
openpyxl
+39
View File
@@ -290,6 +290,44 @@ EOF
fi fi
} }
ensure_runtime_config_json() {
local config_path backup_path
config_path="$SCRIPT_DIR/config.json"
if [ -d "$config_path" ]; then
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
mv "$config_path" "$backup_path"
echo "Warning: moved unexpected directory $config_path to $backup_path"
fi
if [ ! -f "$config_path" ]; then
cat > "$config_path" <<'EOF'
{
"ver": "2.6.5",
"dbg": false,
"host": "0.0.0.0",
"port": 443,
"mongodb": {
"host": "mongodb",
"port": 27017,
"db": "Inventarsystem"
},
"modules": {
"library": {
"enabled": false
},
"student_cards": {
"enabled": false,
"default_borrow_days": 14,
"max_borrow_days": 365
}
}
}
EOF
echo "Created default runtime config at $config_path"
fi
}
ensure_app_image_loaded() { ensure_app_image_loaded() {
if docker image inspect "$APP_IMAGE_VALUE" >/dev/null 2>&1; then if docker image inspect "$APP_IMAGE_VALUE" >/dev/null 2>&1; then
return 0 return 0
@@ -558,6 +596,7 @@ ensure_runtime_dependencies
setup_boot_autostart_service setup_boot_autostart_service
ensure_tls_certificates ensure_tls_certificates
ensure_nginx_config_mount_source ensure_nginx_config_mount_source
ensure_runtime_config_json
setup_scheduled_jobs setup_scheduled_jobs
configure_nuitka_mode configure_nuitka_mode
resolve_app_image resolve_app_image
Binary file not shown.
Binary file not shown.
Binary file not shown.
+24
View File
@@ -234,10 +234,26 @@ load_release_image() {
log_message "Loading app image from release asset $image_asset" log_message "Loading app image from release asset $image_asset"
curl -fL "$image_url" -o "$archive" curl -fL "$image_url" -o "$archive"
docker load -i "$archive" >> "$LOG_FILE" 2>&1 docker load -i "$archive" >> "$LOG_FILE" 2>&1
docker tag "$APP_IMAGE_REPO:$tag" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
trap - RETURN trap - RETURN
} }
refresh_runtime_scripts_from_main() {
local start_url stop_url restart_url update_url
start_url="https://raw.githubusercontent.com/$REPO_SLUG/main/start.sh"
stop_url="https://raw.githubusercontent.com/$REPO_SLUG/main/stop.sh"
restart_url="https://raw.githubusercontent.com/$REPO_SLUG/main/restart.sh"
update_url="https://raw.githubusercontent.com/$REPO_SLUG/main/update.sh"
curl -fsSL "$start_url" -o "$PROJECT_DIR/start.sh" || log_message "WARNING: Could not refresh start.sh from main"
curl -fsSL "$stop_url" -o "$PROJECT_DIR/stop.sh" || log_message "WARNING: Could not refresh stop.sh from main"
curl -fsSL "$restart_url" -o "$PROJECT_DIR/restart.sh" || log_message "WARNING: Could not refresh restart.sh from main"
curl -fsSL "$update_url" -o "$PROJECT_DIR/update.sh" || log_message "WARNING: Could not refresh update.sh from main"
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh"
}
find_local_dist_image_archive() { find_local_dist_image_archive() {
local tag="$1" local tag="$1"
local archive local archive
@@ -277,6 +293,7 @@ load_local_dist_image() {
log_message "Loading app image from local dist artifact: $archive" log_message "Loading app image from local dist artifact: $archive"
if docker load -i "$archive" >> "$LOG_FILE" 2>&1; then if docker load -i "$archive" >> "$LOG_FILE" 2>&1; then
docker tag "$APP_IMAGE_REPO:$tag" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
return 0 return 0
fi fi
@@ -308,6 +325,11 @@ download_and_extract_bundle() {
cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh" cp -f "$tmp_dir/update.sh" "$PROJECT_DIR/update.sh"
fi fi
if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
cp -f "$tmp_dir/config.json" "$PROJECT_DIR/config.json"
log_message "Installed default config.json from release bundle"
fi
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh"
} }
@@ -342,6 +364,7 @@ EOF
docker compose --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1 docker compose --env-file "$ENV_FILE" pull nginx mongodb >> "$LOG_FILE" 2>&1
docker compose --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1 docker compose --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
} }
verify_stack_health() { verify_stack_health() {
@@ -460,6 +483,7 @@ main() {
log_message "Updating from release $latest_tag" log_message "Updating from release $latest_tag"
download_and_extract_bundle "$bundle_url" "$tmp_dir" download_and_extract_bundle "$bundle_url" "$tmp_dir"
refresh_runtime_scripts_from_main
deploy "$latest_tag" "$meta_file" deploy "$latest_tag" "$meta_file"
if ! verify_stack_health; then if ! verify_stack_health; then
log_message "ERROR: Updated stack failed health check" log_message "ERROR: Updated stack failed health check"