Compare commits

...

51 Commits

Author SHA1 Message Date
Aiirondev_dev e7e24b3fae further changes to the new Barcode system 2026-06-06 23:41:37 +02:00
Aiirondev_dev fc80857fbe minor changes to the Inpout scanning 2026-06-06 23:31:50 +02:00
Aiirondev_dev f763ad064d Changes from the deprecated qrcode scanning tool, to a more modern option in the library 2026-06-06 22:46:50 +02:00
Aiirondev_dev bc94ffdc18 further fixes 2026-06-06 17:17:15 +02:00
Aiirondev_dev 76e9670cee implementation of a new Framework for the Barcode scanning via Quagga2 -> only test implementation 2026-06-06 16:53:11 +02:00
Aiirondev_dev 577c6c4bed Removing more of the broken funktionaliti 2026-06-06 15:39:51 +02:00
Aiirondev_dev 3aa19b10d1 Reverted Faulti changes that have proven not worth implementing 2026-06-06 15:03:43 +02:00
Aiirondev_dev ff742d018e removed obsolet file 2026-06-01 14:34:05 +02:00
Aiirondev_dev 6fa40f26b0 Generall fixes, to manage the unused overhead 2026-06-01 13:23:52 +02:00
Aiirondev_dev 777fc81064 fix for the latest Tag 2026-06-01 13:20:10 +02:00
Aiirondev_dev cf5e38e319 removed development release version 2026-06-01 13:15:54 +02:00
Aiirondev_dev b76941d5fe Fix in the release versioning 2026-06-01 13:07:52 +02:00
Aiirondev_dev f46f2674b0 Library style adjustments for mobile 2026-06-01 12:51:28 +02:00
github-actions[bot] 8ebbdcfd54 chore: bump version to v0.8.31.1 2026-06-01 10:35:56 +00:00
Aiirondev_dev 92b5235035 feat: Improve mobile video playback for barcode scanning with autoplay and mute settings 2026-06-01 12:32:42 +02:00
github-actions[bot] 4bda2e044b chore: bump version to v0.8.30 2026-06-01 10:23:51 +00:00
Aiirondev_dev b83b6c0ba2 feat: Enhance barcode scanner with improved mobile compatibility and error handling 2026-06-01 12:22:54 +02:00
github-actions[bot] ab718d6ac2 chore: bump version to v0.8.29 2026-06-01 10:12:29 +00:00
Aiirondev_dev 112d9b6aa0 feat: Make video elements visible for barcode scanning in various templates 2026-06-01 12:10:31 +02:00
github-actions[bot] 5139a50a43 chore: bump version to v0.8.28 2026-06-01 09:56:08 +00:00
Aiirondev_dev 997c7b42d5 feat: Implement hybrid barcode scanner with real-time camera support and image upload fallback 2026-06-01 11:55:28 +02:00
github-actions[bot] 20e5d3bb9a chore: bump version to v0.8.27 2026-06-01 07:01:33 +00:00
Aiirondev_dev f62b22c2f7 feat: Refactor appointment handling to support multi-day events and improve calendar visibility 2026-06-01 09:00:35 +02:00
github-actions[bot] e76d525e4b chore: bump version to v0.8.26 2026-06-01 06:51:36 +00:00
Aiirondev_dev 7117dac67c feat: Enhance calendar view by switching to week view for multi-day events 2026-06-01 08:51:09 +02:00
Aiirondev_dev 5313c507ed feat: Remove background gap events from calendar display for improved visibility of available slots 2026-06-01 08:49:01 +02:00
github-actions[bot] 419fed4492 chore: bump version to v0.8.25 2026-06-01 06:39:43 +00:00
Aiirondev_dev c7112c7a42 feat: Update appointment time range to 08:15 - 20:00 and enhance visual representation of available slots 2026-06-01 08:39:20 +02:00
Aiirondev_dev fb73c9d4e7 feat: Extend time range for appointment slots and add background gap events for unavailable times 2026-06-01 08:30:36 +02:00
github-actions[bot] 8414e27f25 chore: bump version to v0.8.24 2026-06-01 05:38:10 +00:00
Aiirondev_dev 5903f2afdd feat: Refactor appointment retrieval to use dedicated appointment collection and improve time range extraction 2026-06-01 07:37:36 +02:00
github-actions[bot] a31fb4c048 chore: bump version to v0.8.23 2026-05-31 17:21:17 +00:00
Aiirondev_dev 46da45d373 feat: Enhance client booking experience with booking name visibility and success page 2026-05-31 19:20:40 +02:00
Aiirondev_dev 0b1bcef985 feat: Update booking modal with new confirmation buttons and ICS download functionality 2026-05-31 19:11:56 +02:00
github-actions[bot] 99ad9d4f79 chore: bump version to v0.8.22 2026-05-31 17:05:10 +00:00
Aiirondev_dev 89c1a525d8 feat: Enhance calendar functionality with multi-day view and time range selection 2026-05-31 19:04:43 +02:00
github-actions[bot] 9701805552 chore: bump version to v0.8.21 2026-05-31 16:54:47 +00:00
Aiirondev_dev 4227934252 feat: Add ICS export functionality for client slot bookings and enhance client booking interface 2026-05-31 18:54:21 +02:00
Aiirondev_dev d6883d1879 refactor: remove unused date and time parsing functions from backend_server.py and clean up termin_client.html 2026-05-31 18:50:31 +02:00
github-actions[bot] f47e133c45 chore: bump version to v0.8.20 2026-05-31 16:43:58 +00:00
Aiirondev_dev dec3c16d7f feat: Enhance appointment booking interface with date and time selection features 2026-05-31 18:43:04 +02:00
github-actions[bot] 897b2e43ad chore: bump version to v0.8.19 2026-05-31 16:33:17 +00:00
Aiirondev_dev 6dd44508ce fix: Ensure numeric fields are correctly cast to int in get_available function 2026-05-31 18:32:42 +02:00
github-actions[bot] 204ded6c7c chore: bump version to v0.8.18 2026-05-31 16:16:31 +00:00
Aiirondev_dev fbd2168aff feat: Add debug script for tenant-aware appointment lookup 2026-05-31 18:15:53 +02:00
github-actions[bot] 249a2bc2da chore: bump version to v0.8.17 2026-05-31 14:13:47 +00:00
Aiirondev_dev 7ab8f841d0 feat: Enhance tenant management and appointment deletion functionality 2026-05-31 16:13:11 +02:00
github-actions[bot] 43c544244c chore: bump version to v0.8.16 2026-05-31 13:47:10 +00:00
Aiirondev_dev 73e4407d7b feat: Implement upcoming appointments feature for users
- Added `remove_done` function to clean up finished appointments in the database.
- Introduced `get_upcoming_for_user` function to retrieve upcoming appointments for a specific user.
- Updated `backend_server.py` to include a new endpoint for fetching user-specific upcoming events.
- Modified `blueprint.py` to render upcoming events in the user interface.
- Enhanced `terminplan.html` to display a list of upcoming appointments with relevant details and action links.
- Updated configuration to enable the new appointment planning feature.
2026-05-31 15:46:22 +02:00
github-actions[bot] 94fc01c08a chore: bump version to v0.8.15 2026-05-30 12:55:19 +00:00
Aiirondev_dev 788ec1db62 feat: update terminology in calendar interface for clarity and consistency 2026-05-30 14:54:40 +02:00
17 changed files with 1507 additions and 1236 deletions
+14 -89
View File
@@ -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
View File
@@ -1 +1 @@
v0.8.14
v0.8.31.1
+92 -1
View File
@@ -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():
"""
+62 -2
View File
@@ -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 []
+172 -22
View File
@@ -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
+120 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+55 -108
View File
@@ -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
View File
@@ -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
View File
@@ -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');
+464 -10
View File
@@ -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 %}
+121
View File
@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Buchung erfolgreich</title>
<style>
:root {
color-scheme: light;
--bg-a: #0f4c5c;
--bg-b: #16697a;
--ok: #22c55e;
--text: #0f172a;
--muted: #475569;
--card: #ffffff;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Manrope", "Segoe UI", sans-serif;
background: radial-gradient(circle at 20% 20%, rgba(255,255,255,0.14), transparent 45%), linear-gradient(135deg, var(--bg-a), var(--bg-b));
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.success-window {
width: min(900px, 100%);
background: var(--card);
border-radius: 1.25rem;
box-shadow: 0 28px 70px rgba(2, 6, 23, 0.32);
padding: clamp(1.5rem, 4vw, 3rem);
text-align: center;
}
.badge {
width: 84px;
height: 84px;
margin: 0 auto 1.25rem auto;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(34, 197, 94, 0.12);
color: var(--ok);
font-size: 2rem;
font-weight: 800;
}
h1 {
margin: 0 0 0.9rem 0;
font-size: clamp(1.8rem, 5vw, 3rem);
color: var(--text);
line-height: 1.1;
}
p {
margin: 0;
font-size: 1.1rem;
color: var(--muted);
}
.meta {
margin-top: 1.4rem;
padding: 1rem;
border-radius: 0.9rem;
background: #f8fafc;
color: #0f172a;
font-weight: 600;
}
.actions {
margin-top: 1.8rem;
display: flex;
justify-content: center;
gap: .75rem;
flex-wrap: wrap;
}
.btn {
border: 0;
border-radius: 0.8rem;
padding: .85rem 1.25rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-close {
background: #0f4c5c;
color: #fff;
}
.btn-link {
background: #e2e8f0;
color: #0f172a;
}
</style>
</head>
<body>
<main class="success-window" role="main" aria-live="polite">
<div class="badge"></div>
<h1>Buchung erfolgreich</h1>
<p>Sie können das Fenster jetzt schließen.</p>
{% if client_name or slot_start %}
<div class="meta">
{% if client_name %}
<div>Name: {{ client_name }}</div>
{% endif %}
{% if slot_start %}
<div>Termin: {{ slot_start }}</div>
{% endif %}
</div>
{% endif %}
<div class="actions">
<button class="btn btn-close" type="button" onclick="window.close()">Fenster schließen</button>
<a class="btn btn-link" href="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}">Zurück zur Buchungsseite</a>
</div>
</main>
</body>
</html>
+23 -807
View File
@@ -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">&times;</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">&times;</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>
+46 -4
View File
@@ -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>
+18
View File
@@ -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
+3
View File
@@ -53,6 +53,9 @@
"inventory": {
"enabled": true
},
"terminplan": {
"enabled": true
},
"library": {
"enabled": true
},