Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7e24b3fae | |||
| fc80857fbe | |||
| f763ad064d | |||
| bc94ffdc18 | |||
| 76e9670cee | |||
| 577c6c4bed | |||
| 3aa19b10d1 | |||
| ff742d018e | |||
| 6fa40f26b0 | |||
| 777fc81064 | |||
| cf5e38e319 | |||
| b76941d5fe | |||
| f46f2674b0 | |||
| 8ebbdcfd54 | |||
| 92b5235035 | |||
| 4bda2e044b | |||
| b83b6c0ba2 | |||
| ab718d6ac2 | |||
| 112d9b6aa0 | |||
| 5139a50a43 | |||
| 997c7b42d5 | |||
| 20e5d3bb9a | |||
| f62b22c2f7 | |||
| e76d525e4b | |||
| 7117dac67c | |||
| 5313c507ed | |||
| 419fed4492 | |||
| c7112c7a42 | |||
| fb73c9d4e7 | |||
| 8414e27f25 | |||
| 5903f2afdd | |||
| a31fb4c048 | |||
| 46da45d373 | |||
| 0b1bcef985 | |||
| 99ad9d4f79 | |||
| 89c1a525d8 | |||
| 9701805552 | |||
| 4227934252 | |||
| d6883d1879 | |||
| f47e133c45 | |||
| dec3c16d7f |
@@ -15,15 +15,6 @@ on:
|
|||||||
- patch
|
- patch
|
||||||
- minor
|
- minor
|
||||||
- major
|
- major
|
||||||
- development
|
|
||||||
push_dev:
|
|
||||||
description: "If true, push the :dev image to GHCR for development releases"
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- "true"
|
|
||||||
- "false"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -48,13 +39,12 @@ jobs:
|
|||||||
EVENT_NAME: ${{ github.event_name }}
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
|
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
|
||||||
PUSH_DEV: ${{ github.event.inputs.push_dev || 'false' }}
|
|
||||||
run: |
|
run: |
|
||||||
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
||||||
TAG="$REF_NAME"
|
TAG="$REF_NAME"
|
||||||
else
|
else
|
||||||
# Fetch latest release tag via GitHub API (fall back to v3.0.0)
|
# Fetch latest release tag via GitHub API (fall back to v0.8.31)
|
||||||
latest_tag="v3.0.0"
|
latest_tag="v0.8.31"
|
||||||
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null); then
|
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null); then
|
||||||
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||||
if [ -n "$tag_name" ]; then
|
if [ -n "$tag_name" ]; then
|
||||||
@@ -67,7 +57,7 @@ jobs:
|
|||||||
minor=${BASH_REMATCH[2]}
|
minor=${BASH_REMATCH[2]}
|
||||||
patch=${BASH_REMATCH[3]}
|
patch=${BASH_REMATCH[3]}
|
||||||
else
|
else
|
||||||
major=3; minor=0; patch=0
|
major=0; minor=8; patch=31
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Bump strategy: major / minor / patch
|
# Bump strategy: major / minor / patch
|
||||||
@@ -78,30 +68,28 @@ jobs:
|
|||||||
else
|
else
|
||||||
patch=$((patch + 1))
|
patch=$((patch + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${BUMP_TYPE:-}" = "development" ]; then
|
# Zusammenbau des Tags für den manuellen Run
|
||||||
TAG="v${major}.${minor}.${patch}-dev"
|
TAG="v${major}.${minor}.${patch}"
|
||||||
else
|
|
||||||
TAG="v${major}.${minor}.${patch}"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Validierung des erzeugten oder übergebenen Tags
|
||||||
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-dev(\.[0-9]+)?)?$'; then
|
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-dev(\.[0-9]+)?)?$'; then
|
||||||
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z or vX.Y.Z-dev)"
|
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
git fetch --tags --force
|
git fetch --tags --force
|
||||||
LATEST_TAG="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)"
|
LATEST_TAG="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)"
|
||||||
if [ -z "$LATEST_TAG" ]; then
|
if [ -z "$LATEST_TAG" ]; then
|
||||||
LATEST_TAG="v3.0.0"
|
LATEST_TAG="v0.8.31"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TAG_MAJOR="${TAG#v}"
|
TAG_MAJOR="${TAG#v}"
|
||||||
TAG_MAJOR="${TAG_MAJOR%%.*}"
|
TAG_MAJOR="${TAG_MAJOR%%.*}"
|
||||||
LATEST_MAJOR="$(echo "$LATEST_TAG" | grep -Eo '^v[0-9]+' | tr -d 'v')"
|
LATEST_MAJOR="$(echo "$LATEST_TAG" | grep -Eo '^v[0-9]+' | tr -d 'v')"
|
||||||
if [ -z "$LATEST_MAJOR" ]; then
|
if [ -z "$LATEST_MAJOR" ]; then
|
||||||
LATEST_MAJOR="3"
|
LATEST_MAJOR="0"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# If not explicitly bumping major, disallow changing major version
|
# If not explicitly bumping major, disallow changing major version
|
||||||
@@ -123,33 +111,6 @@ jobs:
|
|||||||
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
|
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
|
||||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
||||||
if [ "${BUMP_TYPE:-}" = "development" ]; then
|
|
||||||
echo "is_development=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "is_development=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
if [ "${BUMP_TYPE:-}" = "development" ] && [ "${PUSH_DEV:-}" = "true" ]; then
|
|
||||||
echo "push_dev=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "push_dev=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Update .release-version file
|
|
||||||
run: |
|
|
||||||
echo "${{ steps.meta.outputs.tag }}" > .release-version
|
|
||||||
cat .release-version
|
|
||||||
|
|
||||||
- name: Create and push tag for manual releases
|
|
||||||
if: github.event_name == 'workflow_dispatch'
|
|
||||||
run: |
|
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add .release-version
|
|
||||||
git commit -m "chore: bump version to $TAG" || true
|
|
||||||
git tag "$TAG"
|
|
||||||
git push origin "$TAG"
|
|
||||||
git push origin HEAD:${{ github.ref_name }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -162,26 +123,16 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push release image
|
- name: Build and push release image
|
||||||
if: steps.meta.outputs.is_development != 'true'
|
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: true
|
push: true
|
||||||
|
load: true # Lädt das Image in den lokalen Docker-Daemon für den 'docker save' Schritt
|
||||||
tags: |
|
tags: |
|
||||||
${{ steps.meta.outputs.image }}
|
${{ steps.meta.outputs.image }}
|
||||||
ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
||||||
|
|
||||||
- name: Build and push development image (:dev)
|
|
||||||
if: steps.meta.outputs.is_development == 'true' && steps.meta.outputs.push_dev == 'true'
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
ghcr.io/aiirondev/legendary-octo-garbanzo:dev
|
|
||||||
|
|
||||||
- name: Build local image tar for offline deploy
|
- name: Build local image tar for offline deploy
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -204,30 +155,17 @@ jobs:
|
|||||||
docker build -t "$IMG" .
|
docker build -t "$IMG" .
|
||||||
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
||||||
|
|
||||||
# development tar omitted: dev releases will be versioned (vX.Y.Z-dev) and handled by update.sh using the tag
|
|
||||||
|
|
||||||
- name: Commit .release-version for tag pushes
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
if ! git diff --quiet .release-version; then
|
|
||||||
git add .release-version
|
|
||||||
git commit -m "chore: update version to ${{ steps.meta.outputs.tag }}"
|
|
||||||
git push origin HEAD:${{ github.ref_name }}
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create release-only docker bundle
|
- name: Create release-only docker bundle
|
||||||
run: |
|
run: |
|
||||||
mkdir -p release-bundle
|
mkdir -p release-bundle
|
||||||
cat > release-bundle/docker-compose.yml <<EOF
|
cat > release-bundle/docker-compose.yml <<EOF
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
|
image: \${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
|
||||||
container_name: inventarsystem-app
|
container_name: inventarsystem-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${INVENTAR_HTTP_PORT:-10000}:8000"
|
- "\${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- mongodb
|
- mongodb
|
||||||
- redis
|
- redis
|
||||||
@@ -300,17 +238,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
cat > release-bundle/DEVELOPMENT.md <<EOF
|
|
||||||
This is a development prerelease bundle for tag: ${{ steps.meta.outputs.tag }}
|
|
||||||
|
|
||||||
This prerelease is intentionally separate from normal releases and will not be used by default.
|
|
||||||
|
|
||||||
To install this prerelease on a target host run:
|
|
||||||
|
|
||||||
./update.sh ${{ steps.meta.outputs.tag }}
|
|
||||||
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Make any shipped scripts executable
|
# Make any shipped scripts executable
|
||||||
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
|
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
|
||||||
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
||||||
@@ -319,10 +246,8 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.meta.outputs.tag }}
|
tag_name: ${{ steps.meta.outputs.tag }}
|
||||||
prerelease: ${{ steps.meta.outputs.is_development }}
|
|
||||||
files: |
|
files: |
|
||||||
inventarsystem-docker-bundle.tar.gz
|
inventarsystem-docker-bundle.tar.gz
|
||||||
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
|
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
|
||||||
inventarsystem-image-dev.tar.gz
|
|
||||||
fail_on_unmatched_files: false
|
fail_on_unmatched_files: false
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
v0.8.19
|
v0.8.31.1
|
||||||
|
|||||||
+61
-46
@@ -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,59 +4818,73 @@ def get_user_appointments():
|
|||||||
db = client[MONGODB_DB]
|
db = client[MONGODB_DB]
|
||||||
items_col = db['items']
|
items_col = db['items']
|
||||||
|
|
||||||
# Use the established user-specific route logic for appointments.
|
# Use the appointment collection for the user's appointments
|
||||||
bookings = au.get_ausleihung_by_user(
|
appointments = termin.get_upcoming_for_user(username, limit=250)
|
||||||
username,
|
|
||||||
status=['planned', 'active', 'completed'],
|
|
||||||
use_client_side_verification=True,
|
|
||||||
)
|
|
||||||
bookings = sorted(bookings, key=lambda b: b.get('Start') or datetime.datetime.min)
|
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for booking in bookings:
|
import re as _re
|
||||||
start_dt = booking.get('Start')
|
|
||||||
if not start_dt:
|
def _date_iter(start_value: str, end_value: str):
|
||||||
|
try:
|
||||||
|
start_date = datetime.datetime.strptime(start_value, '%Y-%m-%d').date()
|
||||||
|
end_date = datetime.datetime.strptime(end_value, '%Y-%m-%d').date()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if end_date < start_date:
|
||||||
|
end_date = start_date
|
||||||
|
cursor = start_date
|
||||||
|
days = []
|
||||||
|
while cursor <= end_date:
|
||||||
|
days.append(cursor.strftime('%Y-%m-%d'))
|
||||||
|
cursor += datetime.timedelta(days=1)
|
||||||
|
return days
|
||||||
|
|
||||||
|
for appt in appointments:
|
||||||
|
appt_id = str(appt.get('_id') or '')
|
||||||
|
if not appt_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
end_dt = booking.get('End')
|
date_start = str(appt.get('date_start') or '')
|
||||||
if not end_dt and isinstance(start_dt, datetime.datetime):
|
date_end = str(appt.get('date_end') or date_start)
|
||||||
end_dt = start_dt + datetime.timedelta(minutes=45)
|
time_span = appt.get('time_span', []) or []
|
||||||
elif not end_dt:
|
title = appt.get('note') or f"Termin von {appt.get('user') or ''}"
|
||||||
end_dt = start_dt
|
days_in_range = _date_iter(date_start, date_end) or ([date_start] if date_start else [])
|
||||||
|
|
||||||
item_id = str(booking.get('Item') or '')
|
span_entries = []
|
||||||
item_doc = None
|
for entry in time_span:
|
||||||
if item_id:
|
s = str(entry or '').strip()
|
||||||
try:
|
if not s:
|
||||||
item_doc = items_col.find_one({'_id': ObjectId(item_id)})
|
continue
|
||||||
except Exception:
|
m_date = _re.match(r"^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$", s)
|
||||||
item_doc = None
|
if m_date:
|
||||||
|
span_entries.append((m_date.group(1), m_date.group(2), m_date.group(3)))
|
||||||
|
continue
|
||||||
|
m = _re.match(r"^(\d{2}:\d{2})-(\d{2}:\d{2})$", s)
|
||||||
|
if m:
|
||||||
|
for day in days_in_range:
|
||||||
|
span_entries.append((day, m.group(1), m.group(2)))
|
||||||
|
|
||||||
item_name = item_id or 'Termin'
|
# If the stored span is already day-specific, use it as-is.
|
||||||
if item_doc:
|
# If it was generic, we duplicated it to each day in the range above.
|
||||||
item_name = item_doc.get('Name') or item_doc.get('Code_4') or item_name
|
if not span_entries and days_in_range:
|
||||||
|
# Fallback: create a simple all-day marker for each date so the appointment is visible.
|
||||||
|
for day in days_in_range:
|
||||||
|
span_entries.append((day, '08:00', '16:45'))
|
||||||
|
|
||||||
period = booking.get('Period')
|
for day, start_time, end_time in span_entries:
|
||||||
title = item_name
|
result.append({
|
||||||
if period:
|
'id': f"{appt_id}-{day}-{start_time}",
|
||||||
title = f"{title} - {period}. Std"
|
'title': title,
|
||||||
|
'start': f"{day}T{start_time}",
|
||||||
status = booking.get('VerifiedStatus') or booking.get('Status') or 'unknown'
|
'end': f"{day}T{end_time}",
|
||||||
if status == 'active':
|
'status': 'planned',
|
||||||
status = 'current'
|
'itemId': appt_id,
|
||||||
result.append({
|
'userName': str(appt.get('user') or ''),
|
||||||
'id': str(booking.get('_id')),
|
'notes': str(appt.get('note') or ''),
|
||||||
'title': title,
|
'period': None,
|
||||||
'start': start_dt.isoformat() if isinstance(start_dt, datetime.datetime) else str(start_dt),
|
'isCurrentUser': True,
|
||||||
'end': end_dt.isoformat() if isinstance(end_dt, datetime.datetime) else str(end_dt),
|
'itemBorrower': '',
|
||||||
'status': status,
|
})
|
||||||
'itemId': item_id,
|
|
||||||
'userName': str(booking.get('User') or ''),
|
|
||||||
'notes': str(booking.get('Notes') or ''),
|
|
||||||
'period': period,
|
|
||||||
'isCurrentUser': True,
|
|
||||||
'itemBorrower': '',
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({'ok': True, 'bookings': result})
|
return jsonify({'ok': True, 'bookings': result})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
-22
@@ -1392,28 +1392,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 +1406,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 +1544,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;
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-50
@@ -10,24 +10,6 @@
|
|||||||
<h1>📚 Bibliothek</h1>
|
<h1>📚 Bibliothek</h1>
|
||||||
<p class="subtitle">Bücher, CDs und weitere Medien</p>
|
<p class="subtitle">Bücher, CDs und weitere Medien</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-controls">
|
|
||||||
<!-- Favorites Toggle -->
|
|
||||||
<div class="view-switch">
|
|
||||||
<button id="favoriteToggle" class="toggle-btn" aria-label="Favoriten anzeigen/verbergen">
|
|
||||||
<span class="toggle-icon">⭐</span>
|
|
||||||
<span class="toggle-label">Favoriten</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- View Mode Toggle -->
|
|
||||||
<div class="view-switch">
|
|
||||||
<button id="viewModeToggle" class="toggle-btn" aria-label="Ansichtsmodus wechseln">
|
|
||||||
<span class="toggle-icon">ㄷ</span>
|
|
||||||
<span class="toggle-label">Tabelle</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search & Filter Section -->
|
<!-- Search & Filter Section -->
|
||||||
@@ -205,8 +187,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
// View mode persistence
|
// View mode persistence
|
||||||
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
||||||
@@ -625,50 +606,80 @@ document.getElementById('clearFiltersBtn').addEventListener('click', function()
|
|||||||
closeAllFilters();
|
closeAllFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Favorites toggle
|
|
||||||
document.getElementById('favoriteToggle').addEventListener('click', function() {
|
|
||||||
this.classList.toggle('open');
|
|
||||||
// TODO: Implement favorites filtering
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scanner toggle
|
// Scanner toggle
|
||||||
let scanner = null;
|
// Scanner state tracking
|
||||||
|
let isScanning = false;
|
||||||
|
|
||||||
document.getElementById('scannerBtn').addEventListener('click', function(e) {
|
document.getElementById('scannerBtn').addEventListener('click', function(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const container = document.getElementById('qrContainer');
|
const container = document.getElementById('qrContainer');
|
||||||
const isOpen = container.style.display !== 'none';
|
const isOpen = container.style.display !== 'none';
|
||||||
|
const btn = this;
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
// Close the scanner
|
||||||
container.style.display = 'none';
|
container.style.display = 'none';
|
||||||
if (scanner) scanner.clear();
|
Quagga.stop();
|
||||||
this.classList.remove('open');
|
isScanning = false;
|
||||||
this.setAttribute('aria-expanded', 'false');
|
|
||||||
} else {
|
|
||||||
container.style.display = 'block';
|
|
||||||
this.classList.add('open');
|
|
||||||
this.setAttribute('aria-expanded', 'true');
|
|
||||||
|
|
||||||
if (!scanner) {
|
btn.classList.remove('open');
|
||||||
scanner = new Html5Qrcode('qr-reader');
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
scanner.start(
|
} else {
|
||||||
{ facingMode: "environment" },
|
// Open the scanner
|
||||||
{ fps: 10, qrbox: 250 },
|
container.style.display = 'block';
|
||||||
onScanSuccess,
|
btn.classList.add('open');
|
||||||
onScanError
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
);
|
|
||||||
}
|
Quagga.init({
|
||||||
|
inputStream: {
|
||||||
|
name: "Live",
|
||||||
|
type: "LiveStream",
|
||||||
|
// Targets the element where the video stream will inject
|
||||||
|
target: document.querySelector('#qr-reader'),
|
||||||
|
constraints: {
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
facingMode: "environment" // Forces back camera
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decoder: {
|
||||||
|
// Optimized for 1D barcodes (e.g., student IDs, member cards)
|
||||||
|
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||||
|
}
|
||||||
|
}, function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Initialization error:", err);
|
||||||
|
alert("Kamera konnte nicht gestartet werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Quagga.start();
|
||||||
|
isScanning = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function onScanSuccess(decodedText, decodedResult) {
|
// Single Quagga event listener for successful scans
|
||||||
const studentId = decodedText.trim();
|
Quagga.onDetected(function(data) {
|
||||||
|
const decodedText = data.codeResult.code;
|
||||||
|
const studentId = String(decodedText || '').trim();
|
||||||
|
|
||||||
|
// Stop scanning immediately to prevent multiple triggers
|
||||||
|
Quagga.stop();
|
||||||
|
isScanning = false;
|
||||||
|
|
||||||
|
// Reset UI states to closed
|
||||||
|
const container = document.getElementById('qrContainer');
|
||||||
|
const btn = document.getElementById('scannerBtn');
|
||||||
|
if (container) container.style.display = 'none';
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.remove('open');
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route the scanned data to your input
|
||||||
document.getElementById('studentIdInput').value = studentId;
|
document.getElementById('studentIdInput').value = studentId;
|
||||||
alert('Ausweis gescannt: ' + studentId);
|
alert('Ausweis gescannt: ' + studentId);
|
||||||
}
|
});
|
||||||
|
|
||||||
function onScanError(error) {
|
|
||||||
// Silently ignore scan errors
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -547,7 +547,7 @@
|
|||||||
</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>
|
<script>
|
||||||
// State
|
// State
|
||||||
let libraryItems = [];
|
let libraryItems = [];
|
||||||
@@ -847,10 +847,16 @@
|
|||||||
document.getElementById('detailModal').style.display = 'none';
|
document.getElementById('detailModal').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep track of the scanner state globally/outer scope
|
||||||
|
let scannerRunning = false;
|
||||||
|
let lastScanValue = null;
|
||||||
|
let lastScanAt = 0;
|
||||||
|
let activeStudentCardId = ''; // Managed globally by your system
|
||||||
|
|
||||||
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') {
|
||||||
alert('Dieses Medium ist als defekt/zerstört markiert und kann nicht ausgeliehen werden.');
|
alert('Dieses Medium ist als defekt/zerstör markiert und kann nicht ausgeliehen werden.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -997,130 +1003,71 @@
|
|||||||
processQuickToggleScan(scannedCode);
|
processQuickToggleScan(scannedCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScanError() {
|
// Global Quagga reader hook
|
||||||
// Intentionally silent to avoid UI spam while camera searches codes.
|
Quagga.onDetected(function(data) {
|
||||||
}
|
if (data && data.codeResult && data.codeResult.code) {
|
||||||
|
handleScanSuccess(data.codeResult.code);
|
||||||
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() {
|
async function startScanner() {
|
||||||
if (scannerRunning) return;
|
if (scannerRunning) return;
|
||||||
|
|
||||||
const scannerLoaded = await ensureScannerLibraryLoaded();
|
|
||||||
if (!scannerLoaded) {
|
|
||||||
setScanStatus('Scanner-Bibliothek konnte nicht geladen werden.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const readerWrap = document.getElementById('scanReaderWrap');
|
const readerWrap = document.getElementById('scanReaderWrap');
|
||||||
const toggleBtn = document.getElementById('toggleScannerBtn');
|
const toggleBtn = document.getElementById('toggleScannerBtn');
|
||||||
readerWrap.style.display = 'block';
|
if (readerWrap) readerWrap.style.display = 'block';
|
||||||
|
|
||||||
try {
|
setScanStatus('Initializing camera...', 'warn');
|
||||||
const formats = [];
|
|
||||||
if (typeof Html5QrcodeSupportedFormats !== 'undefined') {
|
Quagga.init({
|
||||||
formats.push(
|
inputStream: {
|
||||||
Html5QrcodeSupportedFormats.QR_CODE,
|
name: "Live",
|
||||||
Html5QrcodeSupportedFormats.EAN_13,
|
type: "LiveStream",
|
||||||
Html5QrcodeSupportedFormats.EAN_8,
|
// Points to the interior viewport layout box
|
||||||
Html5QrcodeSupportedFormats.CODE_128,
|
target: document.querySelector('#libraryQrReader'),
|
||||||
Html5QrcodeSupportedFormats.CODE_39,
|
constraints: {
|
||||||
Html5QrcodeSupportedFormats.UPC_A,
|
width: 640,
|
||||||
Html5QrcodeSupportedFormats.UPC_E,
|
height: 480,
|
||||||
Html5QrcodeSupportedFormats.ITF,
|
facingMode: "environment" // Force rear mobile lenses
|
||||||
Html5QrcodeSupportedFormats.CODABAR
|
},
|
||||||
);
|
},
|
||||||
|
decoder: {
|
||||||
|
// Limited down precisely to standard library/ID configurations
|
||||||
|
readers: [
|
||||||
|
"code_128_reader",
|
||||||
|
"ean_reader",
|
||||||
|
"code_39_reader",
|
||||||
|
"upc_reader",
|
||||||
|
"codabar_reader",
|
||||||
|
"i2of5_reader" // Matches original 'ITF' format mapping
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}, 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scannerConfig = {
|
Quagga.start();
|
||||||
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;
|
scannerRunning = true;
|
||||||
toggleBtn.textContent = 'Scanner stoppen';
|
if (toggleBtn) toggleBtn.textContent = 'Scanner stoppen';
|
||||||
setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn');
|
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() {
|
async function stopScanner() {
|
||||||
if (!scannerRunning || !scannerInstance) return;
|
if (!scannerRunning) return;
|
||||||
const readerWrap = document.getElementById('scanReaderWrap');
|
const readerWrap = document.getElementById('scanReaderWrap');
|
||||||
const toggleBtn = document.getElementById('toggleScannerBtn');
|
const toggleBtn = document.getElementById('toggleScannerBtn');
|
||||||
try {
|
|
||||||
await scannerInstance.clear();
|
Quagga.stop();
|
||||||
} catch (err) {
|
|
||||||
console.error('Scanner stop failed:', err);
|
|
||||||
}
|
|
||||||
scannerRunning = false;
|
scannerRunning = false;
|
||||||
scannerInstance = null;
|
if (readerWrap) readerWrap.style.display = 'none';
|
||||||
readerWrap.style.display = 'none';
|
if (toggleBtn) toggleBtn.textContent = 'Scanner starten';
|
||||||
toggleBtn.textContent = 'Scanner starten';
|
|
||||||
setScanStatus('Scanner gestoppt.', 'warn');
|
setScanStatus('Scanner gestoppt.', 'warn');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+103
-41
@@ -525,8 +525,7 @@
|
|||||||
|
|
||||||
window.isDebug = false; // Set to true only for development environment
|
window.isDebug = false; // Set to true only for development environment
|
||||||
</script>
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||||
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
|
|
||||||
<script>
|
<script>
|
||||||
// Global state
|
// Global state
|
||||||
const highlightItemId = (window.serverVars && window.serverVars.highlightItemId && window.serverVars.highlightItemId !== 'null')
|
const highlightItemId = (window.serverVars && window.serverVars.highlightItemId && window.serverVars.highlightItemId !== 'null')
|
||||||
@@ -535,51 +534,114 @@
|
|||||||
let codeSearchTerm = '';
|
let codeSearchTerm = '';
|
||||||
let descSearchIds = null; // Set of matching IDs or null when disabled
|
let descSearchIds = null; // Set of matching IDs or null when disabled
|
||||||
|
|
||||||
// QR scanner toggle setup
|
// Keep track of the scanner state globally/outer scope
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
let isScanning = false;
|
||||||
const scanButton = document.getElementById('scanButton');
|
let activeScannerCallback = null; // Tracks which function currently owns the scanner output
|
||||||
|
|
||||||
|
function startScanner(targetCallback) {
|
||||||
const qrReader = document.getElementById('qr-reader');
|
const qrReader = document.getElementById('qr-reader');
|
||||||
let html5QrcodeScanner = null;
|
const statusText = document.getElementById('status');
|
||||||
|
|
||||||
if (!scanButton || !qrReader) return;
|
// Store the custom action that should happen when a barcode is found
|
||||||
|
activeScannerCallback = targetCallback;
|
||||||
|
|
||||||
const setScannerUi = (isOpen) => {
|
// Show the container
|
||||||
scanButton.classList.toggle('is-active', isOpen);
|
if (qrReader) qrReader.style.display = 'block';
|
||||||
scanButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
if (statusText) statusText.innerText = "Initializing camera...";
|
||||||
scanButton.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
|
||||||
};
|
|
||||||
|
|
||||||
scanButton.addEventListener('click', function() {
|
Quagga.init({
|
||||||
if (qrReader.style.display !== 'block') {
|
inputStream: {
|
||||||
qrReader.style.display = 'block';
|
name: "Live",
|
||||||
|
type: "LiveStream",
|
||||||
html5QrcodeScanner = new Html5QrcodeScanner(
|
// Make sure this targets your correct HTML container element
|
||||||
'qr-reader', { fps: 10, qrbox: 250 }
|
target: document.querySelector('#scanner-container'),
|
||||||
);
|
constraints: {
|
||||||
|
width: 640,
|
||||||
html5QrcodeScanner.render((decodedText) => {
|
height: 480,
|
||||||
html5QrcodeScanner.clear();
|
facingMode: "environment" // Force rear camera
|
||||||
qrReader.style.display = 'none';
|
},
|
||||||
|
},
|
||||||
// Put scanned code into the search box and trigger search
|
decoder: {
|
||||||
const searchInput = document.getElementById('code-search');
|
// Your required formats mapped to Quagga2 reader strings
|
||||||
if (searchInput) {
|
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||||
searchInput.value = decodedText;
|
}
|
||||||
searchByCode();
|
}, function(err) {
|
||||||
}
|
if (err) {
|
||||||
|
console.error("Initialization error:", err);
|
||||||
setScannerUi(false);
|
if (statusText) statusText.innerText = "Camera Error";
|
||||||
});
|
return;
|
||||||
|
|
||||||
setScannerUi(true);
|
|
||||||
} else {
|
|
||||||
if (html5QrcodeScanner) {
|
|
||||||
html5QrcodeScanner.clear();
|
|
||||||
}
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
setScannerUi(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Quagga.start();
|
||||||
|
isScanning = true;
|
||||||
|
|
||||||
|
// If we are using the main search button, manage its UI state
|
||||||
|
if (!targetCallback) {
|
||||||
|
setScannerUi(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusText) statusText.innerText = "Scanning...";
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScanner() {
|
||||||
|
Quagga.stop();
|
||||||
|
isScanning = false;
|
||||||
|
activeScannerCallback = null; // Reset the callback routing
|
||||||
|
|
||||||
|
const qrReader = document.getElementById('qr-reader');
|
||||||
|
if (qrReader) qrReader.style.display = 'none';
|
||||||
|
|
||||||
|
setScannerUi(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScannerUi(isOpen) {
|
||||||
|
const scanBtn = document.getElementById('scanButton');
|
||||||
|
if (!scanBtn) return;
|
||||||
|
|
||||||
|
scanBtn.classList.toggle('is-active', isOpen);
|
||||||
|
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified global reader callback
|
||||||
|
Quagga.onDetected(function(data) {
|
||||||
|
const barcode = String(data.codeResult.code || '').trim();
|
||||||
|
console.log("Barcode detected:", barcode);
|
||||||
|
|
||||||
|
// Keep a local reference to the active callback before shutting down the engine
|
||||||
|
const currentCallback = activeScannerCallback;
|
||||||
|
|
||||||
|
// Critical: Stop scanning immediately to prevent multiple triggers
|
||||||
|
stopScanner();
|
||||||
|
|
||||||
|
// Route data based on who opened the scanner
|
||||||
|
if (typeof currentCallback === "function") {
|
||||||
|
// Send data directly to the specific edit field's logic
|
||||||
|
currentCallback(barcode);
|
||||||
|
} else {
|
||||||
|
// Fallback Default: Main search box logic
|
||||||
|
const resultText = document.getElementById('barcode-result');
|
||||||
|
if (resultText) resultText.innerText = barcode;
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('code-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = barcode;
|
||||||
|
if (typeof searchByCode === "function") {
|
||||||
|
searchByCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event Listener for the main standalone search button
|
||||||
|
document.getElementById('scanButton')?.addEventListener('click', function() {
|
||||||
|
if (!isScanning) {
|
||||||
|
// Pass null so it defaults to filling the standard search box
|
||||||
|
startScanner(null);
|
||||||
|
} else {
|
||||||
|
stopScanner();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return preferred primary and fallback image URLs based on extension
|
// Return preferred primary and fallback image URLs based on extension
|
||||||
|
|||||||
+123
-74
@@ -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 = '';
|
||||||
@@ -2768,51 +2770,114 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
// No-op in production
|
// No-op in production
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('scanButton').addEventListener('click', function() {
|
|
||||||
const qrReader = document.getElementById('qr-reader');
|
|
||||||
const scanBtn = document.getElementById('scanButton');
|
|
||||||
|
|
||||||
const setScannerUi = (isOpen) => {
|
// Keep track of the scanner state globally/outer scope
|
||||||
if (!scanBtn) return;
|
let isScanning = false;
|
||||||
scanBtn.classList.toggle('is-active', isOpen);
|
let activeScannerCallback = null; // Tracks which function currently owns the scanner output
|
||||||
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
||||||
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
function startScanner(targetCallback) {
|
||||||
};
|
const qrReader = document.getElementById('qr-reader');
|
||||||
|
const statusText = document.getElementById('status');
|
||||||
if (qrReader.style.display === 'none') {
|
|
||||||
qrReader.style.display = 'block';
|
// Store the custom action that should happen when a barcode is found
|
||||||
|
activeScannerCallback = targetCallback;
|
||||||
html5QrcodeScanner = new Html5QrcodeScanner(
|
|
||||||
"qr-reader", {
|
// Show the container
|
||||||
fps: 10,
|
if (qrReader) qrReader.style.display = 'block';
|
||||||
qrbox: 250,
|
if (statusText) statusText.innerText = "Initializing camera...";
|
||||||
rememberLastUsedCamera: true
|
|
||||||
}
|
Quagga.init({
|
||||||
);
|
inputStream: {
|
||||||
|
name: "Live",
|
||||||
html5QrcodeScanner.render((decodedText) => {
|
type: "LiveStream",
|
||||||
html5QrcodeScanner.clear();
|
// Make sure this targets your correct HTML container element
|
||||||
qrReader.style.display = 'none';
|
target: document.querySelector('#scanner-container'),
|
||||||
|
constraints: {
|
||||||
// Instead of navigating to the URL, put the scanned code in the search box
|
width: 640,
|
||||||
const searchInput = document.getElementById('code-search');
|
height: 480,
|
||||||
if (searchInput) {
|
facingMode: "environment" // Force rear camera
|
||||||
searchInput.value = decodedText;
|
},
|
||||||
// Trigger search automatically
|
},
|
||||||
|
decoder: {
|
||||||
|
// Your required formats mapped to Quagga2 reader strings
|
||||||
|
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||||
|
}
|
||||||
|
}, function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Initialization error:", err);
|
||||||
|
if (statusText) statusText.innerText = "Camera Error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Quagga.start();
|
||||||
|
isScanning = true;
|
||||||
|
|
||||||
|
// If we are using the main search button, manage its UI state
|
||||||
|
if (!targetCallback) {
|
||||||
|
setScannerUi(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusText) statusText.innerText = "Scanning...";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScanner() {
|
||||||
|
Quagga.stop();
|
||||||
|
isScanning = false;
|
||||||
|
activeScannerCallback = null; // Reset the callback routing
|
||||||
|
|
||||||
|
const qrReader = document.getElementById('qr-reader');
|
||||||
|
if (qrReader) qrReader.style.display = 'none';
|
||||||
|
|
||||||
|
setScannerUi(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScannerUi(isOpen) {
|
||||||
|
const scanBtn = document.getElementById('scanButton');
|
||||||
|
if (!scanBtn) return;
|
||||||
|
|
||||||
|
scanBtn.classList.toggle('is-active', isOpen);
|
||||||
|
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified global reader callback
|
||||||
|
Quagga.onDetected(function(data) {
|
||||||
|
const barcode = String(data.codeResult.code || '').trim();
|
||||||
|
console.log("Barcode detected:", barcode);
|
||||||
|
|
||||||
|
// Keep a local reference to the active callback before shutting down the engine
|
||||||
|
const currentCallback = activeScannerCallback;
|
||||||
|
|
||||||
|
// Critical: Stop scanning immediately to prevent multiple triggers
|
||||||
|
stopScanner();
|
||||||
|
|
||||||
|
// Route data based on who opened the scanner
|
||||||
|
if (typeof currentCallback === "function") {
|
||||||
|
// Send data directly to the specific edit field's logic
|
||||||
|
currentCallback(barcode);
|
||||||
|
} else {
|
||||||
|
// Fallback Default: Main search box logic
|
||||||
|
const resultText = document.getElementById('barcode-result');
|
||||||
|
if (resultText) resultText.innerText = barcode;
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('code-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = barcode;
|
||||||
|
if (typeof searchByCode === "function") {
|
||||||
searchByCode();
|
searchByCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
setScannerUi(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
setScannerUi(true);
|
|
||||||
} else {
|
|
||||||
if (html5QrcodeScanner) {
|
|
||||||
html5QrcodeScanner.clear();
|
|
||||||
}
|
}
|
||||||
qrReader.style.display = 'none';
|
}
|
||||||
setScannerUi(false);
|
});
|
||||||
|
|
||||||
|
// Event Listener for the main standalone search button
|
||||||
|
document.getElementById('scanButton')?.addEventListener('click', function() {
|
||||||
|
if (!isScanning) {
|
||||||
|
// Pass null so it defaults to filling the standard search box
|
||||||
|
startScanner(null);
|
||||||
|
} else {
|
||||||
|
stopScanner();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2824,26 +2889,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (qrReader.style.display !== 'none') {
|
// Toggle close if it's already running
|
||||||
if (html5QrcodeScanner) {
|
if (isScanning && qrReader.style.display !== 'none') {
|
||||||
html5QrcodeScanner.clear();
|
stopScanner();
|
||||||
}
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
scanEditBtn.textContent = 'Barcode scannen';
|
scanEditBtn.textContent = 'Barcode scannen';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
qrReader.style.display = 'block';
|
|
||||||
scanEditBtn.textContent = 'Scanner schließen';
|
scanEditBtn.textContent = 'Scanner schließen';
|
||||||
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
|
|
||||||
fps: 10,
|
// Start scanner with custom logic mapping to the Code input field
|
||||||
qrbox: 250,
|
startScanner(function(decodedText) {
|
||||||
rememberLastUsedCamera: true
|
editCodeInput.value = decodedText;
|
||||||
});
|
|
||||||
html5QrcodeScanner.render((decodedText) => {
|
|
||||||
html5QrcodeScanner.clear();
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
editCodeInput.value = String(decodedText || '').trim();
|
|
||||||
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
|
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
|
||||||
scanEditBtn.textContent = 'Barcode scannen';
|
scanEditBtn.textContent = 'Barcode scannen';
|
||||||
});
|
});
|
||||||
@@ -2857,26 +2914,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (qrReader.style.display !== 'none') {
|
// Toggle close if it's already running
|
||||||
if (html5QrcodeScanner) {
|
if (isScanning && qrReader.style.display !== 'none') {
|
||||||
html5QrcodeScanner.clear();
|
stopScanner();
|
||||||
}
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
scanIsbnBtn.textContent = 'ISBN scannen';
|
scanIsbnBtn.textContent = 'ISBN scannen';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
qrReader.style.display = 'block';
|
|
||||||
scanIsbnBtn.textContent = 'Scanner schließen';
|
scanIsbnBtn.textContent = 'Scanner schließen';
|
||||||
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
|
|
||||||
fps: 10,
|
// Start scanner with custom logic mapping to the ISBN input field
|
||||||
qrbox: 250,
|
startScanner(function(decodedText) {
|
||||||
rememberLastUsedCamera: true
|
editIsbnInput.value = decodedText;
|
||||||
});
|
|
||||||
html5QrcodeScanner.render((decodedText) => {
|
|
||||||
html5QrcodeScanner.clear();
|
|
||||||
qrReader.style.display = 'none';
|
|
||||||
editIsbnInput.value = String(decodedText || '').trim();
|
|
||||||
scanIsbnBtn.textContent = 'ISBN scannen';
|
scanIsbnBtn.textContent = 'ISBN scannen';
|
||||||
if (typeof fetchBookInfo === 'function') {
|
if (typeof fetchBookInfo === 'function') {
|
||||||
fetchBookInfo('edit');
|
fetchBookInfo('edit');
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -287,7 +287,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const filteredEvents = showCompletedBookings
|
const filteredEvents = showCompletedBookings
|
||||||
? processedEvents
|
? processedEvents
|
||||||
: processedEvents.filter(event => event.extendedProps.status !== 'completed');
|
: processedEvents.filter(event => event.extendedProps.status !== 'completed');
|
||||||
|
|
||||||
// Provide events to calendar
|
// Provide events to calendar
|
||||||
successCallback(filteredEvents);
|
successCallback(filteredEvents);
|
||||||
})
|
})
|
||||||
@@ -314,22 +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
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Small debug script to check tenant-aware lookup for an appointment id.
|
|
||||||
Usage: ./tools/debug_get_appointment.py <appointment_id> [tenant]
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
from bson.objectid import ObjectId
|
|
||||||
from Web.modules.database.settings import MongoClient, MONGODB_HOST, MONGODB_PORT, MONGODB_DB
|
|
||||||
import Web.modules.database.termine as termine
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("Usage: debug_get_appointment.py <appointment_id> [tenant]")
|
|
||||||
sys.exit(2)
|
|
||||||
aid = sys.argv[1]
|
|
||||||
tenant = sys.argv[2] if len(sys.argv) > 2 else None
|
|
||||||
|
|
||||||
if tenant:
|
|
||||||
print(f"Looking up appointment {aid} for tenant {tenant}")
|
|
||||||
else:
|
|
||||||
print(f"Looking up appointment {aid} for default tenant")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Use the module helper directly
|
|
||||||
item = termine.get_item(aid)
|
|
||||||
if item:
|
|
||||||
print('Found with termini.get_item:')
|
|
||||||
print(item)
|
|
||||||
else:
|
|
||||||
print('termin.get_item returned None')
|
|
||||||
|
|
||||||
# Try explicit MongoClient + tenant DB resolution
|
|
||||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
|
||||||
try:
|
|
||||||
from Web.tenant import TenantContext, get_tenant_db
|
|
||||||
if tenant:
|
|
||||||
ctx = TenantContext()
|
|
||||||
ctx.tenant_id = tenant
|
|
||||||
db = ctx.get_database(client)
|
|
||||||
else:
|
|
||||||
db = get_tenant_db(client) if 'get_tenant_db' in dir() else client[MONGODB_DB]
|
|
||||||
|
|
||||||
doc = db['appointments'].find_one({'_id': ObjectId(aid)})
|
|
||||||
print('Direct DB find_one returned:')
|
|
||||||
print(doc)
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
except Exception as e:
|
|
||||||
print('Error during debug lookup:', e)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user