Compare commits

...

5 Commits

4 changed files with 456 additions and 318 deletions
+1 -1
View File
@@ -1 +1 @@
v0.8.20
v0.8.22
+62 -130
View File
@@ -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}")
+17
View File
@@ -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
View File
@@ -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 %}