Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99ad9d4f79 | |||
| 89c1a525d8 | |||
| 9701805552 | |||
| 4227934252 | |||
| d6883d1879 |
+1
-1
@@ -1 +1 @@
|
||||
v0.8.20
|
||||
v0.8.22
|
||||
|
||||
@@ -10,135 +10,6 @@ import Web.modules.database.settings as cfg
|
||||
from Web.tenant import get_tenant_context
|
||||
|
||||
|
||||
def _parse_date_value(value):
|
||||
try:
|
||||
return datetime.datetime.strptime(str(value), '%Y-%m-%d').date()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_time_span_entry(entry):
|
||||
"""Parse entries like '2026-06-01 08:00-12:00' into a date and time window."""
|
||||
text = str(entry or '').strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
match = None
|
||||
import re
|
||||
match = re.match(r'^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$', text)
|
||||
if match:
|
||||
date_text, start_text, end_text = match.groups()
|
||||
return {
|
||||
'date': date_text,
|
||||
'start_time': start_text,
|
||||
'end_time': end_text,
|
||||
'raw': text,
|
||||
}
|
||||
|
||||
match = re.match(r'^(\d{2}:\d{2})-(\d{2}:\d{2})$', text)
|
||||
if match:
|
||||
start_text, end_text = match.groups()
|
||||
return {
|
||||
'date': None,
|
||||
'start_time': start_text,
|
||||
'end_time': end_text,
|
||||
'raw': text,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _iter_date_range(start_date, end_date):
|
||||
if not start_date or not end_date:
|
||||
return []
|
||||
current = start_date
|
||||
days = []
|
||||
while current <= end_date:
|
||||
days.append(current)
|
||||
current += datetime.timedelta(days=1)
|
||||
return days
|
||||
|
||||
|
||||
def _format_display_date(date_value):
|
||||
if not date_value:
|
||||
return ''
|
||||
try:
|
||||
return date_value.strftime('%d.%m.%Y')
|
||||
except Exception:
|
||||
return str(date_value)
|
||||
|
||||
|
||||
def _weekday_name(date_value):
|
||||
names = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
|
||||
try:
|
||||
return names[date_value.weekday()]
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _build_slot_overview(termin_range):
|
||||
start_date = _parse_date_value(termin_range.get('date_start'))
|
||||
end_date = _parse_date_value(termin_range.get('date_end'))
|
||||
slot_length = termin_range.get('slot_lenght')
|
||||
try:
|
||||
slot_length = int(slot_length or 0)
|
||||
except Exception:
|
||||
slot_length = 0
|
||||
|
||||
booked_entries = termin_range.get('slots_booked', []) or []
|
||||
booked_starts = set()
|
||||
for booked in booked_entries:
|
||||
if isinstance(booked, (list, tuple)) and len(booked) >= 1:
|
||||
booked_starts.add(str(booked[0]).strip())
|
||||
elif isinstance(booked, dict):
|
||||
booked_starts.add(str(booked.get('start') or booked.get('value') or '').strip())
|
||||
|
||||
windows_by_date = {}
|
||||
fallback_windows = []
|
||||
for entry in termin_range.get('time_span', []) or []:
|
||||
parsed = _parse_time_span_entry(entry)
|
||||
if not parsed:
|
||||
continue
|
||||
if parsed['date']:
|
||||
windows_by_date.setdefault(parsed['date'], []).append(parsed)
|
||||
else:
|
||||
fallback_windows.append(parsed)
|
||||
|
||||
day_items = []
|
||||
for day in _iter_date_range(start_date, end_date):
|
||||
day_key = day.strftime('%Y-%m-%d')
|
||||
day_windows = windows_by_date.get(day_key, []) or fallback_windows
|
||||
slots = []
|
||||
|
||||
for window in day_windows:
|
||||
try:
|
||||
window_start = datetime.datetime.strptime(f"{day_key} {window['start_time']}", '%Y-%m-%d %H:%M')
|
||||
window_end = datetime.datetime.strptime(f"{day_key} {window['end_time']}", '%Y-%m-%d %H:%M')
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
current = window_start
|
||||
while current + datetime.timedelta(minutes=max(1, slot_length)) <= window_end:
|
||||
start_label = current.strftime('%Y-%m-%d %H:%M')
|
||||
end_label = (current + datetime.timedelta(minutes=max(1, slot_length))).strftime('%H:%M')
|
||||
slots.append({
|
||||
'value': start_label,
|
||||
'label': current.strftime('%H:%M') + ' - ' + end_label,
|
||||
'occupied': start_label in booked_starts,
|
||||
})
|
||||
current += datetime.timedelta(minutes=max(1, slot_length))
|
||||
|
||||
day_items.append({
|
||||
'date': day_key,
|
||||
'display_date': _format_display_date(day),
|
||||
'weekday': _weekday_name(day),
|
||||
'slots': slots,
|
||||
'has_slots': bool(slots),
|
||||
})
|
||||
|
||||
return day_items
|
||||
|
||||
|
||||
def _resolve_public_base_url() -> str:
|
||||
if has_request_context():
|
||||
try:
|
||||
@@ -259,6 +130,68 @@ def build_calendar_ics(appointment_id: str) -> str | None:
|
||||
return '\r\n'.join(ics_lines)
|
||||
|
||||
|
||||
def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str = '') -> str | None:
|
||||
"""Build a single-slot ICS export for a client booking candidate."""
|
||||
item = termin.get_item(appointment_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
try:
|
||||
start_dt = datetime.datetime.strptime(str(slot_start).strip(), '%Y-%m-%d %H:%M')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
slot_minutes = int(item.get('slot_lenght') or 0)
|
||||
except Exception:
|
||||
slot_minutes = 0
|
||||
if slot_minutes <= 0:
|
||||
slot_minutes = 45
|
||||
|
||||
end_dt = start_dt + datetime.timedelta(minutes=slot_minutes)
|
||||
tenant_id = _current_tenant_id()
|
||||
|
||||
try:
|
||||
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
host = _resolve_public_base_url()
|
||||
link = host + '/terminplaner/client/' + str(appointment_id)
|
||||
if tenant_id:
|
||||
link += f'?tenant={tenant_id}'
|
||||
|
||||
title_name = str(client_name or '').strip() or 'Termin'
|
||||
summary = f"{title_name} - Terminbuchung"
|
||||
description_lines = [
|
||||
f"Buchungslink: {link}",
|
||||
f"Geplanter Termin: {start_dt.strftime('%d.%m.%Y %H:%M')} - {end_dt.strftime('%H:%M')}",
|
||||
]
|
||||
|
||||
uid = f"terminplaner-slot-{appointment_id}-{start_dt.strftime('%Y%m%d%H%M')}@invario.eu"
|
||||
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
|
||||
dt_start = start_dt.strftime('%Y%m%dT%H%M%S')
|
||||
dt_end = end_dt.strftime('%Y%m%dT%H%M%S')
|
||||
|
||||
ics_lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Inventarsystem//Terminplaner Client Slot//DE',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
'BEGIN:VEVENT',
|
||||
f'UID:{uid}',
|
||||
f'DTSTAMP:{created_at}',
|
||||
f'SUMMARY:{_escape_ics_text(summary)}',
|
||||
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
|
||||
f'URL:{_escape_ics_text(link)}',
|
||||
f'DTSTART:{dt_start}',
|
||||
f'DTEND:{dt_end}',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
'',
|
||||
]
|
||||
return '\r\n'.join(ics_lines)
|
||||
|
||||
|
||||
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False) -> dict:
|
||||
"""
|
||||
Generates a link for the executive to send to his clients to book a time Slot
|
||||
@@ -459,7 +392,6 @@ def get_available(id):
|
||||
'slots_total': total_slots,
|
||||
'slots_booked': normalized,
|
||||
'slots_left': slots_left,
|
||||
'slot_days': _build_slot_overview(termin_range),
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error getting available slots: {e}")
|
||||
|
||||
@@ -168,6 +168,23 @@ def calendar_export(appointment_id):
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics'
|
||||
return response
|
||||
|
||||
|
||||
@appoint_bp.route('/client_ics/<appointment_id>.ics', methods=['GET'])
|
||||
def client_slot_calendar_export(appointment_id):
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
slot_start = str(request.args.get('start', '') or '').strip()
|
||||
client_name = str(request.args.get('name', '') or '').strip()
|
||||
ics_content = appointment_service.build_client_slot_ics(appointment_id, slot_start, client_name=client_name)
|
||||
if not ics_content:
|
||||
return _appointment_not_found_response()
|
||||
|
||||
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=termin-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
|
||||
return response
|
||||
|
||||
@appoint_bp.route('/')
|
||||
def main():
|
||||
guard = _require_module_enabled()
|
||||
|
||||
+376
-187
@@ -4,67 +4,32 @@
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.slot-day-strip {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(220px, 1fr);
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
scroll-snap-type: x proximity;
|
||||
#client-slot-calendar {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.slot-day-card {
|
||||
scroll-snap-align: start;
|
||||
border: 1px solid rgba(15, 76, 92, 0.12);
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.slot-day-card.is-active {
|
||||
border-color: #0f4c5c;
|
||||
box-shadow: 0 0.75rem 1.75rem rgba(15, 76, 92, 0.12);
|
||||
}
|
||||
|
||||
.slot-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.slot-pill {
|
||||
border: 1px solid rgba(15, 76, 92, 0.18);
|
||||
background: #f7fbfc;
|
||||
color: #0f4c5c;
|
||||
border-radius: 999px;
|
||||
padding: 0.65rem 0.9rem;
|
||||
.fc .fc-timegrid-slot-label-cushion,
|
||||
.fc .fc-timegrid-axis-cushion,
|
||||
.fc .fc-col-header-cell-cushion {
|
||||
font-weight: 600;
|
||||
transition: transform 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.slot-pill:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
background: #e8f4f6;
|
||||
border-color: #0f4c5c;
|
||||
.slot-selected-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(13, 110, 253, 0.1);
|
||||
color: #0d6efd;
|
||||
padding: .35rem .75rem;
|
||||
font-size: .9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.slot-pill.active {
|
||||
background: #0f4c5c;
|
||||
color: #fff;
|
||||
border-color: #0f4c5c;
|
||||
}
|
||||
|
||||
.slot-pill:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.slot-summary {
|
||||
background: linear-gradient(135deg, rgba(15,76,92,0.08), rgba(46,196,182,0.08));
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
.day-slider-wrap {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: .9rem;
|
||||
padding: .8rem 1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -72,14 +37,14 @@
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-xl-10">
|
||||
<div class="col-12 col-xxl-11">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
|
||||
<h1 class="h3 fw-bold mb-3">Termin buchen</h1>
|
||||
<p class="text-muted mb-4">Wählen Sie einen freien Zeitpunkt und tragen Sie Ihren Namen ein. Der Termin wird anschließend im Plan gespeichert.</p>
|
||||
<p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p>
|
||||
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
<div class="fw-semibold">Zeitraum</div>
|
||||
@@ -94,9 +59,9 @@
|
||||
<div>{{ available.slot_lenght }} Minuten</div>
|
||||
</div>
|
||||
|
||||
<div class="slot-summary mb-3">
|
||||
<div class="fw-semibold mb-2">So funktioniert die Buchung</div>
|
||||
<div class="small text-muted mb-0">Wählen Sie zuerst ein Datum und dann einen freien Zeitblock. Der Startzeitpunkt wird automatisch übernommen.</div>
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
<div class="fw-semibold mb-2">Gewählter Termin</div>
|
||||
<div id="selected-slot-badge" class="text-muted small">Noch kein Slot ausgewählt.</div>
|
||||
</div>
|
||||
|
||||
{% if available.slots_booked %}
|
||||
@@ -112,71 +77,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||
<div class="card-body p-4 p-md-5 bg-white">
|
||||
<h2 class="h4 fw-bold mb-4">Buchung absenden</h2>
|
||||
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
|
||||
<input type="hidden" id="start_day_time" name="start_day_time" required>
|
||||
<h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
|
||||
|
||||
<div>
|
||||
<label for="start_date_picker" class="form-label fw-semibold">Startdatum</label>
|
||||
<input type="date" id="start_date_picker" class="form-control form-control-lg" value="{{ available.date_start }}" min="{{ available.date_start }}" max="{{ available.date_end }}">
|
||||
<div class="form-text">Wählen Sie zuerst den Tag. Danach erscheint der passende Zeitbereich.</div>
|
||||
<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>
|
||||
|
||||
{% if available.slot_days %}
|
||||
<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>
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<label class="form-label fw-semibold mb-0">Freie Zeitblöcke</label>
|
||||
<div id="slot-selection-label" class="small text-muted">Bitte ein Datum und einen Zeitblock auswählen</div>
|
||||
</div>
|
||||
|
||||
<div class="slot-day-strip mb-3" id="slot-day-strip">
|
||||
{% for day in available.slot_days %}
|
||||
<button type="button" class="slot-day-card p-3 text-start {% if loop.first %}is-active{% endif %}" data-slot-day="{{ day.date }}" aria-pressed="{% if loop.first %}true{% else %}false{% endif %}">
|
||||
<div class="small text-uppercase text-muted fw-semibold">{{ day.weekday }}</div>
|
||||
<div class="h5 fw-bold mb-1">{{ day.display_date }}</div>
|
||||
<div class="small text-muted">{{ day.slots|length }} mögliche Zeitblöcke</div>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="slot-panels" class="vstack gap-3">
|
||||
{% for day in available.slot_days %}
|
||||
<div class="slot-panel {% if not loop.first %}d-none{% endif %}" data-slot-panel="{{ day.date }}">
|
||||
<div class="fw-semibold mb-2">{{ day.weekday }}, {{ day.display_date }}</div>
|
||||
{% if day.slots %}
|
||||
<div class="slot-pills">
|
||||
{% for slot in day.slots %}
|
||||
<button type="button"
|
||||
class="slot-pill"
|
||||
data-slot-value="{{ slot.value }}"
|
||||
data-slot-label="{{ day.display_date }} {{ slot.label }}"
|
||||
{% if slot.occupied %}disabled{% endif %}>
|
||||
{{ slot.label }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted small">Für diesen Tag gibt es keine freien Zeitblöcke.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</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>
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
|
||||
<div class="p-3 rounded-3 bg-light">
|
||||
<div class="fw-semibold">Ausgewählt</div>
|
||||
<div id="selected-slot-preview" class="text-muted">Noch kein Termin ausgewählt</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
|
||||
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
|
||||
@@ -189,97 +114,361 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="calendarDownloadModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Termin zum Kalender hinzufügen?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">Sie haben einen Termin ausgewählt.</p>
|
||||
<p class="mb-0 small text-muted">Mit einem Klick auf ".ics herunterladen" können Sie den Termin in Apple/Google/Outlook-Kalender importieren.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Später</button>
|
||||
<a id="download-slot-ics" class="btn btn-primary" href="#">.ics herunterladen</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const dayButtons = Array.from(document.querySelectorAll('[data-slot-day]'));
|
||||
const slotPanels = Array.from(document.querySelectorAll('[data-slot-panel]'));
|
||||
const slotButtons = Array.from(document.querySelectorAll('.slot-pill[data-slot-value]'));
|
||||
const hiddenStartInput = document.getElementById('start_day_time');
|
||||
const selectedPreview = document.getElementById('selected-slot-preview');
|
||||
const dayPicker = document.getElementById('start_date_picker');
|
||||
const selectionLabel = document.getElementById('slot-selection-label');
|
||||
const available = {{ available|tojson }};
|
||||
const appointmentId = {{ appointment_id|tojson }};
|
||||
const slider = document.getElementById('day-slider');
|
||||
const sliderLabel = document.getElementById('day-slider-label');
|
||||
const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null;
|
||||
const selectedSlotInput = document.getElementById('start_day_time');
|
||||
const selectedSlotBadge = document.getElementById('selected-slot-badge');
|
||||
const clientNameInput = document.getElementById('client_name');
|
||||
const form = document.getElementById('client-booking-form');
|
||||
const calendarEl = document.getElementById('client-slot-calendar');
|
||||
const modalEl = document.getElementById('calendarDownloadModal');
|
||||
const downloadIcsLink = document.getElementById('download-slot-ics');
|
||||
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||
|
||||
function setPreview(text) {
|
||||
if (selectedPreview) {
|
||||
selectedPreview.textContent = text || 'Noch kein Termin ausgewählt';
|
||||
const slotLength = Number.parseInt(available.slot_lenght, 10) || 45;
|
||||
const bookedStarts = new Set((available.slots_booked || []).map(function (entry) {
|
||||
return String(entry.start || '').trim();
|
||||
}).filter(Boolean));
|
||||
|
||||
function formatDateForInput(dateObj) {
|
||||
const y = dateObj.getFullYear();
|
||||
const m = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(dateObj.getDate()).padStart(2, '0');
|
||||
const hh = String(dateObj.getHours()).padStart(2, '0');
|
||||
const mm = String(dateObj.getMinutes()).padStart(2, '0');
|
||||
return y + '-' + m + '-' + d + ' ' + hh + ':' + mm;
|
||||
}
|
||||
|
||||
function formatDateReadable(value) {
|
||||
if (!value) return 'Noch kein Slot ausgewählt.';
|
||||
return 'Ausgewählt: ' + value;
|
||||
}
|
||||
|
||||
function dateRangeInclusive(startStr, endStr) {
|
||||
const days = [];
|
||||
const start = new Date(startStr + 'T00:00:00');
|
||||
const end = new Date(endStr + 'T00:00:00');
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return days;
|
||||
}
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
const y = cursor.getFullYear();
|
||||
const m = String(cursor.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(cursor.getDate()).padStart(2, '0');
|
||||
days.push(y + '-' + m + '-' + d);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function addMinutes(dateObj, minutes) {
|
||||
return new Date(dateObj.getTime() + minutes * 60000);
|
||||
}
|
||||
|
||||
function formatTimeForCalendar(dateObj) {
|
||||
return String(dateObj.getHours()).padStart(2, '0') + ':' + String(dateObj.getMinutes()).padStart(2, '0') + ':00';
|
||||
}
|
||||
|
||||
function addDays(dateStr, days) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
d.setDate(d.getDate() + days);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return y + '-' + m + '-' + day;
|
||||
}
|
||||
|
||||
function parseTimeSpanEntry(entry) {
|
||||
const value = String(entry || '').trim();
|
||||
let m = value.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$/);
|
||||
if (m) {
|
||||
return { date: m[1], from: m[2], to: m[3] };
|
||||
}
|
||||
m = value.match(/^(\d{2}:\d{2})-(\d{2}:\d{2})$/);
|
||||
if (m) {
|
||||
return { date: null, from: m[1], to: m[2] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildCandidateSlots() {
|
||||
const slots = [];
|
||||
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||
const spans = Array.isArray(available.time_span) ? available.time_span : [];
|
||||
|
||||
spans.forEach(function (entry) {
|
||||
const parsed = parseTimeSpanEntry(entry);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDates = parsed.date ? [parsed.date] : allowedDates;
|
||||
targetDates.forEach(function (date) {
|
||||
const from = new Date(date + 'T' + parsed.from + ':00');
|
||||
const to = new Date(date + 'T' + parsed.to + ':00');
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cursor = new Date(from);
|
||||
while (addMinutes(cursor, slotLength) <= to) {
|
||||
const slotStart = formatDateForInput(cursor);
|
||||
if (!bookedStarts.has(slotStart)) {
|
||||
slots.push({
|
||||
start: slotStart,
|
||||
end: formatDateForInput(addMinutes(cursor, slotLength)),
|
||||
});
|
||||
}
|
||||
cursor = addMinutes(cursor, slotLength);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
const candidateSlots = buildCandidateSlots();
|
||||
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
|
||||
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||
|
||||
let slotMinTime = '08:00:00';
|
||||
let slotMaxTime = '18: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()); });
|
||||
|
||||
if (starts.length > 0 && ends.length > 0) {
|
||||
let minStart = starts[0];
|
||||
let maxEnd = ends[0];
|
||||
starts.forEach(function (d) { if (d < minStart) minStart = d; });
|
||||
ends.forEach(function (d) { if (d > maxEnd) maxEnd = d; });
|
||||
slotMinTime = formatTimeForCalendar(minStart);
|
||||
slotMaxTime = formatTimeForCalendar(maxEnd);
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveDay(dayValue) {
|
||||
dayButtons.forEach((btn) => {
|
||||
const isActive = btn.dataset.slotDay === dayValue;
|
||||
btn.classList.toggle('is-active', isActive);
|
||||
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||
});
|
||||
const visibleStart = allDays[0] || String(available.date_start || '');
|
||||
const visibleEndExclusive = allDays.length > 0
|
||||
? addDays(allDays[allDays.length - 1], 1)
|
||||
: addDays(String(available.date_end || available.date_start || ''), 1);
|
||||
|
||||
slotPanels.forEach((panel) => {
|
||||
panel.classList.toggle('d-none', panel.dataset.slotPanel !== dayValue);
|
||||
});
|
||||
const multiDayDuration = Math.max(1, allDays.length || 1);
|
||||
const initialViewName = multiDayDuration === 1 ? 'timeGridDay' : 'timeGridRange';
|
||||
let selectedSlot = '';
|
||||
let selectedEvent = null;
|
||||
let selectedShownOnce = false;
|
||||
|
||||
if (dayPicker && dayValue) {
|
||||
dayPicker.value = dayValue;
|
||||
const calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
initialView: initialViewName,
|
||||
views: {
|
||||
timeGridRange: {
|
||||
type: 'timeGrid',
|
||||
duration: { days: multiDayDuration },
|
||||
buttonText: 'Zeitraum'
|
||||
}
|
||||
},
|
||||
locale: 'de',
|
||||
firstDay: 1,
|
||||
height: 'auto',
|
||||
allDaySlot: false,
|
||||
editable: true,
|
||||
eventStartEditable: true,
|
||||
eventDurationEditable: false,
|
||||
selectable: false,
|
||||
slotDuration: '00:15:00',
|
||||
snapDuration: '00:15:00',
|
||||
slotMinTime: slotMinTime,
|
||||
slotMaxTime: slotMaxTime,
|
||||
nowIndicator: true,
|
||||
validRange: {
|
||||
start: visibleStart,
|
||||
end: visibleEndExclusive,
|
||||
},
|
||||
visibleRange: {
|
||||
start: visibleStart,
|
||||
end: visibleEndExclusive,
|
||||
},
|
||||
headerToolbar: {
|
||||
left: '',
|
||||
center: 'title',
|
||||
right: multiDayDuration === 1 ? '' : 'timeGridDay,timeGridRange'
|
||||
},
|
||||
events: [],
|
||||
eventDrop: function (info) {
|
||||
if (info.event.id !== 'selected-slot') {
|
||||
return;
|
||||
}
|
||||
const droppedStart = formatDateForInput(info.event.start);
|
||||
if (!slotStartSet.has(droppedStart)) {
|
||||
info.revert();
|
||||
window.alert('Dieser Zeitpunkt ist nicht als freier Slot verfügbar.');
|
||||
return;
|
||||
}
|
||||
applySelectedSlot(droppedStart);
|
||||
},
|
||||
eventClick: function (info) {
|
||||
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
|
||||
if (slotType !== 'free') {
|
||||
return;
|
||||
}
|
||||
applySelectedSlot(info.event.extendedProps.slotStart || '');
|
||||
}
|
||||
});
|
||||
|
||||
if (selectionLabel) {
|
||||
selectionLabel.textContent = dayValue ? ('Datum: ' + dayValue) : 'Bitte ein Datum und einen Zeitblock auswählen';
|
||||
}
|
||||
|
||||
const firstAvailable = slotButtons.find((button) => !button.disabled && button.closest('[data-slot-panel]')?.dataset.slotPanel === dayValue);
|
||||
if (firstAvailable) {
|
||||
firstAvailable.click();
|
||||
} else {
|
||||
hiddenStartInput.value = '';
|
||||
setPreview('Für dieses Datum kein freier Block ausgewählt');
|
||||
slotButtons.forEach((button) => button.classList.remove('active'));
|
||||
}
|
||||
function updateSliderLabel() {
|
||||
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||
const idx = Number.parseInt(slider.value, 10) || 0;
|
||||
sliderLabel.textContent = dateList[idx] || '';
|
||||
}
|
||||
|
||||
function setActiveSlot(button) {
|
||||
if (!button || button.disabled) {
|
||||
function applySelectedSlot(value) {
|
||||
selectedSlot = String(value || '').trim();
|
||||
selectedSlotInput.value = selectedSlot;
|
||||
selectedSlotBadge.innerHTML = selectedSlot
|
||||
? '<span class="slot-selected-chip">' + selectedSlot + '</span>'
|
||||
: 'Noch kein Slot ausgewählt.';
|
||||
|
||||
if (selectedEvent) {
|
||||
selectedEvent.remove();
|
||||
selectedEvent = null;
|
||||
}
|
||||
|
||||
if (!selectedSlot) {
|
||||
return;
|
||||
}
|
||||
|
||||
slotButtons.forEach((item) => item.classList.toggle('active', item === button));
|
||||
hiddenStartInput.value = button.dataset.slotValue || '';
|
||||
setPreview(button.dataset.slotLabel || button.textContent.trim());
|
||||
const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
|
||||
const end = addMinutes(start, slotLength);
|
||||
selectedEvent = calendar.addEvent({
|
||||
id: 'selected-slot',
|
||||
title: 'Ihr ausgewählter Termin',
|
||||
start: start,
|
||||
end: end,
|
||||
color: '#0d6efd',
|
||||
editable: true,
|
||||
});
|
||||
|
||||
const parentPanel = button.closest('[data-slot-panel]');
|
||||
const dayValue = parentPanel ? parentPanel.dataset.slotPanel : '';
|
||||
if (dayValue && dayPicker) {
|
||||
dayPicker.value = dayValue;
|
||||
dayButtons.forEach((btn) => {
|
||||
const isActive = btn.dataset.slotDay === dayValue;
|
||||
btn.classList.toggle('is-active', isActive);
|
||||
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||
});
|
||||
if (selectionLabel) {
|
||||
selectionLabel.textContent = 'Datum: ' + dayValue;
|
||||
if (modal && !selectedShownOnce) {
|
||||
selectedShownOnce = true;
|
||||
modal.show();
|
||||
}
|
||||
|
||||
refreshIcsLink();
|
||||
}
|
||||
|
||||
function refreshIcsLink() {
|
||||
if (!downloadIcsLink) {
|
||||
return;
|
||||
}
|
||||
const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }};
|
||||
const url = new URL(base, window.location.origin);
|
||||
if (selectedSlot) {
|
||||
url.searchParams.set('start', selectedSlot);
|
||||
}
|
||||
const name = String(clientNameInput.value || '').trim();
|
||||
if (name) {
|
||||
url.searchParams.set('name', name);
|
||||
}
|
||||
downloadIcsLink.setAttribute('href', url.toString());
|
||||
}
|
||||
|
||||
candidateSlots.forEach(function (slot) {
|
||||
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
||||
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
||||
calendar.addEvent({
|
||||
title: 'Freier Slot',
|
||||
start: start,
|
||||
end: end,
|
||||
color: '#20c997',
|
||||
textColor: '#062b1f',
|
||||
editable: false,
|
||||
extendedProps: {
|
||||
slotStart: slot.start,
|
||||
slotType: 'free',
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(available.slots_booked || []).forEach(function (booking) {
|
||||
const startStr = String(booking.start || '').trim();
|
||||
if (!startStr) {
|
||||
return;
|
||||
}
|
||||
const start = new Date(startStr.replace(' ', 'T') + ':00');
|
||||
const end = addMinutes(start, slotLength);
|
||||
calendar.addEvent({
|
||||
title: 'Gebucht' + (booking.name ? ' - ' + booking.name : ''),
|
||||
start: start,
|
||||
end: end,
|
||||
color: '#dc3545',
|
||||
editable: false,
|
||||
display: 'block',
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function (ev) {
|
||||
if (!selectedSlotInput.value) {
|
||||
ev.preventDefault();
|
||||
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
|
||||
}
|
||||
});
|
||||
|
||||
clientNameInput.addEventListener('input', refreshIcsLink);
|
||||
|
||||
if (sliderWrap) {
|
||||
sliderWrap.style.display = multiDayDuration > 1 ? '' : 'none';
|
||||
}
|
||||
|
||||
slider.max = String(Math.max(0, allDays.length - 1));
|
||||
slider.value = '0';
|
||||
updateSliderLabel();
|
||||
if (allDays.length > 0) {
|
||||
calendar.gotoDate(allDays[0]);
|
||||
}
|
||||
|
||||
slider.addEventListener('input', function () {
|
||||
const idx = Number.parseInt(slider.value, 10) || 0;
|
||||
updateSliderLabel();
|
||||
if (allDays[idx]) {
|
||||
if (calendar.view.type === 'timeGridDay') {
|
||||
calendar.gotoDate(allDays[idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dayButtons.forEach((button) => {
|
||||
button.addEventListener('click', function () {
|
||||
setActiveDay(button.dataset.slotDay || '');
|
||||
});
|
||||
});
|
||||
|
||||
slotButtons.forEach((button) => {
|
||||
button.addEventListener('click', function () {
|
||||
setActiveSlot(button);
|
||||
});
|
||||
});
|
||||
|
||||
if (dayPicker) {
|
||||
dayPicker.addEventListener('change', function () {
|
||||
setActiveDay(dayPicker.value || '');
|
||||
});
|
||||
}
|
||||
|
||||
const initialDay = dayPicker && dayPicker.value ? dayPicker.value : (dayButtons[0] ? dayButtons[0].dataset.slotDay : '');
|
||||
if (initialDay) {
|
||||
setActiveDay(initialDay);
|
||||
}
|
||||
calendar.render();
|
||||
refreshIcsLink();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user