Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3aeb289ef | |||
| a6b90a6a9d | |||
| 0a5d7dedea | |||
| 74e11b5370 | |||
| c0d6ad7b72 | |||
| 7da7152332 | |||
| 3c9ab099ba | |||
| a81a8775a5 | |||
| 1633118d7f | |||
| a4b900c986 | |||
| a06393a915 | |||
| 1b9bfe1b14 | |||
| 3cdd1552d9 | |||
| 80a206f524 | |||
| 3217da8b15 | |||
| 22bc7c9c3c | |||
| f156cc178a | |||
| 116261d334 | |||
| 042afc19e2 | |||
| 28d049b52a | |||
| 8e478aeba4 | |||
| 1b38d1f0c8 | |||
| e7e24b3fae | |||
| fc80857fbe | |||
| f763ad064d | |||
| bc94ffdc18 | |||
| 76e9670cee | |||
| 577c6c4bed | |||
| 3aa19b10d1 | |||
| ff742d018e | |||
| 6fa40f26b0 | |||
| 777fc81064 | |||
| cf5e38e319 | |||
| b76941d5fe | |||
| f46f2674b0 | |||
| 8ebbdcfd54 | |||
| 92b5235035 | |||
| 4bda2e044b | |||
| b83b6c0ba2 | |||
| ab718d6ac2 | |||
| 112d9b6aa0 | |||
| 5139a50a43 | |||
| 997c7b42d5 | |||
| 20e5d3bb9a | |||
| f62b22c2f7 | |||
| e76d525e4b | |||
| 7117dac67c | |||
| 5313c507ed | |||
| 419fed4492 | |||
| c7112c7a42 | |||
| fb73c9d4e7 | |||
| 8414e27f25 | |||
| 5903f2afdd |
@@ -15,15 +15,6 @@ on:
|
|||||||
- patch
|
- patch
|
||||||
- minor
|
- minor
|
||||||
- major
|
- major
|
||||||
- development
|
|
||||||
push_dev:
|
|
||||||
description: "If true, push the :dev image to GHCR for development releases"
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- "true"
|
|
||||||
- "false"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -48,13 +39,12 @@ jobs:
|
|||||||
EVENT_NAME: ${{ github.event_name }}
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
|
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
|
||||||
PUSH_DEV: ${{ github.event.inputs.push_dev || 'false' }}
|
|
||||||
run: |
|
run: |
|
||||||
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
||||||
TAG="$REF_NAME"
|
TAG="$REF_NAME"
|
||||||
else
|
else
|
||||||
# Fetch latest release tag via GitHub API (fall back to v3.0.0)
|
# Fetch latest release tag via GitHub API (fall back to v0.8.31)
|
||||||
latest_tag="v3.0.0"
|
latest_tag="v0.8.31"
|
||||||
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null); then
|
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null); then
|
||||||
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||||
if [ -n "$tag_name" ]; then
|
if [ -n "$tag_name" ]; then
|
||||||
@@ -67,7 +57,7 @@ jobs:
|
|||||||
minor=${BASH_REMATCH[2]}
|
minor=${BASH_REMATCH[2]}
|
||||||
patch=${BASH_REMATCH[3]}
|
patch=${BASH_REMATCH[3]}
|
||||||
else
|
else
|
||||||
major=3; minor=0; patch=0
|
major=0; minor=8; patch=31
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Bump strategy: major / minor / patch
|
# Bump strategy: major / minor / patch
|
||||||
@@ -78,30 +68,28 @@ jobs:
|
|||||||
else
|
else
|
||||||
patch=$((patch + 1))
|
patch=$((patch + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${BUMP_TYPE:-}" = "development" ]; then
|
# Zusammenbau des Tags für den manuellen Run
|
||||||
TAG="v${major}.${minor}.${patch}-dev"
|
TAG="v${major}.${minor}.${patch}"
|
||||||
else
|
|
||||||
TAG="v${major}.${minor}.${patch}"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Validierung des erzeugten oder übergebenen Tags
|
||||||
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-dev(\.[0-9]+)?)?$'; then
|
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-dev(\.[0-9]+)?)?$'; then
|
||||||
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z or vX.Y.Z-dev)"
|
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
git fetch --tags --force
|
git fetch --tags --force
|
||||||
LATEST_TAG="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)"
|
LATEST_TAG="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)"
|
||||||
if [ -z "$LATEST_TAG" ]; then
|
if [ -z "$LATEST_TAG" ]; then
|
||||||
LATEST_TAG="v3.0.0"
|
LATEST_TAG="v0.8.31"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TAG_MAJOR="${TAG#v}"
|
TAG_MAJOR="${TAG#v}"
|
||||||
TAG_MAJOR="${TAG_MAJOR%%.*}"
|
TAG_MAJOR="${TAG_MAJOR%%.*}"
|
||||||
LATEST_MAJOR="$(echo "$LATEST_TAG" | grep -Eo '^v[0-9]+' | tr -d 'v')"
|
LATEST_MAJOR="$(echo "$LATEST_TAG" | grep -Eo '^v[0-9]+' | tr -d 'v')"
|
||||||
if [ -z "$LATEST_MAJOR" ]; then
|
if [ -z "$LATEST_MAJOR" ]; then
|
||||||
LATEST_MAJOR="3"
|
LATEST_MAJOR="0"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# If not explicitly bumping major, disallow changing major version
|
# If not explicitly bumping major, disallow changing major version
|
||||||
@@ -123,33 +111,6 @@ jobs:
|
|||||||
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
|
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
|
||||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
||||||
if [ "${BUMP_TYPE:-}" = "development" ]; then
|
|
||||||
echo "is_development=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "is_development=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
if [ "${BUMP_TYPE:-}" = "development" ] && [ "${PUSH_DEV:-}" = "true" ]; then
|
|
||||||
echo "push_dev=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "push_dev=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Update .release-version file
|
|
||||||
run: |
|
|
||||||
echo "${{ steps.meta.outputs.tag }}" > .release-version
|
|
||||||
cat .release-version
|
|
||||||
|
|
||||||
- name: Create and push tag for manual releases
|
|
||||||
if: github.event_name == 'workflow_dispatch'
|
|
||||||
run: |
|
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add .release-version
|
|
||||||
git commit -m "chore: bump version to $TAG" || true
|
|
||||||
git tag "$TAG"
|
|
||||||
git push origin "$TAG"
|
|
||||||
git push origin HEAD:${{ github.ref_name }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -162,26 +123,16 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push release image
|
- name: Build and push release image
|
||||||
if: steps.meta.outputs.is_development != 'true'
|
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: true
|
push: true
|
||||||
|
load: true # Lädt das Image in den lokalen Docker-Daemon für den 'docker save' Schritt
|
||||||
tags: |
|
tags: |
|
||||||
${{ steps.meta.outputs.image }}
|
${{ steps.meta.outputs.image }}
|
||||||
ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
||||||
|
|
||||||
- name: Build and push development image (:dev)
|
|
||||||
if: steps.meta.outputs.is_development == 'true' && steps.meta.outputs.push_dev == 'true'
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
ghcr.io/aiirondev/legendary-octo-garbanzo:dev
|
|
||||||
|
|
||||||
- name: Build local image tar for offline deploy
|
- name: Build local image tar for offline deploy
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -204,30 +155,17 @@ jobs:
|
|||||||
docker build -t "$IMG" .
|
docker build -t "$IMG" .
|
||||||
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
||||||
|
|
||||||
# development tar omitted: dev releases will be versioned (vX.Y.Z-dev) and handled by update.sh using the tag
|
|
||||||
|
|
||||||
- name: Commit .release-version for tag pushes
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
if ! git diff --quiet .release-version; then
|
|
||||||
git add .release-version
|
|
||||||
git commit -m "chore: update version to ${{ steps.meta.outputs.tag }}"
|
|
||||||
git push origin HEAD:${{ github.ref_name }}
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create release-only docker bundle
|
- name: Create release-only docker bundle
|
||||||
run: |
|
run: |
|
||||||
mkdir -p release-bundle
|
mkdir -p release-bundle
|
||||||
cat > release-bundle/docker-compose.yml <<EOF
|
cat > release-bundle/docker-compose.yml <<EOF
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
|
image: \${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
|
||||||
container_name: inventarsystem-app
|
container_name: inventarsystem-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${INVENTAR_HTTP_PORT:-10000}:8000"
|
- "\${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- mongodb
|
- mongodb
|
||||||
- redis
|
- redis
|
||||||
@@ -300,17 +238,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
cat > release-bundle/DEVELOPMENT.md <<EOF
|
|
||||||
This is a development prerelease bundle for tag: ${{ steps.meta.outputs.tag }}
|
|
||||||
|
|
||||||
This prerelease is intentionally separate from normal releases and will not be used by default.
|
|
||||||
|
|
||||||
To install this prerelease on a target host run:
|
|
||||||
|
|
||||||
./update.sh ${{ steps.meta.outputs.tag }}
|
|
||||||
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Make any shipped scripts executable
|
# Make any shipped scripts executable
|
||||||
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
|
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
|
||||||
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
||||||
@@ -319,10 +246,8 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.meta.outputs.tag }}
|
tag_name: ${{ steps.meta.outputs.tag }}
|
||||||
prerelease: ${{ steps.meta.outputs.is_development }}
|
|
||||||
files: |
|
files: |
|
||||||
inventarsystem-docker-bundle.tar.gz
|
inventarsystem-docker-bundle.tar.gz
|
||||||
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
|
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
|
||||||
inventarsystem-image-dev.tar.gz
|
|
||||||
fail_on_unmatched_files: false
|
fail_on_unmatched_files: false
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.8.23
|
v0.8.31.1
|
||||||
|
|||||||
+292
-163
@@ -37,6 +37,7 @@ if _CURRENT_DIR not in sys.path:
|
|||||||
import Web.modules.database.user as us
|
import Web.modules.database.user as us
|
||||||
import Web.modules.database.items as it
|
import Web.modules.database.items as it
|
||||||
import Web.modules.database.ausleihung as au
|
import Web.modules.database.ausleihung as au
|
||||||
|
import Web.modules.database.termine as termin
|
||||||
import Web.modules.log.audit_log as al
|
import Web.modules.log.audit_log as al
|
||||||
import push_notifications as pn
|
import push_notifications as pn
|
||||||
import Web.modules.inventarsystem.pdf_export as pdf_export
|
import Web.modules.inventarsystem.pdf_export as pdf_export
|
||||||
@@ -1267,7 +1268,8 @@ def inject_version():
|
|||||||
'inventory_module_enabled': cfg.MODULES.is_enabled('inventory'),
|
'inventory_module_enabled': cfg.MODULES.is_enabled('inventory'),
|
||||||
'terminplan_module_enabled': cfg.MODULES.is_enabled('terminplan'),
|
'terminplan_module_enabled': cfg.MODULES.is_enabled('terminplan'),
|
||||||
'library_module_enabled': cfg.MODULES.is_enabled('library'),
|
'library_module_enabled': cfg.MODULES.is_enabled('library'),
|
||||||
'student_cards_module_enabled': cfg.MODULES.is_enabled('student_cards'),
|
'student_cards_module_enabled': cfg.MODULES.is_enabled('student_cards'),#
|
||||||
|
'mail_module_enabled': cfg.MODULES.is_enabled('mail'),
|
||||||
'is_admin': is_admin,
|
'is_admin': is_admin,
|
||||||
'unread_notification_count': unread_notification_count,
|
'unread_notification_count': unread_notification_count,
|
||||||
'current_permissions': current_permissions,
|
'current_permissions': current_permissions,
|
||||||
@@ -2884,6 +2886,7 @@ def home():
|
|||||||
username=session['username'],
|
username=session['username'],
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||||
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
||||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
||||||
open_item=request.args.get('open_item')
|
open_item=request.args.get('open_item')
|
||||||
@@ -2926,6 +2929,7 @@ def home_admin():
|
|||||||
username=session['username'],
|
username=session['username'],
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||||
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
||||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
||||||
school_info=_get_school_info_for_export(),
|
school_info=_get_school_info_for_export(),
|
||||||
@@ -2946,6 +2950,7 @@ def tutorial_page():
|
|||||||
is_admin=us.check_admin(session['username']),
|
is_admin=us.check_admin(session['username']),
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||||
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
||||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
|
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
|
||||||
)
|
)
|
||||||
@@ -4817,59 +4822,73 @@ def get_user_appointments():
|
|||||||
db = client[MONGODB_DB]
|
db = client[MONGODB_DB]
|
||||||
items_col = db['items']
|
items_col = db['items']
|
||||||
|
|
||||||
# Use the established user-specific route logic for appointments.
|
# Use the appointment collection for the user's appointments
|
||||||
bookings = au.get_ausleihung_by_user(
|
appointments = termin.get_upcoming_for_user(username, limit=250)
|
||||||
username,
|
|
||||||
status=['planned', 'active', 'completed'],
|
|
||||||
use_client_side_verification=True,
|
|
||||||
)
|
|
||||||
bookings = sorted(bookings, key=lambda b: b.get('Start') or datetime.datetime.min)
|
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for booking in bookings:
|
import re as _re
|
||||||
start_dt = booking.get('Start')
|
|
||||||
if not start_dt:
|
def _date_iter(start_value: str, end_value: str):
|
||||||
|
try:
|
||||||
|
start_date = datetime.datetime.strptime(start_value, '%Y-%m-%d').date()
|
||||||
|
end_date = datetime.datetime.strptime(end_value, '%Y-%m-%d').date()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if end_date < start_date:
|
||||||
|
end_date = start_date
|
||||||
|
cursor = start_date
|
||||||
|
days = []
|
||||||
|
while cursor <= end_date:
|
||||||
|
days.append(cursor.strftime('%Y-%m-%d'))
|
||||||
|
cursor += datetime.timedelta(days=1)
|
||||||
|
return days
|
||||||
|
|
||||||
|
for appt in appointments:
|
||||||
|
appt_id = str(appt.get('_id') or '')
|
||||||
|
if not appt_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
end_dt = booking.get('End')
|
date_start = str(appt.get('date_start') or '')
|
||||||
if not end_dt and isinstance(start_dt, datetime.datetime):
|
date_end = str(appt.get('date_end') or date_start)
|
||||||
end_dt = start_dt + datetime.timedelta(minutes=45)
|
time_span = appt.get('time_span', []) or []
|
||||||
elif not end_dt:
|
title = appt.get('note') or f"Termin von {appt.get('user') or ''}"
|
||||||
end_dt = start_dt
|
days_in_range = _date_iter(date_start, date_end) or ([date_start] if date_start else [])
|
||||||
|
|
||||||
item_id = str(booking.get('Item') or '')
|
span_entries = []
|
||||||
item_doc = None
|
for entry in time_span:
|
||||||
if item_id:
|
s = str(entry or '').strip()
|
||||||
try:
|
if not s:
|
||||||
item_doc = items_col.find_one({'_id': ObjectId(item_id)})
|
continue
|
||||||
except Exception:
|
m_date = _re.match(r"^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$", s)
|
||||||
item_doc = None
|
if m_date:
|
||||||
|
span_entries.append((m_date.group(1), m_date.group(2), m_date.group(3)))
|
||||||
|
continue
|
||||||
|
m = _re.match(r"^(\d{2}:\d{2})-(\d{2}:\d{2})$", s)
|
||||||
|
if m:
|
||||||
|
for day in days_in_range:
|
||||||
|
span_entries.append((day, m.group(1), m.group(2)))
|
||||||
|
|
||||||
item_name = item_id or 'Termin'
|
# If the stored span is already day-specific, use it as-is.
|
||||||
if item_doc:
|
# If it was generic, we duplicated it to each day in the range above.
|
||||||
item_name = item_doc.get('Name') or item_doc.get('Code_4') or item_name
|
if not span_entries and days_in_range:
|
||||||
|
# Fallback: create a simple all-day marker for each date so the appointment is visible.
|
||||||
|
for day in days_in_range:
|
||||||
|
span_entries.append((day, '08:00', '16:45'))
|
||||||
|
|
||||||
period = booking.get('Period')
|
for day, start_time, end_time in span_entries:
|
||||||
title = item_name
|
result.append({
|
||||||
if period:
|
'id': f"{appt_id}-{day}-{start_time}",
|
||||||
title = f"{title} - {period}. Std"
|
'title': title,
|
||||||
|
'start': f"{day}T{start_time}",
|
||||||
status = booking.get('VerifiedStatus') or booking.get('Status') or 'unknown'
|
'end': f"{day}T{end_time}",
|
||||||
if status == 'active':
|
'status': 'planned',
|
||||||
status = 'current'
|
'itemId': appt_id,
|
||||||
result.append({
|
'userName': str(appt.get('user') or ''),
|
||||||
'id': str(booking.get('_id')),
|
'notes': str(appt.get('note') or ''),
|
||||||
'title': title,
|
'period': None,
|
||||||
'start': start_dt.isoformat() if isinstance(start_dt, datetime.datetime) else str(start_dt),
|
'isCurrentUser': True,
|
||||||
'end': end_dt.isoformat() if isinstance(end_dt, datetime.datetime) else str(end_dt),
|
'itemBorrower': '',
|
||||||
'status': status,
|
})
|
||||||
'itemId': item_id,
|
|
||||||
'userName': str(booking.get('User') or ''),
|
|
||||||
'notes': str(booking.get('Notes') or ''),
|
|
||||||
'period': period,
|
|
||||||
'isCurrentUser': True,
|
|
||||||
'itemBorrower': '',
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({'ok': True, 'bookings': result})
|
return jsonify({'ok': True, 'bookings': result})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5192,55 +5211,87 @@ def upload_item():
|
|||||||
flash(error_msg, 'error')
|
flash(error_msg, 'error')
|
||||||
return redirect(url_for(success_redirect_endpoint))
|
return redirect(url_for(success_redirect_endpoint))
|
||||||
|
|
||||||
# Check if base code is unique for single-item uploads
|
# -------------------------------------------------------------------
|
||||||
if code_4 and item_count == 1 and not it.is_code_unique(code_4[0]):
|
# 1. PARSE AND UNIFY CODES
|
||||||
existing_item = it._get_items_collection().find_one({"code_4": code_4[0]})
|
# -------------------------------------------------------------------
|
||||||
if existing_item:
|
# Assuming `code_4` might come in as a single string or a list depending on your form setup
|
||||||
error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.'
|
primary_code_raw = request.form.get('code_4', '').strip()
|
||||||
if is_mobile:
|
individual_codes_raw = request.form.get('individual_codes', '').strip()
|
||||||
return jsonify({
|
|
||||||
'success': False,
|
all_item_codes = []
|
||||||
'message': error_msg,
|
|
||||||
'existing_item_id': str(existing_item['_id']),
|
if primary_code_raw:
|
||||||
'redirect_to_item': True
|
all_item_codes.append(primary_code_raw)
|
||||||
}), 400
|
|
||||||
|
if individual_codes_raw:
|
||||||
flash(error_msg, 'info')
|
# Split textarea by newlines, clean whitespace, and ignore empty lines
|
||||||
return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id'])))
|
extra_codes = [c.strip() for c in individual_codes_raw.replace('\r', '').split('\n') if c.strip()]
|
||||||
|
|
||||||
|
# Add to master list, ensuring no duplicates within the submitted list itself
|
||||||
|
for c in extra_codes:
|
||||||
|
if c not in all_item_codes:
|
||||||
|
all_item_codes.append(c)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# 2. OVERRIDE ITEM COUNT
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# If the user scanned/generated codes, the amount of codes IS the item count.
|
||||||
|
# Otherwise, fallback to a manual item_count field if it exists.
|
||||||
|
if len(all_item_codes) > 0:
|
||||||
|
item_count = len(all_item_codes)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
item_count = int(request.form.get('item_count', 1))
|
||||||
|
except ValueError:
|
||||||
|
item_count = 1
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# 3. IMAGE VALIDATION
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
if upload_mode != 'library' and not is_duplicating and not images and not duplicate_images and not book_cover_image:
|
||||||
|
error_msg = 'Bitte laden Sie mindestens ein Bild hoch'
|
||||||
|
if is_mobile:
|
||||||
|
return jsonify({'success': False, 'message': error_msg}), 400
|
||||||
else:
|
else:
|
||||||
error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.'
|
flash(error_msg, 'error')
|
||||||
|
return redirect(url_for(success_redirect_endpoint))
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# 4. UNIFIED DB UNIQUENESS VALIDATION
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Validate every code in our master list against the database
|
||||||
|
for code in all_item_codes:
|
||||||
|
if not it.is_code_unique(code):
|
||||||
|
|
||||||
|
# Special case: If they only uploaded ONE item, redirect them to that existing item
|
||||||
|
if item_count == 1:
|
||||||
|
existing_item = it._get_items_collection().find_one({"code_4": code})
|
||||||
|
if existing_item:
|
||||||
|
error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.'
|
||||||
|
if is_mobile:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': error_msg,
|
||||||
|
'existing_item_id': str(existing_item['_id']),
|
||||||
|
'redirect_to_item': True
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
flash(error_msg, 'info')
|
||||||
|
return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id'])))
|
||||||
|
|
||||||
|
# If multiple items are being uploaded, just throw a standard error
|
||||||
|
error_msg = f'Der Code "{code}" wird bereits von einem anderen Artikel verwendet. Bitte überprüfen Sie Ihre Eingaben.'
|
||||||
if is_mobile:
|
if is_mobile:
|
||||||
return jsonify({'success': False, 'message': error_msg}), 400
|
return jsonify({'success': False, 'message': error_msg}), 400
|
||||||
else:
|
else:
|
||||||
flash(error_msg, 'error')
|
flash(error_msg, 'error')
|
||||||
return redirect(url_for(success_redirect_endpoint))
|
return redirect(url_for(success_redirect_endpoint))
|
||||||
|
|
||||||
# Validate optional per-item codes
|
# -------------------------------------------------------------------
|
||||||
if individual_codes:
|
# 5. BATCH CODE GENERATOR (Remains mostly unchanged)
|
||||||
if len(individual_codes) > item_count:
|
# -------------------------------------------------------------------
|
||||||
error_msg = f'Zu viele Einzelcodes angegeben ({len(individual_codes)}), erlaubt sind maximal {item_count}.'
|
|
||||||
if is_mobile:
|
|
||||||
return jsonify({'success': False, 'message': error_msg}), 400
|
|
||||||
flash(error_msg, 'error')
|
|
||||||
return redirect(url_for(success_redirect_endpoint))
|
|
||||||
|
|
||||||
if len(set(individual_codes)) != len(individual_codes):
|
|
||||||
error_msg = 'Doppelte Einzelcodes erkannt. Bitte alle Codes eindeutig eintragen.'
|
|
||||||
if is_mobile:
|
|
||||||
return jsonify({'success': False, 'message': error_msg}), 400
|
|
||||||
flash(error_msg, 'error')
|
|
||||||
return redirect(url_for(success_redirect_endpoint))
|
|
||||||
|
|
||||||
for specific_code in individual_codes:
|
|
||||||
if not it.is_code_unique(specific_code):
|
|
||||||
error_msg = f'Der Einzelcode "{specific_code}" wird bereits verwendet.'
|
|
||||||
if is_mobile:
|
|
||||||
return jsonify({'success': False, 'message': error_msg}), 400
|
|
||||||
flash(error_msg, 'error')
|
|
||||||
return redirect(url_for(success_redirect_endpoint))
|
|
||||||
|
|
||||||
def generate_unique_batch_code(base_code, position):
|
def generate_unique_batch_code(base_code, position):
|
||||||
"""Generate a unique code for every item in a batch."""
|
"""Generate a unique code for every item in a batch if no specific code is provided."""
|
||||||
if not base_code:
|
if not base_code:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -7933,7 +7984,8 @@ def admin_borrowings():
|
|||||||
'admin_borrowings.html',
|
'admin_borrowings.html',
|
||||||
entries=entries,
|
entries=entries,
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards')
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail')
|
||||||
)
|
)
|
||||||
|
|
||||||
"""-----------------------------------------------------------Audit Routes-------------------------------------------------------"""
|
"""-----------------------------------------------------------Audit Routes-------------------------------------------------------"""
|
||||||
@@ -7996,6 +8048,7 @@ def admin_audit_dashboard():
|
|||||||
audit_rows=audit_rows,
|
audit_rows=audit_rows,
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.error(f"Error loading audit dashboard: {exc}")
|
app.logger.error(f"Error loading audit dashboard: {exc}")
|
||||||
@@ -8811,6 +8864,7 @@ def library_item_invoices(item_id):
|
|||||||
invoices=entries,
|
invoices=entries,
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail')
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error loading invoice history for item {item_id}: {e}")
|
app.logger.error(f"Error loading invoice history for item {item_id}: {e}")
|
||||||
@@ -9361,17 +9415,137 @@ def search_word(word):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "response": str(e)})
|
return jsonify({"success": False, "response": str(e)})
|
||||||
|
|
||||||
|
def _fetch_from_google_books(clean_isbn):
|
||||||
|
"""Source 1: Google Books API (Free, No Key required for basic use)"""
|
||||||
|
try:
|
||||||
|
url = f"https://www.googleapis.com/books/v1/volumes?q=isbn:{clean_isbn}"
|
||||||
|
response = requests.get(url, timeout=5)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if data.get('totalItems', 0) > 0 and data.get('items'):
|
||||||
|
book_info = data['items'][0].get('volumeInfo', {})
|
||||||
|
sale_info = data['items'][0].get('saleInfo', {})
|
||||||
|
|
||||||
|
price = None
|
||||||
|
retail_price = sale_info.get('retailPrice', {})
|
||||||
|
list_price = sale_info.get('listPrice', {})
|
||||||
|
if retail_price and 'amount' in retail_price:
|
||||||
|
price = f"{retail_price['amount']} {retail_price.get('currencyCode', '€')}"
|
||||||
|
elif list_price and 'amount' in list_price:
|
||||||
|
price = f"{list_price['amount']} {list_price.get('currencyCode', '€')}"
|
||||||
|
|
||||||
|
thumbnail = book_info.get('imageLinks', {}).get('thumbnail', '')
|
||||||
|
if thumbnail:
|
||||||
|
thumbnail = thumbnail.replace('http:', 'https:')
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": book_info.get('title', 'Unknown Title'),
|
||||||
|
"authors": ', '.join(book_info.get('authors', ['Unknown Author'])),
|
||||||
|
"publisher": book_info.get('publisher', 'Unknown Publisher'),
|
||||||
|
"publishedDate": book_info.get('publishedDate', 'Unknown Date'),
|
||||||
|
"description": book_info.get('description', 'Keine Beschreibung verfügbar'),
|
||||||
|
"pageCount": book_info.get('pageCount', 'Unknown'),
|
||||||
|
"price": price,
|
||||||
|
"thumbnail": thumbnail,
|
||||||
|
"source": "google-books"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Google Books error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_from_lobid_germany(clean_isbn):
|
||||||
|
"""
|
||||||
|
Source 2: Lobid.org (hbz Network)
|
||||||
|
The ultimate free, keyless fallback for German Schoolbooks and DACH literature.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = f"https://lobid.org/resources?q=isbn:{clean_isbn}&format=json"
|
||||||
|
response = requests.get(url, timeout=5)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if 'member' in data and len(data['member']) > 0:
|
||||||
|
book = data['member'][0]
|
||||||
|
|
||||||
|
# Extract Authors (Lobid uses a nested contribution array)
|
||||||
|
authors = []
|
||||||
|
for contrib in book.get('contribution', []):
|
||||||
|
if 'agent' in contrib and 'label' in contrib['agent']:
|
||||||
|
authors.append(contrib['agent']['label'])
|
||||||
|
|
||||||
|
# Extract Publishers
|
||||||
|
publishers = []
|
||||||
|
for pub in book.get('publication', []):
|
||||||
|
if 'publishedBy' in pub:
|
||||||
|
# sometimes publishedBy is a list, sometimes a string
|
||||||
|
pub_data = pub['publishedBy']
|
||||||
|
if isinstance(pub_data, list):
|
||||||
|
publishers.extend(pub_data)
|
||||||
|
else:
|
||||||
|
publishers.append(pub_data)
|
||||||
|
|
||||||
|
# Extract Date
|
||||||
|
pub_date = 'Unknown Date'
|
||||||
|
if book.get('publication'):
|
||||||
|
pub_date = book['publication'][0].get('startDate', 'Unknown Date')
|
||||||
|
|
||||||
|
# Extract Pages
|
||||||
|
pages = book.get('extent', ['Unknown'])[0] if 'extent' in book else 'Unknown'
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": book.get('title', 'Unknown Title'),
|
||||||
|
"authors": ', '.join(authors) if authors else 'Unknown Author',
|
||||||
|
"publisher": ', '.join(publishers) if publishers else 'Unknown Publisher',
|
||||||
|
"publishedDate": str(pub_date),
|
||||||
|
"description": 'Keine Beschreibung verfügbar (Schulbuch / Fachliteratur)',
|
||||||
|
"pageCount": pages,
|
||||||
|
"price": None,
|
||||||
|
"thumbnail": "", # Lobid rarely has covers, we will fallback to OpenLibrary cover below
|
||||||
|
"source": "lobid-germany"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Lobid error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_from_open_library(clean_isbn):
|
||||||
|
"""Source 3: Open Library Search API (Free, No Key required)"""
|
||||||
|
try:
|
||||||
|
url = f"https://openlibrary.org/search.json?isbn={clean_isbn}"
|
||||||
|
response = requests.get(url, timeout=5)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if data.get('numFound', 0) > 0 and data.get('docs'):
|
||||||
|
book_info = data['docs'][0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": book_info.get('title', 'Unknown Title'),
|
||||||
|
"authors": ', '.join(book_info.get('author_name', ['Unknown Author'])),
|
||||||
|
"publisher": ', '.join(book_info.get('publisher', ['Unknown Publisher'])),
|
||||||
|
"publishedDate": str(book_info.get('publish_date', ['Unknown Date'])[0]),
|
||||||
|
"description": 'Keine Beschreibung verfügbar',
|
||||||
|
"pageCount": book_info.get('number_of_pages_median', 'Unknown'),
|
||||||
|
"price": None,
|
||||||
|
"thumbnail": f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg",
|
||||||
|
"source": "openlibrary"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"OpenLibrary Search error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
@app.route('/fetch_book_info/<isbn>')
|
@app.route('/fetch_book_info/<isbn>')
|
||||||
def fetch_book_info(isbn):
|
def fetch_book_info(isbn):
|
||||||
"""
|
"""
|
||||||
API endpoint to fetch book information by ISBN using Google Books API
|
API endpoint to fetch book information by ISBN using multiple open sources.
|
||||||
|
Optimized for global literature AND German educational books.
|
||||||
Args:
|
|
||||||
isbn (str): ISBN to look up
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Book information or error message
|
|
||||||
"""
|
"""
|
||||||
|
# Authorization Checks
|
||||||
if 'username' not in session or not us.check_admin(session['username']):
|
if 'username' not in session or not us.check_admin(session['username']):
|
||||||
return jsonify({"error": "Not authorized"}), 403
|
return jsonify({"error": "Not authorized"}), 403
|
||||||
|
|
||||||
@@ -9379,79 +9553,33 @@ def fetch_book_info(isbn):
|
|||||||
return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403
|
return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Validation
|
||||||
clean_isbn = normalize_and_validate_isbn(isbn)
|
clean_isbn = normalize_and_validate_isbn(isbn)
|
||||||
if not clean_isbn:
|
if not clean_isbn:
|
||||||
return jsonify({"error": "Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden."}), 400
|
return jsonify({"error": "Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden."}), 400
|
||||||
|
|
||||||
# First source: Google Books
|
# Define the Provider Chain (Order matters: General -> German/School -> Global Fallback)
|
||||||
response = requests.get(
|
providers = [
|
||||||
f"https://www.googleapis.com/books/v1/volumes?q=isbn:{clean_isbn}",
|
_fetch_from_google_books,
|
||||||
timeout=10
|
_fetch_from_lobid_germany,
|
||||||
)
|
_fetch_from_open_library
|
||||||
|
]
|
||||||
|
|
||||||
if response.status_code == 200:
|
# Iterate through providers until a book is found
|
||||||
data = response.json()
|
for provider in providers:
|
||||||
if data.get('totalItems', 0) > 0 and data.get('items'):
|
book_data = provider(clean_isbn)
|
||||||
book_info = data['items'][0].get('volumeInfo', {})
|
|
||||||
sale_info = data['items'][0].get('saleInfo', {})
|
if book_data:
|
||||||
|
# Add the ISBN back into the response payload
|
||||||
price = None
|
book_data["isbn"] = clean_isbn
|
||||||
retail_price = sale_info.get('retailPrice', {})
|
|
||||||
list_price = sale_info.get('listPrice', {})
|
# If a provider found the book but had no cover, assign a default OpenLibrary cover fallback
|
||||||
if retail_price and 'amount' in retail_price:
|
if not book_data.get("thumbnail"):
|
||||||
price = f"{retail_price['amount']} {retail_price.get('currencyCode', '€')}"
|
book_data["thumbnail"] = f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg"
|
||||||
elif list_price and 'amount' in list_price:
|
|
||||||
price = f"{list_price['amount']} {list_price.get('currencyCode', '€')}"
|
return jsonify(book_data)
|
||||||
|
|
||||||
thumbnail = book_info.get('imageLinks', {}).get('thumbnail', '')
|
|
||||||
if thumbnail:
|
|
||||||
thumbnail = thumbnail.replace('http:', 'https:')
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"title": book_info.get('title', 'Unknown Title'),
|
|
||||||
"authors": ', '.join(book_info.get('authors', ['Unknown Author'])),
|
|
||||||
"publisher": book_info.get('publisher', 'Unknown Publisher'),
|
|
||||||
"publishedDate": book_info.get('publishedDate', 'Unknown Date'),
|
|
||||||
"description": book_info.get('description', 'No description available'),
|
|
||||||
"pageCount": book_info.get('pageCount', 'Unknown'),
|
|
||||||
"price": price,
|
|
||||||
"thumbnail": thumbnail,
|
|
||||||
"isbn": clean_isbn,
|
|
||||||
"source": "google-books"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Fallback: OpenLibrary
|
|
||||||
ol_response = requests.get(
|
|
||||||
f"https://openlibrary.org/isbn/{clean_isbn}.json",
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
if ol_response.status_code == 200:
|
|
||||||
ol_data = ol_response.json()
|
|
||||||
author_names = []
|
|
||||||
for author_ref in ol_data.get('authors', []):
|
|
||||||
key = author_ref.get('key')
|
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
author_resp = requests.get(f"https://openlibrary.org{key}.json", timeout=8)
|
|
||||||
if author_resp.status_code == 200:
|
|
||||||
author_names.append(author_resp.json().get('name'))
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"title": ol_data.get('title', 'Unknown Title'),
|
|
||||||
"authors": ', '.join([a for a in author_names if a]) if author_names else 'Unknown Author',
|
|
||||||
"publisher": ', '.join(ol_data.get('publishers', [])) if ol_data.get('publishers') else 'Unknown Publisher',
|
|
||||||
"publishedDate": ol_data.get('publish_date', 'Unknown Date'),
|
|
||||||
"description": (ol_data.get('description', {}).get('value') if isinstance(ol_data.get('description'), dict) else ol_data.get('description')) or 'No description available',
|
|
||||||
"pageCount": ol_data.get('number_of_pages', 'Unknown'),
|
|
||||||
"price": None,
|
|
||||||
"thumbnail": f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg",
|
|
||||||
"isbn": clean_isbn,
|
|
||||||
"source": "openlibrary"
|
|
||||||
})
|
|
||||||
|
|
||||||
|
# If all providers fail
|
||||||
return jsonify({"error": f"Kein Buch zu dieser ISBN gefunden: {clean_isbn}"}), 404
|
return jsonify({"error": f"Kein Buch zu dieser ISBN gefunden: {clean_isbn}"}), 404
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -10071,6 +10199,7 @@ def admin_damaged_items():
|
|||||||
damaged_items=damaged_rows,
|
damaged_items=damaged_rows,
|
||||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||||
|
mail_module_enabled=cfg.MODULES.is_enabled('mail')
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.error(f"Error loading damaged-items admin view: {exc}")
|
app.logger.error(f"Error loading damaged-items admin view: {exc}")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from modules.module_registry import ModuleRegistry as mr
|
||||||
import smtplib
|
import smtplib
|
||||||
|
|
||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
@@ -27,21 +28,24 @@ def send(email: list, subject: str, note: str, sender: str) -> bool:
|
|||||||
Output:
|
Output:
|
||||||
- bool: true if the sending worked and false if it didnt
|
- bool: true if the sending worked and false if it didnt
|
||||||
"""
|
"""
|
||||||
msg = MIMEMultipart()
|
if not mr.registry.is_enabled('mail'):
|
||||||
msg['Subject'] = subject
|
|
||||||
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
|
|
||||||
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
|
|
||||||
msg.attach(MIMEText(note))
|
|
||||||
smtp = None
|
|
||||||
try:
|
|
||||||
smtp = _build_smtp_client()
|
|
||||||
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
return False
|
||||||
finally:
|
else:
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
|
||||||
|
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
|
||||||
|
msg.attach(MIMEText(note))
|
||||||
|
smtp = None
|
||||||
try:
|
try:
|
||||||
if smtp:
|
smtp = _build_smtp_client()
|
||||||
smtp.quit()
|
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
|
||||||
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
return False
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if smtp:
|
||||||
|
smtp.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -82,6 +82,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
|||||||
time_span = item.get('time_span', []) or []
|
time_span = item.get('time_span', []) or []
|
||||||
creator = item.get('user', 'Terminplaner')
|
creator = item.get('user', 'Terminplaner')
|
||||||
note = item.get('note', '') or ''
|
note = item.get('note', '') or ''
|
||||||
|
titel = item.get('title', '') or ''
|
||||||
tenant_id = _current_tenant_id()
|
tenant_id = _current_tenant_id()
|
||||||
try:
|
try:
|
||||||
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
|
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
|
||||||
@@ -108,6 +109,8 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
|||||||
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
|
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
|
||||||
if note:
|
if note:
|
||||||
description_lines.append('Notiz: ' + str(note))
|
description_lines.append('Notiz: ' + str(note))
|
||||||
|
if titel:
|
||||||
|
description_lines.append('Titel: ' + str(titel))
|
||||||
|
|
||||||
ics_lines = [
|
ics_lines = [
|
||||||
'BEGIN:VCALENDAR',
|
'BEGIN:VCALENDAR',
|
||||||
@@ -123,6 +126,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
|||||||
f'URL:{_escape_ics_text(link)}',
|
f'URL:{_escape_ics_text(link)}',
|
||||||
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
|
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
|
||||||
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
|
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
|
||||||
|
f'Titel:{_escape_ics_text(titel)}',
|
||||||
'END:VEVENT',
|
'END:VEVENT',
|
||||||
'END:VCALENDAR',
|
'END:VCALENDAR',
|
||||||
'',
|
'',
|
||||||
@@ -159,7 +163,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
|||||||
if tenant_id:
|
if tenant_id:
|
||||||
link += f'?tenant={tenant_id}'
|
link += f'?tenant={tenant_id}'
|
||||||
|
|
||||||
title_name = str(client_name or '').strip() or 'Termin'
|
title_name = item.get('title') or f"Terminbuchung mit {client_name}" if client_name else "Terminbuchung"
|
||||||
summary = f"{title_name} - Terminbuchung"
|
summary = f"{title_name} - Terminbuchung"
|
||||||
description_lines = [
|
description_lines = [
|
||||||
f"Buchungslink: {link}",
|
f"Buchungslink: {link}",
|
||||||
@@ -185,6 +189,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
|||||||
f'URL:{_escape_ics_text(link)}',
|
f'URL:{_escape_ics_text(link)}',
|
||||||
f'DTSTART:{dt_start}',
|
f'DTSTART:{dt_start}',
|
||||||
f'DTEND:{dt_end}',
|
f'DTEND:{dt_end}',
|
||||||
|
f'Titel:{_escape_ics_text(summary)}',
|
||||||
'END:VEVENT',
|
'END:VEVENT',
|
||||||
'END:VCALENDAR',
|
'END:VCALENDAR',
|
||||||
'',
|
'',
|
||||||
@@ -192,7 +197,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
|||||||
return '\r\n'.join(ics_lines)
|
return '\r\n'.join(ics_lines)
|
||||||
|
|
||||||
|
|
||||||
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False) -> dict:
|
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="") -> dict:
|
||||||
"""
|
"""
|
||||||
Generates a link for the executive to send to his clients to book a time Slot
|
Generates a link for the executive to send to his clients to book a time Slot
|
||||||
|
|
||||||
@@ -208,7 +213,7 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
|||||||
"""
|
"""
|
||||||
normalized_time_span = _normalize_time_span(time_span)
|
normalized_time_span = _normalize_time_span(time_span)
|
||||||
normalized_mail = _normalize_mail_list(mail)
|
normalized_mail = _normalize_mail_list(mail)
|
||||||
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note, calendar_enabled=calendar_enabled)
|
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title)
|
||||||
id_str = str(id)
|
id_str = str(id)
|
||||||
tenant_id = _current_tenant_id()
|
tenant_id = _current_tenant_id()
|
||||||
|
|
||||||
@@ -219,7 +224,7 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
|||||||
link = host + "/terminplaner/client/" + id_str
|
link = host + "/terminplaner/client/" + id_str
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
link += f"?tenant={tenant_id}"
|
link += f"?tenant={tenant_id}"
|
||||||
subject = f"Terminanfrage von {user}"
|
subject = f"{title} - Bitte Termin vereinbaren" if title else f"Terminanfrage von {user} - Bitte Termin vereinbaren"
|
||||||
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
||||||
calendar_link = None
|
calendar_link = None
|
||||||
if calendar_enabled:
|
if calendar_enabled:
|
||||||
@@ -459,6 +464,7 @@ def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
|
|||||||
'note': str(item.get('note') or ''),
|
'note': str(item.get('note') or ''),
|
||||||
'link': link,
|
'link': link,
|
||||||
'calendar_link': calendar_link if item.get('calendar_enabled') else None,
|
'calendar_link': calendar_link if item.get('calendar_enabled') else None,
|
||||||
|
'title': str(item.get('title') or ''),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -173,8 +173,9 @@ def configure():
|
|||||||
mail = request.form.get('mail', '')
|
mail = request.form.get('mail', '')
|
||||||
note = request.form.get('note', '')
|
note = request.form.get('note', '')
|
||||||
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
||||||
|
titel = request.form.get('titel', '').strip()
|
||||||
|
|
||||||
if not start or not end or not time or not slots_amount or not slot_lenght:
|
if not start or not end or not time or not slots_amount or not slot_lenght or not titel:
|
||||||
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
||||||
return render_template(
|
return render_template(
|
||||||
'termin_configure.html',
|
'termin_configure.html',
|
||||||
@@ -183,7 +184,7 @@ def configure():
|
|||||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note, calendar_enabled=add_to_calendar)
|
result = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note, calendar_enabled=add_to_calendar, title=titel)
|
||||||
flash('Der Terminplan wurde angelegt.', 'success')
|
flash('Der Terminplan wurde angelegt.', 'success')
|
||||||
return render_template(
|
return render_template(
|
||||||
'termin_configure.html',
|
'termin_configure.html',
|
||||||
@@ -192,6 +193,7 @@ def configure():
|
|||||||
calendar_link=result.get('calendar_link'),
|
calendar_link=result.get('calendar_link'),
|
||||||
add_to_calendar=add_to_calendar,
|
add_to_calendar=add_to_calendar,
|
||||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
|
title=titel,
|
||||||
)
|
)
|
||||||
elif request.method == "GET":
|
elif request.method == "GET":
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -201,6 +203,7 @@ def configure():
|
|||||||
calendar_link=None,
|
calendar_link=None,
|
||||||
add_to_calendar=False,
|
add_to_calendar=False,
|
||||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
|
title=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -214,8 +217,10 @@ def calendar_export(appointment_id):
|
|||||||
if not ics_content:
|
if not ics_content:
|
||||||
return _appointment_not_found_response()
|
return _appointment_not_found_response()
|
||||||
|
|
||||||
|
title = str(request.args.get('title', '') or '').strip() or appointment_id
|
||||||
|
|
||||||
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
||||||
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics'
|
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{title}-{appointment_id}.ics'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -231,8 +236,10 @@ def client_slot_calendar_export(appointment_id):
|
|||||||
if not ics_content:
|
if not ics_content:
|
||||||
return _appointment_not_found_response()
|
return _appointment_not_found_response()
|
||||||
|
|
||||||
|
title = str(request.args.get('title', '') or '').strip() or appointment_id
|
||||||
|
|
||||||
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
||||||
response.headers['Content-Disposition'] = f'attachment; filename=termin-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
|
response.headers['Content-Disposition'] = f'attachment; filename=termin-{title}-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@appoint_bp.route('/')
|
@appoint_bp.route('/')
|
||||||
|
|||||||
@@ -1295,11 +1295,6 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if 'username' in session %}
|
{% if 'username' in session %}
|
||||||
{% if current_permissions.pages.get('my_borrowed_items', True) %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Hier sehen Sie Ihre bibliotheksbezogenen Ausleihen.">Meine Medien</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_permissions.pages.get('tutorial_page', True) %}
|
{% if current_permissions.pages.get('tutorial_page', True) %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
|
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
|
||||||
|
|||||||
+61
-50
@@ -10,24 +10,6 @@
|
|||||||
<h1>📚 Bibliothek</h1>
|
<h1>📚 Bibliothek</h1>
|
||||||
<p class="subtitle">Bücher, CDs und weitere Medien</p>
|
<p class="subtitle">Bücher, CDs und weitere Medien</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-controls">
|
|
||||||
<!-- Favorites Toggle -->
|
|
||||||
<div class="view-switch">
|
|
||||||
<button id="favoriteToggle" class="toggle-btn" aria-label="Favoriten anzeigen/verbergen">
|
|
||||||
<span class="toggle-icon">⭐</span>
|
|
||||||
<span class="toggle-label">Favoriten</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- View Mode Toggle -->
|
|
||||||
<div class="view-switch">
|
|
||||||
<button id="viewModeToggle" class="toggle-btn" aria-label="Ansichtsmodus wechseln">
|
|
||||||
<span class="toggle-icon">ㄷ</span>
|
|
||||||
<span class="toggle-label">Tabelle</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search & Filter Section -->
|
<!-- Search & Filter Section -->
|
||||||
@@ -205,8 +187,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
// View mode persistence
|
// View mode persistence
|
||||||
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
||||||
@@ -625,50 +606,80 @@ document.getElementById('clearFiltersBtn').addEventListener('click', function()
|
|||||||
closeAllFilters();
|
closeAllFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Favorites toggle
|
|
||||||
document.getElementById('favoriteToggle').addEventListener('click', function() {
|
|
||||||
this.classList.toggle('open');
|
|
||||||
// TODO: Implement favorites filtering
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scanner toggle
|
// Scanner toggle
|
||||||
let scanner = null;
|
// Scanner state tracking
|
||||||
|
let isScanning = false;
|
||||||
|
|
||||||
document.getElementById('scannerBtn').addEventListener('click', function(e) {
|
document.getElementById('scannerBtn').addEventListener('click', function(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const container = document.getElementById('qrContainer');
|
const container = document.getElementById('qrContainer');
|
||||||
const isOpen = container.style.display !== 'none';
|
const isOpen = container.style.display !== 'none';
|
||||||
|
const btn = this;
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
// Close the scanner
|
||||||
container.style.display = 'none';
|
container.style.display = 'none';
|
||||||
if (scanner) scanner.clear();
|
Quagga.stop();
|
||||||
this.classList.remove('open');
|
isScanning = false;
|
||||||
this.setAttribute('aria-expanded', 'false');
|
|
||||||
} else {
|
|
||||||
container.style.display = 'block';
|
|
||||||
this.classList.add('open');
|
|
||||||
this.setAttribute('aria-expanded', 'true');
|
|
||||||
|
|
||||||
if (!scanner) {
|
btn.classList.remove('open');
|
||||||
scanner = new Html5Qrcode('qr-reader');
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
scanner.start(
|
} else {
|
||||||
{ facingMode: "environment" },
|
// Open the scanner
|
||||||
{ fps: 10, qrbox: 250 },
|
container.style.display = 'block';
|
||||||
onScanSuccess,
|
btn.classList.add('open');
|
||||||
onScanError
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
);
|
|
||||||
}
|
Quagga.init({
|
||||||
|
inputStream: {
|
||||||
|
name: "Live",
|
||||||
|
type: "LiveStream",
|
||||||
|
// Targets the element where the video stream will inject
|
||||||
|
target: document.querySelector('#qr-reader'),
|
||||||
|
constraints: {
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
facingMode: "environment" // Forces back camera
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decoder: {
|
||||||
|
// Optimized for 1D barcodes (e.g., student IDs, member cards)
|
||||||
|
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||||
|
}
|
||||||
|
}, function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Initialization error:", err);
|
||||||
|
alert("Kamera konnte nicht gestartet werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Quagga.start();
|
||||||
|
isScanning = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function onScanSuccess(decodedText, decodedResult) {
|
// Single Quagga event listener for successful scans
|
||||||
const studentId = decodedText.trim();
|
Quagga.onDetected(function(data) {
|
||||||
|
const decodedText = data.codeResult.code;
|
||||||
|
const studentId = String(decodedText || '').trim();
|
||||||
|
|
||||||
|
// Stop scanning immediately to prevent multiple triggers
|
||||||
|
Quagga.stop();
|
||||||
|
isScanning = false;
|
||||||
|
|
||||||
|
// Reset UI states to closed
|
||||||
|
const container = document.getElementById('qrContainer');
|
||||||
|
const btn = document.getElementById('scannerBtn');
|
||||||
|
if (container) container.style.display = 'none';
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.remove('open');
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route the scanned data to your input
|
||||||
document.getElementById('studentIdInput').value = studentId;
|
document.getElementById('studentIdInput').value = studentId;
|
||||||
alert('Ausweis gescannt: ' + studentId);
|
alert('Ausweis gescannt: ' + studentId);
|
||||||
}
|
});
|
||||||
|
|
||||||
function onScanError(error) {
|
|
||||||
// Silently ignore scan errors
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
+423
-440
File diff suppressed because it is too large
Load Diff
+107
-45
@@ -373,7 +373,7 @@
|
|||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<div class="filter-header">
|
<div class="filter-header">
|
||||||
<label>Thema:</label>
|
<label>Schlagwort:</label>
|
||||||
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
||||||
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -420,7 +420,7 @@
|
|||||||
<span>Ort</span>
|
<span>Ort</span>
|
||||||
<span>Unterrichtsfach</span>
|
<span>Unterrichtsfach</span>
|
||||||
<span>Jahrgangsstufe</span>
|
<span>Jahrgangsstufe</span>
|
||||||
<span>Thema</span>
|
<span>Schlagwort</span>
|
||||||
<span>Barcode</span>
|
<span>Barcode</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="items-container" class="items-container">
|
<div id="items-container" class="items-container">
|
||||||
@@ -525,8 +525,7 @@
|
|||||||
|
|
||||||
window.isDebug = false; // Set to true only for development environment
|
window.isDebug = false; // Set to true only for development environment
|
||||||
</script>
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||||
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
// Global state
|
// Global state
|
||||||
const highlightItemId = (window.serverVars && window.serverVars.highlightItemId && window.serverVars.highlightItemId !== 'null')
|
const highlightItemId = (window.serverVars && window.serverVars.highlightItemId && window.serverVars.highlightItemId !== 'null')
|
||||||
@@ -535,51 +534,114 @@
|
|||||||
let codeSearchTerm = '';
|
let codeSearchTerm = '';
|
||||||
let descSearchIds = null; // Set of matching IDs or null when disabled
|
let descSearchIds = null; // Set of matching IDs or null when disabled
|
||||||
|
|
||||||
// QR scanner toggle setup
|
// Keep track of the scanner state globally/outer scope
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
let isScanning = false;
|
||||||
const scanButton = document.getElementById('scanButton');
|
let activeScannerCallback = null; // Tracks which function currently owns the scanner output
|
||||||
|
|
||||||
|
function startScanner(targetCallback) {
|
||||||
const qrReader = document.getElementById('qr-reader');
|
const qrReader = document.getElementById('qr-reader');
|
||||||
let html5QrcodeScanner = null;
|
const statusText = document.getElementById('status');
|
||||||
|
|
||||||
if (!scanButton || !qrReader) return;
|
// Store the custom action that should happen when a barcode is found
|
||||||
|
activeScannerCallback = targetCallback;
|
||||||
|
|
||||||
const setScannerUi = (isOpen) => {
|
// Show the container
|
||||||
scanButton.classList.toggle('is-active', isOpen);
|
if (qrReader) qrReader.style.display = 'block';
|
||||||
scanButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
if (statusText) statusText.innerText = "Initializing camera...";
|
||||||
scanButton.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
|
||||||
};
|
|
||||||
|
|
||||||
scanButton.addEventListener('click', function() {
|
Quagga.init({
|
||||||
if (qrReader.style.display !== 'block') {
|
inputStream: {
|
||||||
qrReader.style.display = 'block';
|
name: "Live",
|
||||||
|
type: "LiveStream",
|
||||||
html5QrcodeScanner = new Html5QrcodeScanner(
|
// Make sure this targets your correct HTML container element
|
||||||
'qr-reader', { fps: 10, qrbox: 250 }
|
target: document.querySelector('#scanner-container'),
|
||||||
);
|
constraints: {
|
||||||
|
width: 640,
|
||||||
html5QrcodeScanner.render((decodedText) => {
|
height: 480,
|
||||||
html5QrcodeScanner.clear();
|
facingMode: "environment" // Force rear camera
|
||||||
qrReader.style.display = 'none';
|
},
|
||||||
|
},
|
||||||
// Put scanned code into the search box and trigger search
|
decoder: {
|
||||||
const searchInput = document.getElementById('code-search');
|
// Your required formats mapped to Quagga2 reader strings
|
||||||
if (searchInput) {
|
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||||
searchInput.value = decodedText;
|
}
|
||||||
searchByCode();
|
}, function(err) {
|
||||||
}
|
if (err) {
|
||||||
|
console.error("Initialization error:", err);
|
||||||
setScannerUi(false);
|
if (statusText) statusText.innerText = "Camera Error";
|
||||||
});
|
return;
|
||||||
|
|
||||||
setScannerUi(true);
|
|
||||||
} else {
|
|
||||||
if (html5QrcodeScanner) {
|
|
||||||
html5QrcodeScanner.clear();
|
|
||||||
}
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
setScannerUi(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Quagga.start();
|
||||||
|
isScanning = true;
|
||||||
|
|
||||||
|
// If we are using the main search button, manage its UI state
|
||||||
|
if (!targetCallback) {
|
||||||
|
setScannerUi(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusText) statusText.innerText = "Scanning...";
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScanner() {
|
||||||
|
Quagga.stop();
|
||||||
|
isScanning = false;
|
||||||
|
activeScannerCallback = null; // Reset the callback routing
|
||||||
|
|
||||||
|
const qrReader = document.getElementById('qr-reader');
|
||||||
|
if (qrReader) qrReader.style.display = 'none';
|
||||||
|
|
||||||
|
setScannerUi(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScannerUi(isOpen) {
|
||||||
|
const scanBtn = document.getElementById('scanButton');
|
||||||
|
if (!scanBtn) return;
|
||||||
|
|
||||||
|
scanBtn.classList.toggle('is-active', isOpen);
|
||||||
|
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified global reader callback
|
||||||
|
Quagga.onDetected(function(data) {
|
||||||
|
const barcode = String(data.codeResult.code || '').trim();
|
||||||
|
console.log("Barcode detected:", barcode);
|
||||||
|
|
||||||
|
// Keep a local reference to the active callback before shutting down the engine
|
||||||
|
const currentCallback = activeScannerCallback;
|
||||||
|
|
||||||
|
// Critical: Stop scanning immediately to prevent multiple triggers
|
||||||
|
stopScanner();
|
||||||
|
|
||||||
|
// Route data based on who opened the scanner
|
||||||
|
if (typeof currentCallback === "function") {
|
||||||
|
// Send data directly to the specific edit field's logic
|
||||||
|
currentCallback(barcode);
|
||||||
|
} else {
|
||||||
|
// Fallback Default: Main search box logic
|
||||||
|
const resultText = document.getElementById('barcode-result');
|
||||||
|
if (resultText) resultText.innerText = barcode;
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('code-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = barcode;
|
||||||
|
if (typeof searchByCode === "function") {
|
||||||
|
searchByCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event Listener for the main standalone search button
|
||||||
|
document.getElementById('scanButton')?.addEventListener('click', function() {
|
||||||
|
if (!isScanning) {
|
||||||
|
// Pass null so it defaults to filling the standard search box
|
||||||
|
startScanner(null);
|
||||||
|
} else {
|
||||||
|
stopScanner();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return preferred primary and fallback image URLs based on extension
|
// Return preferred primary and fallback image URLs based on extension
|
||||||
@@ -995,7 +1057,7 @@
|
|||||||
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
||||||
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
||||||
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
||||||
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
|
<p class="item-col-filter3"><strong>Schlagwort:</strong> ${filter3Display}${filter3More}</p>
|
||||||
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
||||||
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
||||||
${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
|
${isGroupedItem ? `<p class="item-col-count"><strong>Verfügbar:</strong> ${availableGroupedCount}</p>` : ''}
|
||||||
@@ -1620,7 +1682,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-group">
|
<div class="detail-group">
|
||||||
<div class="detail-label">Thema:</div>
|
<div class="detail-label">Schlagwort:</div>
|
||||||
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+129
-80
@@ -2395,7 +2395,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<div class="filter-header">
|
<div class="filter-header">
|
||||||
<label>Thema:</label>
|
<label>Schlagwort:</label>
|
||||||
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
<button type="button" class="filter-toggle" onclick="toggleFilterDropdown('filter3-dropdown')">▼</button>
|
||||||
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
<button type="button" class="clear-filter" onclick="clearFilter(3)">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2435,7 +2435,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
</div>
|
</div>
|
||||||
<div class="qr-container">
|
<div class="qr-container">
|
||||||
<button id="scanButton" class="scan-button" aria-controls="qr-reader" aria-expanded="false">Barcode scannen</button>
|
<button id="scanButton" class="scan-button" aria-controls="qr-reader" aria-expanded="false">Barcode scannen</button>
|
||||||
<div id="qr-reader"></div>
|
<div id="qr-reader" style="display: none;">
|
||||||
|
<h3>Status: <span id="status">Bereit</span></h3>
|
||||||
|
<div id="scanner-container"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="table-view-header" class="table-view-header" aria-hidden="true">
|
<div id="table-view-header" class="table-view-header" aria-hidden="true">
|
||||||
@@ -2443,7 +2446,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
<span>Ort</span>
|
<span>Ort</span>
|
||||||
<span>Unterrichtsfach</span>
|
<span>Unterrichtsfach</span>
|
||||||
<span>Jahrgangsstufe</span>
|
<span>Jahrgangsstufe</span>
|
||||||
<span>Thema</span>
|
<span>Schlagwort</span>
|
||||||
<span>Barcode</span>
|
<span>Barcode</span>
|
||||||
<span>Anzahl</span>
|
<span>Anzahl</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2564,7 +2567,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Thema:</h3>
|
<h3>Schlagwort:</h3>
|
||||||
<div class="multi-filter">
|
<div class="multi-filter">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-filter3-1">Wert 1:</label>
|
<label for="edit-filter3-1">Wert 1:</label>
|
||||||
@@ -2729,7 +2732,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
|
|
||||||
window.isDebug = false; // Set to true only for development environment
|
window.isDebug = false; // Set to true only for development environment
|
||||||
</script>
|
</script>
|
||||||
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Function to check if a file is a video
|
// Function to check if a file is a video
|
||||||
function isVideoFile(filename) {
|
function isVideoFile(filename) {
|
||||||
@@ -2739,7 +2742,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize QR Code scanner and global variables
|
// Initialize QR Code scanner and global variables
|
||||||
let html5QrcodeScanner = null;
|
|
||||||
let codeSearchTerm = '';
|
let codeSearchTerm = '';
|
||||||
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 = '';
|
||||||
@@ -2768,51 +2770,114 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
// No-op in production
|
// No-op in production
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('scanButton').addEventListener('click', function() {
|
|
||||||
const qrReader = document.getElementById('qr-reader');
|
|
||||||
const scanBtn = document.getElementById('scanButton');
|
|
||||||
|
|
||||||
const setScannerUi = (isOpen) => {
|
// Keep track of the scanner state globally/outer scope
|
||||||
if (!scanBtn) return;
|
let isScanning = false;
|
||||||
scanBtn.classList.toggle('is-active', isOpen);
|
let activeScannerCallback = null; // Tracks which function currently owns the scanner output
|
||||||
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
||||||
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
function startScanner(targetCallback) {
|
||||||
};
|
const qrReader = document.getElementById('qr-reader');
|
||||||
|
const statusText = document.getElementById('status');
|
||||||
if (qrReader.style.display === 'none') {
|
|
||||||
qrReader.style.display = 'block';
|
// Store the custom action that should happen when a barcode is found
|
||||||
|
activeScannerCallback = targetCallback;
|
||||||
html5QrcodeScanner = new Html5QrcodeScanner(
|
|
||||||
"qr-reader", {
|
// Show the container
|
||||||
fps: 10,
|
if (qrReader) qrReader.style.display = 'block';
|
||||||
qrbox: 250,
|
if (statusText) statusText.innerText = "Initializing camera...";
|
||||||
rememberLastUsedCamera: true
|
|
||||||
}
|
Quagga.init({
|
||||||
);
|
inputStream: {
|
||||||
|
name: "Live",
|
||||||
html5QrcodeScanner.render((decodedText) => {
|
type: "LiveStream",
|
||||||
html5QrcodeScanner.clear();
|
// Make sure this targets your correct HTML container element
|
||||||
qrReader.style.display = 'none';
|
target: document.querySelector('#scanner-container'),
|
||||||
|
constraints: {
|
||||||
// Instead of navigating to the URL, put the scanned code in the search box
|
width: 640,
|
||||||
const searchInput = document.getElementById('code-search');
|
height: 480,
|
||||||
if (searchInput) {
|
facingMode: "environment" // Force rear camera
|
||||||
searchInput.value = decodedText;
|
},
|
||||||
// Trigger search automatically
|
},
|
||||||
|
decoder: {
|
||||||
|
// Your required formats mapped to Quagga2 reader strings
|
||||||
|
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||||
|
}
|
||||||
|
}, function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Initialization error:", err);
|
||||||
|
if (statusText) statusText.innerText = "Camera Error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Quagga.start();
|
||||||
|
isScanning = true;
|
||||||
|
|
||||||
|
// If we are using the main search button, manage its UI state
|
||||||
|
if (!targetCallback) {
|
||||||
|
setScannerUi(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusText) statusText.innerText = "Scanning...";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScanner() {
|
||||||
|
Quagga.stop();
|
||||||
|
isScanning = false;
|
||||||
|
activeScannerCallback = null; // Reset the callback routing
|
||||||
|
|
||||||
|
const qrReader = document.getElementById('qr-reader');
|
||||||
|
if (qrReader) qrReader.style.display = 'none';
|
||||||
|
|
||||||
|
setScannerUi(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScannerUi(isOpen) {
|
||||||
|
const scanBtn = document.getElementById('scanButton');
|
||||||
|
if (!scanBtn) return;
|
||||||
|
|
||||||
|
scanBtn.classList.toggle('is-active', isOpen);
|
||||||
|
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified global reader callback
|
||||||
|
Quagga.onDetected(function(data) {
|
||||||
|
const barcode = String(data.codeResult.code || '').trim();
|
||||||
|
console.log("Barcode detected:", barcode);
|
||||||
|
|
||||||
|
// Keep a local reference to the active callback before shutting down the engine
|
||||||
|
const currentCallback = activeScannerCallback;
|
||||||
|
|
||||||
|
// Critical: Stop scanning immediately to prevent multiple triggers
|
||||||
|
stopScanner();
|
||||||
|
|
||||||
|
// Route data based on who opened the scanner
|
||||||
|
if (typeof currentCallback === "function") {
|
||||||
|
// Send data directly to the specific edit field's logic
|
||||||
|
currentCallback(barcode);
|
||||||
|
} else {
|
||||||
|
// Fallback Default: Main search box logic
|
||||||
|
const resultText = document.getElementById('barcode-result');
|
||||||
|
if (resultText) resultText.innerText = barcode;
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('code-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = barcode;
|
||||||
|
if (typeof searchByCode === "function") {
|
||||||
searchByCode();
|
searchByCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
setScannerUi(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
setScannerUi(true);
|
|
||||||
} else {
|
|
||||||
if (html5QrcodeScanner) {
|
|
||||||
html5QrcodeScanner.clear();
|
|
||||||
}
|
}
|
||||||
qrReader.style.display = 'none';
|
}
|
||||||
setScannerUi(false);
|
});
|
||||||
|
|
||||||
|
// Event Listener for the main standalone search button
|
||||||
|
document.getElementById('scanButton')?.addEventListener('click', function() {
|
||||||
|
if (!isScanning) {
|
||||||
|
// Pass null so it defaults to filling the standard search box
|
||||||
|
startScanner(null);
|
||||||
|
} else {
|
||||||
|
stopScanner();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2824,26 +2889,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (qrReader.style.display !== 'none') {
|
// Toggle close if it's already running
|
||||||
if (html5QrcodeScanner) {
|
if (isScanning && qrReader.style.display !== 'none') {
|
||||||
html5QrcodeScanner.clear();
|
stopScanner();
|
||||||
}
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
scanEditBtn.textContent = 'Barcode scannen';
|
scanEditBtn.textContent = 'Barcode scannen';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
qrReader.style.display = 'block';
|
|
||||||
scanEditBtn.textContent = 'Scanner schließen';
|
scanEditBtn.textContent = 'Scanner schließen';
|
||||||
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
|
|
||||||
fps: 10,
|
// Start scanner with custom logic mapping to the Code input field
|
||||||
qrbox: 250,
|
startScanner(function(decodedText) {
|
||||||
rememberLastUsedCamera: true
|
editCodeInput.value = decodedText;
|
||||||
});
|
|
||||||
html5QrcodeScanner.render((decodedText) => {
|
|
||||||
html5QrcodeScanner.clear();
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
editCodeInput.value = String(decodedText || '').trim();
|
|
||||||
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
|
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
|
||||||
scanEditBtn.textContent = 'Barcode scannen';
|
scanEditBtn.textContent = 'Barcode scannen';
|
||||||
});
|
});
|
||||||
@@ -2857,26 +2914,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (qrReader.style.display !== 'none') {
|
// Toggle close if it's already running
|
||||||
if (html5QrcodeScanner) {
|
if (isScanning && qrReader.style.display !== 'none') {
|
||||||
html5QrcodeScanner.clear();
|
stopScanner();
|
||||||
}
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
scanIsbnBtn.textContent = 'ISBN scannen';
|
scanIsbnBtn.textContent = 'ISBN scannen';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
qrReader.style.display = 'block';
|
|
||||||
scanIsbnBtn.textContent = 'Scanner schließen';
|
scanIsbnBtn.textContent = 'Scanner schließen';
|
||||||
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
|
|
||||||
fps: 10,
|
// Start scanner with custom logic mapping to the ISBN input field
|
||||||
qrbox: 250,
|
startScanner(function(decodedText) {
|
||||||
rememberLastUsedCamera: true
|
editIsbnInput.value = decodedText;
|
||||||
});
|
|
||||||
html5QrcodeScanner.render((decodedText) => {
|
|
||||||
html5QrcodeScanner.clear();
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
editIsbnInput.value = String(decodedText || '').trim();
|
|
||||||
scanIsbnBtn.textContent = 'ISBN scannen';
|
scanIsbnBtn.textContent = 'ISBN scannen';
|
||||||
if (typeof fetchBookInfo === 'function') {
|
if (typeof fetchBookInfo === 'function') {
|
||||||
fetchBookInfo('edit');
|
fetchBookInfo('edit');
|
||||||
@@ -3637,7 +3686,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
|
||||||
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p>
|
||||||
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p>
|
||||||
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
|
<p class="item-col-filter3"><strong>Schlagwort:</strong> ${filter3Display}${filter3More}</p>
|
||||||
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
|
||||||
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
|
||||||
${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''}
|
${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''}
|
||||||
@@ -4180,7 +4229,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
document.getElementById('edit-filter2-3').value = filter2Array[2] || '';
|
document.getElementById('edit-filter2-3').value = filter2Array[2] || '';
|
||||||
document.getElementById('edit-filter2-4').value = filter2Array[3] || '';
|
document.getElementById('edit-filter2-4').value = filter2Array[3] || '';
|
||||||
|
|
||||||
// Fill in filter 3 (Thema)
|
// Fill in filter 3 (Schlagwort)
|
||||||
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []);
|
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 : (item.Filter3 ? [item.Filter3] : []);
|
||||||
document.getElementById('edit-filter3-1').value = filter3Array[0] || '';
|
document.getElementById('edit-filter3-1').value = filter3Array[0] || '';
|
||||||
document.getElementById('edit-filter3-2').value = filter3Array[1] || '';
|
document.getElementById('edit-filter3-2').value = filter3Array[1] || '';
|
||||||
@@ -4494,7 +4543,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-group">
|
<div class="detail-group">
|
||||||
<div class="detail-label">Thema:</div>
|
<div class="detail-label">Schlagwort:</div>
|
||||||
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -267,8 +267,9 @@
|
|||||||
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
|
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
|
||||||
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||||
|
|
||||||
let slotMinTime = '08:00:00';
|
// Show the requested UI time window from 08:15 to 20:00 and highlight available slots
|
||||||
let slotMaxTime = '18:00:00';
|
let slotMinTime = '08:15:00';
|
||||||
|
let slotMaxTime = '20:00:00';
|
||||||
if (candidateSlots.length > 0) {
|
if (candidateSlots.length > 0) {
|
||||||
const starts = candidateSlots.map(function (slot) {
|
const starts = candidateSlots.map(function (slot) {
|
||||||
return new Date(slot.start.replace(' ', 'T') + ':00');
|
return new Date(slot.start.replace(' ', 'T') + ':00');
|
||||||
@@ -277,13 +278,14 @@
|
|||||||
return new Date(slot.end.replace(' ', 'T') + ':00');
|
return new Date(slot.end.replace(' ', 'T') + ':00');
|
||||||
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
|
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
|
||||||
|
|
||||||
|
// Keep the computed min/max for gap calculations below
|
||||||
|
var computedMinStart = null;
|
||||||
|
var computedMaxEnd = null;
|
||||||
if (starts.length > 0 && ends.length > 0) {
|
if (starts.length > 0 && ends.length > 0) {
|
||||||
let minStart = starts[0];
|
computedMinStart = starts[0];
|
||||||
let maxEnd = ends[0];
|
computedMaxEnd = ends[0];
|
||||||
starts.forEach(function (d) { if (d < minStart) minStart = d; });
|
starts.forEach(function (d) { if (d < computedMinStart) computedMinStart = d; });
|
||||||
ends.forEach(function (d) { if (d > maxEnd) maxEnd = d; });
|
ends.forEach(function (d) { if (d > computedMaxEnd) computedMaxEnd = d; });
|
||||||
slotMinTime = formatTimeForCalendar(minStart);
|
|
||||||
slotMaxTime = formatTimeForCalendar(maxEnd);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +357,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// No background greying: show full calendar skeleton and only mark possible slots
|
||||||
|
|
||||||
function updateSliderLabel() {
|
function updateSliderLabel() {
|
||||||
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||||
const idx = Number.parseInt(slider.value, 10) || 0;
|
const idx = Number.parseInt(slider.value, 10) || 0;
|
||||||
@@ -411,6 +415,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No background gaps: keep full skeleton/grid visible
|
||||||
|
|
||||||
|
// Add candidate free slots (blue)
|
||||||
candidateSlots.forEach(function (slot) {
|
candidateSlots.forEach(function (slot) {
|
||||||
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
||||||
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
||||||
@@ -418,8 +425,8 @@
|
|||||||
title: 'Freier Slot',
|
title: 'Freier Slot',
|
||||||
start: start,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
color: '#20c997',
|
color: '#0d6efd',
|
||||||
textColor: '#062b1f',
|
textColor: '#ffffff',
|
||||||
editable: false,
|
editable: false,
|
||||||
extendedProps: {
|
extendedProps: {
|
||||||
slotStart: slot.start,
|
slotStart: slot.start,
|
||||||
|
|||||||
@@ -14,13 +14,15 @@
|
|||||||
<div class="card-body p-4 p-md-5 bg-white">
|
<div class="card-body p-4 p-md-5 bg-white">
|
||||||
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
|
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
|
<labal for="title" class="form-label fw-semibold">Titel des Terminplans</label>
|
||||||
|
<input type="text" id="title" name="title" class="form-control form-control-lg" placeholder="z.B. Elternsprechtag Klasse 10a" value="{{ title or '' }}">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="start_date" class="form-label fw-semibold">Startdatum</label>
|
<label for="start_date" class="form-label fw-semibold">Startdatum</label>
|
||||||
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg" required>
|
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="end_date" class="form-label fw-semibold">Enddatum</label>
|
<label for="end_date" class="form-label fw-semibold">Enddatum</label>
|
||||||
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg" required>
|
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@
|
|||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster</label>
|
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster</label>
|
||||||
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" required></textarea>
|
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" ></textarea>
|
||||||
<div class="form-text">Das Formular überträgt die erzeugten Tageszeilen an das Backend. Sie können die Liste hier bei Bedarf noch anpassen.</div>
|
<div class="form-text">Das Formular überträgt die erzeugten Tageszeilen an das Backend. Sie können die Liste hier bei Bedarf noch anpassen.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,24 +58,26 @@
|
|||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="slots_amounts" class="form-label fw-semibold">Anzahl Slots</label>
|
<label for="slots_amounts" class="form-label fw-semibold">Anzahl Slots</label>
|
||||||
<input type="number" id="slots_amounts" name="slots_amounts" class="form-control" min="1" value="1" required>
|
<input type="number" id="slots_amounts" name="slots_amounts" class="form-control" min="1" value="1">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label for="slot_lenght" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
<label for="slot_lenght" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
||||||
<input type="number" id="slot_lenght" name="slot_lenght" class="form-control" min="1" value="45" required>
|
<input type="number" id="slot_lenght" name="slot_lenght" class="form-control" min="1" value="45" >
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{% if mail_service_enabled %}
|
||||||
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
|
<div>
|
||||||
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
|
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
|
||||||
</div>
|
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
|
||||||
|
</div>
|
||||||
<div>
|
|
||||||
<label for="note" class="form-label fw-semibold">Notiz</label>
|
|
||||||
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="note" class="form-label fw-semibold">Notiz</label>
|
||||||
|
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
|
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
|
||||||
<label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
|
<label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
|
||||||
|
|||||||
@@ -26,12 +26,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="new-booking" class="primary-button">Zur Termin-Konfiguration</button>
|
<button id="new-booking" class="primary-button">Zur Termin-Konfiguration</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="calendar-legend">
|
|
||||||
<span class="legend-item"><span class="legend-color current"></span> Aktuelle Termine</span>
|
|
||||||
<span class="legend-item"><span class="legend-color planned"></span> Geplante Termine</span>
|
|
||||||
<span class="legend-item"><span class="legend-color completed"></span> Abgeschlossene Termine</span>
|
|
||||||
<span class="legend-item"><span class="legend-color your-bookings"></span> Ihre Termine</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calendar-options">
|
<div class="calendar-options">
|
||||||
@@ -287,7 +281,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const filteredEvents = showCompletedBookings
|
const filteredEvents = showCompletedBookings
|
||||||
? processedEvents
|
? processedEvents
|
||||||
: processedEvents.filter(event => event.extendedProps.status !== 'completed');
|
: processedEvents.filter(event => event.extendedProps.status !== 'completed');
|
||||||
|
|
||||||
// Provide events to calendar
|
// Provide events to calendar
|
||||||
successCallback(filteredEvents);
|
successCallback(filteredEvents);
|
||||||
})
|
})
|
||||||
@@ -314,22 +308,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
periodDiv.style.display = 'inline-block';
|
periodDiv.style.display = 'inline-block';
|
||||||
periodDiv.style.fontSize = '10px';
|
periodDiv.style.fontSize = '10px';
|
||||||
info.el.appendChild(periodDiv);
|
info.el.appendChild(periodDiv);
|
||||||
} else {
|
|
||||||
// Check if it is a multi-day event
|
|
||||||
const start = info.event.start;
|
|
||||||
const end = info.event.end;
|
|
||||||
if (start && end && start.toDateString() !== end.toDateString()) {
|
|
||||||
const periodDiv = document.createElement('div');
|
|
||||||
periodDiv.className = 'fc-event-period-marker';
|
|
||||||
periodDiv.textContent = 'Mehrtägig';
|
|
||||||
periodDiv.style.backgroundColor = 'rgba(255,255,255,0.3)';
|
|
||||||
periodDiv.style.borderRadius = '3px';
|
|
||||||
periodDiv.style.padding = '1px 3px';
|
|
||||||
periodDiv.style.marginTop = '2px';
|
|
||||||
periodDiv.style.display = 'inline-block';
|
|
||||||
periodDiv.style.fontSize = '10px';
|
|
||||||
info.el.appendChild(periodDiv);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add borrowed indicator if relevant
|
// Add borrowed indicator if relevant
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
<div class="border rounded-3 p-3">
|
<div class="border rounded-3 p-3">
|
||||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
|
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
<h3 class="h6 fw-bold mb-1">{{ event.title or "Terminplan" }}</h3>
|
||||||
<div class="fw-semibold">{{ event.date_start }} bis {{ event.date_end }}</div>
|
<div class="fw-semibold">{{ event.date_start }} bis {{ event.date_end }}</div>
|
||||||
<div class="text-muted small">ID: {{ event.appointment_id }}</div>
|
<div class="text-muted small">ID: {{ event.appointment_id }}</div>
|
||||||
{% if event.time_span %}
|
{% if event.time_span %}
|
||||||
|
|||||||
+323
-290
@@ -155,6 +155,27 @@
|
|||||||
background-color: #1e7e34;
|
background-color: #1e7e34;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Quagga Scanner Video Styles */
|
||||||
|
#code4-scanner video, #code4-scanner canvas,
|
||||||
|
#isbn-scanner video, #isbn-scanner canvas {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#code4-scanner canvas, #isbn-scanner canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code4-scanner, #isbn-scanner {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* ISBN input group styling */
|
/* ISBN input group styling */
|
||||||
.isbn-input-group {
|
.isbn-input-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -831,7 +852,7 @@
|
|||||||
<!-- Options will be loaded by JavaScript -->
|
<!-- Options will be loaded by JavaScript -->
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="formupload_admin-group">
|
||||||
<label for="filter2-4">Wert 4:</label>
|
<label for="filter2-4">Wert 4:</label>
|
||||||
<select id="filter2-4" name="filter2" class="filter-dropdown-select">
|
<select id="filter2-4" name="filter2" class="filter-dropdown-select">
|
||||||
<option value="">-- Optional --</option>
|
<option value="">-- Optional --</option>
|
||||||
@@ -840,7 +861,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Thema:</h3>
|
<h3>Schlagwort:</h3>
|
||||||
<div class="multi-filter">
|
<div class="multi-filter">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="filter3-1">Wert 1:</label>
|
<label for="filter3-1">Wert 1:</label>
|
||||||
@@ -869,25 +890,41 @@
|
|||||||
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
|
<label for="anschaffungskosten">Anschaffungskosten (€)</label>
|
||||||
<input id="anschaffungskosten" name="anschaffungskosten">
|
<input id="anschaffungskosten" name="anschaffungskosten">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="code_4">Code</label>
|
<label for="scan_mode">Erfassungs-Modus:</label>
|
||||||
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
<select id="scan_mode" name="scan_mode" class="form-control" onchange="toggleScanMode()">
|
||||||
<input id="code_4" name="code_4">
|
<option value="single">Einzel-Code (Scanner stoppt nach 1 Scan)</option>
|
||||||
<button type="button" id="scan-code4-btn" class="fetch-isbn-button">Barcode scannen</button>
|
<option value="continuous">Fortlaufend scannen (Mehrere Codes nacheinander)</option>
|
||||||
|
<option value="range">Code-Bereich generieren (z.B. ABC-001 bis ABC-010)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="range_generator_group" class="form-group" style="display:none; background: #f9f9f9; padding: 15px; border-radius: 5px; border: 1px solid #ddd; margin-bottom: 15px;">
|
||||||
|
<label style="font-weight: bold;">Code-Bereich automatisch generieren:</label>
|
||||||
|
<div style="display: flex; gap: 10px; margin-bottom: 10px; align-items: center;">
|
||||||
|
<input type="text" id="range_prefix" placeholder="Präfix (z.B. IT-)" class="form-control" style="flex: 2;">
|
||||||
|
<input type="number" id="range_start" placeholder="Start (z.B. 1)" class="form-control" style="flex: 1;">
|
||||||
|
<span style="font-weight: bold;">bis</span>
|
||||||
|
<input type="text" id="range_end" placeholder="Ende (z.B. 020)" class="form-control" style="flex: 1;">
|
||||||
</div>
|
</div>
|
||||||
<small style="display:block; color:#666;">Einzelcode manuell setzen oder per Scanner erfassen.</small>
|
<small style="display:block; color:#666; margin-bottom: 10px;">Tipp: Die Anzahl der Ziffern im "Ende"-Feld bestimmt die führenden Nullen (z.B. Ende "050" macht aus Start "1" einen "001").</small>
|
||||||
<div id="code4-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
|
<button type="button" class="btn btn-secondary" onclick="generateCodeRange()">Bereich generieren & einfügen</button>
|
||||||
<small id="code4-scan-status" style="display:block; color:#666; margin-top:6px;"></small>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label for="item_count">Anzahl dieser Sorte</label>
|
<div class="form-group" id="primary_code_group">
|
||||||
<input type="number" id="item_count" name="item_count" min="1" max="100" value="1">
|
<label for="code_4">Basis-Code</label>
|
||||||
<small style="display:block; color:#666;">Es werden eigene Items erstellt, aber in der Übersicht als ein Artikel gebündelt angezeigt.</small>
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<input type="text" id="code_4" name="code_4" class="form-control" placeholder="Haupt-Barcode" required>
|
||||||
|
<button type="button" id="scan-code4-btn" class="btn btn-primary">Barcode scannen</button>
|
||||||
|
</div>
|
||||||
|
<div id="code4-scanner" style="display:none; margin-top: 10px;"></div>
|
||||||
|
<small id="code4-scan-status" class="form-text text-muted"></small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label for="individual_codes">Einzelcodes (optional, je Zeile ein Code)</label>
|
<div class="form-group" id="individual_codes_group">
|
||||||
<textarea id="individual_codes" name="individual_codes" rows="4" placeholder="z.B.\nABC-001\nABC-002"></textarea>
|
<label for="individual_codes">Weitere Einzelcodes (je Zeile ein Code)</label>
|
||||||
<small style="display:block; color:#666;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes können hier bei Bedarf manuell ergänzt werden.</small>
|
<textarea id="individual_codes" name="individual_codes" rows="6" placeholder="z.B. ABC-001 ABC-002" class="form-control"></textarea>
|
||||||
|
<small style="display:block; color:#666; margin-top: 5px;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes werden hier angehängt.</small>
|
||||||
</div>
|
</div>
|
||||||
<!-- Image upload (hidden for library mode) -->
|
<!-- Image upload (hidden for library mode) -->
|
||||||
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
|
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
|
||||||
@@ -970,8 +1007,7 @@
|
|||||||
document.getElementById('csv-help-modal').style.display = 'none';
|
document.getElementById('csv-help-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||||
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
|
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
|
||||||
|
|
||||||
@@ -984,12 +1020,12 @@
|
|||||||
|
|
||||||
// Global variable to store fetched book data
|
// Global variable to store fetched book data
|
||||||
let currentBookData = null;
|
let currentBookData = null;
|
||||||
let code4ScannerInstance = null;
|
|
||||||
let code4ScannerRunning = false;
|
// Quagga2 engine state management properties
|
||||||
let code4LastScanned = '';
|
let scannerRunning = false;
|
||||||
let code4LastScannedAt = 0;
|
let activeScannerCallback = null; // Dynamically handles routing data to the active caller field
|
||||||
let isbnScannerInstance = null;
|
var code4LastScanned = '';
|
||||||
let isbnScannerRunning = false;
|
var code4LastScannedAt = 0;
|
||||||
|
|
||||||
function setCode4ScanStatus(message, isError = false) {
|
function setCode4ScanStatus(message, isError = false) {
|
||||||
const statusEl = document.getElementById('code4-scan-status');
|
const statusEl = document.getElementById('code4-scan-status');
|
||||||
@@ -1042,61 +1078,230 @@
|
|||||||
statusEl.style.color = '#b00020';
|
statusEl.style.color = '#b00020';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Centered Quagga2 Engine Initialization Pattern
|
||||||
|
function runEngineInitialization(targetSelector, activeCallback, completionMsg, errorStatusSetter) {
|
||||||
|
if (scannerRunning) {
|
||||||
|
Quagga.stop();
|
||||||
|
scannerRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeScannerCallback = activeCallback;
|
||||||
|
|
||||||
|
Quagga.init({
|
||||||
|
inputStream: {
|
||||||
|
name: "Live",
|
||||||
|
type: "LiveStream",
|
||||||
|
target: document.querySelector(targetSelector),
|
||||||
|
constraints: {
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
facingMode: "environment" // Force rear camera lenses
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decoder: {
|
||||||
|
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||||
|
}
|
||||||
|
}, function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Initialization error:", err);
|
||||||
|
errorStatusSetter("Kamera konnte nicht gestartet werden.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Quagga.start();
|
||||||
|
scannerRunning = true;
|
||||||
|
errorStatusSetter(completionMsg, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function killScannerHardware() {
|
||||||
|
if (!scannerRunning) return;
|
||||||
|
Quagga.stop();
|
||||||
|
scannerRunning = false;
|
||||||
|
activeScannerCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified tracking capture listener
|
||||||
|
Quagga.onDetected(function(data) {
|
||||||
|
if (!data || !data.codeResult || !data.codeResult.code) return;
|
||||||
|
|
||||||
|
const rawBarcode = String(data.codeResult.code || '').trim();
|
||||||
|
const currentTargetCallback = activeScannerCallback;
|
||||||
|
|
||||||
|
if (typeof currentTargetCallback === "function") {
|
||||||
|
currentTargetCallback(rawBarcode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Globale Variablen für den Scanner-Status
|
||||||
|
var code4LastScanned = '';
|
||||||
|
var code4LastScannedAt = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schaltet das UI basierend auf dem ausgewählten Modus um
|
||||||
|
*/
|
||||||
|
function toggleScanMode() {
|
||||||
|
const mode = document.getElementById('scan_mode').value;
|
||||||
|
const rangeGroup = document.getElementById('range_generator_group');
|
||||||
|
const scanButton = document.getElementById('scan-code4-btn');
|
||||||
|
const scannerBox = document.getElementById('code4-scanner');
|
||||||
|
|
||||||
|
if (scannerBox && scannerBox.style.display !== 'none') {
|
||||||
|
if (typeof killScannerHardware === 'function') killScannerHardware();
|
||||||
|
scannerBox.style.display = 'none';
|
||||||
|
if (scanButton) scanButton.textContent = 'Barcode scannen';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'range') {
|
||||||
|
if(rangeGroup) rangeGroup.style.display = 'block';
|
||||||
|
if(scanButton) scanButton.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
if(rangeGroup) rangeGroup.style.display = 'none'; // Hier wurde der Fehler korrigiert
|
||||||
|
if(scanButton) scanButton.style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generiert eine Liste von Codes basierend auf den Eingaben
|
||||||
|
*/
|
||||||
|
function generateCodeRange() {
|
||||||
|
const prefix = document.getElementById('range_prefix').value.trim();
|
||||||
|
const startStr = document.getElementById('range_start').value.trim();
|
||||||
|
const endStr = document.getElementById('range_end').value.trim();
|
||||||
|
|
||||||
|
const start = parseInt(startStr, 10);
|
||||||
|
const end = parseInt(endStr, 10);
|
||||||
|
|
||||||
|
const paddingLength = endStr.length;
|
||||||
|
|
||||||
|
if (isNaN(start) || isNaN(end) || start > end) {
|
||||||
|
alert("Bitte gültige Zahlen für Start und Ende eingeben. Der Startwert darf nicht größer als der Endwert sein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let generatedCodes = [];
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
let numStr = i.toString().padStart(paddingLength, '0');
|
||||||
|
generatedCodes.push(`${prefix}${numStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeField = document.getElementById('code_4');
|
||||||
|
const individualCodesArea = document.getElementById('individual_codes');
|
||||||
|
|
||||||
|
if (generatedCodes.length > 0) {
|
||||||
|
if (!codeField.value || confirm("Soll der aktuelle Basis-Code überschrieben werden?")) {
|
||||||
|
codeField.value = generatedCodes[0];
|
||||||
|
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
||||||
|
generatedCodes.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generatedCodes.length > 0) {
|
||||||
|
let existing = individualCodesArea.value.trim();
|
||||||
|
individualCodesArea.value = existing ? existing + '\n' + generatedCodes.join('\n') : generatedCodes.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(`Bereich erfolgreich generiert! (${end - start + 1} Codes)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scanner-Funktion (Single & Continuous Modus)
|
||||||
|
*/
|
||||||
function startCode4Scanner() {
|
function startCode4Scanner() {
|
||||||
const scannerBox = document.getElementById('code4-scanner');
|
const scannerBox = document.getElementById('code4-scanner');
|
||||||
const scanButton = document.getElementById('scan-code4-btn');
|
const scanButton = document.getElementById('scan-code4-btn');
|
||||||
const codeField = document.getElementById('code_4');
|
const codeField = document.getElementById('code_4');
|
||||||
|
const individualCodesArea = document.getElementById('individual_codes');
|
||||||
|
const scanMode = document.getElementById('scan_mode').value;
|
||||||
|
|
||||||
if (!scannerBox || !scanButton || !codeField) return;
|
if (!scannerBox || !scanButton || !codeField) return;
|
||||||
|
|
||||||
// Stop ISBN scanner if currently running to avoid camera conflicts.
|
// Beende evtl. laufenden ISBN Scanner
|
||||||
if (isbnScannerRunning && isbnScannerInstance) {
|
const isbnScannerBox = document.getElementById('isbn-scanner');
|
||||||
isbnScannerInstance.clear().catch(() => {});
|
if (isbnScannerBox && isbnScannerBox.style.display !== 'none') {
|
||||||
const isbnScannerBox = document.getElementById('isbn-scanner');
|
killScannerHardware();
|
||||||
|
isbnScannerBox.style.display = 'none';
|
||||||
const isbnScanButton = document.getElementById('scan-isbn-btn');
|
const isbnScanButton = document.getElementById('scan-isbn-btn');
|
||||||
if (isbnScannerBox) isbnScannerBox.style.display = 'none';
|
if (isbnScanButton) isbnScanButton.textContent = 'Barcode scannen';
|
||||||
if (isbnScanButton) isbnScanButton.textContent = 'ISBN scannen';
|
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
|
||||||
isbnScannerRunning = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code4ScannerRunning && code4ScannerInstance) {
|
// Scanner ausschalten, wenn er bereits läuft
|
||||||
code4ScannerInstance.clear().catch(() => {});
|
if (scannerBox.style.display !== 'none') {
|
||||||
|
killScannerHardware();
|
||||||
scannerBox.style.display = 'none';
|
scannerBox.style.display = 'none';
|
||||||
scanButton.textContent = 'Barcode scannen';
|
scanButton.textContent = 'Barcode scannen';
|
||||||
code4ScannerRunning = false;
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
|
||||||
setCode4ScanStatus('Scanner gestoppt.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scanner starten
|
||||||
scannerBox.style.display = 'block';
|
scannerBox.style.display = 'block';
|
||||||
scanButton.textContent = 'Scanner stoppen';
|
scanButton.textContent = scanMode === 'continuous' ? 'Fortlaufenden Scan stoppen' : 'Scanner stoppen';
|
||||||
code4ScannerInstance = new Html5QrcodeScanner('code4-scanner', {
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner initialisiert. Richte die Kamera auf den Barcode...');
|
||||||
fps: 10,
|
|
||||||
qrbox: 250,
|
|
||||||
rememberLastUsedCamera: true
|
|
||||||
});
|
|
||||||
code4ScannerRunning = true;
|
|
||||||
setCode4ScanStatus('Scanner läuft. Der Scan wird im Basis-Codefeld übernommen.');
|
|
||||||
|
|
||||||
code4ScannerInstance.render((decodedText) => {
|
if (typeof runEngineInitialization === 'function') {
|
||||||
const scannedCode = String(decodedText || '').trim();
|
runEngineInitialization(
|
||||||
if (!scannedCode) return;
|
'#code4-scanner',
|
||||||
|
function(decodedText) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (decodedText === code4LastScanned && (now - code4LastScannedAt) < 1500) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
code4LastScanned = decodedText;
|
||||||
|
code4LastScannedAt = now;
|
||||||
|
|
||||||
|
if (scanMode === 'single') {
|
||||||
|
killScannerHardware();
|
||||||
|
scannerBox.style.display = 'none';
|
||||||
|
scanButton.textContent = 'Barcode scannen';
|
||||||
|
|
||||||
|
codeField.value = decodedText;
|
||||||
|
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
||||||
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Code gesetzt: ${decodedText}`);
|
||||||
|
|
||||||
const now = Date.now();
|
} else if (scanMode === 'continuous') {
|
||||||
if (scannedCode === code4LastScanned && (now - code4LastScannedAt) < 1500) {
|
let currentAreaCodes = individualCodesArea.value.split('\n').map(c => c.trim()).filter(c => c !== '');
|
||||||
return;
|
|
||||||
}
|
|
||||||
code4LastScanned = scannedCode;
|
|
||||||
code4LastScannedAt = now;
|
|
||||||
|
|
||||||
codeField.value = scannedCode;
|
if (!codeField.value) {
|
||||||
validateCodeField(codeField);
|
codeField.value = decodedText;
|
||||||
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`);
|
if (typeof validateCodeField === 'function') validateCodeField(codeField);
|
||||||
}, () => {});
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Basis-Code gesetzt: ${decodedText}`);
|
||||||
|
} else if (!currentAreaCodes.includes(decodedText) && codeField.value !== decodedText) {
|
||||||
|
individualCodesArea.value += (individualCodesArea.value ? '\n' : '') + decodedText;
|
||||||
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Zusatz-Code erfasst: ${decodedText}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const oscillator = audioCtx.createOscillator();
|
||||||
|
const gainNode = audioCtx.createGain();
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(audioCtx.destination);
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
oscillator.frequency.value = 800;
|
||||||
|
gainNode.gain.setValueAtTime(0.1, audioCtx.currentTime);
|
||||||
|
oscillator.start();
|
||||||
|
oscillator.stop(audioCtx.currentTime + 0.1);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Audio feedback not supported");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus(`Code ${decodedText} wurde bereits gescannt.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Scanner läuft. Richte die Kamera auf den Barcode.',
|
||||||
|
setCode4ScanStatus
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISBN Scanner-Funktion
|
||||||
|
*/
|
||||||
function startIsbnScanner() {
|
function startIsbnScanner() {
|
||||||
if (!libraryModuleEnabled) {
|
if (!libraryModuleEnabled) {
|
||||||
setIsbnScanStatus('Bibliotheks-Modul ist deaktiviert.', true);
|
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Bibliotheks-Modul ist deaktiviert.', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const scannerBox = document.getElementById('isbn-scanner');
|
const scannerBox = document.getElementById('isbn-scanner');
|
||||||
@@ -1104,61 +1309,75 @@
|
|||||||
const isbnField = document.getElementById('isbn');
|
const isbnField = document.getElementById('isbn');
|
||||||
if (!scannerBox || !scanButton || !isbnField) return;
|
if (!scannerBox || !scanButton || !isbnField) return;
|
||||||
|
|
||||||
// Stop Code_4 scanner if currently running to avoid camera conflicts.
|
const codeScannerBox = document.getElementById('code4-scanner');
|
||||||
if (code4ScannerRunning && code4ScannerInstance) {
|
const codeScanButton = document.getElementById('scan-code4-btn');
|
||||||
code4ScannerInstance.clear().catch(() => {});
|
if (codeScannerBox && codeScannerBox.style.display !== 'none') {
|
||||||
const codeScannerBox = document.getElementById('code4-scanner');
|
killScannerHardware();
|
||||||
const codeScanButton = document.getElementById('scan-code4-btn');
|
codeScannerBox.style.display = 'none';
|
||||||
if (codeScannerBox) codeScannerBox.style.display = 'none';
|
codeScanButton.textContent = 'Barcode scannen';
|
||||||
if (codeScanButton) codeScanButton.textContent = 'Barcode scannen';
|
if (typeof setCode4ScanStatus === 'function') setCode4ScanStatus('Scanner gestoppt.');
|
||||||
code4ScannerRunning = false;
|
|
||||||
setCode4ScanStatus('Scanner gestoppt.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isbnScannerRunning && isbnScannerInstance) {
|
if (scannerBox.style.display !== 'none') {
|
||||||
isbnScannerInstance.clear().catch(() => {});
|
killScannerHardware();
|
||||||
scannerBox.style.display = 'none';
|
scannerBox.style.display = 'none';
|
||||||
scanButton.textContent = 'ISBN scannen';
|
scanButton.textContent = 'Barcode scannen';
|
||||||
isbnScannerRunning = false;
|
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Scanner gestoppt.');
|
||||||
setIsbnScanStatus('Scanner gestoppt.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scannerBox.style.display = 'block';
|
scannerBox.style.display = 'block';
|
||||||
scanButton.textContent = 'Scanner stoppen';
|
scanButton.textContent = 'Scanner stoppen';
|
||||||
isbnScannerInstance = new Html5QrcodeScanner('isbn-scanner', {
|
|
||||||
fps: 10,
|
|
||||||
qrbox: 250,
|
|
||||||
rememberLastUsedCamera: true
|
|
||||||
});
|
|
||||||
isbnScannerRunning = true;
|
|
||||||
setIsbnScanStatus('Scanner läuft. Bitte ISBN-Code erfassen.');
|
|
||||||
|
|
||||||
isbnScannerInstance.render((decodedText) => {
|
runEngineInitialization(
|
||||||
const scannedCode = String(decodedText || '').trim();
|
'#isbn-scanner',
|
||||||
if (!scannedCode) return;
|
function(decodedText) {
|
||||||
|
killScannerHardware();
|
||||||
|
scannerBox.style.display = 'none';
|
||||||
|
scanButton.textContent = 'Barcode scannen';
|
||||||
|
|
||||||
const normalizedIsbn = normalizeIsbnClient(scannedCode);
|
const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(decodedText) : decodedText;
|
||||||
isbnField.value = normalizedIsbn || scannedCode;
|
isbnField.value = normalizedIsbn || decodedText;
|
||||||
updateIsbnLiveValidation();
|
if (typeof updateIsbnLiveValidation === 'function') updateIsbnLiveValidation();
|
||||||
if (normalizedIsbn) {
|
|
||||||
setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
|
|
||||||
} else {
|
|
||||||
setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
isbnScannerInstance.clear().catch(() => {});
|
if (normalizedIsbn) {
|
||||||
scannerBox.style.display = 'none';
|
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
|
||||||
scanButton.textContent = 'ISBN scannen';
|
if (typeof fetchBookInfo === 'function') fetchBookInfo('upload');
|
||||||
isbnScannerRunning = false;
|
} else {
|
||||||
|
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
|
||||||
if (normalizedIsbn) {
|
}
|
||||||
// Automatically load book metadata after a valid ISBN scan.
|
},
|
||||||
fetchBookInfo('upload');
|
'Scanner läuft. Bitte ISBN-Code erfassen.',
|
||||||
}
|
setIsbnScanStatus
|
||||||
}, () => {});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- WICHTIG: Event Listener Binden sobald DOM geladen ist ---
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// 1. Basis-Code Scanner Button aktivieren
|
||||||
|
const scanCode4Btn = document.getElementById('scan-code4-btn');
|
||||||
|
if (scanCode4Btn) {
|
||||||
|
scanCode4Btn.addEventListener('click', startCode4Scanner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ISBN Scanner Button aktivieren
|
||||||
|
const scanIsbnBtn = document.getElementById('scan-isbn-btn');
|
||||||
|
if (scanIsbnBtn) {
|
||||||
|
scanIsbnBtn.addEventListener('click', startIsbnScanner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Dropdown-Modus Wechsel überwachen
|
||||||
|
const scanModeSelect = document.getElementById('scan_mode');
|
||||||
|
if (scanModeSelect) {
|
||||||
|
scanModeSelect.addEventListener('change', toggleScanMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. ISBN Live-Validierung
|
||||||
|
const isbnInput = document.getElementById('isbn');
|
||||||
|
if (isbnInput && typeof updateIsbnLiveValidation === 'function') {
|
||||||
|
isbnInput.addEventListener('input', updateIsbnLiveValidation);
|
||||||
|
}
|
||||||
|
});
|
||||||
// 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}`)
|
||||||
@@ -1558,6 +1777,7 @@
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exported function for use within parent layout fields
|
||||||
function validateCodeField(input, excludeId = null) {
|
function validateCodeField(input, excludeId = null) {
|
||||||
const code = input.value.trim();
|
const code = input.value.trim();
|
||||||
|
|
||||||
@@ -1683,16 +1903,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const scanCodeBtn = document.getElementById('scan-code4-btn');
|
|
||||||
if (scanCodeBtn) {
|
|
||||||
scanCodeBtn.addEventListener('click', startCode4Scanner);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scanIsbnBtn = document.getElementById('scan-isbn-btn');
|
|
||||||
if (scanIsbnBtn) {
|
|
||||||
scanIsbnBtn.addEventListener('click', startIsbnScanner);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isbnField = document.getElementById('isbn');
|
const isbnField = document.getElementById('isbn');
|
||||||
if (isbnField) {
|
if (isbnField) {
|
||||||
isbnField.addEventListener('input', updateIsbnLiveValidation);
|
isbnField.addEventListener('input', updateIsbnLiveValidation);
|
||||||
@@ -1732,188 +1942,11 @@
|
|||||||
if (typeof duplicateData.images === 'string') {
|
if (typeof duplicateData.images === 'string') {
|
||||||
try {
|
try {
|
||||||
duplicateData.images = JSON.parse(duplicateData.images);
|
duplicateData.images = JSON.parse(duplicateData.images);
|
||||||
console.log("Parsed images from string:", duplicateData.images);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to parse images string, using as single item:", duplicateData.images);
|
console.error("Error parsing duplicate images array:", e);
|
||||||
duplicateData.images = [duplicateData.images];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`Final duplicateData.images (${Array.isArray(duplicateData.images) ? 'array' : typeof duplicateData.images}):`, duplicateData.images);
|
console.log("Parsed duplicate images successfully.");
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check for sessionStorage data (takes priority)
|
|
||||||
const sessionData = sessionStorage.getItem('duplicateItemData');
|
|
||||||
if (sessionData) {
|
|
||||||
try {
|
|
||||||
duplicateData = JSON.parse(sessionData);
|
|
||||||
sessionStorage.removeItem('duplicateItemData'); // Clean up
|
|
||||||
console.log("Session storage duplicate data:", duplicateData);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing session duplicate data:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to prefill form with duplicate data
|
|
||||||
function prefillFormWithDuplicateData() {
|
|
||||||
if (!duplicateData) return;
|
|
||||||
|
|
||||||
// Fill basic fields
|
|
||||||
const nameField = document.getElementById('name');
|
|
||||||
if (nameField && duplicateData.name) nameField.value = duplicateData.name;
|
|
||||||
|
|
||||||
const descField = document.getElementById('beschreibung');
|
|
||||||
if (descField && duplicateData.description) descField.value = duplicateData.description;
|
|
||||||
|
|
||||||
const yearField = document.getElementById('anschaffungsjahr');
|
|
||||||
if (yearField && duplicateData.year) yearField.value = duplicateData.year;
|
|
||||||
|
|
||||||
const costField = document.getElementById('anschaffungskosten');
|
|
||||||
if (costField && duplicateData.cost) costField.value = duplicateData.cost;
|
|
||||||
|
|
||||||
// Fill location dropdown
|
|
||||||
const locationField = document.getElementById('ort');
|
|
||||||
if (locationField && duplicateData.location) {
|
|
||||||
// Wait for locations to load, then set value
|
|
||||||
setTimeout(() => {
|
|
||||||
locationField.value = duplicateData.location;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill filter arrays - handle up to 4 values per filter type
|
|
||||||
setTimeout(() => {
|
|
||||||
// Filter 1 (Unterrichtsfach)
|
|
||||||
if (duplicateData.filter1 && Array.isArray(duplicateData.filter1)) {
|
|
||||||
for (let j = 1; j <= Math.min(4, duplicateData.filter1.length); j++) {
|
|
||||||
const filterField = document.getElementById(`filter1-${j}`);
|
|
||||||
if (filterField && duplicateData.filter1[j-1]) {
|
|
||||||
filterField.value = duplicateData.filter1[j-1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter 2 (Jahrgangsstufe)
|
|
||||||
if (duplicateData.filter2 && Array.isArray(duplicateData.filter2)) {
|
|
||||||
for (let j = 1; j <= Math.min(4, duplicateData.filter2.length); j++) {
|
|
||||||
const filterField = document.getElementById(`filter2-${j}`);
|
|
||||||
if (filterField && duplicateData.filter2[j-1]) {
|
|
||||||
filterField.value = duplicateData.filter2[j-1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter 3 (Thema) - text inputs
|
|
||||||
if (duplicateData.filter3 && Array.isArray(duplicateData.filter3)) {
|
|
||||||
for (let j = 1; j <= Math.min(4, duplicateData.filter3.length); j++) {
|
|
||||||
const filterField = document.getElementById(`filter3-${j}`);
|
|
||||||
if (filterField && duplicateData.filter3[j-1]) {
|
|
||||||
filterField.value = duplicateData.filter3[j-1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 200); // Wait for filter options to load
|
|
||||||
|
|
||||||
// Handle images - add hidden inputs for duplicate images
|
|
||||||
if (duplicateData.images && duplicateData.images.length > 0) {
|
|
||||||
console.log("Duplicate images data:", duplicateData.images);
|
|
||||||
const form = document.querySelector('form[action*="upload_item"]');
|
|
||||||
if (form) {
|
|
||||||
// Add hidden field to indicate duplication
|
|
||||||
const isDuplicatingField = document.createElement('input');
|
|
||||||
isDuplicatingField.type = 'hidden';
|
|
||||||
isDuplicatingField.name = 'is_duplicating';
|
|
||||||
isDuplicatingField.value = 'true';
|
|
||||||
form.appendChild(isDuplicatingField);
|
|
||||||
|
|
||||||
// Add each image as a duplicate image
|
|
||||||
const images = duplicateData.images || [];
|
|
||||||
console.log(`Adding ${images.length} images as hidden fields`);
|
|
||||||
|
|
||||||
if (Array.isArray(images)) {
|
|
||||||
images.forEach((imageName, index) => {
|
|
||||||
if (imageName) {
|
|
||||||
console.log(`Adding duplicate image ${index}: ${imageName}`);
|
|
||||||
const imageField = document.createElement('input');
|
|
||||||
imageField.type = 'hidden';
|
|
||||||
imageField.name = 'duplicate_images';
|
|
||||||
imageField.value = imageName;
|
|
||||||
form.appendChild(imageField);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.warn("Images is not an array:", images);
|
|
||||||
// Try to handle as single item if it's a string
|
|
||||||
if (typeof images === 'string' && images) {
|
|
||||||
console.log("Adding single image:", images);
|
|
||||||
const imageField = document.createElement('input');
|
|
||||||
imageField.type = 'hidden';
|
|
||||||
imageField.name = 'duplicate_images';
|
|
||||||
imageField.value = images;
|
|
||||||
form.appendChild(imageField);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show image preview
|
|
||||||
showDuplicateImagePreview(duplicateData.images);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to show preview of duplicate images
|
|
||||||
function showDuplicateImagePreview(images) {
|
|
||||||
const previewContainer = document.getElementById('image-preview-container');
|
|
||||||
if (!previewContainer) return;
|
|
||||||
|
|
||||||
console.log("showDuplicateImagePreview called with:", images);
|
|
||||||
|
|
||||||
// Make sure images is treated as an array
|
|
||||||
if (!Array.isArray(images)) {
|
|
||||||
if (typeof images === 'string') {
|
|
||||||
// Try to parse if it's a JSON string
|
|
||||||
try {
|
|
||||||
images = JSON.parse(images);
|
|
||||||
} catch (e) {
|
|
||||||
// If parsing fails, wrap the single string in an array
|
|
||||||
images = [images];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error("Invalid images data:", images);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
previewContainer.innerHTML = '';
|
|
||||||
console.log(`Processing ${images.length} images for preview`);
|
|
||||||
|
|
||||||
images.forEach((imageName, index) => {
|
|
||||||
console.log(`Processing image ${index}:`, imageName);
|
|
||||||
const preview = document.createElement('div');
|
|
||||||
preview.className = 'image-preview-item';
|
|
||||||
preview.innerHTML = `
|
|
||||||
<img src="/uploads/${imageName}" alt="Duplicate Image ${index + 1}" class="duplicate-image-preview">
|
|
||||||
<div class="image-controls">
|
|
||||||
<span>Von Original übernommen</span>
|
|
||||||
<button type="button" onclick="removeDuplicateImage('${imageName}', this)" class="remove-duplicate-image-button">Entfernen</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
previewContainer.appendChild(preview);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to remove a duplicate image
|
|
||||||
function removeDuplicateImage(imageName, button) {
|
|
||||||
// Remove the hidden input
|
|
||||||
const hiddenInputs = document.querySelectorAll('input[name="duplicate_images"]');
|
|
||||||
hiddenInputs.forEach(input => {
|
|
||||||
if (input.value === imageName) {
|
|
||||||
input.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove the preview
|
|
||||||
const previewItem = button.closest('.image-preview-item');
|
|
||||||
if (previewItem) {
|
|
||||||
previewItem.remove();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ existing['modules'] = {
|
|||||||
'library': {'enabled': True},
|
'library': {'enabled': True},
|
||||||
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
|
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
|
||||||
'terminplan': {'enabled': True},
|
'terminplan': {'enabled': True},
|
||||||
|
'mail': {'enabled': True},
|
||||||
}
|
}
|
||||||
existing['trial'] = trial_config
|
existing['trial'] = trial_config
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Small debug script to check tenant-aware lookup for an appointment id.
|
|
||||||
Usage: ./tools/debug_get_appointment.py <appointment_id> [tenant]
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
from Web.modules.database.settings import MongoClient, MONGODB_HOST, MONGODB_PORT, MONGODB_DB
|
|
||||||
import Web.modules.database.termine as termine
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("Usage: debug_get_appointment.py <appointment_id> [tenant]")
|
|
||||||
sys.exit(2)
|
|
||||||
aid = sys.argv[1]
|
|
||||||
tenant = sys.argv[2] if len(sys.argv) > 2 else None
|
|
||||||
|
|
||||||
if tenant:
|
|
||||||
print(f"Looking up appointment {aid} for tenant {tenant}")
|
|
||||||
else:
|
|
||||||
print(f"Looking up appointment {aid} for default tenant")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Use the module helper directly
|
|
||||||
item = termine.get_item(aid)
|
|
||||||
if item:
|
|
||||||
print('Found with termini.get_item:')
|
|
||||||
print(item)
|
|
||||||
else:
|
|
||||||
print('termin.get_item returned None')
|
|
||||||
|
|
||||||
# Try explicit MongoClient + tenant DB resolution
|
|
||||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
|
||||||
try:
|
|
||||||
from Web.tenant import TenantContext, get_tenant_db
|
|
||||||
if tenant:
|
|
||||||
ctx = TenantContext()
|
|
||||||
ctx.tenant_id = tenant
|
|
||||||
db = ctx.get_database(client)
|
|
||||||
else:
|
|
||||||
db = get_tenant_db(client) if 'get_tenant_db' in dir() else client[MONGODB_DB]
|
|
||||||
|
|
||||||
doc = db['appointments'].find_one({'_id': ObjectId(aid)})
|
|
||||||
print('Direct DB find_one returned:')
|
|
||||||
print(doc)
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
except Exception as e:
|
|
||||||
print('Error during debug lookup:', e)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user