Compare commits

...

59 Commits

Author SHA1 Message Date
Aiirondev_dev 3c9ab099ba Slight fixes for the Scanning 2026-06-11 23:58:11 +02:00
Aiirondev_dev a81a8775a5 correction oif a tabulation error 2026-06-11 22:08:53 +02:00
Aiirondev_dev 1633118d7f center fix for the edit Modal 2026-06-10 12:48:14 +02:00
Aiirondev_dev a4b900c986 Fixes regarding ht eupload changes 2026-06-10 12:39:08 +02:00
Aiirondev_dev a06393a915 New implementiation of the Code Scannning mecanism 2026-06-10 12:26:25 +02:00
Aiirondev_dev 1b9bfe1b14 Fix of the edeting funktion in the Library Module 2026-06-10 11:24:33 +02:00
Aiirondev_dev 3cdd1552d9 Fix of a library funktion 2026-06-10 11:08:43 +02:00
Aiirondev_dev 80a206f524 Finisched the removal of the Author 2026-06-07 23:16:05 +02:00
Aiirondev_dev 3217da8b15 Improved ISBN Funktionality 2026-06-07 22:57:32 +02:00
Aiirondev_dev 22bc7c9c3c upload barcode implementation 2026-06-07 22:28:04 +02:00
Aiirondev_dev f156cc178a library author removing 2026-06-07 22:21:01 +02:00
Aiirondev_dev 116261d334 Slight fixes of thew Code scanning and Items processing 2026-06-07 12:42:27 +02:00
Aiirondev_dev 042afc19e2 slight changes to the Bar code scanner 2026-06-07 12:33:13 +02:00
Aiirondev_dev 28d049b52a smal changes fixes for the library Code Scanner 2026-06-07 00:14:57 +02:00
Aiirondev_dev 8e478aeba4 slight fixes 2026-06-07 00:03:21 +02:00
Aiirondev_dev 1b38d1f0c8 changed the wrapper of the new code scanning library 2026-06-06 23:52:19 +02:00
Aiirondev_dev e7e24b3fae further changes to the new Barcode system 2026-06-06 23:41:37 +02:00
Aiirondev_dev fc80857fbe minor changes to the Inpout scanning 2026-06-06 23:31:50 +02:00
Aiirondev_dev f763ad064d Changes from the deprecated qrcode scanning tool, to a more modern option in the library 2026-06-06 22:46:50 +02:00
Aiirondev_dev bc94ffdc18 further fixes 2026-06-06 17:17:15 +02:00
Aiirondev_dev 76e9670cee implementation of a new Framework for the Barcode scanning via Quagga2 -> only test implementation 2026-06-06 16:53:11 +02:00
Aiirondev_dev 577c6c4bed Removing more of the broken funktionaliti 2026-06-06 15:39:51 +02:00
Aiirondev_dev 3aa19b10d1 Reverted Faulti changes that have proven not worth implementing 2026-06-06 15:03:43 +02:00
Aiirondev_dev ff742d018e removed obsolet file 2026-06-01 14:34:05 +02:00
Aiirondev_dev 6fa40f26b0 Generall fixes, to manage the unused overhead 2026-06-01 13:23:52 +02:00
Aiirondev_dev 777fc81064 fix for the latest Tag 2026-06-01 13:20:10 +02:00
Aiirondev_dev cf5e38e319 removed development release version 2026-06-01 13:15:54 +02:00
Aiirondev_dev b76941d5fe Fix in the release versioning 2026-06-01 13:07:52 +02:00
Aiirondev_dev f46f2674b0 Library style adjustments for mobile 2026-06-01 12:51:28 +02:00
github-actions[bot] 8ebbdcfd54 chore: bump version to v0.8.31.1 2026-06-01 10:35:56 +00:00
Aiirondev_dev 92b5235035 feat: Improve mobile video playback for barcode scanning with autoplay and mute settings 2026-06-01 12:32:42 +02:00
github-actions[bot] 4bda2e044b chore: bump version to v0.8.30 2026-06-01 10:23:51 +00:00
Aiirondev_dev b83b6c0ba2 feat: Enhance barcode scanner with improved mobile compatibility and error handling 2026-06-01 12:22:54 +02:00
github-actions[bot] ab718d6ac2 chore: bump version to v0.8.29 2026-06-01 10:12:29 +00:00
Aiirondev_dev 112d9b6aa0 feat: Make video elements visible for barcode scanning in various templates 2026-06-01 12:10:31 +02:00
github-actions[bot] 5139a50a43 chore: bump version to v0.8.28 2026-06-01 09:56:08 +00:00
Aiirondev_dev 997c7b42d5 feat: Implement hybrid barcode scanner with real-time camera support and image upload fallback 2026-06-01 11:55:28 +02:00
github-actions[bot] 20e5d3bb9a chore: bump version to v0.8.27 2026-06-01 07:01:33 +00:00
Aiirondev_dev f62b22c2f7 feat: Refactor appointment handling to support multi-day events and improve calendar visibility 2026-06-01 09:00:35 +02:00
github-actions[bot] e76d525e4b chore: bump version to v0.8.26 2026-06-01 06:51:36 +00:00
Aiirondev_dev 7117dac67c feat: Enhance calendar view by switching to week view for multi-day events 2026-06-01 08:51:09 +02:00
Aiirondev_dev 5313c507ed feat: Remove background gap events from calendar display for improved visibility of available slots 2026-06-01 08:49:01 +02:00
github-actions[bot] 419fed4492 chore: bump version to v0.8.25 2026-06-01 06:39:43 +00:00
Aiirondev_dev c7112c7a42 feat: Update appointment time range to 08:15 - 20:00 and enhance visual representation of available slots 2026-06-01 08:39:20 +02:00
Aiirondev_dev fb73c9d4e7 feat: Extend time range for appointment slots and add background gap events for unavailable times 2026-06-01 08:30:36 +02:00
github-actions[bot] 8414e27f25 chore: bump version to v0.8.24 2026-06-01 05:38:10 +00:00
Aiirondev_dev 5903f2afdd feat: Refactor appointment retrieval to use dedicated appointment collection and improve time range extraction 2026-06-01 07:37:36 +02:00
github-actions[bot] a31fb4c048 chore: bump version to v0.8.23 2026-05-31 17:21:17 +00:00
Aiirondev_dev 46da45d373 feat: Enhance client booking experience with booking name visibility and success page 2026-05-31 19:20:40 +02:00
Aiirondev_dev 0b1bcef985 feat: Update booking modal with new confirmation buttons and ICS download functionality 2026-05-31 19:11:56 +02:00
github-actions[bot] 99ad9d4f79 chore: bump version to v0.8.22 2026-05-31 17:05:10 +00:00
Aiirondev_dev 89c1a525d8 feat: Enhance calendar functionality with multi-day view and time range selection 2026-05-31 19:04:43 +02:00
github-actions[bot] 9701805552 chore: bump version to v0.8.21 2026-05-31 16:54:47 +00:00
Aiirondev_dev 4227934252 feat: Add ICS export functionality for client slot bookings and enhance client booking interface 2026-05-31 18:54:21 +02:00
Aiirondev_dev d6883d1879 refactor: remove unused date and time parsing functions from backend_server.py and clean up termin_client.html 2026-05-31 18:50:31 +02:00
github-actions[bot] f47e133c45 chore: bump version to v0.8.20 2026-05-31 16:43:58 +00:00
Aiirondev_dev dec3c16d7f feat: Enhance appointment booking interface with date and time selection features 2026-05-31 18:43:04 +02:00
github-actions[bot] 897b2e43ad chore: bump version to v0.8.19 2026-05-31 16:33:17 +00:00
Aiirondev_dev 6dd44508ce fix: Ensure numeric fields are correctly cast to int in get_available function 2026-05-31 18:32:42 +02:00
15 changed files with 2084 additions and 1259 deletions
+11 -86
View File
@@ -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
@@ -79,29 +69,27 @@ jobs:
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"
else
TAG="v${major}.${minor}.${patch}" 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
View File
@@ -1 +1 @@
v0.8.18 v0.8.31.1
+249 -128
View File
@@ -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
@@ -4817,56 +4818,70 @@ 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
if period:
title = f"{title} - {period}. Std"
status = booking.get('VerifiedStatus') or booking.get('Status') or 'unknown'
if status == 'active':
status = 'current'
result.append({ result.append({
'id': str(booking.get('_id')), 'id': f"{appt_id}-{day}-{start_time}",
'title': title, 'title': title,
'start': start_dt.isoformat() if isinstance(start_dt, datetime.datetime) else str(start_dt), 'start': f"{day}T{start_time}",
'end': end_dt.isoformat() if isinstance(end_dt, datetime.datetime) else str(end_dt), 'end': f"{day}T{end_time}",
'status': status, 'status': 'planned',
'itemId': item_id, 'itemId': appt_id,
'userName': str(booking.get('User') or ''), 'userName': str(appt.get('user') or ''),
'notes': str(booking.get('Notes') or ''), 'notes': str(appt.get('note') or ''),
'period': period, 'period': None,
'isCurrentUser': True, 'isCurrentUser': True,
'itemBorrower': '', 'itemBorrower': '',
}) })
@@ -5192,9 +5207,61 @@ 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]}) # -------------------------------------------------------------------
# Assuming `code_4` might come in as a single string or a list depending on your form setup
primary_code_raw = request.form.get('code_4', '').strip()
individual_codes_raw = request.form.get('individual_codes', '').strip()
all_item_codes = []
if primary_code_raw:
all_item_codes.append(primary_code_raw)
if individual_codes_raw:
# Split textarea by newlines, clean whitespace, and ignore empty lines
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:
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: if existing_item:
error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.' error_msg = 'Der Code wird bereits verwendet. Umleitung zum Eintrag.'
if is_mobile: if is_mobile:
@@ -5207,40 +5274,20 @@ def upload_item():
flash(error_msg, 'info') flash(error_msg, 'info')
return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id']))) return redirect(url_for(success_redirect_endpoint, open_item=str(existing_item['_id'])))
else:
error_msg = 'Der Code wird bereits verwendet. Bitte wählen Sie einen anderen Code.' # 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
@@ -9361,35 +9408,14 @@ 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)})
@app.route('/fetch_book_info/<isbn>') def _fetch_from_google_books(clean_isbn):
def fetch_book_info(isbn): """Source 1: Google Books API (Free, No Key required for basic use)"""
"""
API endpoint to fetch book information by ISBN using Google Books API
Args:
isbn (str): ISBN to look up
Returns:
dict: Book information or error message
"""
if 'username' not in session or not us.check_admin(session['username']):
return jsonify({"error": "Not authorized"}), 403
if not cfg.MODULES.is_enabled('library'):
return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403
try: try:
clean_isbn = normalize_and_validate_isbn(isbn) url = f"https://www.googleapis.com/books/v1/volumes?q=isbn:{clean_isbn}"
if not clean_isbn: response = requests.get(url, timeout=5)
return jsonify({"error": "Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden."}), 400 if response.status_code != 200:
return None
# First source: Google Books
response = requests.get(
f"https://www.googleapis.com/books/v1/volumes?q=isbn:{clean_isbn}",
timeout=10
)
if response.status_code == 200:
data = response.json() data = response.json()
if data.get('totalItems', 0) > 0 and data.get('items'): if data.get('totalItems', 0) > 0 and data.get('items'):
book_info = data['items'][0].get('volumeInfo', {}) book_info = data['items'][0].get('volumeInfo', {})
@@ -9407,51 +9433,146 @@ def fetch_book_info(isbn):
if thumbnail: if thumbnail:
thumbnail = thumbnail.replace('http:', 'https:') thumbnail = thumbnail.replace('http:', 'https:')
return jsonify({ return {
"title": book_info.get('title', 'Unknown Title'), "title": book_info.get('title', 'Unknown Title'),
"authors": ', '.join(book_info.get('authors', ['Unknown Author'])), "authors": ', '.join(book_info.get('authors', ['Unknown Author'])),
"publisher": book_info.get('publisher', 'Unknown Publisher'), "publisher": book_info.get('publisher', 'Unknown Publisher'),
"publishedDate": book_info.get('publishedDate', 'Unknown Date'), "publishedDate": book_info.get('publishedDate', 'Unknown Date'),
"description": book_info.get('description', 'No description available'), "description": book_info.get('description', 'Keine Beschreibung verfügbar'),
"pageCount": book_info.get('pageCount', 'Unknown'), "pageCount": book_info.get('pageCount', 'Unknown'),
"price": price, "price": price,
"thumbnail": thumbnail, "thumbnail": thumbnail,
"isbn": clean_isbn,
"source": "google-books" "source": "google-books"
}) }
except Exception as e:
print(f"Google Books error: {e}")
return None
# Fallback: OpenLibrary
ol_response = requests.get( def _fetch_from_lobid_germany(clean_isbn):
f"https://openlibrary.org/isbn/{clean_isbn}.json", """
timeout=10 Source 2: Lobid.org (hbz Network)
) The ultimate free, keyless fallback for German Schoolbooks and DACH literature.
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: try:
author_resp = requests.get(f"https://openlibrary.org{key}.json", timeout=8) url = f"https://lobid.org/resources?q=isbn:{clean_isbn}&format=json"
if author_resp.status_code == 200: response = requests.get(url, timeout=5)
author_names.append(author_resp.json().get('name')) if response.status_code != 200:
except Exception: return None
continue
return jsonify({ data = response.json()
"title": ol_data.get('title', 'Unknown Title'), if 'member' in data and len(data['member']) > 0:
"authors": ', '.join([a for a in author_names if a]) if author_names else 'Unknown Author', book = data['member'][0]
"publisher": ', '.join(ol_data.get('publishers', [])) if ol_data.get('publishers') else 'Unknown Publisher',
"publishedDate": ol_data.get('publish_date', 'Unknown Date'), # Extract Authors (Lobid uses a nested contribution array)
"description": (ol_data.get('description', {}).get('value') if isinstance(ol_data.get('description'), dict) else ol_data.get('description')) or 'No description available', authors = []
"pageCount": ol_data.get('number_of_pages', 'Unknown'), 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, "price": None,
"thumbnail": f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg", "thumbnail": f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg",
"isbn": clean_isbn,
"source": "openlibrary" "source": "openlibrary"
}) }
except Exception as e:
print(f"OpenLibrary Search error: {e}")
return None
@app.route('/fetch_book_info/<isbn>')
def fetch_book_info(isbn):
"""
API endpoint to fetch book information by ISBN using multiple open sources.
Optimized for global literature AND German educational books.
"""
# Authorization Checks
if 'username' not in session or not us.check_admin(session['username']):
return jsonify({"error": "Not authorized"}), 403
if not cfg.MODULES.is_enabled('library'):
return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403
try:
# Validation
clean_isbn = normalize_and_validate_isbn(isbn)
if not clean_isbn:
return jsonify({"error": "Ungültige ISBN. Bitte ISBN-10 oder ISBN-13 verwenden."}), 400
# Define the Provider Chain (Order matters: General -> German/School -> Global Fallback)
providers = [
_fetch_from_google_books,
_fetch_from_lobid_germany,
_fetch_from_open_library
]
# Iterate through providers until a book is found
for provider in providers:
book_data = provider(clean_isbn)
if book_data:
# Add the ISBN back into the response payload
book_data["isbn"] = clean_isbn
# If a provider found the book but had no cover, assign a default OpenLibrary cover fallback
if not book_data.get("thumbnail"):
book_data["thumbnail"] = f"https://covers.openlibrary.org/b/isbn/{clean_isbn}-L.jpg"
return jsonify(book_data)
# 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:
+77 -1
View File
@@ -130,6 +130,68 @@ def build_calendar_ics(appointment_id: str) -> str | None:
return '\r\n'.join(ics_lines) return '\r\n'.join(ics_lines)
def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str = '') -> str | None:
"""Build a single-slot ICS export for a client booking candidate."""
item = termin.get_item(appointment_id)
if not item:
return None
try:
start_dt = datetime.datetime.strptime(str(slot_start).strip(), '%Y-%m-%d %H:%M')
except Exception:
return None
try:
slot_minutes = int(item.get('slot_lenght') or 0)
except Exception:
slot_minutes = 0
if slot_minutes <= 0:
slot_minutes = 45
end_dt = start_dt + datetime.timedelta(minutes=slot_minutes)
tenant_id = _current_tenant_id()
try:
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
except Exception:
host = _resolve_public_base_url()
link = host + '/terminplaner/client/' + str(appointment_id)
if tenant_id:
link += f'?tenant={tenant_id}'
title_name = str(client_name or '').strip() or 'Termin'
summary = f"{title_name} - Terminbuchung"
description_lines = [
f"Buchungslink: {link}",
f"Geplanter Termin: {start_dt.strftime('%d.%m.%Y %H:%M')} - {end_dt.strftime('%H:%M')}",
]
uid = f"terminplaner-slot-{appointment_id}-{start_dt.strftime('%Y%m%d%H%M')}@invario.eu"
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
dt_start = start_dt.strftime('%Y%m%dT%H%M%S')
dt_end = end_dt.strftime('%Y%m%dT%H%M%S')
ics_lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Inventarsystem//Terminplaner Client Slot//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
f'UID:{uid}',
f'DTSTAMP:{created_at}',
f'SUMMARY:{_escape_ics_text(summary)}',
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
f'URL:{_escape_ics_text(link)}',
f'DTSTART:{dt_start}',
f'DTEND:{dt_end}',
'END:VEVENT',
'END:VCALENDAR',
'',
]
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) -> 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
@@ -293,6 +355,17 @@ def get_available(id):
time_span = termin_range.get('time_span', []) time_span = termin_range.get('time_span', [])
slot_lenght = termin_range.get('slot_lenght') slot_lenght = termin_range.get('slot_lenght')
total_slots = termin_range.get('slots', 0) total_slots = termin_range.get('slots', 0)
# Ensure numeric fields are cast to int when stored as strings
try:
total_slots = int(termin_range.get('slots', 0) or 0)
except Exception:
total_slots = 0
try:
slot_lenght = int(termin_range.get('slot_lenght') or 0)
except Exception:
slot_lenght = termin_range.get('slot_lenght')
booked = termin_range.get('slots_booked', []) or [] booked = termin_range.get('slots_booked', []) or []
# Normalize booked entries to dicts for easier consumption # Normalize booked entries to dicts for easier consumption
@@ -306,7 +379,10 @@ def get_available(id):
normalized.append({'value': s}) normalized.append({'value': s})
slots_used = len(normalized) slots_used = len(normalized)
slots_left = max(0, total_slots - slots_used) try:
slots_left = max(0, int(total_slots) - slots_used)
except Exception:
slots_left = max(0, slots_used - slots_used)
return { return {
'date_start': date_start, 'date_start': date_start,
+71 -4
View File
@@ -47,6 +47,28 @@ def client(appointment_id):
if not available: if not available:
return _appointment_not_found_response() return _appointment_not_found_response()
current_user = str(session.get('username', '') or '').strip()
appointment_item = termin.get_item(appointment_id) or {}
appointment_owner = str(appointment_item.get('user', '') or '').strip()
can_view_booking_names = False
if current_user:
try:
can_view_booking_names = bool(us.check_admin(current_user) or current_user == appointment_owner)
except Exception:
can_view_booking_names = bool(current_user == appointment_owner)
available_for_view = dict(available)
if not can_view_booking_names:
sanitized_bookings = []
for booking in (available.get('slots_booked') or []):
if isinstance(booking, dict):
sanitized_bookings.append({'start': booking.get('start', '')})
elif isinstance(booking, (list, tuple)) and len(booking) >= 1:
sanitized_bookings.append({'start': booking[0]})
else:
sanitized_bookings.append({'start': ''})
available_for_view['slots_booked'] = sanitized_bookings
if request.method == 'POST': if request.method == 'POST':
start_daytime = request.form.get('start_day_time') start_daytime = request.form.get('start_day_time')
username = request.form.get('client_name') username = request.form.get('client_name')
@@ -55,22 +77,50 @@ def client(appointment_id):
return render_template( return render_template(
'termin_client.html', 'termin_client.html',
appointment_id=appointment_id, appointment_id=appointment_id,
available=available, available=available_for_view,
current_user=session.get('username', ''), current_user=session.get('username', ''),
tenant_id=_current_tenant_id(),
can_view_booking_names=can_view_booking_names,
) )
if appointment_service.book_slot(appointment_id, start_daytime, username): if appointment_service.book_slot(appointment_id, start_daytime, username):
flash('Der Termin wurde gespeichert.', 'success') return redirect(
return redirect(url_for('terminplaner.client', appointment_id=appointment_id)) url_for(
'terminplaner.client_success',
appointment_id=appointment_id,
tenant=_current_tenant_id() or None,
start=start_daytime,
name=username,
)
)
flash('Der Termin konnte nicht gespeichert werden.', 'error') flash('Der Termin konnte nicht gespeichert werden.', 'error')
return render_template( return render_template(
'termin_client.html', 'termin_client.html',
appointment_id=appointment_id, appointment_id=appointment_id,
available=available, available=available_for_view,
current_user=session.get('username', ''), current_user=session.get('username', ''),
tenant_id=_current_tenant_id(), tenant_id=_current_tenant_id(),
can_view_booking_names=can_view_booking_names,
)
@appoint_bp.route('/client/success/<appointment_id>', methods=['GET'])
def client_success(appointment_id):
guard = _require_module_enabled()
if guard:
return guard
slot_start = str(request.args.get('start', '') or '').strip()
client_name = str(request.args.get('name', '') or '').strip()
return render_template(
'termin_client_success.html',
appointment_id=appointment_id,
slot_start=slot_start,
client_name=client_name,
tenant_id=_current_tenant_id(),
) )
@@ -168,6 +218,23 @@ def calendar_export(appointment_id):
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics' response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics'
return response return response
@appoint_bp.route('/client_ics/<appointment_id>.ics', methods=['GET'])
def client_slot_calendar_export(appointment_id):
guard = _require_module_enabled()
if guard:
return guard
slot_start = str(request.args.get('start', '') or '').strip()
client_name = str(request.args.get('name', '') or '').strip()
ics_content = appointment_service.build_client_slot_ics(appointment_id, slot_start, client_name=client_name)
if not ics_content:
return _appointment_not_found_response()
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'
return response
@appoint_bp.route('/') @appoint_bp.route('/')
def main(): def main():
guard = _require_module_enabled() guard = _require_module_enabled()
+29 -27
View File
@@ -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>
@@ -1392,28 +1387,7 @@
{% endif %} {% endif %}
{% else %} {% else %}
<nav class="navbar navbar-expand-lg navbar-dark" id="loginNavbar"> <!-- No navbar for anonymous users on public pages. -->
<div class="container-fluid">
<a class="navbar-brand py-0" href="{{ url_for('home') }}">
{% set school_logo_thumb = school_info.get('logo_thumb') if school_info else '' %}
{% set school_logo_path = school_info.get('logo_path') if school_info else '' %}
{% if school_logo_thumb or school_logo_path %}
{% if school_logo_thumb %}
<img src="{{ url_for('uploaded_file', filename=school_logo_thumb) }}" alt="{{ school_info.name or 'Schullogo' }}" class="invario-logo">
{% else %}
<img src="{{ url_for('uploaded_file', filename=school_logo_path) }}" alt="{{ school_info.name or 'Schullogo' }}" class="invario-logo">
{% endif %}
{% else %}
<img src="{{ url_for('static', filename='img/invario-logo.png') }}" alt="Invario" class="invario-logo">
{% endif %}
</a>
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('impressum') }}">Impressum</a>
</li>
</ul>
</div>
</nav>
{% endif %} {% endif %}
<div class="container"> <div class="container">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
@@ -1427,6 +1401,11 @@
{% endwith %} {% endwith %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
{% if 'username' not in session %}
<div class="guest-impressum-footer">
<a href="{{ url_for('impressum') }}">Impressum</a>
</div>
{% endif %}
<!-- Cookie consent banner --> <!-- Cookie consent banner -->
<style> <style>
#cookie-banner { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(33,37,41,.98); color: #fff; padding: 14px 16px; display: none; z-index: 2000; box-shadow: 0 -2px 8px rgba(0,0,0,.25); } #cookie-banner { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(33,37,41,.98); color: #fff; padding: 14px 16px; display: none; z-index: 2000; box-shadow: 0 -2px 8px rgba(0,0,0,.25); }
@@ -1560,6 +1539,29 @@
margin-bottom: 2px; margin-bottom: 2px;
} }
.guest-impressum-footer {
position: fixed;
left: 0;
right: 0;
bottom: 10px;
text-align: center;
z-index: 1200;
pointer-events: none;
}
.guest-impressum-footer a {
pointer-events: auto;
font-size: 0.76rem;
color: rgba(15, 23, 42, 0.72);
text-decoration: none;
border-bottom: 1px solid rgba(15, 23, 42, 0.28);
}
.guest-impressum-footer a:hover {
color: rgba(15, 23, 42, 0.92);
border-bottom-color: rgba(15, 23, 42, 0.52);
}
.notification-toast.show { .notification-toast.show {
display: block; display: block;
} }
+60 -49
View File
@@ -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) {
document.getElementById('studentIdInput').value = studentId; const decodedText = data.codeResult.code;
alert('Ausweis gescannt: ' + studentId); 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');
} }
function onScanError(error) { // Route the scanned data to your input
// Silently ignore scan errors document.getElementById('studentIdInput').value = studentId;
} alert('Ausweis gescannt: ' + studentId);
});
</script> </script>
<style> <style>
+313 -330
View File
@@ -431,7 +431,7 @@
type="text" type="text"
id="librarySearch" id="librarySearch"
class="library-search-input" class="library-search-input"
placeholder="Nach Titel, Autor, ISBN suchen..." placeholder="Nach Titel, ISBN suchen..."
> >
<button <button
id="filterToggleBtn" id="filterToggleBtn"
@@ -458,16 +458,14 @@
<div id="scanStatus" class="library-scan-status"> <div id="scanStatus" class="library-scan-status">
Hinweis: Im Schnellmodus zuerst den Schülerausweis scannen, danach den Buch-/Mediencode. Hinweis: Im Schnellmodus zuerst den Schülerausweis scannen, danach den Buch-/Mediencode.
</div> </div>
<div id="scanReaderWrap" class="library-scan-reader-wrap"> <div id="scanReaderWrap" class="library-scan-reader-wrap" style="display: none;">
<div id="libraryQrReader" class="library-scan-reader"></div> <div id="libraryQrReader" class="library-scan-reader">
<div id="library-scanner-container"></div>
</div>
</div> </div>
</div> </div>
<div id="filterPanel" class="library-filter-panel"> <div id="filterPanel" class="library-filter-panel">
<div class="filter-row"> <div class="filter-row">
<div class="filter-item">
<label for="filterAuthor">Autor/Künstler:</label>
<input type="text" id="filterAuthor" placeholder="z.B. Goethe">
</div>
<div class="filter-item"> <div class="filter-item">
<label for="filterISBN">ISBN:</label> <label for="filterISBN">ISBN:</label>
<input type="text" id="filterISBN" placeholder="z.B. 978-3..."> <input type="text" id="filterISBN" placeholder="z.B. 978-3...">
@@ -514,7 +512,6 @@
<thead> <thead>
<tr> <tr>
<th style="width: 24%;">Titel</th> <th style="width: 24%;">Titel</th>
<th style="width: 14%;">Autor/Künstler</th>
<th style="width: 12%;">ISBN/Code</th> <th style="width: 12%;">ISBN/Code</th>
<th style="width: 8%;">Typ</th> <th style="width: 8%;">Typ</th>
<th style="width: 8%;">Anzahl</th> <th style="width: 8%;">Anzahl</th>
@@ -547,15 +544,17 @@
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
<script> <script>
// State // =========================================================================
// 1. GLOBAL STATE DEFINITIONS
// =========================================================================
let libraryItems = []; let libraryItems = [];
let filteredItems = []; let filteredItems = [];
let visibleItems = []; let visibleItems = [];
let currentSearchTerm = ''; let currentSearchTerm = '';
let activeFilters = { let activeFilters = {
author: '',
isbn: '', isbn: '',
type: '', type: '',
status: '' status: ''
@@ -571,13 +570,20 @@
const RENDER_BATCH_COUNT = 120; const RENDER_BATCH_COUNT = 120;
let renderedCount = INITIAL_RENDER_COUNT; let renderedCount = INITIAL_RENDER_COUNT;
let filterPanelOpen = false; let filterPanelOpen = false;
// Scanner Related State Variables
let scannerInstance = null; let scannerInstance = null;
let scannerRunning = false; let scannerRunning = false;
let activeScannerCallback = null;
let activeStudentCardId = ''; let activeStudentCardId = '';
let lastScanValue = ''; let lastScanValue = '';
let lastScanAt = 0; let lastScanAt = 0;
const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1'); const canEditLibraryItems = (document.getElementById('libraryTableContainer')?.dataset.canEdit === '1');
// =========================================================================
// 2. DATA LOADING & FILTERING ENGINE
// =========================================================================
async function fetchLibraryPage(offset, limit) { async function fetchLibraryPage(offset, limit) {
const response = await fetch(`/api/library_items?offset=${offset}&limit=${limit}`); const response = await fetch(`/api/library_items?offset=${offset}&limit=${limit}`);
if (!response.ok) { if (!response.ok) {
@@ -586,7 +592,6 @@
return response.json(); return response.json();
} }
// Load items progressively: first page immediately, rest in background
async function loadLibraryItems() { async function loadLibraryItems() {
if (pagingState.loading) return; if (pagingState.loading) return;
pagingState.loading = true; pagingState.loading = true;
@@ -625,7 +630,6 @@
pagingState.total = page.total || pagingState.total; pagingState.total = page.total || pagingState.total;
pagingState.hasMore = Boolean(page.has_more); pagingState.hasMore = Boolean(page.has_more);
// Keep the current view reactive while avoiding a full rerender burst.
applyFiltersAndSearch(false); applyFiltersAndSearch(false);
if ('requestIdleCallback' in window) { if ('requestIdleCallback' in window) {
@@ -641,18 +645,16 @@
} }
function filterItems(items) { function filterItems(items) {
const author = activeFilters.author;
const isbn = activeFilters.isbn; const isbn = activeFilters.isbn;
const type = activeFilters.type; const type = activeFilters.type;
const status = activeFilters.status; const status = activeFilters.status;
return items.filter(item => { return items.filter(item => {
const authorMatch = !author || (item.Autor || item.Author || '').toLowerCase().includes(author);
const isbnMatch = !isbn || (item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(isbn); const isbnMatch = !isbn || (item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(isbn);
const typeMatch = !type || (item.ItemType || 'book') === type; const typeMatch = !type || (item.ItemType || 'book') === type;
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available'); const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusMatch = !status || statusKey === status; const statusMatch = !status || statusKey === status;
return authorMatch && isbnMatch && typeMatch && statusMatch; return isbnMatch && typeMatch && statusMatch;
}); });
} }
@@ -660,7 +662,6 @@
if (!currentSearchTerm) return items; if (!currentSearchTerm) return items;
return items.filter(item => return items.filter(item =>
(item.Name || '').toLowerCase().includes(currentSearchTerm) || (item.Name || '').toLowerCase().includes(currentSearchTerm) ||
(item.Autor || item.Author || '').toLowerCase().includes(currentSearchTerm) ||
(item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(currentSearchTerm) (item.ISBN || item.Code_4 || item.Code4 || '').toLowerCase().includes(currentSearchTerm)
); );
} }
@@ -677,7 +678,6 @@
renderItems(); renderItems();
} }
// Display current view with incremental rendering
function renderItems() { function renderItems() {
const tbody = document.getElementById('itemsTableBody'); const tbody = document.getElementById('itemsTableBody');
const emptyState = document.getElementById('emptyState'); const emptyState = document.getElementById('emptyState');
@@ -695,8 +695,7 @@
emptyState.style.display = 'none'; emptyState.style.display = 'none';
const rowsToRender = visibleItems.slice(0, renderedCount); const rowsToRender = visibleItems.slice(0, renderedCount);
tbody.innerHTML = rowsToRender.map(item => ` tbody.innerHTML = rowsToRender.map(item => {
${(() => {
const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available'); const statusKey = item.LibraryDisplayStatus || (item.Verfuegbar === false ? 'borrowed' : 'available');
const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available'); const statusClass = statusKey === 'damaged' ? 'status-damaged' : (statusKey === 'borrowed' ? 'status-borrowed' : 'status-available');
const statusText = statusKey === 'damaged' ? 'Defekt/Zerstört' : (statusKey === 'borrowed' ? 'Ausgeliehen' : 'Verfügbar'); const statusText = statusKey === 'damaged' ? 'Defekt/Zerstört' : (statusKey === 'borrowed' ? 'Ausgeliehen' : 'Verfügbar');
@@ -705,7 +704,6 @@
return ` return `
<tr> <tr>
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td> <td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
<td>${escapeHtml(item.Autor || item.Author || '-')}</td>
<td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td> <td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td>
<td>${getItemTypeLabel(item.ItemType || 'book')}</td> <td>${getItemTypeLabel(item.ItemType || 'book')}</td>
<td style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td> <td style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td>
@@ -722,10 +720,8 @@
${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''} ${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''}
${canEditLibraryItems ? `<button class="button" style="background:#dc2626;color:#fff; margin-left:6px;" onclick="confirmDeleteLibraryItem('${item._id}')">Löschen</button>` : ''} ${canEditLibraryItems ? `<button class="button" style="background:#dc2626;color:#fff; margin-left:6px;" onclick="confirmDeleteLibraryItem('${item._id}')">Löschen</button>` : ''}
</td> </td>
</tr> </tr>`;
`; }).join('');
})()}
`).join('');
const canLoadMoreRows = renderedCount < visibleItems.length; const canLoadMoreRows = renderedCount < visibleItems.length;
if (canLoadMoreRows) { if (canLoadMoreRows) {
@@ -740,13 +736,6 @@
} }
} }
// Confirm and call library item delete endpoint
function confirmDeleteLibraryItem(itemId) {
if (!itemId) return;
if (!confirm('Dieses Bibliotheksmedium revisionssicher löschen? Diese Aktion deaktiviert alle zugehörigen Exemplare und archiviert Medien.')) return;
deleteLibraryItem(itemId);
}
async function deleteLibraryItem(itemId) { async function deleteLibraryItem(itemId) {
if (!itemId) return; if (!itemId) return;
document.body.style.cursor = 'wait'; document.body.style.cursor = 'wait';
@@ -770,7 +759,6 @@
} }
loadLibraryItems(); loadLibraryItems();
} else { } else {
// Server redirected to HTML (flash messages). Reload to show state.
location.reload(); location.reload();
} }
} catch (e) { } catch (e) {
@@ -781,72 +769,183 @@
} }
} }
// Filter toggle function confirmDeleteLibraryItem(itemId) {
document.getElementById('filterToggleBtn').addEventListener('click', () => { if (!itemId) return;
filterPanelOpen = !filterPanelOpen; if (!confirm('Dieses Bibliotheksmedium revisionssicher löschen? Diese Aktion deaktiviert alle zugehörigen Exemplare und archiviert Medien.')) return;
const panel = document.getElementById('filterPanel'); deleteLibraryItem(itemId);
const btn = document.getElementById('filterToggleBtn');
panel.classList.toggle('open');
btn.classList.toggle('active');
});
// Apply filters
document.getElementById('applyFilterBtn').addEventListener('click', () => {
activeFilters.author = document.getElementById('filterAuthor').value.toLowerCase();
activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
activeFilters.type = document.getElementById('filterType').value;
activeFilters.status = document.getElementById('filterStatus').value;
applyFiltersAndSearch(true);
});
// Clear filters
document.getElementById('clearFilterBtn').addEventListener('click', () => {
document.getElementById('filterAuthor').value = '';
document.getElementById('filterISBN').value = '';
document.getElementById('filterType').value = '';
document.getElementById('filterStatus').value = '';
activeFilters = { author: '', isbn: '', type: '', status: '' };
applyFiltersAndSearch(true);
});
// Search input
document.getElementById('librarySearch').addEventListener('input', (e) => {
currentSearchTerm = (e.target.value || '').toLowerCase();
applyFiltersAndSearch(true);
});
document.getElementById('loadMoreBtn').addEventListener('click', () => {
renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length);
renderItems();
});
// Helper functions
function getItemTypeLabel(type) {
const labels = { 'book': 'Buch', 'cd': 'CD', 'dvd': 'DVD', 'other': 'Sonstige' };
return labels[type] || type;
} }
function escapeHtml(text) { // =========================================================================
const div = document.createElement('div'); // 3. CORE SCANNER ROUTING ENGINE (QUAGGA2)
div.textContent = text; // =========================================================================
return div.innerHTML; function startScanner(targetCallback) {
const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
activeScannerCallback = targetCallback;
if (readerWrap) readerWrap.style.display = 'block';
setScanStatus('Initializing camera...', 'warn');
Quagga.init({
inputStream: {
name: "Live",
type: "LiveStream",
target: document.querySelector('#library-scanner-container'),
constraints: {
width: 640,
height: 480,
facingMode: "environment"
},
},
decoder: {
readers: [
"code_128_reader",
"ean_reader",
"code_39_reader",
"upc_reader",
"codabar_reader",
"i2of5_reader"
]
}
}, function(err) {
if (err) {
console.error('Scanner start failed:', err);
if (readerWrap) readerWrap.style.display = 'none';
const detail = (err && (err.message || err.name)) ? ` (${err.message || err.name})` : '';
setScanStatus(`Scanner konnte nicht gestartet werden${detail}`, 'error');
return;
} }
function showItemDetail(itemId) { Quagga.start();
// Load and show detail modal scannerRunning = true;
fetch(`/api/item_detail/${itemId}`)
.then(r => r.text()) if (!targetCallback && toggleBtn) {
.then(html => { toggleBtn.textContent = 'Scanner stoppen';
document.getElementById('detailContent').innerHTML = html; }
document.getElementById('detailModal').style.display = 'flex'; setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn');
});
}
function stopScanner() {
if (!scannerRunning) return;
const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
Quagga.stop();
scannerRunning = false;
activeScannerCallback = null;
if (readerWrap) readerWrap.style.display = 'none';
if (toggleBtn) toggleBtn.textContent = 'Scanner starten';
setScanStatus('Scanner gestoppt.', 'warn');
}
Quagga.onDetected(function(data) {
if (!data || !data.codeResult || !data.codeResult.code) return;
const barcode = String(data.codeResult.code || '').trim();
console.log("Barcode detected:", barcode);
const currentCallback = activeScannerCallback;
stopScanner();
if (typeof currentCallback === "function") {
currentCallback(barcode);
} else {
handleScanSuccess(barcode);
}
});
function handleScanSuccess(decodedText) {
const scannedCode = normalizeScannedCode(decodedText);
if (!scannedCode) return;
const now = Date.now();
if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) {
return;
}
lastScanValue = scannedCode;
lastScanAt = now;
const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only';
if (mode === 'card_only') {
setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
return;
}
processQuickToggleScan(scannedCode);
}
async function processQuickToggleScan(scannedCode) {
if (!activeStudentCardId) {
setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
return;
}
try {
setScanStatus('Verarbeite Mediencode...', 'warn');
const response = await fetch('/api/library_scan_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
student_card_id: activeStudentCardId,
item_code: scannedCode
}) })
.catch(err => console.error('Error loading detail:', err)); });
const result = await response.json();
if (!response.ok || !result.ok) {
setScanStatus(result.message || 'Scan-Aktion fehlgeschlagen.', 'error');
return;
} }
function closeDetailModal() { if (result.action === 'borrowed') {
document.getElementById('detailModal').style.display = 'none'; setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok');
} else if (result.action === 'returned') {
setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok');
} else {
setScanStatus(result.message || 'Aktion durchgeführt.', 'ok');
} }
await loadLibraryItems();
} catch (err) {
console.error('Quick scan action failed:', err);
setScanStatus('Fehler beim Verarbeiten des Scans.', 'error');
}
}
function scanIntoEditCode() {
const scanReaderWrap = document.getElementById('scanReaderWrap');
const editCodeInput = document.getElementById('edit-code4');
const scanEditBtn = document.getElementById('scan-edit-code-btn');
if (!scanReaderWrap || !editCodeInput || !scanEditBtn) {
return;
}
if (scannerRunning && scanReaderWrap.style.display !== 'none') {
stopScanner();
scanEditBtn.textContent = 'Barcode scannen';
return;
}
scanEditBtn.textContent = 'Scanner schließen';
startScanner(function(decodedText) {
editCodeInput.value = decodedText;
if(typeof validateCodeField === "function") {
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
}
scanEditBtn.textContent = 'Barcode scannen';
});
}
// =========================================================================
// 4. UI INTERACTIONS & UTILITIES
// =========================================================================
function borrowItem(itemId) { function borrowItem(itemId) {
const selectedItem = (libraryItems || []).find(item => item._id === itemId); const selectedItem = (libraryItems || []).find(item => item._id === itemId);
if (selectedItem && selectedItem.LibraryDisplayStatus === 'damaged') { if (selectedItem && selectedItem.LibraryDisplayStatus === 'damaged') {
@@ -920,210 +1019,47 @@
if (!el) return; if (!el) return;
el.textContent = message; el.textContent = message;
el.classList.remove('ok', 'warn', 'error'); el.classList.remove('ok', 'warn', 'error');
if (kind) { if (kind) el.classList.add(kind);
el.classList.add(kind);
}
} }
function setActiveStudentCard(cardId) { function setActiveStudentCard(cardId) {
activeStudentCardId = (cardId || '').trim().toUpperCase(); activeStudentCardId = (cardId || '').trim().toUpperCase();
const input = document.getElementById('activeStudentCard'); const input = document.getElementById('activeStudentCard');
if (input) { if (input) input.value = activeStudentCardId;
input.value = activeStudentCardId;
}
} }
function normalizeScannedCode(code) { function normalizeScannedCode(code) {
return (code || '').trim(); return (code || '').trim();
} }
async function processQuickToggleScan(scannedCode) { function getItemTypeLabel(type) {
if (!activeStudentCardId) { const labels = { 'book': 'Buch', 'cd': 'CD', 'dvd': 'DVD', 'other': 'Sonstige' };
setActiveStudentCard(scannedCode); return labels[type] || type;
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
return;
} }
try { function escapeHtml(text) {
setScanStatus('Verarbeite Mediencode...', 'warn'); const div = document.createElement('div');
const response = await fetch('/api/library_scan_action', { div.textContent = text;
method: 'POST', return div.innerHTML;
headers: {'Content-Type': 'application/json'}, }
body: JSON.stringify({
student_card_id: activeStudentCardId, function showItemDetail(itemId) {
item_code: scannedCode fetch(`/api/item_detail/${itemId}`)
.then(r => r.text())
.then(html => {
document.getElementById('detailContent').innerHTML = html;
document.getElementById('detailModal').style.display = 'flex';
}) })
}); .catch(err => console.error('Error loading detail:', err));
const result = await response.json();
if (!response.ok || !result.ok) {
setScanStatus(result.message || 'Scan-Aktion fehlgeschlagen.', 'error');
return;
} }
if (result.action === 'borrowed') { function closeDetailModal() {
setScanStatus(`Ausgeliehen: ${result.item_name}`, 'ok'); document.getElementById('detailModal').style.display = 'none';
} else if (result.action === 'returned') {
setScanStatus(`Zurückgegeben: ${result.item_name}`, 'ok');
} else {
setScanStatus(result.message || 'Aktion durchgeführt.', 'ok');
}
await loadLibraryItems();
} catch (err) {
console.error('Quick scan action failed:', err);
setScanStatus('Fehler beim Verarbeiten des Scans.', 'error');
}
}
function handleScanSuccess(decodedText) {
const scannedCode = normalizeScannedCode(decodedText);
if (!scannedCode) return;
const now = Date.now();
if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) {
return;
}
lastScanValue = scannedCode;
lastScanAt = now;
const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only';
if (mode === 'card_only') {
setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}`, 'ok');
return;
}
processQuickToggleScan(scannedCode);
}
function handleScanError() {
// Intentionally silent to avoid UI spam while camera searches codes.
}
async function ensureScannerLibraryLoaded() {
if (typeof Html5QrcodeScanner !== 'undefined') {
return true;
}
const sources = [
'https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js'
];
for (const src of sources) {
try {
await new Promise((resolve, reject) => {
const existing = document.querySelector(`script[data-scanner-src="${src}"]`);
if (existing) {
const onLoad = () => resolve();
const onError = () => reject(new Error('Script load failed'));
existing.addEventListener('load', onLoad, { once: true });
existing.addEventListener('error', onError, { once: true });
setTimeout(() => {
existing.removeEventListener('load', onLoad);
existing.removeEventListener('error', onError);
if (typeof Html5QrcodeScanner !== 'undefined') {
resolve();
} else {
reject(new Error('Script not available'));
}
}, 1200);
return;
}
const script = document.createElement('script');
script.src = src;
script.async = true;
script.defer = true;
script.dataset.scannerSrc = src;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Script load failed'));
document.head.appendChild(script);
});
if (typeof Html5QrcodeScanner !== 'undefined') {
return true;
}
} catch (err) {
console.warn('Scanner library load failed from', src, err);
}
}
return false;
}
async function startScanner() {
if (scannerRunning) return;
const scannerLoaded = await ensureScannerLibraryLoaded();
if (!scannerLoaded) {
setScanStatus('Scanner-Bibliothek konnte nicht geladen werden.', 'error');
return;
}
const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
readerWrap.style.display = 'block';
try {
const formats = [];
if (typeof Html5QrcodeSupportedFormats !== 'undefined') {
formats.push(
Html5QrcodeSupportedFormats.QR_CODE,
Html5QrcodeSupportedFormats.EAN_13,
Html5QrcodeSupportedFormats.EAN_8,
Html5QrcodeSupportedFormats.CODE_128,
Html5QrcodeSupportedFormats.CODE_39,
Html5QrcodeSupportedFormats.UPC_A,
Html5QrcodeSupportedFormats.UPC_E,
Html5QrcodeSupportedFormats.ITF,
Html5QrcodeSupportedFormats.CODABAR
);
}
const scannerConfig = {
fps: 10,
rememberLastUsedCamera: true,
aspectRatio: 1.333334
};
if (formats.length > 0) {
scannerConfig.formatsToSupport = formats;
}
scannerInstance = scannerInstance || new Html5QrcodeScanner(
'libraryQrReader',
scannerConfig,
false
);
scannerInstance.render(handleScanSuccess, handleScanError);
scannerRunning = true;
toggleBtn.textContent = 'Scanner stoppen';
setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn');
} catch (err) {
console.error('Scanner start failed:', err);
readerWrap.style.display = 'none';
const detail = (err && (err.message || err.name)) ? ` (${err.message || err.name})` : '';
setScanStatus(`Scanner konnte nicht gestartet werden${detail}`, 'error');
}
}
async function stopScanner() {
if (!scannerRunning || !scannerInstance) return;
const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
try {
await scannerInstance.clear();
} catch (err) {
console.error('Scanner stop failed:', err);
}
scannerRunning = false;
scannerInstance = null;
readerWrap.style.display = 'none';
toggleBtn.textContent = 'Scanner starten';
setScanStatus('Scanner gestoppt.', 'warn');
} }
// =========================================================================
// 5. EVENT LISTENERS INITIALIZATION & ON-LOAD INITIALIZER
// =========================================================================
function wireScannerUi() { function wireScannerUi() {
const toggleBtn = document.getElementById('toggleScannerBtn'); const toggleBtn = document.getElementById('toggleScannerBtn');
const resetBtn = document.getElementById('resetCardBtn'); const resetBtn = document.getElementById('resetCardBtn');
@@ -1134,7 +1070,7 @@
if (scannerRunning) { if (scannerRunning) {
await stopScanner(); await stopScanner();
} else { } else {
await startScanner(); await startScanner(null);
} }
}); });
} }
@@ -1151,84 +1087,135 @@
if (modeSelect.value === 'quick_toggle' && !activeStudentCardId) { if (modeSelect.value === 'quick_toggle' && !activeStudentCardId) {
setScanStatus('Schnellmodus: zuerst Schülerausweis scannen.', 'warn'); setScanStatus('Schnellmodus: zuerst Schülerausweis scannen.', 'warn');
} else if (modeSelect.value === 'card_only') { } else if (modeSelect.value === 'card_only') {
setScanStatus('Modus aktiv: Nur Ausweis erfassen.', 'warn'); setScanStatus('Nur Ausweis-Modus aktiv.', 'warn');
} else {
setScanStatus('Schnellmodus aktiv: jetzt Mediencode scannen.', 'warn');
} }
}); });
} }
} }
function getLibraryItemById(itemId) { // Run when DOM structure is entirely ready
return libraryItems.find(i => String(i._id) === String(itemId)); document.addEventListener('DOMContentLoaded', () => {
wireScannerUi(); // Setup scanner control buttons
loadLibraryItems(); // Fetch your database items right away!
// Safely connect standard Filters and Search inputs inside DOMContentLoaded
const filterToggleBtn = document.getElementById('filterToggleBtn');
if (filterToggleBtn) {
filterToggleBtn.addEventListener('click', () => {
filterPanelOpen = !filterPanelOpen;
document.getElementById('filterPanel').classList.toggle('open');
filterToggleBtn.classList.toggle('active');
});
} }
function openEditLibraryItem(itemId) { const applyFilterBtn = document.getElementById('applyFilterBtn');
const item = getLibraryItemById(itemId); if (applyFilterBtn) {
if (!item) { applyFilterBtn.addEventListener('click', () => {
alert('Element nicht gefunden.'); activeFilters.isbn = document.getElementById('filterISBN').value.toLowerCase();
return; activeFilters.type = document.getElementById('filterType').value;
activeFilters.status = document.getElementById('filterStatus').value;
applyFiltersAndSearch(true);
});
} }
const modal = document.getElementById('editLibraryModal'); const clearFilterBtn = document.getElementById('clearFilterBtn');
document.getElementById('editLibraryItemId').value = item._id || ''; if (clearFilterBtn) {
document.getElementById('editLibraryName').value = item.Name || ''; clearFilterBtn.addEventListener('click', () => {
document.getElementById('editLibraryAuthor').value = item.Autor || item.Author || ''; document.getElementById('filterISBN').value = '';
document.getElementById('editLibraryDescription').value = item.Beschreibung || ''; document.getElementById('filterType').value = '';
document.getElementById('editLibraryLocation').value = item.Ort || ''; document.getElementById('filterStatus').value = '';
document.getElementById('editLibraryIsbn').value = item.ISBN || ''; activeFilters = { isbn: '', type: '', status: '' };
document.getElementById('editLibraryCode4').value = item.Code_4 || ''; applyFiltersAndSearch(true);
document.getElementById('editLibraryType').value = (item.ItemType || 'book'); });
modal.style.display = 'flex';
} }
function closeEditLibraryModal() { const librarySearch = document.getElementById('librarySearch');
const modal = document.getElementById('editLibraryModal'); if (librarySearch) {
modal.style.display = 'none'; librarySearch.addEventListener('input', (e) => {
currentSearchTerm = (e.target.value || '').toLowerCase();
applyFiltersAndSearch(true);
});
} }
async function submitEditLibraryItem(event) { const loadMoreBtn = document.getElementById('loadMoreBtn');
event.preventDefault(); if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', () => {
renderedCount = Math.min(renderedCount + RENDER_BATCH_COUNT, visibleItems.length);
renderItems();
});
}
// Edit Modal Form processing
const editForm = document.getElementById('editLibraryForm');
if (editForm) {
editForm.addEventListener('submit', async function(e) {
e.preventDefault();
const itemId = document.getElementById('editLibraryItemId').value; const itemId = document.getElementById('editLibraryItemId').value;
const payload = {
const updatedData = {
name: document.getElementById('editLibraryName').value, name: document.getElementById('editLibraryName').value,
autor: document.getElementById('editLibraryAuthor').value, item_type: document.getElementById('editLibraryType').value,
beschreibung: document.getElementById('editLibraryDescription').value,
ort: document.getElementById('editLibraryLocation').value,
isbn: document.getElementById('editLibraryIsbn').value, isbn: document.getElementById('editLibraryIsbn').value,
code_4: document.getElementById('editLibraryCode4').value, code_4: document.getElementById('editLibraryCode4').value,
item_type: document.getElementById('editLibraryType').value ort: document.getElementById('editLibraryLocation').value,
beschreibung: document.getElementById('editLibraryDescription').value
}; };
try { try {
const response = await fetch(`/api/library_item/${itemId}/update`, { const response = await fetch(`/api/library_item/${itemId}/update`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {
body: JSON.stringify(payload) 'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}',
'X-CSRF-Token': '{{ csrf_token }}'
},
body: JSON.stringify(updatedData)
}); });
const result = await response.json(); const result = await response.json();
if (!response.ok || !result.ok) {
alert(result.message || 'Bearbeiten fehlgeschlagen.'); if (response.ok && result.ok) {
alert(result.message || 'Medium erfolgreich aktualisiert!');
closeEditLibraryModal();
pagingState.loading = false;
loadLibraryItems();
} else {
alert(result.message || 'Fehler beim Speichern der Änderungen.');
}
} catch (error) {
console.error('Update failed:', error);
alert('Netzwerkfehler beim Aktualisieren des Mediums.');
}
});
}
});
// Modal Control Functions (accessible globally from table buttons)
function openEditLibraryItem(itemId) {
const item = libraryItems.find(i => i._id === itemId);
if (!item) {
alert('Fehler: Das Medium konnte nicht gefunden werden.');
return; return;
} }
closeEditLibraryModal();
await loadLibraryItems(); // Populate the form fields - Using correct German properties from MongoDB fields
setScanStatus('Bibliotheksmedium aktualisiert.', 'ok'); document.getElementById('editLibraryItemId').value = item._id || '';
} catch (err) { document.getElementById('editLibraryName').value = item.Name || '';
console.error('Library edit failed:', err); document.getElementById('editLibraryType').value = item.ItemType || 'book';
alert('Fehler beim Bearbeiten des Bibliotheksmediums.'); document.getElementById('editLibraryIsbn').value = item.ISBN || '';
} document.getElementById('editLibraryCode4').value = item.Code_4 || item.Code4 || '';
document.getElementById('editLibraryLocation').value = item.Ort || '';
document.getElementById('editLibraryDescription').value = item.Beschreibung || '';
document.getElementById('editLibraryModal').style.display = 'flex';
} }
// Load on page init function closeEditLibraryModal() {
document.addEventListener('DOMContentLoaded', () => { document.getElementById('editLibraryModal').style.display = 'none';
loadLibraryItems();
wireScannerUi();
const editForm = document.getElementById('editLibraryForm');
if (editForm) {
editForm.addEventListener('submit', submitEditLibraryItem);
} }
});
</script> </script>
<div id="editLibraryModal" class="modal" style="display:none;"> <div id="editLibraryModal" class="modal" style="display:none;">
@@ -1242,10 +1229,6 @@
<label for="editLibraryName">Titel</label> <label for="editLibraryName">Titel</label>
<input id="editLibraryName" required> <input id="editLibraryName" required>
</div> </div>
<div>
<label for="editLibraryAuthor">Autor/Künstler</label>
<input id="editLibraryAuthor">
</div>
<div> <div>
<label for="editLibraryType">Medientyp</label> <label for="editLibraryType">Medientyp</label>
<select id="editLibraryType"> <select id="editLibraryType">
+94 -32
View File
@@ -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",
// Make sure this targets your correct HTML container element
target: document.querySelector('#scanner-container'),
constraints: {
width: 640,
height: 480,
facingMode: "environment" // Force rear camera
},
},
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;
}
html5QrcodeScanner = new Html5QrcodeScanner( Quagga.start();
'qr-reader', { fps: 10, qrbox: 250 } isScanning = true;
);
html5QrcodeScanner.render((decodedText) => { // If we are using the main search button, manage its UI state
html5QrcodeScanner.clear(); if (!targetCallback) {
qrReader.style.display = 'none'; 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;
// Put scanned code into the search box and trigger search
const searchInput = document.getElementById('code-search'); const searchInput = document.getElementById('code-search');
if (searchInput) { if (searchInput) {
searchInput.value = decodedText; searchInput.value = barcode;
if (typeof searchByCode === "function") {
searchByCode(); searchByCode();
} }
}
setScannerUi(false); }
}); });
setScannerUi(true); // 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 { } else {
if (html5QrcodeScanner) { stopScanner();
html5QrcodeScanner.clear();
} }
qrReader.style.display = 'none';
setScannerUi(false);
}
});
}); });
// Return preferred primary and fallback image URLs based on extension // Return preferred primary and fallback image URLs based on extension
+111 -62
View File
@@ -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">
@@ -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 = '';
@@ -2769,50 +2771,113 @@ document.addEventListener('DOMContentLoaded', ()=>{
return; return;
} }
document.getElementById('scanButton').addEventListener('click', function() { // Keep track of the scanner state globally/outer scope
const qrReader = document.getElementById('qr-reader'); let isScanning = false;
const scanBtn = document.getElementById('scanButton'); let activeScannerCallback = null; // Tracks which function currently owns the scanner output
const setScannerUi = (isOpen) => { function startScanner(targetCallback) {
const qrReader = document.getElementById('qr-reader');
const statusText = document.getElementById('status');
// Store the custom action that should happen when a barcode is found
activeScannerCallback = targetCallback;
// Show the container
if (qrReader) qrReader.style.display = 'block';
if (statusText) statusText.innerText = "Initializing camera...";
Quagga.init({
inputStream: {
name: "Live",
type: "LiveStream",
// Make sure this targets your correct HTML container element
target: document.querySelector('#scanner-container'),
constraints: {
width: 640,
height: 480,
facingMode: "environment" // Force rear camera
},
},
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; if (!scanBtn) return;
scanBtn.classList.toggle('is-active', isOpen); scanBtn.classList.toggle('is-active', isOpen);
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen'; scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
};
if (qrReader.style.display === 'none') {
qrReader.style.display = 'block';
html5QrcodeScanner = new Html5QrcodeScanner(
"qr-reader", {
fps: 10,
qrbox: 250,
rememberLastUsedCamera: true
} }
);
html5QrcodeScanner.render((decodedText) => { // Unified global reader callback
html5QrcodeScanner.clear(); Quagga.onDetected(function(data) {
qrReader.style.display = 'none'; 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;
// Instead of navigating to the URL, put the scanned code in the search box
const searchInput = document.getElementById('code-search'); const searchInput = document.getElementById('code-search');
if (searchInput) { if (searchInput) {
searchInput.value = decodedText; searchInput.value = barcode;
// Trigger search automatically if (typeof searchByCode === "function") {
searchByCode(); searchByCode();
} }
}
setScannerUi(false); }
}); });
setScannerUi(true); // 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 { } else {
if (html5QrcodeScanner) { stopScanner();
html5QrcodeScanner.clear();
}
qrReader.style.display = 'none';
setScannerUi(false);
} }
}); });
@@ -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');
+463 -9
View File
@@ -2,17 +2,49 @@
{% block title %}Termin buchen - Inventarsystem{% endblock %} {% block title %}Termin buchen - Inventarsystem{% endblock %}
{% block head %}
{{ super() }}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet">
<style>
#client-slot-calendar {
min-height: 520px;
}
.fc .fc-timegrid-slot-label-cushion,
.fc .fc-timegrid-axis-cushion,
.fc .fc-col-header-cell-cushion {
font-weight: 600;
}
.slot-selected-chip {
display: inline-flex;
align-items: center;
gap: .4rem;
border-radius: 999px;
background: rgba(13, 110, 253, 0.1);
color: #0d6efd;
padding: .35rem .75rem;
font-size: .9rem;
font-weight: 700;
}
.day-slider-wrap {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: .9rem;
padding: .8rem 1rem;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container py-4"> <div class="container py-4">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-12 col-xl-10"> <div class="col-12 col-xxl-11">
<div class="row g-4"> <div class="row g-4">
<div class="col-12 col-lg-5"> <div class="col-12 col-lg-4">
<div class="card border-0 shadow-lg rounded-4 h-100"> <div class="card border-0 shadow-lg rounded-4 h-100">
<div class="card-body p-4 p-md-5"> <div class="card-body p-4 p-md-5">
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p> <p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
<h1 class="h3 fw-bold mb-3">Termin buchen</h1> <h1 class="h3 fw-bold mb-3">Termin buchen</h1>
<p class="text-muted mb-4">Wählen Sie einen freien Zeitpunkt und tragen Sie Ihren Namen ein. Der Termin wird anschließend im Plan gespeichert.</p> <p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p>
<div class="p-3 rounded-3 bg-light mb-3"> <div class="p-3 rounded-3 bg-light mb-3">
<div class="fw-semibold">Zeitraum</div> <div class="fw-semibold">Zeitraum</div>
@@ -27,12 +59,24 @@
<div>{{ available.slot_lenght }} Minuten</div> <div>{{ available.slot_lenght }} Minuten</div>
</div> </div>
<div class="p-3 rounded-3 bg-light mb-3">
<div class="fw-semibold mb-2">Gewählter Termin</div>
<div id="selected-slot-badge" class="text-muted small">Noch kein Slot ausgewählt.</div>
</div>
{% if available.slots_booked %} {% if available.slots_booked %}
<div class="p-3 rounded-3 bg-light"> <div class="p-3 rounded-3 bg-light">
<div class="fw-semibold mb-2">Bereits gebucht</div> <div class="fw-semibold mb-2">Bereits gebucht</div>
<ul class="mb-0 small"> <ul class="mb-0 small">
{% for booking in available.slots_booked %} {% for booking in available.slots_booked %}
<li>{{ booking.start }}{% if booking.name %} - {{ booking.name }}{% endif %}</li> <li>
{{ booking.start }}
{% if can_view_booking_names and booking.name %}
- {{ booking.name }}
{% else %}
- Belegt
{% endif %}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
@@ -40,15 +84,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-lg-7"> <div class="col-12 col-lg-8">
<div class="card border-0 shadow-lg rounded-4 h-100"> <div class="card border-0 shadow-lg rounded-4 h-100">
<div class="card-body p-4 p-md-5 bg-white"> <div class="card-body p-4 p-md-5 bg-white">
<h2 class="h4 fw-bold mb-4">Buchung absenden</h2> <h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
<div class="day-slider-wrap mb-3">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
<span class="small text-muted">Tag wählen</span>
<strong id="day-slider-label" class="small"></strong>
</div>
<input id="day-slider" type="range" class="form-range m-0" min="0" max="0" value="0">
</div>
<div id="client-slot-calendar" class="mb-4"></div>
<form id="client-booking-form" method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
<div> <div>
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label> <label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="2026-05-29 10:30" required> <input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="Bitte im Kalender auswählen" readonly required>
<div class="form-text">Tragen Sie Datum und Uhrzeit im Format YYYY-MM-DD HH:MM ein, sofern kein Kalenderfeld genutzt wird.</div> <div class="form-text">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</div>
</div> </div>
<div> <div>
<label for="client_name" class="form-label fw-semibold">Ihr Name</label> <label for="client_name" class="form-label fw-semibold">Ihr Name</label>
@@ -66,4 +121,403 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal fade" id="calendarDownloadModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Termin zum Kalender hinzufügen?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
</div>
<div class="modal-body">
<p class="mb-2">Sie haben einen Termin ausgewählt.</p>
<p class="mb-0 small text-muted">Mit einem Klick auf ".ics herunterladen" können Sie den Termin in Apple/Google/Outlook-Kalender importieren.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button id="confirm-booking-only" type="button" class="btn btn-primary">Jetzt buchen</button>
<button id="confirm-booking-with-ics" type="button" class="btn btn-success">Buchen + .ics</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
<script>
(function () {
const available = {{ available|tojson }};
const appointmentId = {{ appointment_id|tojson }};
const slider = document.getElementById('day-slider');
const sliderLabel = document.getElementById('day-slider-label');
const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null;
const selectedSlotInput = document.getElementById('start_day_time');
const selectedSlotBadge = document.getElementById('selected-slot-badge');
const clientNameInput = document.getElementById('client_name');
const form = document.getElementById('client-booking-form');
const calendarEl = document.getElementById('client-slot-calendar');
const modalEl = document.getElementById('calendarDownloadModal');
const confirmBookingOnlyBtn = document.getElementById('confirm-booking-only');
const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
const slotLength = Number.parseInt(available.slot_lenght, 10) || 45;
const bookedStarts = new Set((available.slots_booked || []).map(function (entry) {
return String(entry.start || '').trim();
}).filter(Boolean));
function formatDateForInput(dateObj) {
const y = dateObj.getFullYear();
const m = String(dateObj.getMonth() + 1).padStart(2, '0');
const d = String(dateObj.getDate()).padStart(2, '0');
const hh = String(dateObj.getHours()).padStart(2, '0');
const mm = String(dateObj.getMinutes()).padStart(2, '0');
return y + '-' + m + '-' + d + ' ' + hh + ':' + mm;
}
function formatDateReadable(value) {
if (!value) return 'Noch kein Slot ausgewählt.';
return 'Ausgewählt: ' + value;
}
function dateRangeInclusive(startStr, endStr) {
const days = [];
const start = new Date(startStr + 'T00:00:00');
const end = new Date(endStr + 'T00:00:00');
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return days;
}
const cursor = new Date(start);
while (cursor <= end) {
const y = cursor.getFullYear();
const m = String(cursor.getMonth() + 1).padStart(2, '0');
const d = String(cursor.getDate()).padStart(2, '0');
days.push(y + '-' + m + '-' + d);
cursor.setDate(cursor.getDate() + 1);
}
return days;
}
function addMinutes(dateObj, minutes) {
return new Date(dateObj.getTime() + minutes * 60000);
}
function formatTimeForCalendar(dateObj) {
return String(dateObj.getHours()).padStart(2, '0') + ':' + String(dateObj.getMinutes()).padStart(2, '0') + ':00';
}
function addDays(dateStr, days) {
const d = new Date(dateStr + 'T00:00:00');
d.setDate(d.getDate() + days);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return y + '-' + m + '-' + day;
}
function parseTimeSpanEntry(entry) {
const value = String(entry || '').trim();
let m = value.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$/);
if (m) {
return { date: m[1], from: m[2], to: m[3] };
}
m = value.match(/^(\d{2}:\d{2})-(\d{2}:\d{2})$/);
if (m) {
return { date: null, from: m[1], to: m[2] };
}
return null;
}
function buildCandidateSlots() {
const slots = [];
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
const spans = Array.isArray(available.time_span) ? available.time_span : [];
spans.forEach(function (entry) {
const parsed = parseTimeSpanEntry(entry);
if (!parsed) {
return;
}
const targetDates = parsed.date ? [parsed.date] : allowedDates;
targetDates.forEach(function (date) {
const from = new Date(date + 'T' + parsed.from + ':00');
const to = new Date(date + 'T' + parsed.to + ':00');
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) {
return;
}
let cursor = new Date(from);
while (addMinutes(cursor, slotLength) <= to) {
const slotStart = formatDateForInput(cursor);
if (!bookedStarts.has(slotStart)) {
slots.push({
start: slotStart,
end: formatDateForInput(addMinutes(cursor, slotLength)),
});
}
cursor = addMinutes(cursor, slotLength);
}
});
});
return slots;
}
const candidateSlots = buildCandidateSlots();
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
// Show the requested UI time window from 08:15 to 20:00 and highlight available slots
let slotMinTime = '08:15:00';
let slotMaxTime = '20:00:00';
if (candidateSlots.length > 0) {
const starts = candidateSlots.map(function (slot) {
return new Date(slot.start.replace(' ', 'T') + ':00');
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
const ends = candidateSlots.map(function (slot) {
return new Date(slot.end.replace(' ', 'T') + ':00');
}).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) {
computedMinStart = starts[0];
computedMaxEnd = ends[0];
starts.forEach(function (d) { if (d < computedMinStart) computedMinStart = d; });
ends.forEach(function (d) { if (d > computedMaxEnd) computedMaxEnd = d; });
}
}
const visibleStart = allDays[0] || String(available.date_start || '');
const visibleEndExclusive = allDays.length > 0
? addDays(allDays[allDays.length - 1], 1)
: addDays(String(available.date_end || available.date_start || ''), 1);
const multiDayDuration = Math.max(1, allDays.length || 1);
const initialViewName = multiDayDuration === 1 ? 'timeGridDay' : 'timeGridRange';
let selectedSlot = '';
let selectedEvent = null;
let allowImmediateSubmit = false;
const calendar = new FullCalendar.Calendar(calendarEl, {
initialView: initialViewName,
views: {
timeGridRange: {
type: 'timeGrid',
duration: { days: multiDayDuration },
buttonText: 'Zeitraum'
}
},
locale: 'de',
firstDay: 1,
height: 'auto',
allDaySlot: false,
editable: true,
eventStartEditable: true,
eventDurationEditable: false,
selectable: false,
slotDuration: '00:15:00',
snapDuration: '00:15:00',
slotMinTime: slotMinTime,
slotMaxTime: slotMaxTime,
nowIndicator: true,
validRange: {
start: visibleStart,
end: visibleEndExclusive,
},
visibleRange: {
start: visibleStart,
end: visibleEndExclusive,
},
headerToolbar: {
left: '',
center: 'title',
right: multiDayDuration === 1 ? '' : 'timeGridDay,timeGridRange'
},
events: [],
eventDrop: function (info) {
if (info.event.id !== 'selected-slot') {
return;
}
const droppedStart = formatDateForInput(info.event.start);
if (!slotStartSet.has(droppedStart)) {
info.revert();
window.alert('Dieser Zeitpunkt ist nicht als freier Slot verfügbar.');
return;
}
applySelectedSlot(droppedStart);
},
eventClick: function (info) {
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
if (slotType !== 'free') {
return;
}
applySelectedSlot(info.event.extendedProps.slotStart || '');
}
});
// No background greying: show full calendar skeleton and only mark possible slots
function updateSliderLabel() {
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
const idx = Number.parseInt(slider.value, 10) || 0;
sliderLabel.textContent = dateList[idx] || '';
}
function applySelectedSlot(value) {
selectedSlot = String(value || '').trim();
selectedSlotInput.value = selectedSlot;
selectedSlotBadge.innerHTML = selectedSlot
? '<span class="slot-selected-chip">' + selectedSlot + '</span>'
: 'Noch kein Slot ausgewählt.';
if (selectedEvent) {
selectedEvent.remove();
selectedEvent = null;
}
if (!selectedSlot) {
return;
}
const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
const end = addMinutes(start, slotLength);
selectedEvent = calendar.addEvent({
id: 'selected-slot',
title: 'Ihr ausgewählter Termin',
start: start,
end: end,
color: '#0d6efd',
editable: true,
});
refreshIcsLink();
}
function getIcsDownloadUrl() {
const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }};
const url = new URL(base, window.location.origin);
if (selectedSlot) {
url.searchParams.set('start', selectedSlot);
}
const name = String(clientNameInput.value || '').trim();
if (name) {
url.searchParams.set('name', name);
}
return url.toString();
}
function refreshIcsLink() {
const icsUrl = getIcsDownloadUrl();
if (confirmBookingWithIcsBtn) {
confirmBookingWithIcsBtn.setAttribute('data-ics-url', icsUrl);
}
}
// No background gaps: keep full skeleton/grid visible
// Add candidate free slots (blue)
candidateSlots.forEach(function (slot) {
const start = new Date(slot.start.replace(' ', 'T') + ':00');
const end = new Date(slot.end.replace(' ', 'T') + ':00');
calendar.addEvent({
title: 'Freier Slot',
start: start,
end: end,
color: '#0d6efd',
textColor: '#ffffff',
editable: false,
extendedProps: {
slotStart: slot.start,
slotType: 'free',
}
});
});
(available.slots_booked || []).forEach(function (booking) {
const startStr = String(booking.start || '').trim();
if (!startStr) {
return;
}
const start = new Date(startStr.replace(' ', 'T') + ':00');
const end = addMinutes(start, slotLength);
calendar.addEvent({
title: 'Gebucht' + (booking.name ? ' - ' + booking.name : ''),
start: start,
end: end,
color: '#dc3545',
editable: false,
display: 'block',
});
});
form.addEventListener('submit', function (ev) {
if (!selectedSlotInput.value) {
ev.preventDefault();
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
return;
}
if (allowImmediateSubmit) {
return;
}
ev.preventDefault();
if (modal) {
modal.show();
}
});
if (confirmBookingOnlyBtn) {
confirmBookingOnlyBtn.addEventListener('click', function () {
allowImmediateSubmit = true;
if (modal) {
modal.hide();
}
form.submit();
});
}
if (confirmBookingWithIcsBtn) {
confirmBookingWithIcsBtn.addEventListener('click', function () {
const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl();
if (icsUrl) {
window.open(icsUrl, '_blank');
}
allowImmediateSubmit = true;
if (modal) {
modal.hide();
}
form.submit();
});
}
clientNameInput.addEventListener('input', refreshIcsLink);
if (sliderWrap) {
sliderWrap.style.display = multiDayDuration > 1 ? '' : 'none';
}
slider.max = String(Math.max(0, allDays.length - 1));
slider.value = '0';
updateSliderLabel();
if (allDays.length > 0) {
calendar.gotoDate(allDays[0]);
}
slider.addEventListener('input', function () {
const idx = Number.parseInt(slider.value, 10) || 0;
updateSliderLabel();
if (allDays[idx]) {
if (calendar.view.type === 'timeGridDay') {
calendar.gotoDate(allDays[idx]);
}
}
});
calendar.render();
refreshIcsLink();
})();
</script>
{% endblock %} {% endblock %}
+121
View File
@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Buchung erfolgreich</title>
<style>
:root {
color-scheme: light;
--bg-a: #0f4c5c;
--bg-b: #16697a;
--ok: #22c55e;
--text: #0f172a;
--muted: #475569;
--card: #ffffff;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Manrope", "Segoe UI", sans-serif;
background: radial-gradient(circle at 20% 20%, rgba(255,255,255,0.14), transparent 45%), linear-gradient(135deg, var(--bg-a), var(--bg-b));
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.success-window {
width: min(900px, 100%);
background: var(--card);
border-radius: 1.25rem;
box-shadow: 0 28px 70px rgba(2, 6, 23, 0.32);
padding: clamp(1.5rem, 4vw, 3rem);
text-align: center;
}
.badge {
width: 84px;
height: 84px;
margin: 0 auto 1.25rem auto;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(34, 197, 94, 0.12);
color: var(--ok);
font-size: 2rem;
font-weight: 800;
}
h1 {
margin: 0 0 0.9rem 0;
font-size: clamp(1.8rem, 5vw, 3rem);
color: var(--text);
line-height: 1.1;
}
p {
margin: 0;
font-size: 1.1rem;
color: var(--muted);
}
.meta {
margin-top: 1.4rem;
padding: 1rem;
border-radius: 0.9rem;
background: #f8fafc;
color: #0f172a;
font-weight: 600;
}
.actions {
margin-top: 1.8rem;
display: flex;
justify-content: center;
gap: .75rem;
flex-wrap: wrap;
}
.btn {
border: 0;
border-radius: 0.8rem;
padding: .85rem 1.25rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-close {
background: #0f4c5c;
color: #fff;
}
.btn-link {
background: #e2e8f0;
color: #0f172a;
}
</style>
</head>
<body>
<main class="success-window" role="main" aria-live="polite">
<div class="badge"></div>
<h1>Buchung erfolgreich</h1>
<p>Sie können das Fenster jetzt schließen.</p>
{% if client_name or slot_start %}
<div class="meta">
{% if client_name %}
<div>Name: {{ client_name }}</div>
{% endif %}
{% if slot_start %}
<div>Termin: {{ slot_start }}</div>
{% endif %}
</div>
{% endif %}
<div class="actions">
<button class="btn btn-close" type="button" onclick="window.close()">Fenster schließen</button>
<a class="btn btn-link" href="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}">Zurück zur Buchungsseite</a>
</div>
</main>
</body>
</html>
-16
View File
@@ -314,22 +314,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
+292 -268
View File
@@ -831,7 +831,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>
@@ -870,24 +870,40 @@
<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>
<small style="display:block; color:#666;">Einzelcode manuell setzen oder per Scanner erfassen.</small>
<div id="code4-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></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;">
<small id="code4-scan-status" style="display:block; color:#666; margin-top:6px;"></small> <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>
<div class="form-group"> <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>
<label for="item_count">Anzahl dieser Sorte</label> <button type="button" class="btn btn-secondary" onclick="generateCodeRange()">Bereich generieren & einfügen</button>
<input type="number" id="item_count" name="item_count" min="1" max="100" value="1">
<small style="display:block; color:#666;">Es werden eigene Items erstellt, aber in der Übersicht als ein Artikel gebündelt angezeigt.</small>
</div> </div>
<div class="form-group">
<label for="individual_codes">Einzelcodes (optional, je Zeile ein Code)</label> <div class="form-group" id="primary_code_group">
<textarea id="individual_codes" name="individual_codes" rows="4" placeholder="z.B.\nABC-001\nABC-002"></textarea> <label for="code_4">Basis-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> <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 class="form-group" id="individual_codes_group">
<label for="individual_codes">Weitere Einzelcodes (je Zeile ein Code)</label>
<textarea id="individual_codes" name="individual_codes" rows="6" placeholder="z.B.&#10;ABC-001&#10;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 +986,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 +999,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 scannerRunning = false;
let activeScannerCallback = null; // Dynamically handles routing data to the active caller field
let code4LastScanned = ''; let code4LastScanned = '';
let code4LastScannedAt = 0; let code4LastScannedAt = 0;
let isbnScannerInstance = null;
let isbnScannerRunning = false;
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 +1057,232 @@
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
let code4LastScanned = '';
let 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');
// Scanner stoppen, falls der Modus gewechselt wird und er noch läuft
if (scannerBox && scannerBox.style.display !== 'none') {
if (typeof killScannerHardware === 'function') killScannerHardware();
scannerBox.style.display = 'none';
if (scanButton) scanButton.textContent = 'Barcode scannen';
}
if (mode === 'range') {
rangeGroup.style.display = 'block';
if(scanButton) scanButton.style.display = 'none'; // Verstecke Scanner im Range-Modus
} else {
rangeGroup.style.none = 'none';
if(rangeGroup) rangeGroup.style.display = 'none';
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) {
isbnScannerInstance.clear().catch(() => {});
const isbnScannerBox = document.getElementById('isbn-scanner'); const isbnScannerBox = document.getElementById('isbn-scanner');
if (isbnScannerBox && isbnScannerBox.style.display !== 'none') {
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) => {
const scannedCode = String(decodedText || '').trim();
if (!scannedCode) return;
if (typeof runEngineInitialization === 'function') {
runEngineInitialization(
'#code4-scanner',
function(decodedText) {
const now = Date.now(); const now = Date.now();
if (scannedCode === code4LastScanned && (now - code4LastScannedAt) < 1500) { if (decodedText === code4LastScanned && (now - code4LastScannedAt) < 1500) {
return; return;
} }
code4LastScanned = scannedCode; code4LastScanned = decodedText;
code4LastScannedAt = now; code4LastScannedAt = now;
codeField.value = scannedCode; if (scanMode === 'single') {
validateCodeField(codeField); killScannerHardware();
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`); 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}`);
} else if (scanMode === 'continuous') {
let currentAreaCodes = individualCodesArea.value.split('\n').map(c => c.trim()).filter(c => c !== '');
if (!codeField.value) {
codeField.value = decodedText;
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 +1290,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.
if (code4ScannerRunning && code4ScannerInstance) {
code4ScannerInstance.clear().catch(() => {});
const codeScannerBox = document.getElementById('code4-scanner'); const codeScannerBox = document.getElementById('code4-scanner');
const codeScanButton = document.getElementById('scan-code4-btn'); const codeScanButton = document.getElementById('scan-code4-btn');
if (codeScannerBox) codeScannerBox.style.display = 'none'; if (codeScannerBox && codeScannerBox.style.display !== 'none') {
if (codeScanButton) codeScanButton.textContent = 'Barcode scannen'; killScannerHardware();
code4ScannerRunning = false; codeScannerBox.style.display = 'none';
setCode4ScanStatus('Scanner gestoppt.'); codeScanButton.textContent = 'Barcode scannen';
if (typeof setCode4ScanStatus === 'function') 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();
const normalizedIsbn = normalizeIsbnClient(scannedCode);
isbnField.value = normalizedIsbn || scannedCode;
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(() => {});
scannerBox.style.display = 'none'; scannerBox.style.display = 'none';
scanButton.textContent = 'ISBN scannen'; scanButton.textContent = 'Barcode scannen';
isbnScannerRunning = false;
const normalizedIsbn = typeof normalizeIsbnClient === 'function' ? normalizeIsbnClient(decodedText) : decodedText;
isbnField.value = normalizedIsbn || decodedText;
if (typeof updateIsbnLiveValidation === 'function') updateIsbnLiveValidation();
if (normalizedIsbn) { if (normalizedIsbn) {
// Automatically load book metadata after a valid ISBN scan. if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
fetchBookInfo('upload'); if (typeof fetchBookInfo === 'function') fetchBookInfo('upload');
} else {
if (typeof setIsbnScanStatus === 'function') setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
} }
}, () => {}); },
'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 +1758,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();
@@ -1732,188 +1933,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>
-54
View File
@@ -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()