Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71e2895362 | |||
| d2c7e57f8d | |||
| 111d40b787 | |||
| 1a9513d442 | |||
| 3900059eb1 | |||
| 07b1cc5e79 | |||
| 6583f7a368 | |||
| 8e11fbf969 | |||
| 7d13dc264c |
@@ -38,7 +38,7 @@ def _active_record_query(extra_query=None):
|
|||||||
return base_query
|
return base_query
|
||||||
|
|
||||||
|
|
||||||
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = ()):
|
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = (), clients_p_slot: int=1):
|
||||||
try:
|
try:
|
||||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||||
db = _get_tenant_db(client)
|
db = _get_tenant_db(client)
|
||||||
@@ -56,7 +56,8 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
|||||||
'title': title,
|
'title': title,
|
||||||
'custom_fields': custom_fields,
|
'custom_fields': custom_fields,
|
||||||
'calendar_enabled': bool(calendar_enabled),
|
'calendar_enabled': bool(calendar_enabled),
|
||||||
'slots_booked': [], # -> [(start_time, name,(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
'clients_per_slot': clients_p_slot,
|
||||||
|
'slots_booked': [], # -> [(start_time, (names),(custom1, custom2,...)), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
||||||
'Created': datetime.datetime.now(),
|
'Created': datetime.datetime.now(),
|
||||||
'LastUpdated': datetime.datetime.now()
|
'LastUpdated': datetime.datetime.now()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
|
|||||||
return '\r\n'.join(ics_lines)
|
return '\r\n'.join(ics_lines)
|
||||||
|
|
||||||
|
|
||||||
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = ()) -> dict:
|
def new(date_start: str, date_end: str, time_span: list, slots, slot_length, user: str, mail: list=None, note:str="", calendar_enabled: bool=False, title: str="", custom_fields: list = (), client_per_slot: int=1) -> dict:
|
||||||
"""
|
"""
|
||||||
Generates a link for the executive to send to his clients to book a time Slot
|
Generates a link for the executive to send to his clients to book a time Slot
|
||||||
"""
|
"""
|
||||||
@@ -220,7 +220,7 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
|
|||||||
normalized_time_span = _normalize_time_span(time_span)
|
normalized_time_span = _normalize_time_span(time_span)
|
||||||
normalized_mail = _normalize_mail_list(mail or [])
|
normalized_mail = _normalize_mail_list(mail or [])
|
||||||
|
|
||||||
id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title, custom_fields=custom_fields)
|
id = termin.add(date_start, date_end, normalized_time_span, slots_int, slot_length_int, user, normalized_mail, note, calendar_enabled=calendar_enabled, title=title, custom_fields=custom_fields, clients_p_slot=client_per_slot)
|
||||||
id_str = str(id)
|
id_str = str(id)
|
||||||
tenant_id = _current_tenant_id()
|
tenant_id = _current_tenant_id()
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def book_slot(id, date_start_time, name, custom:list=()):
|
def book_slot(id, date_start_time, name, custom: tuple = ()):
|
||||||
try:
|
try:
|
||||||
item = termin.get_item(id)
|
item = termin.get_item(id)
|
||||||
if not item:
|
if not item:
|
||||||
@@ -269,18 +269,32 @@ def book_slot(id, date_start_time, name, custom:list=()):
|
|||||||
if not isinstance(slots, list):
|
if not isinstance(slots, list):
|
||||||
slots = []
|
slots = []
|
||||||
|
|
||||||
|
# 1. Check overall total booking capacity
|
||||||
capacity = int(item.get('slots', 0) or 0)
|
capacity = int(item.get('slots', 0) or 0)
|
||||||
if capacity and len(slots) >= capacity:
|
if capacity and len(slots) >= capacity:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 2. Prevent the exact same user from double-booking the exact same slot
|
||||||
for existing in slots:
|
for existing in slots:
|
||||||
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
|
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
|
||||||
if existing[0] == date_start_time and existing[1] == name:
|
if existing[0] == date_start_time and existing[1] == name:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 3. Check concurrent capacity for this specific time slot
|
||||||
|
clients_per_slot = int(item.get('clients_per_slot', 1) or 1)
|
||||||
|
bookings_at_time = sum(
|
||||||
|
1 for existing in slots
|
||||||
|
if isinstance(existing, (list, tuple)) and len(existing) > 0 and existing[0] == date_start_time
|
||||||
|
)
|
||||||
|
|
||||||
|
if bookings_at_time >= clients_per_slot:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Append the new booking successfully
|
||||||
slots.append((date_start_time, name, custom))
|
slots.append((date_start_time, name, custom))
|
||||||
success = termin.update(id, slots)
|
success = termin.update(id, slots)
|
||||||
return bool(success)
|
return bool(success)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error booking slot: {e}")
|
print(f"Error booking slot: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -331,32 +345,46 @@ def get_available(id):
|
|||||||
date_end = termin_range.get('date_end')
|
date_end = termin_range.get('date_end')
|
||||||
time_span = termin_range.get('time_span', [])
|
time_span = termin_range.get('time_span', [])
|
||||||
|
|
||||||
|
# Safe integer parsing without heavy try-except blocks
|
||||||
try:
|
try:
|
||||||
total_slots = int(termin_range.get('slots', 0) or 0)
|
total_slots = int(termin_range.get('slots', 0) or 0)
|
||||||
except Exception:
|
except (ValueError, TypeError):
|
||||||
total_slots = 0
|
total_slots = 0
|
||||||
|
|
||||||
|
# Support both spellings gracefully
|
||||||
|
raw_length = termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0
|
||||||
try:
|
try:
|
||||||
slot_length = int(termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0)
|
slot_length = int(raw_length)
|
||||||
except Exception:
|
except (ValueError, TypeError):
|
||||||
slot_length = termin_range.get('slot_length') or termin_range.get('slot_lenght')
|
slot_length = raw_length
|
||||||
|
|
||||||
|
clients_per_slot = int(termin_range.get('clients_per_slot', 1) or 1)
|
||||||
booked = termin_range.get('slots_booked', []) or []
|
booked = termin_range.get('slots_booked', []) or []
|
||||||
|
|
||||||
|
# Normalize the bookings list safely
|
||||||
normalized = []
|
normalized = []
|
||||||
|
bookings_by_time = {} # Tracks how many people are in each specific time slot
|
||||||
|
|
||||||
for s in booked:
|
for s in booked:
|
||||||
if isinstance(s, (list, tuple)) and len(s) >= 2:
|
if isinstance(s, (list, tuple)) and len(s) >= 2:
|
||||||
normalized.append({'start': s[0], 'name': s[1]})
|
slot_time = s[0]
|
||||||
|
item = {'start': slot_time, 'name': s[1]}
|
||||||
elif isinstance(s, dict):
|
elif isinstance(s, dict):
|
||||||
normalized.append(s)
|
slot_time = s.get('start')
|
||||||
|
item = s
|
||||||
else:
|
else:
|
||||||
normalized.append({'value': s})
|
slot_time = str(s)
|
||||||
|
item = {'value': s}
|
||||||
|
|
||||||
|
normalized.append(item)
|
||||||
|
|
||||||
|
# Count concurrent bookings per timestamp
|
||||||
|
if slot_time:
|
||||||
|
bookings_by_time[slot_time] = bookings_by_time.get(slot_time, 0) + 1
|
||||||
|
|
||||||
|
# Calculate remaining total capacity safely
|
||||||
slots_used = len(normalized)
|
slots_used = len(normalized)
|
||||||
try:
|
slots_left = max(0, total_slots - slots_used)
|
||||||
slots_left = max(0, int(total_slots) - slots_used)
|
|
||||||
except Exception:
|
|
||||||
slots_left = max(0, slots_used - slots_used)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'date_start': date_start,
|
'date_start': date_start,
|
||||||
@@ -365,8 +393,10 @@ def get_available(id):
|
|||||||
'slot_length': slot_length,
|
'slot_length': slot_length,
|
||||||
'slot_lenght': slot_length,
|
'slot_lenght': slot_length,
|
||||||
'slots_total': total_slots,
|
'slots_total': total_slots,
|
||||||
|
'clients_per_slot': clients_per_slot,
|
||||||
'slots_booked': normalized,
|
'slots_booked': normalized,
|
||||||
'slots_left': slots_left,
|
'slots_left': slots_left,
|
||||||
|
'bookings_by_time': bookings_by_time
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting available slots: {e}")
|
print(f"Error getting available slots: {e}")
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import Web.modules.terminplaner.backend_server as appointment_service
|
|||||||
import Web.modules.database.settings as cfg
|
import Web.modules.database.settings as cfg
|
||||||
import Web.modules.database.termine as termin
|
import Web.modules.database.termine as termin
|
||||||
import Web.modules.database.user as us
|
import Web.modules.database.user as us
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from flask import make_response, flash, redirect, url_for, session
|
||||||
|
|
||||||
# Create a blueprint instance
|
# Create a blueprint instance
|
||||||
appoint_bp = Blueprint('terminplaner', __name__)
|
appoint_bp = Blueprint('terminplaner', __name__)
|
||||||
@@ -34,6 +37,66 @@ def _current_tenant_id():
|
|||||||
pass
|
pass
|
||||||
return str(session.get('tenant_id', '') or '').strip()
|
return str(session.get('tenant_id', '') or '').strip()
|
||||||
|
|
||||||
|
@appoint_bp.route('/client/<appointment_id>/export_csv')
|
||||||
|
def export_csv(appointment_id):
|
||||||
|
"""
|
||||||
|
Generiert einen CSV-Export aller Buchungen für den Ersteller.
|
||||||
|
"""
|
||||||
|
# 1. Berechtigungsprüfung (analog zur Client-Route)
|
||||||
|
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 = False
|
||||||
|
if current_user:
|
||||||
|
try:
|
||||||
|
can_view = bool(us.check_admin(current_user) or current_user == appointment_owner)
|
||||||
|
except Exception:
|
||||||
|
can_view = bool(current_user == appointment_owner)
|
||||||
|
|
||||||
|
if not can_view:
|
||||||
|
flash('Nicht autorisiert für diesen Export.', 'error')
|
||||||
|
return redirect(url_for('terminplaner.client', appointment_id=appointment_id))
|
||||||
|
|
||||||
|
custom_fields = appointment_item.get('custom_fields', [])
|
||||||
|
bookings = appointment_item.get('slots_booked', []) or []
|
||||||
|
|
||||||
|
# 2. CSV im Speicher erstellen
|
||||||
|
si = io.StringIO()
|
||||||
|
# Wir nutzen ein Semikolon als Trennzeichen – das öffnet Excel im deutschsprachigen Raum direkt fehlerfrei
|
||||||
|
cw = csv.writer(si, delimiter=';', quoting=csv.QUOTE_MINIMAL)
|
||||||
|
|
||||||
|
# Tabellenkopf schreiben
|
||||||
|
headers = ['Zeitpunkt', 'Name'] + list(custom_fields)
|
||||||
|
cw.writerow(headers)
|
||||||
|
|
||||||
|
# Datenzeilen schreiben
|
||||||
|
for booking in bookings:
|
||||||
|
if isinstance(booking, (list, tuple)):
|
||||||
|
row = [booking[0], booking[1]]
|
||||||
|
|
||||||
|
# Antworten holen und sicherstellen, dass Lücken (falls vorhanden) mit Leerstrings gefüllt werden
|
||||||
|
answers = booking[2] if len(booking) > 2 else []
|
||||||
|
for i in range(len(custom_fields)):
|
||||||
|
if i < len(answers):
|
||||||
|
row.append(answers[i])
|
||||||
|
else:
|
||||||
|
row.append('')
|
||||||
|
cw.writerow(row)
|
||||||
|
|
||||||
|
# 3. Response vorbereiten und UTF-8-BOM für Excel mitsenden
|
||||||
|
response_content = si.getvalue()
|
||||||
|
output = make_response(response_content)
|
||||||
|
|
||||||
|
output.data = b'\xef\xbb\xbf' + output.data
|
||||||
|
|
||||||
|
# Dateiname generieren
|
||||||
|
title_slug = appointment_item.get('title', 'export').replace(' ', '_')
|
||||||
|
output.headers["Content-Disposition"] = f"attachment; filename=buchungen_{title_slug}.csv"
|
||||||
|
output.headers["Content-Type"] = "text/csv; charset=utf-8"
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
||||||
def client(appointment_id):
|
def client(appointment_id):
|
||||||
"""
|
"""
|
||||||
@@ -51,9 +114,9 @@ def client(appointment_id):
|
|||||||
appointment_item = termin.get_item(appointment_id) or {}
|
appointment_item = termin.get_item(appointment_id) or {}
|
||||||
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
||||||
|
|
||||||
# Extract the custom fields defined by the appointment owner
|
|
||||||
custom_fields = appointment_item.get('custom_fields', [])
|
custom_fields = appointment_item.get('custom_fields', [])
|
||||||
|
|
||||||
|
# Permissions check
|
||||||
can_view_booking_names = False
|
can_view_booking_names = False
|
||||||
if current_user:
|
if current_user:
|
||||||
try:
|
try:
|
||||||
@@ -61,6 +124,7 @@ def client(appointment_id):
|
|||||||
except Exception:
|
except Exception:
|
||||||
can_view_booking_names = bool(current_user == appointment_owner)
|
can_view_booking_names = bool(current_user == appointment_owner)
|
||||||
|
|
||||||
|
# Sanitize data for public/client view
|
||||||
available_for_view = dict(available)
|
available_for_view = dict(available)
|
||||||
if not can_view_booking_names:
|
if not can_view_booking_names:
|
||||||
sanitized_bookings = []
|
sanitized_bookings = []
|
||||||
@@ -76,21 +140,19 @@ def client(appointment_id):
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
action = request.form.get('action', 'book')
|
action = request.form.get('action', 'book')
|
||||||
|
|
||||||
# Fall 1: ADMIN/AUTOR storniert einen Termin
|
# Case 1: Admin/Owner cancels a booking
|
||||||
if action == 'delete' and can_view_booking_names:
|
if action == 'delete' and can_view_booking_names:
|
||||||
slot_time = request.form.get('slot_time')
|
slot_time = request.form.get('slot_time')
|
||||||
client_name = request.form.get('target_client_name')
|
client_name = request.form.get('target_client_name')
|
||||||
|
|
||||||
# Aktuelle Buchungen direkt aus dem DB-Item holen
|
|
||||||
current_slots = appointment_item.get('slots_booked', []) or []
|
current_slots = appointment_item.get('slots_booked', []) or []
|
||||||
|
|
||||||
# Filtere den zu löschenden Slot heraus (Prüfung auf Zeit und Name)
|
# Keep everything EXCEPT the item targeted for deletion
|
||||||
updated_slots = [
|
updated_slots = [
|
||||||
slot for slot in current_slots
|
slot for slot in current_slots
|
||||||
if not (isinstance(slot, (list, tuple)) and slot[0] == slot_time and slot[1] == client_name)
|
if not (isinstance(slot, (list, tuple)) and slot[0] == slot_time and slot[1] == client_name)
|
||||||
]
|
]
|
||||||
|
|
||||||
# In DB schreiben via deiner existierenden termin.update() Funktion
|
|
||||||
if termin.update(appointment_id, updated_slots):
|
if termin.update(appointment_id, updated_slots):
|
||||||
flash('Buchung wurde erfolgreich gelöscht.', 'success')
|
flash('Buchung wurde erfolgreich gelöscht.', 'success')
|
||||||
else:
|
else:
|
||||||
@@ -98,11 +160,11 @@ def client(appointment_id):
|
|||||||
|
|
||||||
return redirect(url_for('terminplaner.client', appointment_id=appointment_id, tenant=_current_tenant_id() or None))
|
return redirect(url_for('terminplaner.client', appointment_id=appointment_id, tenant=_current_tenant_id() or None))
|
||||||
|
|
||||||
# Fall 2: NORMALER CLIENT bucht einen Termin
|
# Case 2: Client books a slot
|
||||||
elif action == 'book':
|
elif action == 'book':
|
||||||
start_daytime = request.form.get('start_day_time')
|
start_daytime = request.form.get('start_day_time')
|
||||||
username = request.form.get('client_name')
|
username = request.form.get('client_name')
|
||||||
custom_answers = request.form.getlist('custom_answers')
|
custom_answers = tuple(request.form.getlist('custom_answers')) # Cast to tuple for DB safety
|
||||||
|
|
||||||
if not start_daytime or not username:
|
if not start_daytime or not username:
|
||||||
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
||||||
@@ -127,7 +189,7 @@ def client(appointment_id):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
flash('Der Termin konnte nicht gespeichert werden.', 'error')
|
flash('Der Termin konnte nicht gespeichert werden. Eventuell ist der Slot bereits voll.', 'error')
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'termin_client.html',
|
'termin_client.html',
|
||||||
@@ -136,7 +198,8 @@ def client(appointment_id):
|
|||||||
current_user=session.get('username', ''),
|
current_user=session.get('username', ''),
|
||||||
tenant_id=_current_tenant_id(),
|
tenant_id=_current_tenant_id(),
|
||||||
can_view_booking_names=can_view_booking_names,
|
can_view_booking_names=can_view_booking_names,
|
||||||
custom_fields=custom_fields
|
custom_fields=custom_fields,
|
||||||
|
appointment_item=appointment_item
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -188,7 +251,7 @@ def delete_appointment(appointment_id):
|
|||||||
@appoint_bp.route('/configure', methods=['GET', 'POST'])
|
@appoint_bp.route('/configure', methods=['GET', 'POST'])
|
||||||
def configure():
|
def configure():
|
||||||
"""
|
"""
|
||||||
Route for authenticated persons to configure a new appointment for them
|
Route for authenticated persons to configure a new appointment schedule
|
||||||
"""
|
"""
|
||||||
guard = _require_module_enabled()
|
guard = _require_module_enabled()
|
||||||
if guard:
|
if guard:
|
||||||
@@ -209,6 +272,11 @@ def configure():
|
|||||||
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
||||||
title = request.form.get('title', '').strip()
|
title = request.form.get('title', '').strip()
|
||||||
custom = request.form.getlist('custom_fields')
|
custom = request.form.getlist('custom_fields')
|
||||||
|
|
||||||
|
try:
|
||||||
|
clients_p_slot = int(request.form.get('clients_per_slot', 1))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
clients_p_slot = 1
|
||||||
|
|
||||||
if not start or not end or not time or not slots_amount or not slot_length or not title:
|
if not start or not end or not time or not slots_amount or not slot_length or not title:
|
||||||
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
||||||
@@ -219,28 +287,54 @@ def configure():
|
|||||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Variablen im Funktionsaufruf aktualisiert
|
# Call the database service function (standardized to match your underlying code)
|
||||||
result = appointment_service.new(start, end, time, slots_amount, slot_length, session["username"], mail, note, calendar_enabled=add_to_calendar, title=title, custom_fields=custom)
|
inserted_id = appointment_service.new(
|
||||||
|
date_start=start,
|
||||||
|
date_end=end,
|
||||||
|
time_span=time,
|
||||||
|
slots=slots_amount,
|
||||||
|
slot_length=slot_length,
|
||||||
|
user=session["username"],
|
||||||
|
mail=mail,
|
||||||
|
note=note,
|
||||||
|
calendar_enabled=add_to_calendar,
|
||||||
|
title=title,
|
||||||
|
custom_fields=custom,
|
||||||
|
clients_per_slot=clients_p_slot
|
||||||
|
)
|
||||||
|
|
||||||
|
if not inserted_id:
|
||||||
|
flash('Fehler beim Erstellen des Terminplans.', 'error')
|
||||||
|
return redirect(url_for('terminplaner.configure'))
|
||||||
|
|
||||||
|
# Resolve the URL string here using Flask's native url_for instead of relying on the database layer
|
||||||
|
generated_link = url_for(
|
||||||
|
'terminplaner.client',
|
||||||
|
appointment_id=str(inserted_id),
|
||||||
|
tenant=_current_tenant_id() or None,
|
||||||
|
_external=True
|
||||||
|
)
|
||||||
|
|
||||||
flash('Der Terminplan wurde angelegt.', 'success')
|
flash('Der Terminplan wurde angelegt.', 'success')
|
||||||
return render_template(
|
return render_template(
|
||||||
'termin_configure.html',
|
'termin_configure.html',
|
||||||
school_periods=cfg.SCHOOL_PERIODS,
|
school_periods=cfg.SCHOOL_PERIODS,
|
||||||
generated_link=result['link'],
|
generated_link=generated_link,
|
||||||
calendar_link=result.get('calendar_link'),
|
calendar_link=None, # Update with calendar service link generation if needed
|
||||||
add_to_calendar=add_to_calendar,
|
add_to_calendar=add_to_calendar,
|
||||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
title=title,
|
title=title,
|
||||||
)
|
)
|
||||||
elif request.method == "GET":
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'termin_configure.html',
|
'termin_configure.html',
|
||||||
school_periods=cfg.SCHOOL_PERIODS,
|
school_periods=cfg.SCHOOL_PERIODS,
|
||||||
generated_link=None,
|
generated_link=None,
|
||||||
calendar_link=None,
|
calendar_link=None,
|
||||||
add_to_calendar=False,
|
add_to_calendar=False,
|
||||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||||
title=None,
|
title=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
|
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
|
||||||
|
|||||||
+118
-140
@@ -97,16 +97,26 @@
|
|||||||
<div class="card-body p-4 p-md-5 bg-white">
|
<div class="card-body p-4 p-md-5 bg-white">
|
||||||
|
|
||||||
{% if can_view_booking_names %}
|
{% if can_view_booking_names %}
|
||||||
|
<!-- ADMIN / AUTOREN ANSICHT -->
|
||||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="h4 fw-bold mb-0">Eingegangene Buchungen</h2>
|
<h2 class="h4 fw-bold mb-0">Eingegangene Buchungen</h2>
|
||||||
<div class="text-muted small">Verwalten Sie die Termine Ihrer Klienten</div>
|
<div class="text-muted small">Verwalten Sie die Termine Ihrer Klienten</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge bg-primary rounded-pill fs-6 px-3 align-self-start align-self-md-center">
|
<div class="d-flex gap-2 align-self-start align-self-md-center">
|
||||||
{{ available.slots_total - available.slots_left }} von {{ available.slots_total }} belegt
|
<!-- Neuer CSV Export Button -->
|
||||||
</span>
|
<a href="{{ url_for('terminplaner.export_csv', appointment_id=appointment_id) }}" class="btn btn-outline-success rounded-3 d-inline-flex align-items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download" viewBox="0 0 16 16">
|
||||||
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v2.5a.5.5 0 0 1 .5-.5"/>
|
||||||
|
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z"/>
|
||||||
|
</svg>
|
||||||
|
Excel / CSV Export
|
||||||
|
</a>
|
||||||
|
<span class="badge bg-primary rounded-pill fs-6 px-3 d-flex align-items-center">
|
||||||
|
{{ available.slots_total - available.slots_left }} von {{ available.slots_total }} belegt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-light p-3 rounded-3 mb-4">
|
<div class="bg-light p-3 rounded-3 mb-4">
|
||||||
<div class="row g-2 align-items-center">
|
<div class="row g-2 align-items-center">
|
||||||
<div class="col-12 col-md-8">
|
<div class="col-12 col-md-8">
|
||||||
@@ -126,6 +136,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="py-3">Zeitpunkt</th>
|
<th scope="col" class="py-3">Zeitpunkt</th>
|
||||||
<th scope="col" class="py-3">Name</th>
|
<th scope="col" class="py-3">Name</th>
|
||||||
|
<!-- Spaltenüberschriften für Custom Fields -->
|
||||||
{% for field_label in custom_fields %}
|
{% for field_label in custom_fields %}
|
||||||
<th scope="col" class="py-3">{{ field_label }}</th>
|
<th scope="col" class="py-3">{{ field_label }}</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -133,58 +144,45 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="booking-table-body">
|
<tbody id="booking-table-body">
|
||||||
{% for booking in available.slots_booked %}
|
{# Wir loopen jetzt über die rohen DB-Daten statt über die beschnittene Service-Funktion #}
|
||||||
<tr>
|
{% for booking in appointment_item.get('slots_booked', []) %}
|
||||||
<td class="fw-semibold text-primary py-3 text-nowrap">
|
<tr>
|
||||||
{% if booking is mapping %}
|
<!-- 1. ZEITPUNKT -->
|
||||||
{{ booking.start }}
|
<td class="fw-semibold text-primary py-3 text-nowrap">
|
||||||
|
{{ booking[0] }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- 2. CLIENT NAME -->
|
||||||
|
<td>
|
||||||
|
<span class="fw-bold text-dark">{{ booking[1] }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- 3. CUSTOM FIELDS (Präziser Index-Abgleich gegen Spaltenkopf) -->
|
||||||
|
{% for field in custom_fields %}
|
||||||
|
<td>
|
||||||
|
{# Prüft ob für dieses Feld eine Antwort am exakt gleichen Index existiert #}
|
||||||
|
{% if booking|length > 2 and booking[2] and booking[2]|length > loop.index0 %}
|
||||||
|
{% set answer = booking[2][loop.index0] %}
|
||||||
|
{{ answer if answer else '<span class="text-muted small">-</span>'|safe }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ booking[0] }}
|
<span class="text-muted small">-</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<td>
|
<!-- 4. AKTION (LÖSCHEN) -->
|
||||||
<span class="fw-bold text-dark">
|
<td class="text-end">
|
||||||
{% if booking is mapping %}
|
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" onsubmit="return confirm('Möchten Sie diesen Termin wirklich löschen?');" class="d-inline">
|
||||||
{{ booking.name }}
|
<input type="hidden" name="action" value="delete">
|
||||||
{% else %}
|
<input type="hidden" name="slot_time" value="{{ booking[0] }}">
|
||||||
{{ booking[1] }}
|
<input type="hidden" name="target_client_name" value="{{ booking[1] }}">
|
||||||
{% endif %}
|
|
||||||
</span>
|
<button type="submit" class="btn btn-sm btn-outline-danger px-3 rounded-3">
|
||||||
</td>
|
Löschen
|
||||||
|
</button>
|
||||||
{% set answers = none %}
|
</form>
|
||||||
{% if booking is mapping %}
|
</td>
|
||||||
{# Versucht alle gängigen Bezeichnungen für das Antwort-Array zu treffen #}
|
</tr>
|
||||||
{% set answers = booking.custom or booking.custom_fields or booking.custom_answers %}
|
|
||||||
{% elif booking|length > 2 %}
|
|
||||||
{% set answers = booking[2] %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if answers %}
|
|
||||||
{% for answer in answers %}
|
|
||||||
<td>{{ answer if answer else '<span class="text-muted small">-</span>'|safe }}</td>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
{# Fallback: Zeige Striche, wenn keine Antworten da sind #}
|
|
||||||
{% for field in custom_fields %}
|
|
||||||
<td><span class="text-muted small">-</span></td>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<td class="text-end">
|
|
||||||
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" onsubmit="return confirm('Möchten Sie diesen Termin wirklich löschen?');" class="d-inline">
|
|
||||||
<input type="hidden" name="action" value="delete">
|
|
||||||
|
|
||||||
<input type="hidden" name="slot_time" value="{% if booking is mapping %}{{ booking.start }}{% else %}{{ booking[0] }}{% endif %}">
|
|
||||||
<input type="hidden" name="target_client_name" value="{% if booking is mapping %}{{ booking.name }}{% else %}{{ booking[1] }}{% endif %}">
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger px-3 rounded-3">
|
|
||||||
Löschen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -324,6 +322,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
(function () {
|
(function () {
|
||||||
const available = {{ available|tojson }};
|
const available = {{ available|tojson }};
|
||||||
const appointmentId = {{ appointment_id|tojson }};
|
const appointmentId = {{ appointment_id|tojson }};
|
||||||
|
const canViewBookingNames = {{ can_view_booking_names|tojson }}; // Injected from Flask
|
||||||
const slider = document.getElementById('day-slider');
|
const slider = document.getElementById('day-slider');
|
||||||
const sliderLabel = document.getElementById('day-slider-label');
|
const sliderLabel = document.getElementById('day-slider-label');
|
||||||
const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null;
|
const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null;
|
||||||
@@ -337,10 +336,10 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics');
|
const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics');
|
||||||
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||||
|
|
||||||
const slotLength = Number.parseInt(available.slot_lenght, 10) || 45;
|
// Fallback for both spellings of slot length
|
||||||
const bookedStarts = new Set((available.slots_booked || []).map(function (entry) {
|
const slotLength = Number.parseInt(available.slot_length || available.slot_lenght, 10) || 45;
|
||||||
return String(entry.start || '').trim();
|
const clientsPerSlot = Number.parseInt(available.clients_per_slot, 10) || 1;
|
||||||
}).filter(Boolean));
|
const bookingsByTime = available.bookings_by_time || {};
|
||||||
|
|
||||||
function formatDateForInput(dateObj) {
|
function formatDateForInput(dateObj) {
|
||||||
const y = dateObj.getFullYear();
|
const y = dateObj.getFullYear();
|
||||||
@@ -404,6 +403,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fullyBookedSlots = [];
|
||||||
|
|
||||||
function buildCandidateSlots() {
|
function buildCandidateSlots() {
|
||||||
const slots = [];
|
const slots = [];
|
||||||
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||||
@@ -411,9 +412,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
|
|
||||||
spans.forEach(function (entry) {
|
spans.forEach(function (entry) {
|
||||||
const parsed = parseTimeSpanEntry(entry);
|
const parsed = parseTimeSpanEntry(entry);
|
||||||
if (!parsed) {
|
if (!parsed) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetDates = parsed.date ? [parsed.date] : allowedDates;
|
const targetDates = parsed.date ? [parsed.date] : allowedDates;
|
||||||
targetDates.forEach(function (date) {
|
targetDates.forEach(function (date) {
|
||||||
@@ -426,10 +425,19 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
let cursor = new Date(from);
|
let cursor = new Date(from);
|
||||||
while (addMinutes(cursor, slotLength) <= to) {
|
while (addMinutes(cursor, slotLength) <= to) {
|
||||||
const slotStart = formatDateForInput(cursor);
|
const slotStart = formatDateForInput(cursor);
|
||||||
if (!bookedStarts.has(slotStart)) {
|
const currentBookingsCount = bookingsByTime[slotStart] || 0;
|
||||||
|
|
||||||
|
// Check if the current individual timestamp still has capacity left
|
||||||
|
if (currentBookingsCount < clientsPerSlot) {
|
||||||
slots.push({
|
slots.push({
|
||||||
start: slotStart,
|
start: slotStart,
|
||||||
end: formatDateForInput(addMinutes(cursor, slotLength)),
|
end: formatDateForInput(addMinutes(cursor, slotLength)),
|
||||||
|
bookingsCount: currentBookingsCount
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fullyBookedSlots.push({
|
||||||
|
start: slotStart,
|
||||||
|
end: formatDateForInput(addMinutes(cursor, slotLength))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
cursor = addMinutes(cursor, slotLength);
|
cursor = addMinutes(cursor, slotLength);
|
||||||
@@ -444,27 +452,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
|
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
|
||||||
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||||
|
|
||||||
// Show the requested UI time window from 08:15 to 20:00 and highlight available slots
|
let slotMinTime = '08:00:00';
|
||||||
let slotMinTime = '08:15:00';
|
|
||||||
let slotMaxTime = '20:00: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 visibleStart = allDays[0] || String(available.date_start || '');
|
||||||
const visibleEndExclusive = allDays.length > 0
|
const visibleEndExclusive = allDays.length > 0
|
||||||
@@ -499,14 +488,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
slotMinTime: slotMinTime,
|
slotMinTime: slotMinTime,
|
||||||
slotMaxTime: slotMaxTime,
|
slotMaxTime: slotMaxTime,
|
||||||
nowIndicator: true,
|
nowIndicator: true,
|
||||||
validRange: {
|
validRange: { start: visibleStart, end: visibleEndExclusive },
|
||||||
start: visibleStart,
|
visibleRange: { start: visibleStart, end: visibleEndExclusive },
|
||||||
end: visibleEndExclusive,
|
|
||||||
},
|
|
||||||
visibleRange: {
|
|
||||||
start: visibleStart,
|
|
||||||
end: visibleEndExclusive,
|
|
||||||
},
|
|
||||||
headerToolbar: {
|
headerToolbar: {
|
||||||
left: '',
|
left: '',
|
||||||
center: 'title',
|
center: 'title',
|
||||||
@@ -514,28 +497,22 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
},
|
},
|
||||||
events: [],
|
events: [],
|
||||||
eventDrop: function (info) {
|
eventDrop: function (info) {
|
||||||
if (info.event.id !== 'selected-slot') {
|
if (info.event.id !== 'selected-slot') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const droppedStart = formatDateForInput(info.event.start);
|
const droppedStart = formatDateForInput(info.event.start);
|
||||||
if (!slotStartSet.has(droppedStart)) {
|
if (!slotStartSet.has(droppedStart)) {
|
||||||
info.revert();
|
info.revert();
|
||||||
window.alert('Dieser Zeitpunkt ist nicht als freier Slot verfügbar.');
|
window.alert('Dieser Zeitpunkt ist ausgebucht oder nicht verfügbar.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applySelectedSlot(droppedStart);
|
applySelectedSlot(droppedStart);
|
||||||
},
|
},
|
||||||
eventClick: function (info) {
|
eventClick: function (info) {
|
||||||
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
|
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
|
||||||
if (slotType !== 'free') {
|
if (slotType !== 'free') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
applySelectedSlot(info.event.extendedProps.slotStart || '');
|
applySelectedSlot(info.event.extendedProps.slotStart || '');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// No background greying: show full calendar skeleton and only mark possible slots
|
|
||||||
|
|
||||||
function updateSliderLabel() {
|
function updateSliderLabel() {
|
||||||
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||||
const idx = Number.parseInt(slider.value, 10) || 0;
|
const idx = Number.parseInt(slider.value, 10) || 0;
|
||||||
@@ -554,9 +531,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
selectedEvent = null;
|
selectedEvent = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedSlot) {
|
if (!selectedSlot) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
|
const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
|
||||||
const end = addMinutes(start, slotLength);
|
const end = addMinutes(start, slotLength);
|
||||||
@@ -575,13 +550,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
function getIcsDownloadUrl() {
|
function getIcsDownloadUrl() {
|
||||||
const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }};
|
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);
|
const url = new URL(base, window.location.origin);
|
||||||
if (selectedSlot) {
|
if (selectedSlot) url.searchParams.set('start', selectedSlot);
|
||||||
url.searchParams.set('start', selectedSlot);
|
|
||||||
}
|
|
||||||
const name = String(clientNameInput.value || '').trim();
|
const name = String(clientNameInput.value || '').trim();
|
||||||
if (name) {
|
if (name) url.searchParams.set('name', name);
|
||||||
url.searchParams.set('name', name);
|
|
||||||
}
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,14 +563,19 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No background gaps: keep full skeleton/grid visible
|
// Render Active/Available Slots with Live Seat Counts
|
||||||
|
|
||||||
// Add candidate free slots (blue)
|
|
||||||
candidateSlots.forEach(function (slot) {
|
candidateSlots.forEach(function (slot) {
|
||||||
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
||||||
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
||||||
|
|
||||||
|
let displayTitle = 'Freier Slot';
|
||||||
|
if (clientsPerSlot > 1) {
|
||||||
|
const remainingSeats = clientsPerSlot - slot.bookingsCount;
|
||||||
|
displayTitle += ' (' + remainingSeats + '/' + clientsPerSlot + ' frei)';
|
||||||
|
}
|
||||||
|
|
||||||
calendar.addEvent({
|
calendar.addEvent({
|
||||||
title: 'Freier Slot',
|
title: displayTitle,
|
||||||
start: start,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
color: '#0d6efd',
|
color: '#0d6efd',
|
||||||
@@ -612,46 +588,54 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
(available.slots_booked || []).forEach(function (booking) {
|
// Client View Optimization: Render solid red events ONLY if a slot is 100% full
|
||||||
const startStr = String(booking.start || '').trim();
|
fullyBookedSlots.forEach(function (slot) {
|
||||||
if (!startStr) {
|
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
||||||
return;
|
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
||||||
}
|
|
||||||
const start = new Date(startStr.replace(' ', 'T') + ':00');
|
|
||||||
const end = addMinutes(start, slotLength);
|
|
||||||
calendar.addEvent({
|
calendar.addEvent({
|
||||||
title: 'Gebucht' + (booking.name ? ' - ' + booking.name : ''),
|
title: 'Ausgebucht',
|
||||||
start: start,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
color: '#dc3545',
|
color: '#dc3545',
|
||||||
editable: false,
|
editable: false,
|
||||||
display: 'block',
|
display: 'block'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin Management View: Render detailed name blocks exclusively for management interaction
|
||||||
|
if (canViewBookingNames) {
|
||||||
|
(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: 'Belegt: ' + (booking.name || 'Anonym'),
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
color: '#9e9e9e',
|
||||||
|
editable: false,
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Listeners and Setup
|
||||||
form.addEventListener('submit', function (ev) {
|
form.addEventListener('submit', function (ev) {
|
||||||
if (!selectedSlotInput.value) {
|
if (!selectedSlotInput.value) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
|
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (allowImmediateSubmit) return;
|
||||||
if (allowImmediateSubmit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (modal) {
|
if (modal) modal.show();
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (confirmBookingOnlyBtn) {
|
if (confirmBookingOnlyBtn) {
|
||||||
confirmBookingOnlyBtn.addEventListener('click', function () {
|
confirmBookingOnlyBtn.addEventListener('click', function () {
|
||||||
allowImmediateSubmit = true;
|
allowImmediateSubmit = true;
|
||||||
if (modal) {
|
if (modal) modal.hide();
|
||||||
modal.hide();
|
|
||||||
}
|
|
||||||
form.submit();
|
form.submit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -659,13 +643,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (confirmBookingWithIcsBtn) {
|
if (confirmBookingWithIcsBtn) {
|
||||||
confirmBookingWithIcsBtn.addEventListener('click', function () {
|
confirmBookingWithIcsBtn.addEventListener('click', function () {
|
||||||
const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl();
|
const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl();
|
||||||
if (icsUrl) {
|
if (icsUrl) window.open(icsUrl, '_blank');
|
||||||
window.open(icsUrl, '_blank');
|
|
||||||
}
|
|
||||||
allowImmediateSubmit = true;
|
allowImmediateSubmit = true;
|
||||||
if (modal) {
|
if (modal) modal.hide();
|
||||||
modal.hide();
|
|
||||||
}
|
|
||||||
form.submit();
|
form.submit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -686,10 +666,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
slider.addEventListener('input', function () {
|
slider.addEventListener('input', function () {
|
||||||
const idx = Number.parseInt(slider.value, 10) || 0;
|
const idx = Number.parseInt(slider.value, 10) || 0;
|
||||||
updateSliderLabel();
|
updateSliderLabel();
|
||||||
if (allDays[idx]) {
|
if (allDays[idx] && calendar.view.type === 'timeGridDay') {
|
||||||
if (calendar.view.type === 'timeGridDay') {
|
calendar.gotoDate(allDays[idx]);
|
||||||
calendar.gotoDate(allDays[idx]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,10 @@
|
|||||||
<label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
<label for="slot_length" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
||||||
<input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
|
<input type="number" id="slot_length" name="slot_length" class="form-control form-control-lg" min="1" value="45">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label fw-semibold text-primary">Nutzer pro Slot</label>
|
||||||
|
<input type="number" id="clients_per_slot" name="clients_per_slot" value="1">
|
||||||
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
|
<label class="form-label fw-semibold text-primary">Automatisch berechnete Slots</label>
|
||||||
<div id="slots_amounts_display" class="form-control form-control-lg bg-primary-subtle text-primary fw-bold d-flex align-items-center">0</div>
|
<div id="slots_amounts_display" class="form-control form-control-lg bg-primary-subtle text-primary fw-bold d-flex align-items-center">0</div>
|
||||||
@@ -149,6 +153,7 @@
|
|||||||
const defaultPauseEndInput = document.getElementById('default_pause_end');
|
const defaultPauseEndInput = document.getElementById('default_pause_end');
|
||||||
|
|
||||||
const slotLengthInput = document.getElementById('slot_length');
|
const slotLengthInput = document.getElementById('slot_length');
|
||||||
|
const clientsperslot = document.getElementById('clients_per_slot')
|
||||||
const slotsAmountsInput = document.getElementById('slots_amounts');
|
const slotsAmountsInput = document.getElementById('slots_amounts');
|
||||||
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element
|
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element
|
||||||
|
|
||||||
@@ -198,16 +203,31 @@
|
|||||||
if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt";
|
if (slotsAmountsDisplay) slotsAmountsDisplay.innerText = totalSlots + " Slots gesamt";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper utility in case your timeToMinutes handler doesn't catch empty strings safely
|
||||||
|
function safeTimeToMinutes(timeString) {
|
||||||
|
if (!timeString || typeof timeString !== 'string' || !timeString.includes(':')) {
|
||||||
|
return 0; // Return 0 instead of NaN for empty or missing values
|
||||||
|
}
|
||||||
|
const parts = timeString.split(':');
|
||||||
|
const hours = parseInt(parts[0], 10);
|
||||||
|
const minutes = parseInt(parts[1], 10);
|
||||||
|
if (isNaN(hours) || isNaN(minutes)) return 0;
|
||||||
|
return (hours * 60) + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
|
// Berechnet die Slots basierend auf den konfigurierten Zeiten & Pausen
|
||||||
function calculateSlots() {
|
function calculateSlots() {
|
||||||
if (!slotLengthInput || !slotsAmountsInput) return;
|
if (!slotLengthInput || !slotsAmountsInput || !clientsperslot) return;
|
||||||
|
|
||||||
const slotLength = parseInt(slotLengthInput.value, 10);
|
const slotLength = parseInt(slotLengthInput.value, 10);
|
||||||
if (isNaN(slotLength) || slotLength <= 0) {
|
if (isNaN(slotLength) || slotLength <= 0) {
|
||||||
updateSlotsDisplay(0);
|
updateSlotsDisplay(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIX 1: Make sure we parse the input VALUE, defaulting to 1 if empty or invalid
|
||||||
|
const multiplier = parseInt(clientsperslot.value, 10) || 1;
|
||||||
|
|
||||||
let totalSlots = 0;
|
let totalSlots = 0;
|
||||||
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
||||||
|
|
||||||
@@ -219,19 +239,20 @@
|
|||||||
|
|
||||||
if (!startTime || !endTime) return;
|
if (!startTime || !endTime) return;
|
||||||
|
|
||||||
const startMins = timeToMinutes(startTime);
|
// Using safety parsing to prevent missing values from generating NaN
|
||||||
const endMins = timeToMinutes(endTime);
|
const startMins = safeTimeToMinutes(startTime);
|
||||||
const pauseStartMins = timeToMinutes(pauseStart);
|
const endMins = safeTimeToMinutes(endTime);
|
||||||
const pauseEndMins = timeToMinutes(pauseEnd);
|
const pauseStartMins = safeTimeToMinutes(pauseStart);
|
||||||
|
const pauseEndMins = safeTimeToMinutes(pauseEnd);
|
||||||
|
|
||||||
if (endMins <= startMins) return; // Ungültige Zeit
|
if (isNaN(startMins) || isNaN(endMins) || endMins <= startMins) return;
|
||||||
|
|
||||||
// Wenn eine gültige Pause innerhalb der Start/Endzeit existiert
|
// Wenn eine gültige Pause innerhalb der Start/Endzeit existiert
|
||||||
if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) {
|
if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) {
|
||||||
// Segment 1 (Vor der Pause)
|
// Segment 1 (Vor der Pause)
|
||||||
const segment1 = pauseStartMins - startMins;
|
const segment1 = pauseStartMins - startMins;
|
||||||
totalSlots += Math.floor(segment1 / slotLength);
|
totalSlots += Math.floor(segment1 / slotLength);
|
||||||
|
|
||||||
// Segment 2 (Nach der Pause)
|
// Segment 2 (Nach der Pause)
|
||||||
const effectivePauseEnd = Math.min(pauseEndMins, endMins);
|
const effectivePauseEnd = Math.min(pauseEndMins, endMins);
|
||||||
const segment2 = endMins - effectivePauseEnd;
|
const segment2 = endMins - effectivePauseEnd;
|
||||||
@@ -244,7 +265,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
updateSlotsDisplay(totalSlots);
|
// FIX 2: Multiply by our safely parsed input value scalar
|
||||||
|
const totalSlotscomplete = totalSlots * multiplier;
|
||||||
|
|
||||||
|
// Safety check: if anything went wrong anyway, fallback to 0 instead of displaying NaN
|
||||||
|
updateSlotsDisplay(isNaN(totalSlotscomplete) ? 0 : totalSlotscomplete);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu
|
// Synchronisiert das Textfeld und fügt den Break-Cut hinzu
|
||||||
@@ -363,33 +388,33 @@
|
|||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
const container = document.getElementById('custom-fields-container');
|
const container = document.getElementById('custom-fields-container');
|
||||||
|
|
||||||
// Monitor inputs inside the container using event delegation
|
// Monitor inputs inside the container using event delegation
|
||||||
container.addEventListener('input', function (event) {
|
container.addEventListener('input', function (event) {
|
||||||
// Only trigger if the user is typing inside our dynamic inputs
|
// Only trigger if the user is typing inside our dynamic inputs
|
||||||
if (event.target.classList.contains('custom-dynamic-input')) {
|
if (event.target.classList.contains('custom-dynamic-input')) {
|
||||||
const allInputs = container.querySelectorAll('.custom-dynamic-input');
|
const allInputs = container.querySelectorAll('.custom-dynamic-input');
|
||||||
const lastInput = allInputs[allInputs.length - 1];
|
const lastInput = allInputs[allInputs.length - 1];
|
||||||
|
|
||||||
// If the user typed something into the absolute last input field, spawn a new one
|
// If the user typed something into the absolute last input field, spawn a new one
|
||||||
if (lastInput.value.trim() !== '') {
|
if (lastInput.value.trim() !== '') {
|
||||||
createNewFieldRow(container);
|
createNewFieldRow(container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to generate and append a clean new input row
|
// Helper function to generate and append a clean new input row
|
||||||
function createNewFieldRow(targetContainer) {
|
function createNewFieldRow(targetContainer) {
|
||||||
const rowDiv = document.createElement('div');
|
const rowDiv = document.createElement('div');
|
||||||
rowDiv.className = 'mb-3 custom-field-row';
|
rowDiv.className = 'mb-3 custom-field-row';
|
||||||
|
|
||||||
rowDiv.innerHTML = `
|
rowDiv.innerHTML = `
|
||||||
<input type="text"
|
<input type="text"
|
||||||
name="custom_fields"
|
name="custom_fields"
|
||||||
class="form-control form-control-lg custom-dynamic-input"
|
class="form-control form-control-lg custom-dynamic-input"
|
||||||
placeholder="Nächstes Zusatzfeld...">
|
placeholder="Nächstes Zusatzfeld...">
|
||||||
`;
|
`;
|
||||||
|
|
||||||
targetContainer.appendChild(rowDiv);
|
targetContainer.appendChild(rowDiv);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -409,6 +434,10 @@
|
|||||||
slotLengthInput.addEventListener('input', calculateSlots);
|
slotLengthInput.addEventListener('input', calculateSlots);
|
||||||
slotLengthInput.addEventListener('change', calculateSlots);
|
slotLengthInput.addEventListener('change', calculateSlots);
|
||||||
}
|
}
|
||||||
|
if (clientsperslot) {
|
||||||
|
clientsperslot.addEventListener('input', calculateSlots);
|
||||||
|
clientsperslot.addEventListener('change', calculateSlots);
|
||||||
|
}
|
||||||
|
|
||||||
if (startDateInput.value && endDateInput.value) {
|
if (startDateInput.value && endDateInput.value) {
|
||||||
renderRows();
|
renderRows();
|
||||||
|
|||||||
Reference in New Issue
Block a user