Compare commits
51 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 | |||
| 897b2e43ad | |||
| 6dd44508ce | |||
| 204ded6c7c | |||
| fbd2168aff | |||
| 249a2bc2da | |||
| 7ab8f841d0 | |||
| 43c544244c | |||
| 73e4407d7b | |||
| 94fc01c08a | |||
| 788ec1db62 |
@@ -15,15 +15,6 @@ on:
|
||||
- patch
|
||||
- minor
|
||||
- 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:
|
||||
contents: write
|
||||
@@ -48,13 +39,12 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
|
||||
PUSH_DEV: ${{ github.event.inputs.push_dev || 'false' }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
||||
TAG="$REF_NAME"
|
||||
else
|
||||
# Fetch latest release tag via GitHub API (fall back to v3.0.0)
|
||||
latest_tag="v3.0.0"
|
||||
# Fetch latest release tag via GitHub API (fall back to v0.8.31)
|
||||
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
|
||||
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||
if [ -n "$tag_name" ]; then
|
||||
@@ -67,7 +57,7 @@ jobs:
|
||||
minor=${BASH_REMATCH[2]}
|
||||
patch=${BASH_REMATCH[3]}
|
||||
else
|
||||
major=3; minor=0; patch=0
|
||||
major=0; minor=8; patch=31
|
||||
fi
|
||||
|
||||
# Bump strategy: major / minor / patch
|
||||
@@ -78,30 +68,28 @@ jobs:
|
||||
else
|
||||
patch=$((patch + 1))
|
||||
fi
|
||||
|
||||
if [ "${BUMP_TYPE:-}" = "development" ]; then
|
||||
TAG="v${major}.${minor}.${patch}-dev"
|
||||
else
|
||||
TAG="v${major}.${minor}.${patch}"
|
||||
fi
|
||||
|
||||
# Zusammenbau des Tags für den manuellen Run
|
||||
TAG="v${major}.${minor}.${patch}"
|
||||
fi
|
||||
|
||||
# Validierung des erzeugten oder übergebenen Tags
|
||||
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
|
||||
fi
|
||||
|
||||
git fetch --tags --force
|
||||
LATEST_TAG="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)"
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
LATEST_TAG="v3.0.0"
|
||||
LATEST_TAG="v0.8.31"
|
||||
fi
|
||||
|
||||
TAG_MAJOR="${TAG#v}"
|
||||
TAG_MAJOR="${TAG_MAJOR%%.*}"
|
||||
LATEST_MAJOR="$(echo "$LATEST_TAG" | grep -Eo '^v[0-9]+' | tr -d 'v')"
|
||||
if [ -z "$LATEST_MAJOR" ]; then
|
||||
LATEST_MAJOR="3"
|
||||
LATEST_MAJOR="0"
|
||||
fi
|
||||
|
||||
# If not explicitly bumping major, disallow changing major version
|
||||
@@ -123,33 +111,6 @@ jobs:
|
||||
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
|
||||
echo "tag=$TAG" >> "$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
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -162,26 +123,16 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push release image
|
||||
if: steps.meta.outputs.is_development != 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
load: true # Lädt das Image in den lokalen Docker-Daemon für den 'docker save' Schritt
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image }}
|
||||
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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -204,30 +155,17 @@ jobs:
|
||||
docker build -t "$IMG" .
|
||||
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
|
||||
run: |
|
||||
mkdir -p release-bundle
|
||||
cat > release-bundle/docker-compose.yml <<EOF
|
||||
services:
|
||||
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
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||
- "\${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||
depends_on:
|
||||
- mongodb
|
||||
- redis
|
||||
@@ -300,17 +238,6 @@ jobs:
|
||||
fi
|
||||
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
|
||||
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
|
||||
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
||||
@@ -319,10 +246,8 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.meta.outputs.tag }}
|
||||
prerelease: ${{ steps.meta.outputs.is_development }}
|
||||
files: |
|
||||
inventarsystem-docker-bundle.tar.gz
|
||||
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
|
||||
inventarsystem-image-dev.tar.gz
|
||||
fail_on_unmatched_files: false
|
||||
generate_release_notes: true
|
||||
generate_release_notes: true
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v0.8.14
|
||||
v0.8.31.1
|
||||
|
||||
+92
-1
@@ -37,6 +37,7 @@ if _CURRENT_DIR not in sys.path:
|
||||
import Web.modules.database.user as us
|
||||
import Web.modules.database.items as it
|
||||
import Web.modules.database.ausleihung as au
|
||||
import Web.modules.database.termine as termin
|
||||
import Web.modules.log.audit_log as al
|
||||
import push_notifications as pn
|
||||
import Web.modules.inventarsystem.pdf_export as pdf_export
|
||||
@@ -84,7 +85,7 @@ from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
from tenant import get_tenant_context, get_tenant_trial_status, purge_expired_trial_tenants
|
||||
from tenant import get_tenant_context, get_tenant_db, get_tenant_trial_status, purge_expired_trial_tenants
|
||||
|
||||
|
||||
app = Flask(__name__, static_folder='static') # Correctly set static folder
|
||||
@@ -4803,6 +4804,96 @@ def get_bookings():
|
||||
client.close()
|
||||
|
||||
|
||||
@app.route('/get_user_appointments')
|
||||
def get_user_appointments():
|
||||
"""Return the current user's planned and active appointments for the calendar."""
|
||||
if 'username' not in session:
|
||||
return jsonify({'ok': False, 'error': 'unauthorized'}), 401
|
||||
|
||||
client = None
|
||||
try:
|
||||
username = session.get('username')
|
||||
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
items_col = db['items']
|
||||
|
||||
# Use the appointment collection for the user's appointments
|
||||
appointments = termin.get_upcoming_for_user(username, limit=250)
|
||||
|
||||
result = []
|
||||
import re as _re
|
||||
|
||||
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
|
||||
|
||||
date_start = str(appt.get('date_start') or '')
|
||||
date_end = str(appt.get('date_end') or date_start)
|
||||
time_span = appt.get('time_span', []) or []
|
||||
title = appt.get('note') or f"Termin von {appt.get('user') or ''}"
|
||||
days_in_range = _date_iter(date_start, date_end) or ([date_start] if date_start else [])
|
||||
|
||||
span_entries = []
|
||||
for entry in time_span:
|
||||
s = str(entry or '').strip()
|
||||
if not s:
|
||||
continue
|
||||
m_date = _re.match(r"^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$", s)
|
||||
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)))
|
||||
|
||||
# If the stored span is already day-specific, use it as-is.
|
||||
# If it was generic, we duplicated it to each day in the range above.
|
||||
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'))
|
||||
|
||||
for day, start_time, end_time in span_entries:
|
||||
result.append({
|
||||
'id': f"{appt_id}-{day}-{start_time}",
|
||||
'title': title,
|
||||
'start': f"{day}T{start_time}",
|
||||
'end': f"{day}T{end_time}",
|
||||
'status': 'planned',
|
||||
'itemId': appt_id,
|
||||
'userName': str(appt.get('user') or ''),
|
||||
'notes': str(appt.get('note') or ''),
|
||||
'period': None,
|
||||
'isCurrentUser': True,
|
||||
'itemBorrower': '',
|
||||
})
|
||||
|
||||
return jsonify({'ok': True, 'bookings': result})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e), 'bookings': []}), 500
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
|
||||
@app.route('/api/booking_conflicts')
|
||||
def api_booking_conflicts():
|
||||
"""
|
||||
|
||||
@@ -102,7 +102,7 @@ def update(id,slots_used: list):
|
||||
items = db['appointments']
|
||||
|
||||
update_data = {
|
||||
'slots_booked': [slots_used],
|
||||
'slots_booked': slots_used,
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
}
|
||||
|
||||
@@ -166,9 +166,69 @@ def remove(id):
|
||||
result = items.delete_one({'_id': ObjectId(id)})
|
||||
|
||||
client.close()
|
||||
return result.modified_count > 0
|
||||
return result.deleted_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error removing appointment: {e}")
|
||||
return False
|
||||
|
||||
def remove_done():
|
||||
"""removose already finisched appointments"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
|
||||
today = datetime.date.today().strftime('%Y-%m-%d')
|
||||
removed_count = 0
|
||||
|
||||
cursor = items.find(
|
||||
_active_record_query(
|
||||
{
|
||||
'date_end': {'$lt': today},
|
||||
}
|
||||
)
|
||||
).sort('date_start', 1)
|
||||
|
||||
for item in cursor:
|
||||
item['_id'] = str(item.get('_id'))
|
||||
result = items.delete_one({'_id': ObjectId(item['_id'])})
|
||||
removed_count += result.deleted_count
|
||||
|
||||
client.close()
|
||||
return removed_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error removing appointment: {e}")
|
||||
return False
|
||||
|
||||
def get_upcoming_for_user(user: str, limit: int = 25):
|
||||
"""Return upcoming appointment plans for a user ordered by start date."""
|
||||
remove_done()
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
|
||||
today = datetime.date.today().strftime('%Y-%m-%d')
|
||||
cursor = items.find(
|
||||
_active_record_query(
|
||||
{
|
||||
'user': str(user or '').strip(),
|
||||
'date_end': {'$gte': today},
|
||||
}
|
||||
)
|
||||
).sort('date_start', 1)
|
||||
|
||||
results = []
|
||||
for item in cursor:
|
||||
item['_id'] = str(item.get('_id'))
|
||||
results.append(item)
|
||||
if len(results) >= max(1, int(limit)):
|
||||
break
|
||||
|
||||
client.close()
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"Error retrieving upcoming appointments: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@@ -3,13 +3,39 @@ Class for all funktions of the executive -> Lehrer
|
||||
"""
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
from flask import url_for
|
||||
from flask import url_for, has_request_context, request
|
||||
import Web.modules.emailservice.email as mail_service
|
||||
import Web.modules.database.termine as termin
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.tenant import get_tenant_context
|
||||
|
||||
|
||||
def _resolve_public_base_url() -> str:
|
||||
if has_request_context():
|
||||
try:
|
||||
return request.url_root.rstrip('/')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tenant_context = get_tenant_context()
|
||||
subdomain = ''
|
||||
if tenant_context:
|
||||
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
|
||||
return f"https://{subdomain}.invario.eu" if subdomain else "https://invario.eu"
|
||||
|
||||
|
||||
def _current_tenant_id() -> str:
|
||||
tenant_context = get_tenant_context()
|
||||
if tenant_context and getattr(tenant_context, 'tenant_id', None):
|
||||
return str(tenant_context.tenant_id)
|
||||
if has_request_context():
|
||||
try:
|
||||
return str(request.args.get('tenant', '') or request.args.get('tenant_id', '') or '').strip()
|
||||
except Exception:
|
||||
return ''
|
||||
return ''
|
||||
|
||||
|
||||
def _normalize_time_span(time_span):
|
||||
if isinstance(time_span, list):
|
||||
return [str(entry).strip() for entry in time_span if str(entry).strip()]
|
||||
@@ -56,15 +82,14 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
||||
time_span = item.get('time_span', []) or []
|
||||
creator = item.get('user', 'Terminplaner')
|
||||
note = item.get('note', '') or ''
|
||||
tenant_id = _current_tenant_id()
|
||||
try:
|
||||
link = url_for('terminplaner.client', appointment_id=str(appointment_id), _external=True)
|
||||
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
tenant_context = get_tenant_context()
|
||||
subdomain = ''
|
||||
if tenant_context:
|
||||
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
|
||||
host = f"https://{subdomain}.invario.eu" if subdomain else "https://invario.eu"
|
||||
host = _resolve_public_base_url()
|
||||
link = host + "/terminplaner/client/" + str(appointment_id)
|
||||
if tenant_id:
|
||||
link += f"?tenant={tenant_id}"
|
||||
|
||||
try:
|
||||
start_date = datetime.datetime.strptime(str(date_start), '%Y-%m-%d').date()
|
||||
@@ -105,6 +130,68 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
||||
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:
|
||||
"""
|
||||
Generates a link for the executive to send to his clients to book a time Slot
|
||||
@@ -123,30 +210,26 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
||||
normalized_mail = _normalize_mail_list(mail)
|
||||
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note, calendar_enabled=calendar_enabled)
|
||||
id_str = str(id)
|
||||
|
||||
tenant_context = get_tenant_context()
|
||||
subdomain = ''
|
||||
if tenant_context:
|
||||
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
|
||||
tenant_id = _current_tenant_id()
|
||||
|
||||
try:
|
||||
link = url_for('terminplaner.client', appointment_id=id_str, _external=True)
|
||||
link = url_for('terminplaner.client', appointment_id=id_str, tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
host = f"https://{subdomain}.invario.eu" if subdomain else "https://invario.eu"
|
||||
host = _resolve_public_base_url()
|
||||
link = host + "/terminplaner/client/" + id_str
|
||||
if tenant_id:
|
||||
link += f"?tenant={tenant_id}"
|
||||
subject = f"Terminanfrage von {user}"
|
||||
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
||||
calendar_link = None
|
||||
if calendar_enabled:
|
||||
try:
|
||||
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, _external=True)
|
||||
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
tenant_context = get_tenant_context()
|
||||
subdomain = ''
|
||||
if tenant_context:
|
||||
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
|
||||
host = f"https://{subdomain}.invario.eu" if subdomain else "https://invario.eu"
|
||||
host = _resolve_public_base_url()
|
||||
calendar_link = host + "/terminplaner/calendar/" + id_str + ".ics"
|
||||
if tenant_id:
|
||||
calendar_link += f"?tenant={tenant_id}"
|
||||
|
||||
email_body = note_link
|
||||
if calendar_link:
|
||||
@@ -272,6 +355,17 @@ def get_available(id):
|
||||
time_span = termin_range.get('time_span', [])
|
||||
slot_lenght = termin_range.get('slot_lenght')
|
||||
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 []
|
||||
|
||||
# Normalize booked entries to dicts for easier consumption
|
||||
@@ -285,7 +379,10 @@ def get_available(id):
|
||||
normalized.append({'value': s})
|
||||
|
||||
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 {
|
||||
'date_start': date_start,
|
||||
@@ -312,4 +409,57 @@ def get_available_user(id):
|
||||
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
|
||||
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
|
||||
"""
|
||||
return get_available(id)
|
||||
return get_available(id)
|
||||
|
||||
|
||||
def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
|
||||
"""Return upcoming appointment plans for overview display."""
|
||||
user_name = str(user or '').strip()
|
||||
if not user_name:
|
||||
return []
|
||||
|
||||
appointments = termin.get_upcoming_for_user(user_name, limit=limit)
|
||||
host = _resolve_public_base_url()
|
||||
tenant_id = _current_tenant_id()
|
||||
|
||||
result = []
|
||||
for item in appointments:
|
||||
appointment_id = str(item.get('_id') or '')
|
||||
if not appointment_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
link = url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
link = host + '/terminplaner/client/' + appointment_id
|
||||
if tenant_id:
|
||||
link += f'?tenant={tenant_id}'
|
||||
|
||||
try:
|
||||
calendar_link = url_for('terminplaner.calendar_export', appointment_id=appointment_id, tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
calendar_link = host + '/terminplaner/calendar/' + appointment_id + '.ics'
|
||||
if tenant_id:
|
||||
calendar_link += f'?tenant={tenant_id}'
|
||||
|
||||
slots_total = int(item.get('slots', 0) or 0)
|
||||
slots_booked = item.get('slots_booked', []) or []
|
||||
if not isinstance(slots_booked, list):
|
||||
slots_booked = []
|
||||
|
||||
result.append(
|
||||
{
|
||||
'appointment_id': appointment_id,
|
||||
'date_start': str(item.get('date_start') or ''),
|
||||
'date_end': str(item.get('date_end') or ''),
|
||||
'time_span': item.get('time_span', []) or [],
|
||||
'slots_total': slots_total,
|
||||
'slots_booked': len(slots_booked),
|
||||
'slots_left': max(0, slots_total - len(slots_booked)),
|
||||
'note': str(item.get('note') or ''),
|
||||
'link': link,
|
||||
'calendar_link': calendar_link if item.get('calendar_enabled') else None,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -2,6 +2,8 @@ from flask import Blueprint, render_template, request, session, url_for, redirec
|
||||
from flask import Response
|
||||
import Web.modules.terminplaner.backend_server as appointment_service
|
||||
import Web.modules.database.settings as cfg
|
||||
import Web.modules.database.termine as termin
|
||||
import Web.modules.database.user as us
|
||||
|
||||
# Create a blueprint instance
|
||||
appoint_bp = Blueprint('terminplaner', __name__)
|
||||
@@ -21,6 +23,17 @@ def _appointment_not_found_response():
|
||||
error_message='Der Termin wurde nicht gefunden.',
|
||||
), 404
|
||||
|
||||
|
||||
def _current_tenant_id():
|
||||
try:
|
||||
from Web.tenant import get_tenant_context
|
||||
ctx = get_tenant_context()
|
||||
if ctx and getattr(ctx, 'tenant_id', None):
|
||||
return str(ctx.tenant_id)
|
||||
except Exception:
|
||||
pass
|
||||
return str(session.get('tenant_id', '') or '').strip()
|
||||
|
||||
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
||||
def client(appointment_id):
|
||||
"""
|
||||
@@ -34,6 +47,28 @@ def client(appointment_id):
|
||||
if not available:
|
||||
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':
|
||||
start_daytime = request.form.get('start_day_time')
|
||||
username = request.form.get('client_name')
|
||||
@@ -42,23 +77,80 @@ def client(appointment_id):
|
||||
return render_template(
|
||||
'termin_client.html',
|
||||
appointment_id=appointment_id,
|
||||
available=available,
|
||||
available=available_for_view,
|
||||
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):
|
||||
flash('Der Termin wurde gespeichert.', 'success')
|
||||
return redirect(url_for('terminplaner.client', appointment_id=appointment_id))
|
||||
return redirect(
|
||||
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')
|
||||
|
||||
return render_template(
|
||||
'termin_client.html',
|
||||
appointment_id=appointment_id,
|
||||
available=available,
|
||||
available=available_for_view,
|
||||
current_user=session.get('username', ''),
|
||||
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(),
|
||||
)
|
||||
|
||||
|
||||
@appoint_bp.route('/delete/<appointment_id>', methods=['POST'])
|
||||
def delete_appointment(appointment_id):
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
if 'username' not in session:
|
||||
flash('Bitte mit einem Konto anmelden.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
appointment = termin.get_item(appointment_id)
|
||||
if not appointment:
|
||||
return _appointment_not_found_response()
|
||||
|
||||
current_user = str(session.get('username', '')).strip()
|
||||
appointment_user = str(appointment.get('user', '')).strip()
|
||||
if not us.check_admin(current_user) and appointment_user != current_user:
|
||||
flash('Sie dürfen diesen Termin nicht löschen.', 'error')
|
||||
return redirect(url_for('terminplaner.main', tenant=_current_tenant_id() or None))
|
||||
|
||||
if termin.remove(appointment_id):
|
||||
flash('Der Terminplan wurde gelöscht.', 'success')
|
||||
else:
|
||||
flash('Der Terminplan konnte nicht gelöscht werden.', 'error')
|
||||
|
||||
return redirect(url_for('terminplaner.main', tenant=_current_tenant_id() or None))
|
||||
|
||||
@appoint_bp.route('/configure', methods=['GET', 'POST'])
|
||||
def configure():
|
||||
"""
|
||||
@@ -126,14 +218,37 @@ def calendar_export(appointment_id):
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics'
|
||||
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('/')
|
||||
def main():
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
current_user = session.get('username', '')
|
||||
upcoming_events = appointment_service.get_user_upcoming_events(current_user) if current_user else []
|
||||
tenant_id = _current_tenant_id()
|
||||
|
||||
return render_template(
|
||||
'terminplaner.html',
|
||||
school_periods=cfg.SCHOOL_PERIODS,
|
||||
current_user=session.get('username', ''),
|
||||
current_user=current_user,
|
||||
upcoming_events=upcoming_events,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
+29
-22
@@ -1392,28 +1392,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" id="loginNavbar">
|
||||
<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>
|
||||
<!-- No navbar for anonymous users on public pages. -->
|
||||
{% endif %}
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
@@ -1427,6 +1406,11 @@
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
{% if 'username' not in session %}
|
||||
<div class="guest-impressum-footer">
|
||||
<a href="{{ url_for('impressum') }}">Impressum</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Cookie consent banner -->
|
||||
<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); }
|
||||
@@ -1560,6 +1544,29 @@
|
||||
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 {
|
||||
display: block;
|
||||
}
|
||||
|
||||
+61
-50
@@ -10,24 +10,6 @@
|
||||
<h1>📚 Bibliothek</h1>
|
||||
<p class="subtitle">Bücher, CDs und weitere Medien</p>
|
||||
</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>
|
||||
|
||||
<!-- Search & Filter Section -->
|
||||
@@ -205,8 +187,7 @@
|
||||
</form>
|
||||
</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>
|
||||
// View mode persistence
|
||||
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
||||
@@ -625,50 +606,80 @@ document.getElementById('clearFiltersBtn').addEventListener('click', function()
|
||||
closeAllFilters();
|
||||
});
|
||||
|
||||
// Favorites toggle
|
||||
document.getElementById('favoriteToggle').addEventListener('click', function() {
|
||||
this.classList.toggle('open');
|
||||
// TODO: Implement favorites filtering
|
||||
});
|
||||
|
||||
// Scanner toggle
|
||||
let scanner = null;
|
||||
// Scanner state tracking
|
||||
let isScanning = false;
|
||||
|
||||
document.getElementById('scannerBtn').addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const container = document.getElementById('qrContainer');
|
||||
const isOpen = container.style.display !== 'none';
|
||||
const btn = this;
|
||||
|
||||
if (isOpen) {
|
||||
// Close the scanner
|
||||
container.style.display = 'none';
|
||||
if (scanner) scanner.clear();
|
||||
this.classList.remove('open');
|
||||
this.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
this.classList.add('open');
|
||||
this.setAttribute('aria-expanded', 'true');
|
||||
Quagga.stop();
|
||||
isScanning = false;
|
||||
|
||||
if (!scanner) {
|
||||
scanner = new Html5Qrcode('qr-reader');
|
||||
scanner.start(
|
||||
{ facingMode: "environment" },
|
||||
{ fps: 10, qrbox: 250 },
|
||||
onScanSuccess,
|
||||
onScanError
|
||||
);
|
||||
}
|
||||
btn.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
// Open the scanner
|
||||
container.style.display = 'block';
|
||||
btn.classList.add('open');
|
||||
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) {
|
||||
const studentId = decodedText.trim();
|
||||
// Single Quagga event listener for successful scans
|
||||
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;
|
||||
alert('Ausweis gescannt: ' + studentId);
|
||||
}
|
||||
|
||||
function onScanError(error) {
|
||||
// Silently ignore scan errors
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -547,7 +547,7 @@
|
||||
</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>
|
||||
// State
|
||||
let libraryItems = [];
|
||||
@@ -847,10 +847,16 @@
|
||||
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) {
|
||||
const selectedItem = (libraryItems || []).find(item => item._id === itemId);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -997,130 +1003,71 @@
|
||||
processQuickToggleScan(scannedCode);
|
||||
}
|
||||
|
||||
function handleScanError() {
|
||||
// Intentionally silent to avoid UI spam while camera searches codes.
|
||||
}
|
||||
|
||||
async function ensureScannerLibraryLoaded() {
|
||||
if (typeof Html5QrcodeScanner !== 'undefined') {
|
||||
return true;
|
||||
// Global Quagga reader hook
|
||||
Quagga.onDetected(function(data) {
|
||||
if (data && data.codeResult && data.codeResult.code) {
|
||||
handleScanSuccess(data.codeResult.code);
|
||||
}
|
||||
|
||||
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';
|
||||
if (readerWrap) 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
|
||||
);
|
||||
setScanStatus('Initializing camera...', 'warn');
|
||||
|
||||
Quagga.init({
|
||||
inputStream: {
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
// Points to the interior viewport layout box
|
||||
target: document.querySelector('#libraryQrReader'),
|
||||
constraints: {
|
||||
width: 640,
|
||||
height: 480,
|
||||
facingMode: "environment" // Force rear mobile lenses
|
||||
},
|
||||
},
|
||||
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 = {
|
||||
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);
|
||||
Quagga.start();
|
||||
scannerRunning = true;
|
||||
toggleBtn.textContent = 'Scanner stoppen';
|
||||
if (toggleBtn) 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;
|
||||
if (!scannerRunning) return;
|
||||
const readerWrap = document.getElementById('scanReaderWrap');
|
||||
const toggleBtn = document.getElementById('toggleScannerBtn');
|
||||
try {
|
||||
await scannerInstance.clear();
|
||||
} catch (err) {
|
||||
console.error('Scanner stop failed:', err);
|
||||
}
|
||||
|
||||
Quagga.stop();
|
||||
|
||||
scannerRunning = false;
|
||||
scannerInstance = null;
|
||||
readerWrap.style.display = 'none';
|
||||
toggleBtn.textContent = 'Scanner starten';
|
||||
if (readerWrap) readerWrap.style.display = 'none';
|
||||
if (toggleBtn) toggleBtn.textContent = 'Scanner starten';
|
||||
setScanStatus('Scanner gestoppt.', 'warn');
|
||||
}
|
||||
|
||||
|
||||
+103
-41
@@ -525,8 +525,7 @@
|
||||
|
||||
window.isDebug = false; // Set to true only for development environment
|
||||
</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>
|
||||
// Global state
|
||||
const highlightItemId = (window.serverVars && window.serverVars.highlightItemId && window.serverVars.highlightItemId !== 'null')
|
||||
@@ -535,51 +534,114 @@
|
||||
let codeSearchTerm = '';
|
||||
let descSearchIds = null; // Set of matching IDs or null when disabled
|
||||
|
||||
// QR scanner toggle setup
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const scanButton = document.getElementById('scanButton');
|
||||
// Keep track of the scanner state globally/outer scope
|
||||
let isScanning = false;
|
||||
let activeScannerCallback = null; // Tracks which function currently owns the scanner output
|
||||
|
||||
function startScanner(targetCallback) {
|
||||
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) => {
|
||||
scanButton.classList.toggle('is-active', isOpen);
|
||||
scanButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
scanButton.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
||||
};
|
||||
// Show the container
|
||||
if (qrReader) qrReader.style.display = 'block';
|
||||
if (statusText) statusText.innerText = "Initializing camera...";
|
||||
|
||||
scanButton.addEventListener('click', function() {
|
||||
if (qrReader.style.display !== 'block') {
|
||||
qrReader.style.display = 'block';
|
||||
|
||||
html5QrcodeScanner = new Html5QrcodeScanner(
|
||||
'qr-reader', { fps: 10, qrbox: 250 }
|
||||
);
|
||||
|
||||
html5QrcodeScanner.render((decodedText) => {
|
||||
html5QrcodeScanner.clear();
|
||||
qrReader.style.display = 'none';
|
||||
|
||||
// Put scanned code into the search box and trigger search
|
||||
const searchInput = document.getElementById('code-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = decodedText;
|
||||
searchByCode();
|
||||
}
|
||||
|
||||
setScannerUi(false);
|
||||
});
|
||||
|
||||
setScannerUi(true);
|
||||
} else {
|
||||
if (html5QrcodeScanner) {
|
||||
html5QrcodeScanner.clear();
|
||||
}
|
||||
qrReader.style.display = 'none';
|
||||
setScannerUi(false);
|
||||
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;
|
||||
|
||||
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
|
||||
|
||||
+123
-74
@@ -2435,7 +2435,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>
|
||||
<div class="qr-container">
|
||||
<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 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
|
||||
</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>
|
||||
// Function to check if a file is a video
|
||||
function isVideoFile(filename) {
|
||||
@@ -2739,7 +2742,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
}
|
||||
|
||||
// Initialize QR Code scanner and global variables
|
||||
let html5QrcodeScanner = null;
|
||||
let codeSearchTerm = '';
|
||||
let descSearchIds = null; // Set of matching IDs or null when disabled
|
||||
let currentUsername = '';
|
||||
@@ -2768,51 +2770,114 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
// No-op in production
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('scanButton').addEventListener('click', function() {
|
||||
const qrReader = document.getElementById('qr-reader');
|
||||
const scanBtn = document.getElementById('scanButton');
|
||||
|
||||
const setScannerUi = (isOpen) => {
|
||||
if (!scanBtn) return;
|
||||
scanBtn.classList.toggle('is-active', isOpen);
|
||||
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
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) => {
|
||||
html5QrcodeScanner.clear();
|
||||
qrReader.style.display = 'none';
|
||||
|
||||
// Instead of navigating to the URL, put the scanned code in the search box
|
||||
const searchInput = document.getElementById('code-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = decodedText;
|
||||
// Trigger search automatically
|
||||
// Keep track of the scanner state globally/outer scope
|
||||
let isScanning = false;
|
||||
let activeScannerCallback = null; // Tracks which function currently owns the scanner output
|
||||
|
||||
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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (qrReader.style.display !== 'none') {
|
||||
if (html5QrcodeScanner) {
|
||||
html5QrcodeScanner.clear();
|
||||
}
|
||||
qrReader.style.display = 'none';
|
||||
// Toggle close if it's already running
|
||||
if (isScanning && qrReader.style.display !== 'none') {
|
||||
stopScanner();
|
||||
scanEditBtn.textContent = 'Barcode scannen';
|
||||
return;
|
||||
}
|
||||
|
||||
qrReader.style.display = 'block';
|
||||
scanEditBtn.textContent = 'Scanner schließen';
|
||||
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
|
||||
fps: 10,
|
||||
qrbox: 250,
|
||||
rememberLastUsedCamera: true
|
||||
});
|
||||
html5QrcodeScanner.render((decodedText) => {
|
||||
html5QrcodeScanner.clear();
|
||||
qrReader.style.display = 'none';
|
||||
editCodeInput.value = String(decodedText || '').trim();
|
||||
|
||||
// Start scanner with custom logic mapping to the Code input field
|
||||
startScanner(function(decodedText) {
|
||||
editCodeInput.value = decodedText;
|
||||
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
|
||||
scanEditBtn.textContent = 'Barcode scannen';
|
||||
});
|
||||
@@ -2857,26 +2914,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
return;
|
||||
}
|
||||
|
||||
if (qrReader.style.display !== 'none') {
|
||||
if (html5QrcodeScanner) {
|
||||
html5QrcodeScanner.clear();
|
||||
}
|
||||
qrReader.style.display = 'none';
|
||||
// Toggle close if it's already running
|
||||
if (isScanning && qrReader.style.display !== 'none') {
|
||||
stopScanner();
|
||||
scanIsbnBtn.textContent = 'ISBN scannen';
|
||||
return;
|
||||
}
|
||||
|
||||
qrReader.style.display = 'block';
|
||||
scanIsbnBtn.textContent = 'Scanner schließen';
|
||||
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
|
||||
fps: 10,
|
||||
qrbox: 250,
|
||||
rememberLastUsedCamera: true
|
||||
});
|
||||
html5QrcodeScanner.render((decodedText) => {
|
||||
html5QrcodeScanner.clear();
|
||||
qrReader.style.display = 'none';
|
||||
editIsbnInput.value = String(decodedText || '').trim();
|
||||
|
||||
// Start scanner with custom logic mapping to the ISBN input field
|
||||
startScanner(function(decodedText) {
|
||||
editIsbnInput.value = decodedText;
|
||||
scanIsbnBtn.textContent = 'ISBN scannen';
|
||||
if (typeof fetchBookInfo === 'function') {
|
||||
fetchBookInfo('edit');
|
||||
|
||||
@@ -2,17 +2,49 @@
|
||||
|
||||
{% 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 %}
|
||||
<div class="container py-4">
|
||||
<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="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-body p-4 p-md-5">
|
||||
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
|
||||
<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="fw-semibold">Zeitraum</div>
|
||||
@@ -27,12 +59,24 @@
|
||||
<div>{{ available.slot_lenght }} Minuten</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 %}
|
||||
<div class="p-3 rounded-3 bg-light">
|
||||
<div class="fw-semibold mb-2">Bereits gebucht</div>
|
||||
<ul class="mb-0 small">
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -40,15 +84,26 @@
|
||||
</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-body p-4 p-md-5 bg-white">
|
||||
<h2 class="h4 fw-bold mb-4">Buchung absenden</h2>
|
||||
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id) }}" class="vstack gap-3">
|
||||
<h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<div class="form-text">Tragen Sie Datum und Uhrzeit im Format YYYY-MM-DD HH:MM ein, sofern kein Kalenderfeld genutzt wird.</div>
|
||||
<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">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
|
||||
@@ -56,7 +111,7 @@
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
|
||||
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main') }}">Zur Übersicht</a>
|
||||
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -66,4 +121,403 @@
|
||||
</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 %}
|
||||
|
||||
@@ -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>
|
||||
+23
-807
@@ -13,7 +13,7 @@
|
||||
{% block content %}
|
||||
<div class="calendar-container">
|
||||
<div class="calendar-header">
|
||||
<h1>Schulstunden-Terminplan für Ausleihen</h1>
|
||||
<h1>Schulstunden-Terminplan für Termine</h1>
|
||||
<div class="calendar-actions">
|
||||
<button id="prev-day">Vorheriger Tag</button>
|
||||
<span id="current-day-display"></span>
|
||||
@@ -24,20 +24,20 @@
|
||||
<button type="button" class="calendar-view-btn" data-calendar-view="timeGridWeek">Woche</button>
|
||||
<button type="button" class="calendar-view-btn" data-calendar-view="dayGridMonth">Monat</button>
|
||||
</div>
|
||||
<button id="new-booking" class="primary-button">Neue Reservierung</button>
|
||||
<button id="new-booking" class="primary-button">Zur Termin-Konfiguration</button>
|
||||
</div>
|
||||
<div class="calendar-legend">
|
||||
<span class="legend-item"><span class="legend-color current"></span> Aktuelle Ausleihungen</span>
|
||||
<span class="legend-item"><span class="legend-color planned"></span> Geplante Ausleihungen</span>
|
||||
<span class="legend-item"><span class="legend-color completed"></span> Abgeschlossene Ausleihungen</span>
|
||||
<span class="legend-item"><span class="legend-color your-bookings"></span> Ihre Ausleihungen</span>
|
||||
<span class="legend-item"><span class="legend-color current"></span> Aktuelle Termine</span>
|
||||
<span class="legend-item"><span class="legend-color planned"></span> Geplante Termine</span>
|
||||
<span class="legend-item"><span class="legend-color completed"></span> Abgeschlossene Termine</span>
|
||||
<span class="legend-item"><span class="legend-color your-bookings"></span> Ihre Termine</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-options">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" id="show-completed-bookings">
|
||||
Abgeschlossene Ausleihungen anzeigen
|
||||
Abgeschlossene Termine anzeigen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -73,128 +73,16 @@
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for new bookings -->
|
||||
<div id="booking-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close">×</span>
|
||||
<h2>Neue Reservierung</h2>
|
||||
<form id="booking-form">
|
||||
<input type="hidden" name="user_id" id="user-id" value="{{ current_user }}">
|
||||
|
||||
<!-- Booking Type -->
|
||||
<div class="form-group">
|
||||
<label for="booking-type">Reservierungstyp:</label>
|
||||
<select id="booking-type" name="booking_type">
|
||||
<option value="single">Einzeltermin</option>
|
||||
<option value="range">Zeitraum</option>
|
||||
<option value="recurring">Wiederkehrend</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Start Date -->
|
||||
<div class="form-group" id="start-date-group">
|
||||
<label for="booking-date" id="booking-date-label">Datum:</label>
|
||||
<input type="date" id="booking-date" name="booking_date" required>
|
||||
</div>
|
||||
|
||||
<!-- End Date (for range) -->
|
||||
<div class="form-group" id="end-date-group" style="display:none;">
|
||||
<label for="booking-end-date">Bis:</label>
|
||||
<input type="date" id="booking-end-date" name="booking_end_date">
|
||||
</div>
|
||||
|
||||
<!-- Period Range Selection -->
|
||||
<div class="form-group">
|
||||
<label for="period-start-select">Von Schulstunde:</label>
|
||||
<select id="period-start-select" name="period_start" required>
|
||||
<option value="">-- Bitte wählen --</option>
|
||||
{% for period, details in school_periods.items() %}
|
||||
<option value="{{ period }}">{{ details.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="period-end-select">Bis Schulstunde:</label>
|
||||
<select id="period-end-select" name="period_end" required>
|
||||
<option value="">-- Bitte wählen --</option>
|
||||
{% for period, details in school_periods.items() %}
|
||||
<option value="{{ period }}">{{ details.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Recurring Options -->
|
||||
<div id="recurring-options" style="display:none;">
|
||||
<div class="form-group">
|
||||
<label for="recurrence-pattern">Wiederholungsmuster:</label>
|
||||
<select id="recurrence-pattern" name="recurrence_pattern">
|
||||
<option value="daily">Täglich</option>
|
||||
<option value="weekly" selected>Wöchentlich</option>
|
||||
<option value="monthly">Monatlich</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="recurrence-end-date">Wiederholung bis:</label>
|
||||
<input type="date" id="recurrence-end-date" name="recurrence_end_date">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="weekly-options">
|
||||
<label>Wochentage:</label>
|
||||
<div class="weekday-selector">
|
||||
<label><input type="checkbox" name="weekdays" value="1"> Mo</label>
|
||||
<label><input type="checkbox" name="weekdays" value="2"> Di</label>
|
||||
<label><input type="checkbox" name="weekdays" value="3"> Mi</label>
|
||||
<label><input type="checkbox" name="weekdays" value="4"> Do</label>
|
||||
<label><input type="checkbox" name="weekdays" value="5"> Fr</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="monthly-options" style="display:none;">
|
||||
<label>Tag des Monats:</label>
|
||||
<select id="day-of-month" name="day_of_month">
|
||||
<option value="same_day">Gleicher Tag</option>
|
||||
<option value="first_weekday">Erster gleicher Wochentag</option>
|
||||
<option value="last_weekday">Letzter gleicher Wochentag</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Selection -->
|
||||
<div class="form-group">
|
||||
<label for="item-select">Objekt:</label>
|
||||
<select id="item-select" name="item_id" required>
|
||||
<option value="">-- Bitte wählen --</option>
|
||||
<!-- Items will be loaded dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-group">
|
||||
<label for="booking-notes">Notizen:</label>
|
||||
<textarea id="booking-notes" name="notes"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Booking Summary -->
|
||||
<div class="booking-summary" id="booking-summary" style="display:none;">
|
||||
<!-- Will be populated dynamically -->
|
||||
</div>
|
||||
|
||||
<button type="submit" class="primary-button">Ausleihung speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for event details -->
|
||||
<div id="event-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close">×</span>
|
||||
<h2>Ausleihungs Details</h2>
|
||||
<h2>Termin-Details</h2>
|
||||
<div id="event-details">
|
||||
<!-- Event details will be loaded dynamically -->
|
||||
</div>
|
||||
<div id="event-actions">
|
||||
<button id="cancel-booking" class="danger-button">Ausleihung stornieren</button>
|
||||
<button id="cancel-booking" class="danger-button">Termin stornieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,7 +98,6 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize variables
|
||||
const calendarEl = document.getElementById('calendar');
|
||||
const bookingModal = document.getElementById('booking-modal');
|
||||
const eventModal = document.getElementById('event-modal');
|
||||
const newBookingBtn = document.getElementById('new-booking');
|
||||
let currentEventId = null;
|
||||
@@ -326,7 +213,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const endStr = info.endStr;
|
||||
|
||||
// Load events from the server
|
||||
fetch('/get_bookings?start=' + startStr + '&end=' + endStr)
|
||||
fetch('/get_user_appointments?start=' + startStr + '&end=' + endStr)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
@@ -400,7 +287,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const filteredEvents = showCompletedBookings
|
||||
? processedEvents
|
||||
: processedEvents.filter(event => event.extendedProps.status !== 'completed');
|
||||
|
||||
|
||||
// Provide events to calendar
|
||||
successCallback(filteredEvents);
|
||||
})
|
||||
@@ -427,22 +314,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
periodDiv.style.display = 'inline-block';
|
||||
periodDiv.style.fontSize = '10px';
|
||||
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
|
||||
@@ -452,7 +323,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const borrowerIndicator = document.createElement('div');
|
||||
borrowerIndicator.className = 'borrowed-indicator';
|
||||
borrowerIndicator.innerHTML = '⚠️'; // Warning symbol
|
||||
borrowerIndicator.title = `Bereits ausgeliehen von: ${info.event.extendedProps.itemBorrower}`;
|
||||
borrowerIndicator.title = `Bereits verknüpft mit: ${info.event.extendedProps.itemBorrower}`;
|
||||
borrowerIndicator.style.position = 'absolute';
|
||||
borrowerIndicator.style.top = '2px';
|
||||
borrowerIndicator.style.left = '2px';
|
||||
@@ -526,53 +397,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('current-day-display').textContent = dateStr;
|
||||
}
|
||||
|
||||
// Load available items for the booking form
|
||||
function loadAvailableItems() {
|
||||
const itemSelect = document.getElementById('item-select');
|
||||
itemSelect.innerHTML = '<option value="">-- Bitte wählen --</option>';
|
||||
|
||||
fetch('/get_items?available_only=true')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Access the items array in the response
|
||||
const items = data.items || [];
|
||||
|
||||
items.forEach(item => {
|
||||
const option = document.createElement('option');
|
||||
const itemId = item._id.$oid || item._id || item.id;
|
||||
option.value = itemId;
|
||||
option.textContent = item.Name;
|
||||
itemSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
itemSelect.innerHTML += '<option value="" disabled>Keine Objekte gefunden</option>';
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error loading items:', error));
|
||||
}
|
||||
|
||||
// Open booking modal
|
||||
function openBookingModal(date = null) {
|
||||
// Set date to today if not provided
|
||||
if (!date) {
|
||||
date = calendar.getDate();
|
||||
}
|
||||
|
||||
// Format the date for the date picker
|
||||
const formatDate = (date) => {
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
document.getElementById('booking-date').value = formatDate(date);
|
||||
|
||||
// Load available items
|
||||
loadAvailableItems();
|
||||
|
||||
// Show modal
|
||||
bookingModal.style.display = 'block';
|
||||
}
|
||||
|
||||
// Show event details
|
||||
function showEventDetails(event) {
|
||||
const details = document.getElementById('event-details');
|
||||
@@ -584,7 +408,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Get status text based on status value
|
||||
let statusText = 'Unbekannt';
|
||||
if (event.extendedProps.status === 'current') {
|
||||
statusText = 'Aktuell ausgeliehen';
|
||||
statusText = 'Aktiv';
|
||||
} else if (event.extendedProps.status === 'planned') {
|
||||
statusText = 'Geplant';
|
||||
} else if (event.extendedProps.status === 'completed') {
|
||||
@@ -603,17 +427,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check if item is already borrowed by someone else
|
||||
let borrowerInfo = '';
|
||||
if (event.extendedProps.itemBorrower && event.extendedProps.itemBorrower !== event.extendedProps.userName) {
|
||||
borrowerInfo = `<p class="warning"><strong>Hinweis:</strong> Dieses Objekt ist aktuell ausgeliehen von <strong>${event.extendedProps.itemBorrower}</strong></p>`;
|
||||
borrowerInfo = `<p class="warning"><strong>Hinweis:</strong> Dieser Termin ist aktuell bereits mit <strong>${event.extendedProps.itemBorrower}</strong> verknüpft.</p>`;
|
||||
}
|
||||
|
||||
// Populate details
|
||||
details.innerHTML = `
|
||||
<p><strong>Objekt:</strong> ${event.title}</p>
|
||||
<p><strong>Termin:</strong> ${event.title}</p>
|
||||
<p><strong>Datum:</strong> ${new Date(event.start).toLocaleDateString('de-DE')}</p>
|
||||
<p><strong>Von:</strong> ${new Date(event.start).toLocaleTimeString('de-DE', {hour: '2-digit', minute:'2-digit'})}</p>
|
||||
<p><strong>Bis:</strong> ${new Date(event.end).toLocaleTimeString('de-DE', {hour: '2-digit', minute:'2-digit'})}</p>
|
||||
${periodText}
|
||||
<p><strong>Ausgeliehen von:</strong> ${event.extendedProps.userName}</p>
|
||||
<p><strong>Erstellt von:</strong> ${event.extendedProps.userName}</p>
|
||||
${event.extendedProps.notes ? `<p><strong>Notizen:</strong> ${event.extendedProps.notes}</p>` : ''}
|
||||
<p><strong>Status:</strong> ${statusText}</p>
|
||||
${borrowerInfo}
|
||||
@@ -631,149 +455,27 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
eventModal.style.display = 'block';
|
||||
}
|
||||
|
||||
// New booking button
|
||||
// Route users to the dedicated configuration flow instead of creating bookings here.
|
||||
newBookingBtn.addEventListener('click', function() {
|
||||
openBookingModal();
|
||||
window.location.href = '/terminplaner/configure';
|
||||
});
|
||||
|
||||
// Close modals when clicking X
|
||||
document.querySelectorAll('.close').forEach(closeBtn => {
|
||||
// Close event details modal when clicking X
|
||||
document.querySelectorAll('#event-modal .close').forEach(closeBtn => {
|
||||
closeBtn.addEventListener('click', function() {
|
||||
bookingModal.style.display = 'none';
|
||||
eventModal.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// Close modals when clicking outside
|
||||
// Close event details modal when clicking outside
|
||||
window.addEventListener('click', function(event) {
|
||||
if (event.target === bookingModal) {
|
||||
bookingModal.style.display = 'none';
|
||||
}
|
||||
if (event.target === eventModal) {
|
||||
eventModal.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Find the form submission handler (around line 562) and enhance the debug information
|
||||
|
||||
document.getElementById('booking-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate form fields
|
||||
const itemId = document.getElementById('item-select').value;
|
||||
const periodStart = document.getElementById('period-start-select').value;
|
||||
const periodEnd = document.getElementById('period-end-select').value;
|
||||
const startDate = document.getElementById('booking-date').value;
|
||||
|
||||
console.log("DEBUG - Form submission - Required fields:", {
|
||||
itemId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
startDate,
|
||||
"itemId exists": Boolean(itemId),
|
||||
"periodStart exists": Boolean(periodStart),
|
||||
"periodEnd exists": Boolean(periodEnd),
|
||||
"startDate exists": Boolean(startDate)
|
||||
});
|
||||
|
||||
// Check required fields
|
||||
if (!itemId || !periodStart || !periodEnd || !startDate) {
|
||||
alert('Bitte füllen Sie alle erforderlichen Felder aus.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate period range
|
||||
if (parseInt(periodStart) > parseInt(periodEnd)) {
|
||||
alert('Die Start-Schulstunde darf nicht nach der End-Schulstunde liegen.');
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = document.getElementById('user-id').value || 'Admin'; // Get user ID or use fallback
|
||||
const bookingType = document.getElementById('booking-type').value;
|
||||
|
||||
// Convert JSON data to FormData
|
||||
const formData = new FormData();
|
||||
|
||||
// Add all required fields with correct names the server expects
|
||||
formData.append('item_id', itemId);
|
||||
formData.append('booking_date', startDate);
|
||||
formData.append('period_start', periodStart);
|
||||
formData.append('period_end', periodEnd);
|
||||
formData.append('notes', document.getElementById('booking-notes').value || '');
|
||||
formData.append('user_id', userId);
|
||||
|
||||
if (bookingType === 'range') {
|
||||
const endDate = document.getElementById('booking-end-date').value;
|
||||
if (!endDate) {
|
||||
alert('Bitte wählen Sie ein Enddatum für den Zeitraum.');
|
||||
return;
|
||||
}
|
||||
formData.append('booking_end_date', endDate);
|
||||
formData.append('booking_type', 'range');
|
||||
} else if (bookingType === 'recurring') {
|
||||
formData.append('booking_type', 'single'); // Server expects 'single' structure for these individually sent requests
|
||||
const dates = calculateDates();
|
||||
if (dates.length === 0) {
|
||||
alert('Es konnten keine Termine berechnet werden. Bitte überprüfen Sie Ihre Eingaben.');
|
||||
return;
|
||||
}
|
||||
if (confirm(`Möchten Sie wirklich ${dates.length} Termine planen?`)) {
|
||||
processMultipleDates(formData, dates, periodStart, periodEnd);
|
||||
}
|
||||
return; // Use processMultipleDates instead
|
||||
} else {
|
||||
formData.append('booking_end_date', startDate); // For single bookings, end = start
|
||||
formData.append('booking_type', 'single');
|
||||
}
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]') ? document.querySelector('meta[name="csrf-token"]').content : '';
|
||||
|
||||
// Submit with FormData instead of JSON
|
||||
fetch('/plan_booking', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: formData // Remove the Content-Type header and JSON.stringify
|
||||
})
|
||||
.then(response => {
|
||||
|
||||
if (!response.ok) {
|
||||
return response.json().then(data => {
|
||||
console.error("DEBUG - Error response data:", data);
|
||||
|
||||
// Show detailed error information
|
||||
let errorMsg = data.error || `Server returned ${response.status}: ${response.statusText}`;
|
||||
|
||||
if (data.missing_fields) {
|
||||
errorMsg += "\nMissing fields: " + data.missing_fields.join(", ");
|
||||
}
|
||||
|
||||
throw new Error(errorMsg);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
|
||||
if (data.success) {
|
||||
bookingModal.style.display = 'none';
|
||||
alert('Ausleihe erfolgreich geplant!');
|
||||
calendar.refetchEvents();
|
||||
} else {
|
||||
const errorMsg = data.errors ? data.errors.join(', ') : (data.error || 'Unbekannter Fehler');
|
||||
alert('Fehler: ' + errorMsg);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('DEBUG - Error submitting booking:', error);
|
||||
alert('Ein Fehler ist aufgetreten: ' + error.message);
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel booking
|
||||
document.getElementById('cancel-booking').addEventListener('click', function() {
|
||||
if (confirm('Möchten Sie diese Ausleihe wirklich stornieren?')) {
|
||||
if (confirm('Möchten Sie diesen Termin wirklich stornieren?')) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]') ? document.querySelector('meta[name="csrf-token"]').content : '';
|
||||
fetch('/cancel_booking/' + currentEventId, {
|
||||
method: 'POST',
|
||||
@@ -786,7 +488,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (data.success) {
|
||||
eventModal.style.display = 'none';
|
||||
calendar.refetchEvents();
|
||||
alert('Ausleihe erfolgreich storniert!');
|
||||
alert('Termin erfolgreich storniert!');
|
||||
} else {
|
||||
alert('Fehler: ' + data.error);
|
||||
}
|
||||
@@ -802,492 +504,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
showCompletedBookings = this.checked;
|
||||
calendar.refetchEvents();
|
||||
});
|
||||
|
||||
// Helper functions for dates
|
||||
function formatDate(date) {
|
||||
if (!date) return '';
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Setup booking form controls
|
||||
const bookingTypeSelect = document.getElementById('booking-type');
|
||||
const startDateGroup = document.getElementById('start-date-group');
|
||||
const endDateGroup = document.getElementById('end-date-group');
|
||||
const recurringOptions = document.getElementById('recurring-options');
|
||||
const bookingDateLabel = document.getElementById('booking-date-label');
|
||||
const recurrencePatternSelect = document.getElementById('recurrence-pattern');
|
||||
const weeklyOptions = document.getElementById('weekly-options');
|
||||
const monthlyOptions = document.getElementById('monthly-options');
|
||||
const bookingSummary = document.getElementById('booking-summary');
|
||||
|
||||
// Add this code after the DOMContentLoaded event initialization
|
||||
// Setup booking type toggle
|
||||
bookingTypeSelect.addEventListener('change', function() {
|
||||
const value = this.value;
|
||||
|
||||
// Reset all form visibility
|
||||
endDateGroup.style.display = 'none';
|
||||
recurringOptions.style.display = 'none';
|
||||
bookingDateLabel.textContent = 'Datum:';
|
||||
|
||||
if (value === 'range') {
|
||||
endDateGroup.style.display = 'block';
|
||||
bookingDateLabel.textContent = 'Von:';
|
||||
} else if (value === 'recurring') {
|
||||
recurringOptions.style.display = 'block';
|
||||
bookingDateLabel.textContent = 'Start:';
|
||||
}
|
||||
|
||||
updateBookingSummary();
|
||||
});
|
||||
|
||||
// Setup recurrence pattern toggle
|
||||
recurrencePatternSelect.addEventListener('change', function() {
|
||||
const value = this.value;
|
||||
|
||||
// Reset visibility
|
||||
weeklyOptions.style.display = value === 'weekly' ? 'block' : 'none';
|
||||
monthlyOptions.style.display = value === 'monthly' ? 'block' : 'none';
|
||||
|
||||
updateBookingSummary();
|
||||
updateWeekdaySelection();
|
||||
});
|
||||
|
||||
// Validate period range
|
||||
function validatePeriodRange() {
|
||||
const periodStart = parseInt(document.getElementById('period-start-select').value);
|
||||
const periodEnd = parseInt(document.getElementById('period-end-select').value);
|
||||
|
||||
if (periodStart && periodEnd && periodStart > periodEnd) {
|
||||
alert('Die Start-Schulstunde darf nicht nach der End-Schulstunde liegen.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Update booking summary to include period range
|
||||
function updateBookingSummary() {
|
||||
const bookingType = bookingTypeSelect.value;
|
||||
const startDate = new Date(document.getElementById('booking-date').value);
|
||||
|
||||
// Get the period select elements directly
|
||||
const periodStartSelect = document.getElementById('period-start-select');
|
||||
const periodEndSelect = document.getElementById('period-end-select');
|
||||
const periodStart = periodStartSelect ? periodStartSelect.value : "";
|
||||
const periodEnd = periodEndSelect ? periodEndSelect.value : "";
|
||||
|
||||
// Get the text labels safely
|
||||
let periodLabelStart = "Keine Schulstunde ausgewählt";
|
||||
let periodLabelEnd = "Keine Schulstunde ausgewählt";
|
||||
|
||||
if (periodStartSelect && periodStartSelect.selectedIndex >= 0) {
|
||||
periodLabelStart = periodStartSelect.options[periodStartSelect.selectedIndex].text;
|
||||
}
|
||||
|
||||
if (periodEndSelect && periodEndSelect.selectedIndex >= 0) {
|
||||
periodLabelEnd = periodEndSelect.options[periodEndSelect.selectedIndex].text;
|
||||
}
|
||||
|
||||
// Hide summary for single bookings or if date is invalid
|
||||
if (bookingType === 'single' || isNaN(startDate.getTime())) {
|
||||
bookingSummary.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
bookingSummary.style.display = 'block';
|
||||
let summaryHTML = '<h4>Reservierungsübersicht:</h4>';
|
||||
|
||||
const formattedStartDate = startDate.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
if (bookingType === 'range') {
|
||||
const endDate = new Date(document.getElementById('booking-end-date').value);
|
||||
|
||||
if (isNaN(endDate.getTime())) {
|
||||
summaryHTML += '<p>Bitte wählen Sie ein gültiges Enddatum.</p>';
|
||||
} else {
|
||||
const formattedEndDate = endDate.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
summaryHTML += `
|
||||
<p>Zeitraum vom ${formattedStartDate} bis ${formattedEndDate}</p>
|
||||
<p>Schulstunden: ${periodLabelStart} bis ${periodLabelEnd}</p>
|
||||
`;
|
||||
}
|
||||
} else if (bookingType === 'recurring') {
|
||||
const pattern = document.getElementById('recurrence-pattern').value;
|
||||
const endDate = new Date(document.getElementById('recurrence-end-date').value);
|
||||
|
||||
if (isNaN(endDate.getTime())) {
|
||||
summaryHTML += '<p>Bitte wählen Sie ein gültiges Enddatum für die Wiederholung.</p>';
|
||||
bookingSummary.innerHTML = summaryHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedEndDate = endDate.toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
let patternText = '';
|
||||
let selectedDates = [];
|
||||
|
||||
if (pattern === 'daily') {
|
||||
patternText = 'Täglich';
|
||||
selectedDates = calculateDailyDates(startDate, endDate, 5);
|
||||
}
|
||||
else if (pattern === 'weekly') {
|
||||
const selectedWeekdays = [];
|
||||
document.querySelectorAll('input[name="weekdays"]:checked').forEach(cb => {
|
||||
selectedWeekdays.push(parseInt(cb.value));
|
||||
});
|
||||
|
||||
if (selectedWeekdays.length === 0) {
|
||||
summaryHTML += '<p>Bitte wählen Sie mindestens einen Wochentag aus.</p>';
|
||||
bookingSummary.innerHTML = summaryHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
const weekdayNames = ['', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
||||
patternText = 'Wöchentlich am ' + selectedWeekdays.map(day => weekdayNames[day]).join(', ');
|
||||
|
||||
selectedDates = calculateWeeklyDates(startDate, endDate, selectedWeekdays, 5);
|
||||
}
|
||||
else if (pattern === 'monthly') {
|
||||
const monthOption = document.getElementById('day-of-month').value;
|
||||
|
||||
if (monthOption === 'same_day') {
|
||||
patternText = `Monatlich am ${startDate.getDate()}. Tag des Monats`;
|
||||
} else if (monthOption === 'first_weekday') {
|
||||
patternText = `Monatlich am ersten ${startDate.toLocaleDateString('de-DE', {weekday: 'long'})} des Monats`;
|
||||
} else if (monthOption === 'last_weekday') {
|
||||
patternText = `Monatlich am letzten ${startDate.toLocaleDateString('de-DE', {weekday: 'long'})} des Monats`;
|
||||
}
|
||||
|
||||
selectedDates = calculateMonthlyDates(startDate, endDate, monthOption, 5);
|
||||
}
|
||||
|
||||
summaryHTML += `
|
||||
<p>${patternText} bis zum ${formattedEndDate}</p>
|
||||
<p>Schulstunden: ${periodLabelStart} bis ${periodLabelEnd}</p>
|
||||
`;
|
||||
|
||||
if (selectedDates.length > 0) {
|
||||
summaryHTML += '<p>Beispieltermine:</p><ul>';
|
||||
selectedDates.forEach(date => {
|
||||
summaryHTML += `<li>${date.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}</li>`;
|
||||
});
|
||||
summaryHTML += '</ul>';
|
||||
}
|
||||
}
|
||||
|
||||
bookingSummary.innerHTML = summaryHTML;
|
||||
}
|
||||
|
||||
// Date calculation functions
|
||||
function calculateDailyDates(startDate, endDate, maxSamples) {
|
||||
const dates = [];
|
||||
let currentDate = new Date(startDate);
|
||||
|
||||
while (currentDate <= endDate && dates.length < maxSamples) {
|
||||
dates.push(new Date(currentDate));
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function calculateWeeklyDates(startDate, endDate, weekdays, maxSamples) {
|
||||
const dates = [];
|
||||
let currentDate = new Date(startDate);
|
||||
|
||||
// Sort weekdays numerically
|
||||
weekdays.sort((a, b) => a - b);
|
||||
|
||||
// If the start date's weekday isn't in the selected weekdays,
|
||||
// move to the next selected weekday
|
||||
let startWeekday = currentDate.getDay();
|
||||
startWeekday = startWeekday === 0 ? 7 : startWeekday;
|
||||
|
||||
if (!weekdays.includes(startWeekday)) {
|
||||
// Find next weekday
|
||||
let nextWeekday = weekdays.find(day => day > startWeekday);
|
||||
|
||||
if (!nextWeekday) {
|
||||
// If no higher weekday, take the first one and add a week
|
||||
nextWeekday = weekdays[0];
|
||||
currentDate.setDate(currentDate.getDate() + (7 - startWeekday + nextWeekday));
|
||||
} else {
|
||||
// Move to the next higher weekday
|
||||
currentDate.setDate(currentDate.getDate() + (nextWeekday - startWeekday));
|
||||
}
|
||||
}
|
||||
|
||||
while (currentDate <= endDate && dates.length < maxSamples) {
|
||||
dates.push(new Date(currentDate));
|
||||
|
||||
// Find the next occurrence
|
||||
let currentWeekday = currentDate.getDay();
|
||||
currentWeekday = currentWeekday === 0 ? 7 : currentWeekday;
|
||||
|
||||
let nextWeekday = weekdays.find(day => day > currentWeekday);
|
||||
|
||||
if (!nextWeekday) {
|
||||
// If no higher weekday, take the first one and add a week
|
||||
nextWeekday = weekdays[0];
|
||||
currentDate.setDate(currentDate.getDate() + (7 - currentWeekday + nextWeekday));
|
||||
} else {
|
||||
// Move to the next higher weekday
|
||||
currentDate.setDate(currentDate.getDate() + (nextWeekday - currentWeekday));
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function calculateMonthlyDates(startDate, endDate, option, maxSamples) {
|
||||
const dates = [];
|
||||
let currentDate = new Date(startDate);
|
||||
|
||||
while (currentDate <= endDate && dates.length < maxSamples) {
|
||||
dates.push(new Date(currentDate));
|
||||
|
||||
// Move to next month
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
|
||||
if (option === 'same_day') {
|
||||
// Keep the same day of month
|
||||
const targetDay = startDate.getDate();
|
||||
// Adjust if the day doesn't exist in this month
|
||||
const lastDayOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0).getDate();
|
||||
currentDate.setDate(Math.min(targetDay, lastDayOfMonth));
|
||||
}
|
||||
else if (option === 'first_weekday' || option === 'last_weekday') {
|
||||
const targetWeekday = startDate.getDay();
|
||||
|
||||
if (option === 'first_weekday') {
|
||||
// Set to first day of month
|
||||
currentDate.setDate(1);
|
||||
|
||||
// Find first occurrence of target weekday
|
||||
while (currentDate.getDay() !== targetWeekday) {
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
} else {
|
||||
// Set to last day of month
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
currentDate.setDate(0);
|
||||
|
||||
// Find last occurrence of target weekday going backwards
|
||||
while (currentDate.getDay() !== targetWeekday) {
|
||||
currentDate.setDate(currentDate.getDate() - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
// Update booking summary when relevant fields change
|
||||
document.getElementById('booking-end-date').addEventListener('change', updateBookingSummary);
|
||||
document.getElementById('recurrence-end-date').addEventListener('change', updateBookingSummary);
|
||||
document.getElementById('period-start-select').addEventListener('change', updateBookingSummary);
|
||||
document.getElementById('period-end-select').addEventListener('change', updateBookingSummary);
|
||||
|
||||
// Add listeners to all weekday checkboxes
|
||||
document.querySelectorAll('input[name="weekdays"]').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', updateBookingSummary);
|
||||
});
|
||||
|
||||
// Select the correct weekday checkbox based on chosen date
|
||||
function updateWeekdaySelection() {
|
||||
const date = new Date(document.getElementById('booking-date').value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
// Clear all checkboxes
|
||||
document.querySelectorAll('input[name="weekdays"]').forEach(cb => cb.checked = false);
|
||||
|
||||
// Get day of week (0=Sunday, 1=Monday, etc.)
|
||||
let dayOfWeek = date.getDay();
|
||||
// Convert to 1=Monday, 2=Tuesday, ... 7=Sunday
|
||||
dayOfWeek = dayOfWeek === 0 ? 7 : dayOfWeek;
|
||||
|
||||
// Check the corresponding checkbox
|
||||
const checkbox = document.querySelector(`input[name="weekdays"][value="${dayOfWeek}"]`);
|
||||
if (checkbox) checkbox.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Process multiple dates for a booking with period ranges
|
||||
function processMultipleDates(formData, dates, periodStart, periodEnd) {
|
||||
// Track progress
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
let totalCount = dates.length;
|
||||
let completedCount = 0;
|
||||
|
||||
// Close modal early to show progress
|
||||
bookingModal.style.display = 'none';
|
||||
|
||||
// Create a progress indicator
|
||||
const progressDiv = document.createElement('div');
|
||||
progressDiv.className = 'booking-progress';
|
||||
progressDiv.innerHTML = `
|
||||
<div class="progress-overlay">
|
||||
<div class="progress-content">
|
||||
<h3>Erstelle Reservierungen...</h3>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="progress-text">0 von ${totalCount} abgeschlossen</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(progressDiv);
|
||||
|
||||
// Submit bookings sequentially
|
||||
function processNextDate(index) {
|
||||
if (index >= dates.length) {
|
||||
// All done
|
||||
document.body.removeChild(progressDiv);
|
||||
|
||||
if (errorCount > 0) {
|
||||
alert(`${successCount} Reservierungen erfolgreich erstellt. ${errorCount} fehlgeschlagen.`);
|
||||
} else {
|
||||
alert(`${successCount} Reservierungen erfolgreich erstellt!`);
|
||||
}
|
||||
|
||||
// Refresh calendar
|
||||
calendar.refetchEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDate = dates[index];
|
||||
|
||||
// Create new FormData for this date
|
||||
const newFormData = new FormData();
|
||||
|
||||
// Copy original form values except dates and booking type
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key !== 'booking_date' && key !== 'booking_end_date' &&
|
||||
key !== 'start_date' && key !== 'end_date' &&
|
||||
key !== 'recurrence_end_date' && key !== 'booking_type' &&
|
||||
key !== 'recurrence_pattern' && !key.startsWith('weekdays')) {
|
||||
newFormData.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Format the current date with the period start time
|
||||
const periodStart = formData.get('period_start');
|
||||
const periodEnd = formData.get('period_end');
|
||||
|
||||
const periodStartInfo = schoolPeriods[periodStart];
|
||||
const periodEndInfo = schoolPeriods[periodEnd];
|
||||
|
||||
if (periodStartInfo && periodStartInfo.start) {
|
||||
// Format date for start_date with proper time
|
||||
const startYear = currentDate.getFullYear();
|
||||
const startMonth = String(currentDate.getMonth() + 1).padStart(2, '0');
|
||||
const startDay = String(currentDate.getDate()).padStart(2, '0');
|
||||
const startTimeStr = `${startYear}-${startMonth}-${startDay}T${periodStartInfo.start}:00`;
|
||||
|
||||
// Set the start_date field that server expects
|
||||
newFormData.append('start_date', startTimeStr);
|
||||
}
|
||||
|
||||
// For the end date, use the same day with the end period time
|
||||
if (periodEndInfo && periodEndInfo.end) {
|
||||
const endYear = currentDate.getFullYear();
|
||||
const endMonth = String(currentDate.getMonth() + 1).padStart(2, '0');
|
||||
const endDay = String(currentDate.getDate()).padStart(2, '0');
|
||||
const endTimeStr = `${endYear}-${endMonth}-${endDay}T${periodEndInfo.end}:00`;
|
||||
|
||||
// Set the end_date field that server expects
|
||||
newFormData.append('end_date', endTimeStr);
|
||||
}
|
||||
|
||||
// Debug the data being sent
|
||||
console.log(`Submitting booking ${index + 1}/${dates.length}:`,
|
||||
newFormData.get('start_date'),
|
||||
newFormData.get('end_date'));
|
||||
|
||||
// Submit this booking
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]') ? document.querySelector('meta[name="csrf-token"]').content : '';
|
||||
fetch('/plan_booking', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: newFormData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
completedCount++;
|
||||
|
||||
if (data.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorCount++;
|
||||
console.error('Booking failed:', data.error || 'Unknown error');
|
||||
}
|
||||
|
||||
updateProgress();
|
||||
processNextDate(index + 1);
|
||||
})
|
||||
.catch(error => {
|
||||
completedCount++;
|
||||
errorCount++;
|
||||
console.error('Booking request error:', error);
|
||||
updateProgress();
|
||||
processNextDate(index + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
const progressBar = progressDiv.querySelector('.progress-bar');
|
||||
const progressText = progressDiv.querySelector('.progress-text');
|
||||
|
||||
const percentage = Math.round((completedCount / totalCount) * 100);
|
||||
progressBar.style.width = `${percentage}%`;
|
||||
progressText.textContent = `${completedCount} von ${totalCount} abgeschlossen`;
|
||||
}
|
||||
|
||||
// Start processing
|
||||
processNextDate(0);
|
||||
}
|
||||
|
||||
// Safely attach event listeners only if elements exist
|
||||
const periodStartSelect = document.getElementById('period-start-select');
|
||||
const periodEndSelect = document.getElementById('period-end-select');
|
||||
const bookingDateElement = document.getElementById('booking-date');
|
||||
|
||||
if (periodStartSelect) {
|
||||
periodStartSelect.addEventListener('change', updateBookingSummary);
|
||||
}
|
||||
|
||||
if (periodEndSelect) {
|
||||
periodEndSelect.addEventListener('change', updateBookingSummary);
|
||||
}
|
||||
|
||||
if (bookingDateElement) {
|
||||
bookingDateElement.addEventListener('change', updateBookingSummary);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<p class="lead mb-0" style="max-width: 60ch; opacity: .95;">Erstellen Sie neue Terminreihen, teilen Sie Buchungslinks und öffnen Sie den Kalender für bestehende Reservierungen.</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-light btn-lg fw-semibold" href="{{ url_for('terminplaner.configure') }}">Neue Planung</a>
|
||||
<a class="btn btn-outline-light btn-lg fw-semibold" href="{{ url_for('terminplan') }}">Kalender öffnen</a>
|
||||
<a class="btn btn-light btn-lg fw-semibold" href="{{ url_for('terminplaner.configure', tenant=tenant_id) }}">Neue Planung</a>
|
||||
<a class="btn btn-outline-light btn-lg fw-semibold" href="{{ url_for('terminplan', tenant=tenant_id) }}">Kalender öffnen</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -27,7 +27,7 @@
|
||||
<div class="display-6 mb-3">🗓️</div>
|
||||
<h2 class="h4 fw-bold">Kalender</h2>
|
||||
<p class="mb-4 text-muted">Sehen Sie vorhandene Termine, ihre Auslastung und die aktuellen Reservierungen im Kalender.</p>
|
||||
<a class="btn btn-primary w-100" href="{{ url_for('terminplan') }}">Zum Kalender</a>
|
||||
<a class="btn btn-primary w-100" href="{{ url_for('terminplan', tenant=tenant_id) }}">Zum Kalender</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@
|
||||
<div class="display-6 mb-3">✍️</div>
|
||||
<h2 class="h4 fw-bold">Neue Planung</h2>
|
||||
<p class="mb-4 text-muted">Erstellen Sie einen neuen Terminplan und verschicken Sie den Buchungslink an Ihre Zielgruppe.</p>
|
||||
<a class="btn btn-outline-primary w-100" href="{{ url_for('terminplaner.configure') }}">Konfiguration öffnen</a>
|
||||
<a class="btn btn-outline-primary w-100" href="{{ url_for('terminplaner.configure', tenant=tenant_id) }}">Konfiguration öffnen</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,6 +58,48 @@
|
||||
<h2 class="h5 fw-bold mb-2">Angemeldet als {{ current_user }}</h2>
|
||||
<p class="mb-0 text-muted">Sie können Termine anlegen, den Kalender prüfen und Buchungslinks verteilen.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-4 rounded-4 bg-white shadow-sm">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
|
||||
<h2 class="h5 fw-bold mb-0">Kommende Termine</h2>
|
||||
<span class="text-muted small">Alle offenen Terminpläne dieses Nutzers</span>
|
||||
</div>
|
||||
|
||||
{% if upcoming_events %}
|
||||
<div class="vstack gap-3">
|
||||
{% for event in upcoming_events %}
|
||||
<div class="border rounded-3 p-3">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
|
||||
<div>
|
||||
<div class="fw-semibold">{{ event.date_start }} bis {{ event.date_end }}</div>
|
||||
<div class="text-muted small">ID: {{ event.appointment_id }}</div>
|
||||
{% if event.time_span %}
|
||||
<div class="small mt-1">Zeitfenster: {{ event.time_span|join(' | ') }}</div>
|
||||
{% endif %}
|
||||
{% if event.note %}
|
||||
<div class="small mt-1 text-muted">Notiz: {{ event.note }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-lg-end">
|
||||
<div class="small mb-2">Gebucht: <strong>{{ event.slots_booked }}</strong> / {{ event.slots_total }} | Frei: <strong>{{ event.slots_left }}</strong></div>
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-lg-end">
|
||||
<a class="btn btn-sm btn-primary" href="{{ event.link }}" target="_blank" rel="noopener">Client-Link öffnen</a>
|
||||
{% if event.calendar_link %}
|
||||
<a class="btn btn-sm btn-outline-primary" href="{{ event.calendar_link }}">.ics</a>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('terminplaner.delete_appointment', appointment_id=event.appointment_id, tenant=tenant_id) }}" class="d-inline" onsubmit="return confirm('Diesen Terminplan wirklich löschen?');">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Entfernen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted">Keine kommenden Termine gefunden.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -473,6 +473,20 @@ class TenantContext:
|
||||
if not has_request_context():
|
||||
return None
|
||||
|
||||
# Query parameters are useful for public links that must open a specific tenant
|
||||
# even when the host/subdomain cannot be mapped reliably.
|
||||
tenant_from_query = (
|
||||
request.args.get('tenant', '').strip()
|
||||
or request.args.get('tenant_id', '').strip()
|
||||
or request.args.get('tenantId', '').strip()
|
||||
)
|
||||
if tenant_from_query:
|
||||
matched_tenant = _find_registered_tenant_id(tenant_from_query) or tenant_from_query
|
||||
self.tenant_id = matched_tenant
|
||||
self.config = get_tenant_config(matched_tenant)
|
||||
session['tenant_id'] = matched_tenant
|
||||
return self._get_db_name(matched_tenant)
|
||||
|
||||
# Priority 1: X-Tenant-ID header (for testing/internal APIs)
|
||||
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
|
||||
if tenant_from_header:
|
||||
@@ -531,6 +545,10 @@ class TenantContext:
|
||||
potential_subdomain = parts[0]
|
||||
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
|
||||
matched_tenant = _find_registered_tenant_id(potential_subdomain)
|
||||
if not matched_tenant and potential_subdomain.startswith('school'):
|
||||
matched_tenant = _find_registered_tenant_id('schule' + potential_subdomain[len('school'):])
|
||||
elif not matched_tenant and potential_subdomain.startswith('schule'):
|
||||
matched_tenant = _find_registered_tenant_id('school' + potential_subdomain[len('schule'):])
|
||||
if matched_tenant:
|
||||
self.subdomain = potential_subdomain
|
||||
self.tenant_id = matched_tenant
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"inventory": {
|
||||
"enabled": true
|
||||
},
|
||||
"terminplan": {
|
||||
"enabled": true
|
||||
},
|
||||
"library": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user