Compare commits

...

31 Commits

Author SHA1 Message Date
Aiirondev_dev 9527fc10cf Fix of the Nav Bar for the Terminplaner Module 2026-06-27 11:31:47 +02:00
Aiirondev_dev 1122618a51 slight mispelled variable name 2026-06-27 11:05:35 +02:00
Aiirondev_dev 54d56fc463 link klorrektion 2026-06-27 10:53:28 +02:00
Aiirondev_dev 9da4ff3ec8 circuilar import fix 2026-06-27 10:08:12 +02:00
Aiirondev_dev 884464cfe5 pdf download for a client Brief 2026-06-27 10:00:10 +02:00
Aiirondev_dev 0be4e3ca58 return is beeing ignored to the submition 2026-06-27 00:35:44 +02:00
Aiirondev_dev e2aeea46f9 Fix of the encryption of when getting the upcoming events for a user 2026-06-27 00:16:59 +02:00
Aiirondev_dev 865fddd45b removal of unused encryption of user to compensate before right implementation of encryption 2026-06-27 00:08:41 +02:00
Aiirondev_dev 1835195d2f development debugging version 2026-06-26 23:58:15 +02:00
Aiirondev_dev 19a585b2ec Implementation of encryption for sensitive information that may be providet by the client, to ensure that all information important or not is safe 2026-06-26 23:39:05 +02:00
Aiirondev_dev 925f07e96f removal of development debugging 2026-06-26 22:00:53 +02:00
Aiirondev_dev b8beec8209 development Changes that will sort out the defect Link pasting in the Other Tab 2026-06-26 21:34:57 +02:00
Aiirondev_dev dd9390c649 fix of a slight mistake with the Link passing 2026-06-26 21:27:09 +02:00
Aiirondev_dev f31c4e2ff7 fix of misspelled atribute of a Arg 2026-06-26 21:18:50 +02:00
Aiirondev_dev 71e2895362 vital changes to the processing of the generation off a new appointment 2026-06-26 19:40:01 +02:00
Aiirondev_dev d2c7e57f8d changes to the Event lisseer and the Display of the booking range for the client 2026-06-26 19:37:04 +02:00
Aiirondev_dev 111d40b787 activate changes calculation akso when changing the Client amount 2026-06-26 19:27:21 +02:00
Aiirondev_dev 1a9513d442 fix 2026-06-26 19:22:21 +02:00
Aiirondev_dev 3900059eb1 Configurierung des Slot amount generation 2026-06-26 19:13:30 +02:00
Aiirondev_dev 07b1cc5e79 Implementation of a multiple Clients per slot logic 2026-06-26 18:59:09 +02:00
Aiirondev_dev 6583f7a368 bug fix 2026-06-26 17:53:13 +02:00
Aiirondev_dev 8e11fbf969 addition of a csv export funktion for further Processing of the User 2026-06-26 17:41:51 +02:00
Aiirondev_dev 7d13dc264c further changes to also reflekt all relevant changes 2026-06-26 17:32:24 +02:00
Aiirondev_dev 480bd00a54 further itegration of the new view of the User 2026-06-26 17:15:43 +02:00
Aiirondev_dev fdf699363a adjustments to the user review of his appointments 2026-06-26 17:04:25 +02:00
Aiirondev_dev dd495a8b13 decent changes 2026-06-26 16:52:12 +02:00
Aiirondev_dev ca57961d4b slight start of adjustments 2026-06-26 16:43:21 +02:00
Aiirondev_dev 3923939387 slight fix of the unused field at the end of the List 2026-06-26 14:18:23 +02:00
Aiirondev_dev eb9c655398 Addition of the custom fields in the Terminplaner 2026-06-26 13:55:04 +02:00
Aiirondev_dev 4c2657218d slight adjustments, removal of unused descriptive material 2026-06-26 10:37:19 +02:00
Aiirondev_dev 875cfa01a3 Some changes to the decryption of the Borrowers for the decryption of the USer 2026-06-25 15:24:23 +02:00
8 changed files with 1033 additions and 274 deletions
+15 -2
View File
@@ -3302,7 +3302,13 @@ def api_library_items():
borrower = doc.get('User', '')
# Decrypt if value is encrypted (decrypt_text returns original if not encrypted)
try:
borrower = decrypt_text(borrower) if borrower else ''
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
student_cards = db['student_cards']
card = student_cards.find_one({'_id': ObjectId(borrow_id)})
card = _decrypt_student_card_doc(card)
client.close()
borrower = card['SchülerName']
except Exception:
# Fallback: keep original string if decryption fails
borrower = borrower or ''
@@ -8368,8 +8374,15 @@ def admin_create_invoice(borrow_id):
flash('Für diese Ausleihe existiert bereits eine Rechnung. Bitte Korrekturbuchung verwenden.', 'warning')
return redirect(url_for('admin_borrowings'))
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
student_cards = db['student_cards']
card = student_cards.find_one({'_id': ObjectId(borrow_id)})
card = _decrypt_student_card_doc(card)
client.close()
borrower = card['SchülerName']
invoice_number = existing_invoice.get('invoice_number') or _build_invoice_number(borrow_doc['_id'], now)
borrower = borrow_doc.get('User', '')
item_name = item_doc.get('Name', '')
item_code = item_doc.get('Code_4', '')
+156 -77
View File
@@ -18,9 +18,11 @@ Collection Structure:
- Status fields: slots_used_by
"""
import Web.modules.database.settings as cfg
import Web.modules.inventarsystem.data_protection as dp
from Web.modules.database.settings import MongoClient
from bson.objectid import ObjectId
import datetime
import ast
def _get_tenant_db(client):
@@ -37,8 +39,47 @@ def _active_record_query(extra_query=None):
base_query.update(extra_query)
return base_query
def _decrypt_appointment(item):
"""Helper function to safely decrypt appointment fields back to their original types."""
if not item:
return item
try:
if 'user' in item and item['user']:
item['user'] = dp.decrypt_text(item['user'])
if 'note' in item and item['note']:
item['note'] = dp.decrypt_text(item['note'])
if 'title' in item and item['title']:
item['title'] = dp.decrypt_text(item['title'])
if 'mail' in item and item['mail']:
decrypted_mail = dp.decrypt_text(item['mail'])
try:
item['mail'] = ast.literal_eval(decrypted_mail)
except Exception:
item['mail'] = decrypted_mail
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=""):
if 'custom_fields' in item and item['custom_fields']:
item['custom_fields'] = [dp.decrypt_text(field) for field in item['custom_fields']]
if 'slots_booked' in item and item['slots_booked']:
# If it's a string, it was encrypted during an update execution
if isinstance(item['slots_booked'], str):
decrypted_slots = dp.decrypt_text(item['slots_booked'])
try:
item['slots_booked'] = ast.literal_eval(decrypted_slots)
except Exception:
item['slots_booked'] = decrypted_slots
except Exception as e:
print(f"Error during decryption: {e}")
return item
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):
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
@@ -50,60 +91,53 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
'time_span': time_span,
'slots': slots,
'slot_lenght': slot_lenght,
'user': user,
'mail': mail,
'note': note,
'title': title,
'user': dp.encrypt_text(user.strip()),
'mail': dp.encrypt_text(str(mail)),
'note': dp.encrypt_text(note),
'title': dp.encrypt_text(title),
'custom_fields': [dp.encrypt_text(str(field)) for field in custom_fields],
'calendar_enabled': bool(calendar_enabled),
'slots_booked': [], # -> [(start_time, name), ...]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(),
'LastUpdated': datetime.datetime.now()
}
result = items.insert_one(item)
return result.inserted_id
except Exception as e:
print(f"Exception accured: {e}")
print(f"Exception occurred in add: {e}")
return None
finally:
if client:
client.close()
def get_item(id):
"""
Retrieve a specific appointment by its ID.
Args:
id (str): ID of the appointsment to retrieve
Returns:
dict: The appointment document or None if not found
"""
"""Retrieve a specific appointment by its ID and decrypt it."""
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
client.close()
return item
return _decrypt_appointment(item)
except Exception as e:
print(f"Error retrieving item: {e}")
return None
finally:
if client:
client.close()
def update(id,slots_used: list):
"""
Update an existing appointment.
Args:
id (str): ID of the item to update
Returns:
bool: True if successful, False otherwise
"""
def update(id, slots_used: list):
"""Update an existing appointment's booked slots securely."""
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
update_data = {
'slots_booked': slots_used,
'slots_booked': dp.encrypt_text(str(slots_used)),
'LastUpdated': datetime.datetime.now()
}
@@ -112,53 +146,81 @@ def update(id,slots_used: list):
{'$set': update_data}
)
client.close()
return result.modified_count > 0
except Exception as e:
print(f"Error updating item: {e}")
return False
finally:
if client:
client.close()
def remove_slot(id, date_start_time, name):
"""
Remove a booked slot from an appointment's `slots_booked`.
Args:
id (str): Appointment ID
date_start_time: The start time value used when booking
name (str): Name associated with the booking
Returns:
bool: True if a slot was removed, False otherwise
Remove a booked slot from an appointment's encrypted `slots_booked` list.
Because the array is stored as an encrypted string blob, we must decrypt,
modify it in Python, and re-encrypt it.
"""
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
# Attempt to pull the exact element (stored as an array/tuple)
item = items.find_one({'_id': ObjectId(id)})
if not item or 'slots_booked' not in item or not item['slots_booked']:
return False
try:
decrypted_slots = dp.decrypt_text(item['slots_booked'])
slots_list = ast.literal_eval(decrypted_slots)
except Exception as e:
print(f"Failed to decrypt or parse slots: {e}")
return False
# Structure format note: [(start_time, (names), (custom1, custom2...)), ...]
updated_slots = []
removed_any = False
for slot in slots_list:
slot_start = slot[0]
slot_names = slot[1]
if slot_start == date_start_time and (slot_names == name or name in slot_names):
removed_any = True
continue
updated_slots.append(slot)
if not removed_any:
return False
result = items.update_one(
{'_id': ObjectId(id)},
{'$pull': {'slots_booked': [date_start_time, name]}}
{
'$set': {
'slots_booked': dp.encrypt_text(str(updated_slots)),
'LastUpdated': datetime.datetime.now()
}
}
)
client.close()
return result.modified_count > 0
except Exception as e:
print(f"Error removing slot: {e}")
return False
finally:
if client:
client.close()
def remove(id):
"""
Soft-delete an appointment by setting its `Deleted` flag.
Args:
id (str): Appointment ID
Returns:
bool: True if the appointment was marked deleted, False otherwise
Hard-delete an appointment plan by its ID.
(Note: If your docstring mentions a soft-delete 'Deleted' flag,
change items.delete_one to items.update_one with {'$set': {'Deleted': True}})
"""
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
@@ -166,70 +228,87 @@ def remove(id):
result = items.delete_one({'_id': ObjectId(id)})
client.close()
return result.deleted_count > 0
except Exception as e:
print(f"Error removing appointment: {e}")
return False
finally:
if client:
client.close()
def remove_done():
"""removose already finisched appointments"""
"""Remove all expired appointments whose end date is prior to today in a single call."""
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
today = datetime.date.today().strftime('%Y-%m-%d')
removed_count = 0
cursor = items.find(
result = items.delete_many(
_active_record_query(
{
'date_end': {'$lt': today},
}
)
).sort('date_start', 1)
)
for item in cursor:
item['_id'] = str(item.get('_id'))
result = items.delete_one({'_id': ObjectId(item['_id'])})
removed_count += result.deleted_count
client.close()
return removed_count > 0
return result.deleted_count > 0
except Exception as e:
print(f"Error removing appointment: {e}")
print(f"Error cleaning up finished appointments: {e}")
return False
finally:
if client:
client.close()
def get_upcoming_for_user(user: str, limit: int = 25):
"""Return upcoming appointment plans for a user ordered by start date."""
remove_done()
"""
Return upcoming appointment plans for a user, handling encrypted database records.
"""
try:
if hasattr(globals(), 'remove_done'):
remove_done()
except Exception:
pass
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = _get_tenant_db(client)
items = db['appointments']
today = datetime.date.today().strftime('%Y-%m-%d')
target_user = str(user or '').strip()
cursor = items.find(
_active_record_query(
{
'user': str(user or '').strip(),
'date_end': {'$gte': today},
}
)
_active_record_query({
'date_end': {'$gte': today},
})
).sort('date_start', 1)
results = []
for item in cursor:
item['_id'] = str(item.get('_id'))
results.append(item)
decrypted_item = _decrypt_appointment(item)
if not decrypted_item:
continue
if decrypted_item.get('user', '').strip() != target_user:
continue
decrypted_item['_id'] = str(decrypted_item.get('_id'))
results.append(decrypted_item)
if len(results) >= max(1, int(limit)):
break
client.close()
return results
except Exception as e:
print(f"Error retrieving upcoming appointments: {e}")
return []
finally:
if client:
client.close()
+60 -22
View File
@@ -8,6 +8,7 @@ import Web.modules.emailservice.email as mail_service
import Web.modules.database.termine as termin
import Web.modules.database.settings as cfg
from Web.tenant import get_tenant_context
import Web.modules.inventarsystem.data_protection as dp
def _resolve_public_base_url() -> str:
@@ -99,8 +100,11 @@ def build_calendar_ics(appointment_id: str) -> str | None:
return None
uid = f"terminplaner-{appointment_id}@invario.eu"
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
summary = f"Terminplan für {creator}"
created_at = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
summary = titel if titel else f"Terminplan für {creator}"
description_lines = [
f"Buchungslink: {link}",
f"Zeitraum: {date_start} bis {date_end}",
@@ -109,7 +113,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
if note:
description_lines.append('Notiz: ' + str(note))
if titel:
if titel and not summary == titel:
description_lines.append('Titel: ' + str(titel))
ics_lines = [
@@ -125,8 +129,7 @@ def build_calendar_ics(appointment_id: str) -> str | None:
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
f'URL:{_escape_ics_text(link)}',
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
f'Titel:{_escape_ics_text(titel)}',
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + datetime.timedelta(days=1))}',
'END:VEVENT',
'END:VCALENDAR',
'',
@@ -172,7 +175,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
]
uid = f"terminplaner-slot-{appointment_id}-{start_dt.strftime('%Y%m%d%H%M')}@invario.eu"
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
created_at = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
dt_start = start_dt.strftime('%Y%m%dT%H%M%S')
dt_end = end_dt.strftime('%Y%m%dT%H%M%S')
@@ -198,7 +201,7 @@ def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str
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="") -> 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 = (), clients_per_slot: int=1) -> dict:
"""
Generates a link for the executive to send to his clients to book a time Slot
"""
@@ -207,6 +210,11 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
except (ValueError, TypeError):
slots_int = 0
try:
custom_fields.pop(-1)
except :
pass
try:
slot_length_int = int(slot_length)
except (ValueError, TypeError):
@@ -215,7 +223,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_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)
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=clients_per_slot)
id_str = str(id)
tenant_id = _current_tenant_id()
@@ -254,7 +262,7 @@ def new(date_start: str, date_end: str, time_span: list, slots, slot_length, use
}
def book_slot(id, date_start_time, name):
def book_slot(id, date_start_time, name, custom: tuple = ()):
try:
item = termin.get_item(id)
if not item:
@@ -264,18 +272,32 @@ def book_slot(id, date_start_time, name):
if not isinstance(slots, list):
slots = []
# 1. Check overall total booking capacity
capacity = int(item.get('slots', 0) or 0)
if capacity and len(slots) >= capacity:
return False
# 2. Prevent the exact same user from double-booking the exact same slot
for existing in slots:
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
if existing[0] == date_start_time and existing[1] == name:
return False
slots.append((date_start_time, name))
# 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))
success = termin.update(id, slots)
return bool(success)
except Exception as e:
print(f"Error booking slot: {e}")
return False
@@ -326,32 +348,46 @@ def get_available(id):
date_end = termin_range.get('date_end')
time_span = termin_range.get('time_span', [])
# Safe integer parsing without heavy try-except blocks
try:
total_slots = int(termin_range.get('slots', 0) or 0)
except Exception:
except (ValueError, TypeError):
total_slots = 0
# Support both spellings gracefully
raw_length = termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0
try:
slot_length = int(termin_range.get('slot_length') or termin_range.get('slot_lenght') or 0)
except Exception:
slot_length = termin_range.get('slot_length') or termin_range.get('slot_lenght')
slot_length = int(raw_length)
except (ValueError, TypeError):
slot_length = raw_length
clients_per_slot = int(termin_range.get('clients_per_slot', 1) or 1)
booked = termin_range.get('slots_booked', []) or []
# Normalize the bookings list safely
normalized = []
bookings_by_time = {} # Tracks how many people are in each specific time slot
for s in booked:
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):
normalized.append(s)
slot_time = s.get('start')
item = s
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)
try:
slots_left = max(0, int(total_slots) - slots_used)
except Exception:
slots_left = max(0, slots_used - slots_used)
slots_left = max(0, total_slots - slots_used)
return {
'date_start': date_start,
@@ -360,8 +396,10 @@ def get_available(id):
'slot_length': slot_length,
'slot_lenght': slot_length,
'slots_total': total_slots,
'clients_per_slot': clients_per_slot,
'slots_booked': normalized,
'slots_left': slots_left,
'bookings_by_time': bookings_by_time
}
except Exception as e:
print(f"Error getting available slots: {e}")
@@ -370,7 +408,6 @@ def get_available(id):
def get_available_user(id):
return get_available(id)
def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
user_name = str(user or '').strip()
if not user_name:
@@ -418,6 +455,7 @@ def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
'link': link,
'calendar_link': calendar_link if item.get('calendar_enabled') else None,
'title': str(item.get('title') or ''),
'custom_fields': list(item.get('custom_fields')),
}
)
+340 -42
View File
@@ -1,14 +1,57 @@
from flask import Blueprint, render_template, request, session, url_for, redirect, flash
from flask import Response
from flask import Blueprint, render_template, request, session, url_for, redirect, flash, make_response, Response, send_file
import Web.modules.terminplaner.backend_server as appointment_service
import Web.modules.database.settings as cfg
import Web.modules.database.termine as termin
import Web.modules.database.user as us
from Web.modules.terminplaner.backend_server import _resolve_public_base_url
import csv
import io
import qrcode
import os
import tempfile
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib.colors import grey, HexColor
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image
# Create a blueprint instance
appoint_bp = Blueprint('terminplaner', __name__)
def _get_school_info_for_export():
"""
Get school information for PDF exports from configuration or database.
Returns default info if not configured.
"""
try:
if hasattr(cfg, 'get_school_info'):
return cfg.get_school_info()
school_info = {
'name': 'Schulname',
'address': 'Schuladresse',
'postal_code': 'PLZ',
'city': 'Stadt',
'school_number': '000000',
'it_admin': 'IT-Beauftragter/in',
'logo_path': '',
}
return school_info
except Exception:
# Return defaults if anything fails
return {
'name': 'Schulname',
'address': 'Schuladresse',
'postal_code': 'PLZ',
'city': 'Stadt',
'school_number': '000000',
'it_admin': 'IT-Beauftragter/in',
'logo_path': '',
}
def _require_module_enabled():
if not cfg.MODULES.is_enabled('terminplan'):
flash('Der Terminplaner ist deaktiviert.', 'info')
@@ -34,10 +77,70 @@ def _current_tenant_id():
pass
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'])
def client(appointment_id):
"""
The Route for the terminplaner to work with the client
The Route for the terminplaner to work with the client and allow owners to manage it.
"""
guard = _require_module_enabled()
if guard:
@@ -50,6 +153,10 @@ def client(appointment_id):
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()
custom_fields = appointment_item.get('custom_fields', [])
# Permissions check
can_view_booking_names = False
if current_user:
try:
@@ -57,6 +164,7 @@ def client(appointment_id):
except Exception:
can_view_booking_names = bool(current_user == appointment_owner)
# Sanitize data for public/client view
available_for_view = dict(available)
if not can_view_booking_names:
sanitized_bookings = []
@@ -70,31 +178,59 @@ def client(appointment_id):
available_for_view['slots_booked'] = sanitized_bookings
if request.method == 'POST':
start_daytime = request.form.get('start_day_time')
username = request.form.get('client_name')
if not start_daytime or not username:
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
return render_template(
'termin_client.html',
appointment_id=appointment_id,
available=available_for_view,
current_user=session.get('username', ''),
tenant_id=_current_tenant_id(),
can_view_booking_names=can_view_booking_names,
)
action = request.form.get('action', 'book')
if appointment_service.book_slot(appointment_id, start_daytime, username):
return redirect(
url_for(
'terminplaner.client_success',
# Case 1: Admin/Owner cancels a booking
if action == 'delete' and can_view_booking_names:
slot_time = request.form.get('slot_time')
client_name = request.form.get('target_client_name')
current_slots = appointment_item.get('slots_booked', []) or []
# Keep everything EXCEPT the item targeted for deletion
updated_slots = [
slot for slot in current_slots
if not (isinstance(slot, (list, tuple)) and slot[0] == slot_time and slot[1] == client_name)
]
if termin.update(appointment_id, updated_slots):
flash('Buchung wurde erfolgreich gelöscht.', 'success')
else:
flash('Fehler beim Löschen der Buchung.', 'error')
return redirect(url_for('terminplaner.client', appointment_id=appointment_id, tenant=_current_tenant_id() or None))
# Case 2: Client books a slot
elif action == 'book':
start_daytime = request.form.get('start_day_time')
username = request.form.get('client_name')
custom_answers = tuple(request.form.getlist('custom_answers')) # Cast to tuple for DB safety
if not start_daytime or not username:
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
return render_template(
'termin_client.html',
appointment_id=appointment_id,
tenant=_current_tenant_id() or None,
start=start_daytime,
name=username,
available=available_for_view,
current_user=session.get('username', ''),
tenant_id=_current_tenant_id(),
can_view_booking_names=can_view_booking_names,
custom_fields=custom_fields,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
)
)
flash('Der Termin konnte nicht gespeichert werden.', 'error')
if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers):
return redirect(
url_for(
'terminplaner.client_success',
appointment_id=appointment_id,
tenant=_current_tenant_id() or None,
start=start_daytime,
name=username,
)
)
flash('Der Termin konnte nicht gespeichert werden. Eventuell ist der Slot bereits voll.', 'error')
return render_template(
'termin_client.html',
@@ -103,6 +239,9 @@ def client(appointment_id):
current_user=session.get('username', ''),
tenant_id=_current_tenant_id(),
can_view_booking_names=can_view_booking_names,
custom_fields=custom_fields,
appointment_item=appointment_item,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
)
@@ -154,7 +293,7 @@ def delete_appointment(appointment_id):
@appoint_bp.route('/configure', methods=['GET', 'POST'])
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()
if guard:
@@ -169,13 +308,18 @@ def configure():
end = request.form.get('end_date')
time = request.form.get('time_frame')
slots_amount = request.form.get('slots_amounts')
slot_length = request.form.get('slot_length') # Korrigiert: slot_length
slot_length = request.form.get('slot_length')
mail = request.form.get('mail', '')
note = request.form.get('note', '')
add_to_calendar = request.form.get('add_to_calendar') == 'on'
title = request.form.get('title', '').strip() # Korrigiert: title statt titel
title = request.form.get('title', '').strip()
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
# Abfrage ebenfalls auf title und slot_length angepasst
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')
return render_template(
@@ -185,28 +329,48 @@ def configure():
email_service_enabled=cfg.EMAIL_ENABLED,
)
# Variablen im Funktionsaufruf aktualisiert
result = appointment_service.new(start, end, time, slots_amount, slot_length, session["username"], mail, note, calendar_enabled=add_to_calendar, title=title)
# Call the database service function (standardized to match your underlying code)
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'))
flash('Der Terminplan wurde angelegt.', 'success')
return render_template(
'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS,
generated_link=result['link'],
calendar_link=result.get('calendar_link'),
generated_link=inserted_id['link'],
calendar_link=None,
add_to_calendar=add_to_calendar,
email_service_enabled=cfg.EMAIL_ENABLED,
title=title,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
)
elif request.method == "GET":
return render_template(
'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS,
generated_link=None,
calendar_link=None,
add_to_calendar=False,
email_service_enabled=cfg.EMAIL_ENABLED,
title=None,
)
return render_template(
'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS,
generated_link=None,
calendar_link=None,
add_to_calendar=False,
email_service_enabled=cfg.EMAIL_ENABLED,
title=None,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
)
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
@@ -244,6 +408,138 @@ def client_slot_calendar_export(appointment_id):
response.headers['Content-Disposition'] = f'attachment; filename=termin-{title}-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
return response
@appoint_bp.route('/export_pdf_brief/<plan_id>', methods=['GET'])
def export_pdf_brief(plan_id):
# 1. Daten holen (Hier als Beispiel, passe dies auf deine Datenbank an)
# terminplan = Terminplan.query.get(plan_id)
school_info = _get_school_info_for_export()
school_name = school_info.get('name', 'Schulname')
address = school_info.get('address', 'Adresse')
postal_code = school_info.get('postal_code', 'PLZ')
city = school_info.get('city', 'Stadt')
school_number = school_info.get('school_number', '000000')
it_admin = school_info.get('it_admin', 'IT-Beauftragter/in')
tenant_id = _current_tenant_id()
try:
link = url_for('terminplaner.client', appointment_id=plan_id, tenant=tenant_id or None, _external=True)
except Exception:
host = _resolve_public_base_url()
link = host + "/terminplaner/client/" + plan_id
if tenant_id:
link += f"?tenant={tenant_id}"
schul_daten = {
"schulname": school_name,
"strasse": address,
"plz_ort": f"{postal_code} {city}",
"schulnummer": school_number,
"it_admin": it_admin
}
plan_daten = {
"titel": termin.get_item(plan_id).get('title', 'Terminplan'), # terminplan.title
"link": link, # terminplan.link
"notizen": termin.get_item(plan_id).get('note', '') # terminplan.note
}
# 2. QR-Code Bild im temporären Ordner erstellen
qr = qrcode.QRCode(version=1, box_size=10, border=0)
qr.add_data(plan_daten["link"])
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
fd, qr_path = tempfile.mkstemp(suffix=".png")
os.close(fd)
img.save(qr_path)
# 3. PDF im Speicher aufbauen (BytesIO)
pdf_buffer = io.BytesIO()
doc = SimpleDocTemplate(
pdf_buffer,
pagesize=A4,
rightMargin=2*cm,
leftMargin=2.5*cm,
topMargin=2.5*cm,
bottomMargin=2*cm
)
styles = getSampleStyleSheet()
styles.add(ParagraphStyle(name='Sender', fontSize=8, textColor=grey))
styles.add(ParagraphStyle(name='Address', fontSize=10, leading=14))
styles.add(ParagraphStyle(name='Date', fontSize=10, alignment=2))
styles.add(ParagraphStyle(name='Subject', fontSize=14, fontName='Helvetica-Bold', spaceAfter=16, textColor=HexColor('#0f4c5c')))
styles.add(ParagraphStyle(name='Body', fontSize=11, leading=16, spaceAfter=12))
styles.add(ParagraphStyle(name='Notes', fontSize=10, leading=14, textColor=HexColor("#444444")))
elements = []
# Absenderzeile
sender_text = f"<u>{schul_daten['schulname']}{schul_daten['strasse']}{schul_daten['plz_ort']}</u>"
elements.append(Paragraph(sender_text, styles['Sender']))
elements.append(Spacer(1, 1.5*cm))
# Sichtfenster-Adresse (Generisch)
elements.append(Paragraph("An die<br/>Teilnehmerinnen und Teilnehmer<br/>des Termins", styles['Address']))
elements.append(Spacer(1, 2*cm))
# Datum (Hier statisch zum Test, ggf. dynamisch per datetime)
import datetime
heute = datetime.datetime.now().strftime("%d.%m.%Y")
elements.append(Paragraph(f"{schul_daten['plz_ort']}, den {heute}", styles['Date']))
elements.append(Spacer(1, 1*cm))
# Betreff
elements.append(Paragraph(f"Einladung zur Terminbuchung: {plan_daten['titel']}", styles['Subject']))
# Text
elements.append(Paragraph("Sehr geehrte Damen und Herren,", styles['Body']))
elements.append(Paragraph("hiermit möchten wir Sie herzlich einladen, einen Termin für unsere anstehende Veranstaltung zu buchen. Um den Prozess für alle Beteiligten so einfach und effizient wie möglich zu gestalten, nutzen wir unser Online-Buchungssystem.", styles['Body']))
elements.append(Spacer(1, 0.5*cm))
# Link
elements.append(Paragraph("<b>Ihr persönlicher Buchungslink:</b>", styles['Body']))
link_html = f'<a href="{plan_daten["link"]}?" color="#16697a">{plan_daten["link"]}</a>'
elements.append(Paragraph(link_html, styles['Body']))
# Das vorhin erstellte QR-Code Bild einfügen
elements.append(Spacer(1, 0.2*cm))
elements.append(Image(qr_path, width=3*cm, height=3*cm, hAlign='LEFT'))
elements.append(Spacer(1, 0.5*cm))
# Notizen (falls vorhanden)
if plan_daten.get('notizen'):
elements.append(Paragraph("<b>Zusätzliche Informationen zum Termin:</b>", styles['Body']))
elements.append(Paragraph(plan_daten['notizen'], styles['Notes']))
elements.append(Spacer(1, 1.5*cm))
# Grußformel
elements.append(Paragraph("Mit freundlichen Grüßen,", styles['Body']))
elements.append(Spacer(1, 1.5*cm))
elements.append(Paragraph(f"<b>{schul_daten['schulname']}</b>", styles['Body']))
# PDF fertigstellen
doc.build(elements)
# Temporäres Bild löschen
if os.path.exists(qr_path):
os.remove(qr_path)
# Buffer auf Anfang zurücksetzen
pdf_buffer.seek(0)
# An Nutzer ausliefern
return send_file(
pdf_buffer,
mimetype='application/pdf',
as_attachment=True,
download_name=f"Einladung_{plan_daten['titel'].replace(' ', '_')}.pdf"
)
@appoint_bp.route('/')
def main():
guard = _require_module_enabled()
@@ -254,10 +550,12 @@ def main():
upcoming_events = appointment_service.get_user_upcoming_events(current_user) if current_user else []
tenant_id = _current_tenant_id()
return render_template(
'terminplaner.html',
school_periods=cfg.SCHOOL_PERIODS,
current_user=current_user,
upcoming_events=upcoming_events,
tenant_id=tenant_id,
appointment_module_enabled=cfg.MODULES.is_enabled('terminplan')
)
+47
View File
@@ -1144,6 +1144,17 @@
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
{% if current_permissions.actions.get('can_view_logs', True) and current_permissions.pages.get('admin_audit_dashboard', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if current_permissions.pages.get('home', True) %}
<li><a class="dropdown-item" href="{{ url_for('home') }}">Inventarsystem</a></li>
{% endif %}
@@ -1155,6 +1166,42 @@
</ul>
</li>
</ul>
<div class="d-flex">
{% if 'username' in session %}
<div class="function-search-wrap">
<form class="function-search-form" data-function-search="true">
<input
class="function-search-input"
type="search"
name="function_search"
placeholder="Funktion suchen..."
list="function-search-options"
autocomplete="off"
>
<button class="function-search-btn" type="submit">Los</button>
</form>
</div>
{% if current_tenant_db %}
<span class="navbar-text tenant-badge" title="Aktive Tenant-Datenbank">{{ current_tenant_db }}</span>
{% endif %}
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2 user-menu-wrap">
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
👤
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
{% if current_permissions.pages.get('notifications_view', True) %}
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
</div>
</nav>
+274 -119
View File
@@ -39,13 +39,19 @@
<div class="row justify-content-center">
<div class="col-12 col-xxl-11">
<div class="row g-4">
<div class="col-12 col-lg-4">
<div class="card border-0 shadow-lg rounded-4 h-100">
<div class="card-body p-4 p-md-5">
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
<h1 class="h3 fw-bold mb-3">Termin buchen - {{ available.title }}</h1>
<p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p>
{% if can_view_booking_names %}
<h1 class="h3 fw-bold mb-3">Termin Übersicht - {{ available.title }}</h1>
<p class="text-muted mb-4">Hier können Sie alle Buchungen, die bereits eingegangen sind, einsehen und verwalten.</p>
{% else %}
<h1 class="h3 fw-bold mb-3">Termin buchen - {{ available.title }}</h1>
<p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p>
{% endif %}
<div class="p-3 rounded-3 bg-light mb-3">
<div class="fw-semibold">Zeitraum</div>
<div>{{ available.date_start }} bis {{ available.date_end }}</div>
@@ -58,23 +64,24 @@
<div class="fw-semibold">Slot-Länge</div>
<div>{{ available.slot_lenght }} Minuten</div>
</div>
{% if not can_view_booking_names %}
<div class="p-3 rounded-3 bg-light mb-3">
<div class="fw-semibold mb-2">Gewählter Termin</div>
<div id="selected-slot-badge" class="text-muted small">Noch kein Slot ausgewählt.</div>
</div>
{% endif %}
{% if available.slots_booked %}
<div class="p-3 rounded-3 bg-light">
<div class="fw-semibold mb-2">Bereits gebucht</div>
<ul class="mb-0 small">
<ul class="mb-0 small text-muted">
{% for booking in available.slots_booked %}
<li>
{{ booking.start }}
{% if can_view_booking_names and booking.name %}
- {{ booking.name }}
{% if booking is mapping %}
{{ booking.get('start', '') }} - Belegt
{% else %}
- Belegt
{{ booking[0] }} - {% if can_view_booking_names %}<span class="fw-semibold text-dark">{{ booking[1] }}</span>{% else %}Belegt{% endif %}
{% endif %}
</li>
{% endfor %}
@@ -84,39 +91,161 @@
</div>
</div>
</div>
<div class="col-12 col-lg-8">
<div class="card border-0 shadow-lg rounded-4 h-100">
<div class="card-body p-4 p-md-5 bg-white">
<h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
<div class="day-slider-wrap mb-3">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
<span class="small text-muted">Tag wählen</span>
<strong id="day-slider-label" class="small"></strong>
{% 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>
<h2 class="h4 fw-bold mb-0">Eingegangene Buchungen</h2>
<div class="text-muted small">Verwalten Sie die Termine Ihrer Klienten</div>
</div>
<div class="d-flex gap-2 align-self-start align-self-md-center">
<!-- Neuer CSV Export Button -->
<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>
<input id="day-slider" type="range" class="form-range m-0" min="0" max="0" value="0">
</div>
<div id="client-slot-calendar" class="mb-4"></div>
<form id="client-booking-form" method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
<div>
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="Bitte im Kalender auswählen" readonly required>
<div class="form-text">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</div>
<div class="bg-light p-3 rounded-3 mb-4">
<div class="row g-2 align-items-center">
<div class="col-12 col-md-8">
<label for="table-search" class="form-label small fw-semibold text-muted mb-1">Live-Filter (Name, Uhrzeit, Zusatzfelder...)</label>
<input type="text" id="table-search" class="form-control form-control-lg bg-white border" placeholder="Suchbegriff eingeben...">
</div>
<div class="col-12 col-md-4 d-flex align-items-end h-100 mt-md-4">
<button type="button" id="clear-filter-btn" class="btn btn-outline-secondary w-100 btn-lg">Filter zurücksetzen</button>
</div>
</div>
</div>
<div>
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
{% if available.slots_booked %}
<div class="table-responsive">
<table class="table table-hover align-middle border-top" id="admin-booking-table">
<thead class="table-light">
<tr>
<th scope="col" class="py-3">Zeitpunkt</th>
<th scope="col" class="py-3">Name</th>
<!-- Spaltenüberschriften für Custom Fields -->
{% for field_label in custom_fields %}
<th scope="col" class="py-3">{{ field_label }}</th>
{% endfor %}
<th scope="col" class="py-3 text-end">Aktion</th>
</tr>
</thead>
<tbody id="booking-table-body">
{# Wir loopen jetzt über die rohen DB-Daten statt über die beschnittene Service-Funktion #}
{% for booking in appointment_item.get('slots_booked', []) %}
<tr>
<!-- 1. ZEITPUNKT -->
<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 %}
<span class="text-muted small">-</span>
{% endif %}
</td>
{% endfor %}
<!-- 4. AKTION (LÖSCHEN) -->
<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="{{ booking[0] }}">
<input type="hidden" name="target_client_name" value="{{ booking[1] }}">
<button type="submit" class="btn btn-sm btn-outline-danger px-3 rounded-3">
Löschen
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
<div id="no-results-msg" class="text-center py-4 text-muted d-none">
Keine Buchungen entsprechen Ihrem Filter.
</div>
{% else %}
<div class="text-center py-5 text-muted">
<p class="mb-0">Bisher sind noch keine Buchungen für diesen Terminplaner eingegangen.</p>
</div>
{% endif %}
<div class="pt-4 border-top mt-4">
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
</div>
</form>
{% else %}
<h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
<div class="day-slider-wrap mb-3">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
<span class="small text-muted">Tag wählen</span>
<strong id="day-slider-label" class="small"></strong>
</div>
<input id="day-slider" type="range" class="form-range m-0" min="0" max="0" value="0">
</div>
<div id="client-slot-calendar" class="mb-4"></div>
<form id="client-booking-form" method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
<div>
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="Bitte im Kalender auswählen" readonly required>
<div class="form-text">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</div>
</div>
<div>
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
</div>
{% if custom_fields %}
{% for field_label in custom_fields %}
<div>
<label class="form-label fw-semibold">{{ field_label }}</label>
<input type="text" name="custom_answers" class="form-control form-control-lg" placeholder="{{ field_label }} eingeben" required>
</div>
{% endfor %}
{% endif %}
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
<input type="hidden" name="action" value="book">
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
</div>
</form>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
@@ -142,11 +271,58 @@
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const searchInput = document.getElementById('table-search');
const clearBtn = document.getElementById('clear-filter-btn');
const tableBody = document.getElementById('booking-table-body');
const noResultsMsg = document.getElementById('no-results-msg');
if (searchInput && tableBody) {
const rows = tableBody.querySelectorAll('tr');
searchInput.addEventListener('input', function () {
const query = this.value.toLowerCase().trim();
let visibleRowsCount = 0;
rows.forEach(row => {
// Liest den Textinhalt aller Spalten der Zeile aus
const rowText = row.textContent.toLowerCase();
if (rowText.includes(query)) {
row.classList.remove('d-none');
visibleRowsCount++;
} else {
row.classList.add('d-none');
}
});
// Falls kein Filterergebnis gefunden wurde, Nachricht einblenden
if (visibleRowsCount === 0 && query !== '') {
noResultsMsg.classList.remove('d-none');
} else {
noResultsMsg.classList.add('d-none');
}
});
// Filter zurücksetzen Button
if (clearBtn) {
clearBtn.addEventListener('click', function () {
searchInput.value = '';
rows.forEach(row => row.classList.remove('d-none'));
noResultsMsg.classList.add('d-none');
searchInput.focus();
});
}
}
});
</script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
<script>
(function () {
const available = {{ available|tojson }};
const appointmentId = {{ appointment_id|tojson }};
const canViewBookingNames = {{ can_view_booking_names|tojson }}; // Injected from Flask
const slider = document.getElementById('day-slider');
const sliderLabel = document.getElementById('day-slider-label');
const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null;
@@ -160,10 +336,10 @@
const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
const slotLength = Number.parseInt(available.slot_lenght, 10) || 45;
const bookedStarts = new Set((available.slots_booked || []).map(function (entry) {
return String(entry.start || '').trim();
}).filter(Boolean));
// Fallback for both spellings of slot length
const slotLength = Number.parseInt(available.slot_length || available.slot_lenght, 10) || 45;
const clientsPerSlot = Number.parseInt(available.clients_per_slot, 10) || 1;
const bookingsByTime = available.bookings_by_time || {};
function formatDateForInput(dateObj) {
const y = dateObj.getFullYear();
@@ -227,6 +403,8 @@
return null;
}
const fullyBookedSlots = [];
function buildCandidateSlots() {
const slots = [];
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
@@ -234,9 +412,7 @@
spans.forEach(function (entry) {
const parsed = parseTimeSpanEntry(entry);
if (!parsed) {
return;
}
if (!parsed) return;
const targetDates = parsed.date ? [parsed.date] : allowedDates;
targetDates.forEach(function (date) {
@@ -249,10 +425,19 @@
let cursor = new Date(from);
while (addMinutes(cursor, slotLength) <= to) {
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({
start: slotStart,
end: formatDateForInput(addMinutes(cursor, slotLength)),
bookingsCount: currentBookingsCount
});
} else {
fullyBookedSlots.push({
start: slotStart,
end: formatDateForInput(addMinutes(cursor, slotLength))
});
}
cursor = addMinutes(cursor, slotLength);
@@ -267,27 +452,8 @@
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
// Show the requested UI time window from 08:15 to 20:00 and highlight available slots
let slotMinTime = '08:15:00';
let slotMinTime = '08: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 visibleEndExclusive = allDays.length > 0
@@ -322,14 +488,8 @@
slotMinTime: slotMinTime,
slotMaxTime: slotMaxTime,
nowIndicator: true,
validRange: {
start: visibleStart,
end: visibleEndExclusive,
},
visibleRange: {
start: visibleStart,
end: visibleEndExclusive,
},
validRange: { start: visibleStart, end: visibleEndExclusive },
visibleRange: { start: visibleStart, end: visibleEndExclusive },
headerToolbar: {
left: '',
center: 'title',
@@ -337,28 +497,22 @@
},
events: [],
eventDrop: function (info) {
if (info.event.id !== 'selected-slot') {
return;
}
if (info.event.id !== 'selected-slot') return;
const droppedStart = formatDateForInput(info.event.start);
if (!slotStartSet.has(droppedStart)) {
info.revert();
window.alert('Dieser Zeitpunkt ist nicht als freier Slot verfügbar.');
window.alert('Dieser Zeitpunkt ist ausgebucht oder nicht verfügbar.');
return;
}
applySelectedSlot(droppedStart);
},
eventClick: function (info) {
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
if (slotType !== 'free') {
return;
}
if (slotType !== 'free') return;
applySelectedSlot(info.event.extendedProps.slotStart || '');
}
});
// No background greying: show full calendar skeleton and only mark possible slots
function updateSliderLabel() {
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
const idx = Number.parseInt(slider.value, 10) || 0;
@@ -377,9 +531,7 @@
selectedEvent = null;
}
if (!selectedSlot) {
return;
}
if (!selectedSlot) return;
const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
const end = addMinutes(start, slotLength);
@@ -398,13 +550,9 @@
function getIcsDownloadUrl() {
const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }};
const url = new URL(base, window.location.origin);
if (selectedSlot) {
url.searchParams.set('start', selectedSlot);
}
if (selectedSlot) url.searchParams.set('start', selectedSlot);
const name = String(clientNameInput.value || '').trim();
if (name) {
url.searchParams.set('name', name);
}
if (name) url.searchParams.set('name', name);
return url.toString();
}
@@ -415,14 +563,19 @@
}
}
// No background gaps: keep full skeleton/grid visible
// Add candidate free slots (blue)
// Render Active/Available Slots with Live Seat Counts
candidateSlots.forEach(function (slot) {
const start = new Date(slot.start.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({
title: 'Freier Slot',
title: displayTitle,
start: start,
end: end,
color: '#0d6efd',
@@ -435,46 +588,54 @@
});
});
(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);
// Client View Optimization: Render solid red events ONLY if a slot is 100% full
fullyBookedSlots.forEach(function (slot) {
const start = new Date(slot.start.replace(' ', 'T') + ':00');
const end = new Date(slot.end.replace(' ', 'T') + ':00');
calendar.addEvent({
title: 'Gebucht' + (booking.name ? ' - ' + booking.name : ''),
title: 'Ausgebucht',
start: start,
end: end,
color: '#dc3545',
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) {
if (!selectedSlotInput.value) {
ev.preventDefault();
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
return;
}
if (allowImmediateSubmit) {
return;
}
if (allowImmediateSubmit) return;
ev.preventDefault();
if (modal) {
modal.show();
}
if (modal) modal.show();
});
if (confirmBookingOnlyBtn) {
confirmBookingOnlyBtn.addEventListener('click', function () {
allowImmediateSubmit = true;
if (modal) {
modal.hide();
}
if (modal) modal.hide();
form.submit();
});
}
@@ -482,13 +643,9 @@
if (confirmBookingWithIcsBtn) {
confirmBookingWithIcsBtn.addEventListener('click', function () {
const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl();
if (icsUrl) {
window.open(icsUrl, '_blank');
}
if (icsUrl) window.open(icsUrl, '_blank');
allowImmediateSubmit = true;
if (modal) {
modal.hide();
}
if (modal) modal.hide();
form.submit();
});
}
@@ -509,10 +666,8 @@
slider.addEventListener('input', function () {
const idx = Number.parseInt(slider.value, 10) || 0;
updateSliderLabel();
if (allDays[idx]) {
if (calendar.view.type === 'timeGridDay') {
calendar.gotoDate(allDays[idx]);
}
if (allDays[idx] && calendar.view.type === 'timeGridDay') {
calendar.gotoDate(allDays[idx]);
}
});
+95 -10
View File
@@ -70,6 +70,10 @@
<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">
</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">
<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>
@@ -77,6 +81,16 @@
<div class="form-text">Wird aus der gebuchten Zeit (abzüglich Pausen) und der Slot-Länge berechnet.</div>
</div>
</div>
<div id="custom-fields-container" class="mt-4">
<h3 class="mb-3">Benutzerdefinierte Felder</h3>
<div class="mb-3 custom-field-row">
<input type="text"
name="custom_fields"
class="form-control form-control-lg custom-dynamic-input"
placeholder="Zusatzfeld hinzufügen (z.B. Firma, Kundennummer...)">
</div>
</div>
{% if mail_service_enabled %}
<div>
@@ -139,8 +153,9 @@
const defaultPauseEndInput = document.getElementById('default_pause_end');
const slotLengthInput = document.getElementById('slot_length');
const clientsperslot = document.getElementById('clients_per_slot')
const slotsAmountsInput = document.getElementById('slots_amounts');
const slotsAmountsDisplay = document.getElementById('slots_amounts_display'); // Neu: Anzeige-Element
const slotsAmountsDisplay = document.getElementById('slots_amounts_display');
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea) {
return;
@@ -188,16 +203,31 @@
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
function calculateSlots() {
if (!slotLengthInput || !slotsAmountsInput) return;
if (!slotLengthInput || !slotsAmountsInput || !clientsperslot) return;
const slotLength = parseInt(slotLengthInput.value, 10);
if (isNaN(slotLength) || slotLength <= 0) {
updateSlotsDisplay(0);
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;
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
@@ -209,19 +239,20 @@
if (!startTime || !endTime) return;
const startMins = timeToMinutes(startTime);
const endMins = timeToMinutes(endTime);
const pauseStartMins = timeToMinutes(pauseStart);
const pauseEndMins = timeToMinutes(pauseEnd);
// Using safety parsing to prevent missing values from generating NaN
const startMins = safeTimeToMinutes(startTime);
const endMins = safeTimeToMinutes(endTime);
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
if (pauseStartMins > 0 && pauseEndMins > 0 && pauseStartMins < pauseEndMins && pauseStartMins > startMins && pauseStartMins < endMins) {
// Segment 1 (Vor der Pause)
const segment1 = pauseStartMins - startMins;
totalSlots += Math.floor(segment1 / slotLength);
// Segment 2 (Nach der Pause)
const effectivePauseEnd = Math.min(pauseEndMins, endMins);
const segment2 = endMins - effectivePauseEnd;
@@ -234,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
@@ -351,6 +386,39 @@
syncTextarea();
}
document.addEventListener('DOMContentLoaded', function () {
const container = document.getElementById('custom-fields-container');
// Monitor inputs inside the container using event delegation
container.addEventListener('input', function (event) {
// Only trigger if the user is typing inside our dynamic inputs
if (event.target.classList.contains('custom-dynamic-input')) {
const allInputs = container.querySelectorAll('.custom-dynamic-input');
const lastInput = allInputs[allInputs.length - 1];
// If the user typed something into the absolute last input field, spawn a new one
if (lastInput.value.trim() !== '') {
createNewFieldRow(container);
}
}
});
// Helper function to generate and append a clean new input row
function createNewFieldRow(targetContainer) {
const rowDiv = document.createElement('div');
rowDiv.className = 'mb-3 custom-field-row';
rowDiv.innerHTML = `
<input type="text"
name="custom_fields"
class="form-control form-control-lg custom-dynamic-input"
placeholder="Nächstes Zusatzfeld...">
`;
targetContainer.appendChild(rowDiv);
}
});
// Event Listeners Registration
buildButton.addEventListener('click', renderRows);
startDateInput.addEventListener('change', renderRows);
@@ -366,10 +434,27 @@
slotLengthInput.addEventListener('input', calculateSlots);
slotLengthInput.addEventListener('change', calculateSlots);
}
if (clientsperslot) {
clientsperslot.addEventListener('input', calculateSlots);
clientsperslot.addEventListener('change', calculateSlots);
}
if (startDateInput.value && endDateInput.value) {
renderRows();
}
const configForm = document.querySelector('form');
if (configForm) {
configForm.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
if (event.target.tagName === 'TEXTAREA') return;
if (event.target.tagName === 'BUTTON' && event.target.type === 'submit') return;
event.preventDefault();
}
});
}
})();
</script>
{% endblock %}
+46 -2
View File
@@ -6,6 +6,8 @@
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12 col-lg-11 col-xl-10">
<!-- Hero Sektion -->
<section class="p-4 p-md-5 rounded-4 shadow-lg" style="background: linear-gradient(135deg, rgba(15,76,92,0.96), rgba(22,105,122,0.92)); color: #fff;">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-start align-items-lg-end">
<div>
@@ -20,6 +22,39 @@
</div>
</section>
<!-- NEU EINGEBAUT: Erfolgsmeldung für gerade erstellte Buchungslinks -->
{% if generated_link %}
<div class="alert alert-success mt-4 shadow-sm rounded-4">
<div class="fw-bold mb-1">Buchungslink erstellt</div>
<div class="mb-2">
{% if mail_service_enabled %}
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
{% else %}
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
{% endif %}
</div>
<a href="{{ generated_link }}" class="d-inline-block text-break mb-3">{{ generated_link }}</a>
<!-- PDF Export per ReportLab -->
<div class="pt-3 border-top border-success-subtle">
<div class="fw-semibold mb-1">Einladungsbrief (inkl. QR-Code)</div>
<p class="small text-muted mb-2">Laden Sie einen automatisch generierten Brief herunter, der den Buchungslink und einen passenden QR-Code enthält.</p>
<a href="{{ url_for('terminplaner.export_pdf_brief', plan_id=plan_id) }}" class="btn btn-outline-success btn-sm">
📄 Brief als PDF herunterladen
</a>
</div>
{% if calendar_link %}
<div class="mt-3 pt-3 border-top border-success-subtle">
<div class="fw-semibold mb-1">Kalendereintrag</div>
<a href="{{ calendar_link }}" class="btn btn-outline-primary btn-sm">.ics herunterladen</a>
</div>
{% endif %}
</div>
{% endif %}
<!-- ENDE NEUER BLOCK -->
<!-- Info-Karten -->
<div class="row g-4 mt-1">
<div class="col-12 col-md-4">
<div class="card h-100 shadow-sm border-0 rounded-4">
@@ -59,6 +94,7 @@
<p class="mb-0 text-muted">Sie können Termine anlegen, den Kalender prüfen und Buchungslinks verteilen.</p>
</div>
<!-- "Tabelle" (Liste) der kommenden Termine -->
<div class="mt-4 p-4 rounded-4 bg-white shadow-sm">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
<h2 class="h5 fw-bold mb-0">Kommende Termine</h2>
@@ -84,10 +120,18 @@
<div class="text-lg-end">
<div class="small mb-2">Gebucht: <strong>{{ event.slots_booked }}</strong> / {{ event.slots_total }} | Frei: <strong>{{ event.slots_left }}</strong></div>
<div class="d-flex flex-wrap gap-2 justify-content-lg-end">
<a class="btn btn-sm btn-primary" href="{{ event.link }}" target="_blank" rel="noopener">Client-Link öffnen</a>
<!-- Hinzugefügt: Direkter PDF-Export auch bei bestehenden Plänen -->
<a class="btn btn-sm btn-outline-success" href="{{ url_for('terminplaner.export_pdf_brief', plan_id=event.appointment_id) }}" title="Brief herunterladen">
📄 PDF
</a>
<a class="btn btn-sm btn-primary" href="{{ event.link }}" target="_blank" rel="noopener">Client-Link</a>
{% if event.calendar_link %}
<a class="btn btn-sm btn-outline-primary" href="{{ event.calendar_link }}">.ics</a>
{% endif %}
<form method="post" action="{{ url_for('terminplaner.delete_appointment', appointment_id=event.appointment_id, tenant=tenant_id) }}" class="d-inline" onsubmit="return confirm('Diesen Terminplan wirklich löschen?');">
<button type="submit" class="btn btn-sm btn-outline-danger">Entfernen</button>
</form>
@@ -105,4 +149,4 @@
</div>
</div>
</div>
{% endblock %}
{% endblock %}