Compare commits

...

9 Commits

5 changed files with 326 additions and 194 deletions
+3 -2
View File
@@ -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()
} }
+44 -14
View File
@@ -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}")
+118 -24
View File
@@ -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
View File
@@ -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]);
}
} }
}); });
+43 -14
View File
@@ -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();