feat: Enhance appointment booking interface with date and time selection features

This commit is contained in:
2026-05-31 18:43:04 +02:00
parent 897b2e43ad
commit dec3c16d7f
2 changed files with 349 additions and 3 deletions
+130
View File
@@ -10,6 +10,135 @@ 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:
@@ -330,6 +459,7 @@ 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}")
+219 -3
View File
@@ -2,6 +2,73 @@
{% block title %}Termin buchen - Inventarsystem{% endblock %}
{% block head %}
{{ super() }}
<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;
}
.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;
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-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;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
@@ -27,6 +94,11 @@
<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>
{% if available.slots_booked %}
<div class="p-3 rounded-3 bg-light">
<div class="fw-semibold mb-2">Bereits gebucht</div>
@@ -45,15 +117,66 @@
<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>
<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="2026-05-29 10:30" required>
<div class="form-text">Tragen Sie Datum und Uhrzeit im Format YYYY-MM-DD HH:MM ein, sofern kein Kalenderfeld genutzt wird.</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>
{% if available.slot_days %}
<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>
</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>
@@ -66,4 +189,97 @@
</div>
</div>
</div>
<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');
function setPreview(text) {
if (selectedPreview) {
selectedPreview.textContent = text || 'Noch kein Termin ausgewählt';
}
}
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');
});
slotPanels.forEach((panel) => {
panel.classList.toggle('d-none', panel.dataset.slotPanel !== dayValue);
});
if (dayPicker && dayValue) {
dayPicker.value = dayValue;
}
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 setActiveSlot(button) {
if (!button || button.disabled) {
return;
}
slotButtons.forEach((item) => item.classList.toggle('active', item === button));
hiddenStartInput.value = button.dataset.slotValue || '';
setPreview(button.dataset.slotLabel || button.textContent.trim());
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;
}
}
}
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);
}
})();
</script>
{% endblock %}