Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19a585b2ec | |||
| 925f07e96f | |||
| b8beec8209 | |||
| dd9390c649 | |||
| f31c4e2ff7 | |||
| 71e2895362 | |||
| d2c7e57f8d | |||
| 111d40b787 | |||
| 1a9513d442 | |||
| 3900059eb1 | |||
| 07b1cc5e79 | |||
| 6583f7a368 | |||
| 8e11fbf969 | |||
| 7d13dc264c | |||
| 480bd00a54 | |||
| fdf699363a | |||
| dd495a8b13 | |||
| ca57961d4b | |||
| 3923939387 | |||
| eb9c655398 | |||
| 4c2657218d | |||
| 875cfa01a3 | |||
| c0c61a41a3 |
+26
-21
@@ -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 ''
|
||||
@@ -3821,7 +3827,7 @@ def library_admin():
|
||||
show_library_features=True,
|
||||
upload_mode='library',
|
||||
page_title='Bücher hochladen',
|
||||
back_target='home_admin'
|
||||
back_target='library'
|
||||
)
|
||||
|
||||
|
||||
@@ -5074,7 +5080,7 @@ def upload_item():
|
||||
Enhanced for mobile browser compatibility.
|
||||
|
||||
Returns:
|
||||
flask.Response: Redirect to admin homepage or JSON response
|
||||
flask.Response: Redirect to admin homepage
|
||||
"""
|
||||
# Check if the user is authenticated
|
||||
if 'username' not in session:
|
||||
@@ -5087,7 +5093,12 @@ def upload_item():
|
||||
return jsonify({'success': False, 'message': 'Einfüge-Rechte erforderlich'}), 403
|
||||
|
||||
can_access_admin_home = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
|
||||
success_redirect_endpoint = 'home_admin' if can_access_admin_home else 'home'
|
||||
if can_access_admin_home:
|
||||
success_redirect_endpoint = 'home_admin'
|
||||
elif cfg.MODULES.is_enabled('library') and _page_access_allowed(permissions, 'home_library'):
|
||||
success_redirect_endpoint = 'home_library'
|
||||
else:
|
||||
success_redirect_endpoint = 'home'
|
||||
|
||||
# Detect if request is from mobile device
|
||||
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
|
||||
@@ -6044,22 +6055,9 @@ def upload_item():
|
||||
except Exception:
|
||||
app.logger.warning('Audit write failed for library_item_created')
|
||||
|
||||
if is_mobile:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': success_msg,
|
||||
'itemId': str(item_id),
|
||||
'stats': {
|
||||
'processed': processed_count,
|
||||
'errors': error_count,
|
||||
'skipped': skipped_count,
|
||||
'duplicates': len(duplicate_images) if duplicate_images else 0,
|
||||
'totalImages': len(image_filenames)
|
||||
}
|
||||
})
|
||||
else:
|
||||
flash(success_msg, 'success')
|
||||
return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id)))
|
||||
|
||||
flash(success_msg, 'success')
|
||||
return redirect(url_for(success_redirect_endpoint, highlight_item=str(item_id)))
|
||||
else:
|
||||
error_msg = 'Fehler beim Hinzufügen des Elements'
|
||||
if is_mobile:
|
||||
@@ -8376,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', '')
|
||||
|
||||
|
||||
+144
-72
@@ -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,54 +228,64 @@ 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, matching by encrypted username."""
|
||||
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')
|
||||
|
||||
encrypted_user = dp.encrypt_text(str(user or '').strip())
|
||||
|
||||
cursor = items.find(
|
||||
_active_record_query(
|
||||
{
|
||||
'user': str(user or '').strip(),
|
||||
'user': encrypted_user,
|
||||
'date_end': {'$gte': today},
|
||||
}
|
||||
)
|
||||
@@ -222,14 +294,14 @@ def get_upcoming_for_user(user: str, limit: int = 25):
|
||||
results = []
|
||||
for item in cursor:
|
||||
item['_id'] = str(item.get('_id'))
|
||||
results.append(item)
|
||||
results.append(_decrypt_appointment(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()
|
||||
@@ -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')),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ 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
|
||||
import csv
|
||||
import io
|
||||
from flask import make_response, flash, redirect, url_for, session
|
||||
|
||||
# Create a blueprint instance
|
||||
appoint_bp = Blueprint('terminplaner', __name__)
|
||||
@@ -34,10 +37,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 +113,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 +124,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 +138,58 @@ 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
|
||||
)
|
||||
)
|
||||
|
||||
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 +198,8 @@ 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
|
||||
)
|
||||
|
||||
|
||||
@@ -154,7 +251,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 +266,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 +287,46 @@ 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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
|
||||
|
||||
+274
-119
@@ -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]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,6 +153,7 @@
|
||||
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
|
||||
|
||||
@@ -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,6 +434,10 @@
|
||||
slotLengthInput.addEventListener('input', calculateSlots);
|
||||
slotLengthInput.addEventListener('change', calculateSlots);
|
||||
}
|
||||
if (clientsperslot) {
|
||||
clientsperslot.addEventListener('input', calculateSlots);
|
||||
clientsperslot.addEventListener('change', calculateSlots);
|
||||
}
|
||||
|
||||
if (startDateInput.value && endDateInput.value) {
|
||||
renderRows();
|
||||
|
||||
@@ -954,7 +954,7 @@
|
||||
<div class="isbn-input-group">
|
||||
<input type="text" id="isbn" name="isbn" placeholder="ISBN oder Barcode eingeben..." required>
|
||||
<button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button>
|
||||
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Bild abrufen</button>
|
||||
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Informationen abrufen</button>
|
||||
</div>
|
||||
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
|
||||
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
|
||||
|
||||
Reference in New Issue
Block a user