Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5afcaa5e19 | |||
| 3aa44b64b8 | |||
| 86d8f19313 | |||
| e74798f252 | |||
| 31c3d700a8 | |||
| a3c114e4b5 | |||
| 170a2a53ab | |||
| a8ac08d5b0 | |||
| 05c4c73635 | |||
| 76dd00831e | |||
| 205a56ff24 | |||
| 60ec21f34e | |||
| 57f6d2f8e7 | |||
| 53575c1876 | |||
| 0f372f5056 | |||
| c078de0076 | |||
| daccf3334b | |||
| ef605e080b | |||
| 1755ec222d | |||
| 06ab3e1f4d | |||
| a2c79c80bc | |||
| 7ac66ae3ba | |||
| 444a1df1aa | |||
| 0581b5c5e9 | |||
| 2faf284a0e | |||
| 025e03f9ce | |||
| a96e103733 | |||
| b636d06908 | |||
| a85461711c | |||
| bf0a50ad57 | |||
| 9212ad04f5 | |||
| 1931e07013 | |||
| c81aa882e9 |
@@ -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
@@ -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", "-"]
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Inventarsystem
|
# Inventarsystem
|
||||||
|
|
||||||
|
[](https://github.com/AIIrondev/legendary-octo-garbanzo/actions/workflows/release-docker.yml)
|
||||||
|
|
||||||
[](https://wakatime.com/badge/user/30b8509f-5e17-4d16-b6b8-3ca0f3f936d3/project/8a380b7f-389f-4a7e-8877-0fe9e1a4c243)
|
[](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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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'])
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ pytz
|
|||||||
requests
|
requests
|
||||||
reportlab
|
reportlab
|
||||||
python-barcode
|
python-barcode
|
||||||
|
openpyxl
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ pytz
|
|||||||
requests
|
requests
|
||||||
reportlab
|
reportlab
|
||||||
python-barcode
|
python-barcode
|
||||||
|
openpyxl
|
||||||
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user