Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f47e133c45 | |||
| dec3c16d7f | |||
| 897b2e43ad | |||
| 6dd44508ce |
+1
-1
@@ -1 +1 @@
|
|||||||
v0.8.18
|
v0.8.20
|
||||||
|
|||||||
@@ -10,6 +10,135 @@ import Web.modules.database.settings as cfg
|
|||||||
from Web.tenant import get_tenant_context
|
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:
|
def _resolve_public_base_url() -> str:
|
||||||
if has_request_context():
|
if has_request_context():
|
||||||
try:
|
try:
|
||||||
@@ -293,6 +422,17 @@ def get_available(id):
|
|||||||
time_span = termin_range.get('time_span', [])
|
time_span = termin_range.get('time_span', [])
|
||||||
slot_lenght = termin_range.get('slot_lenght')
|
slot_lenght = termin_range.get('slot_lenght')
|
||||||
total_slots = termin_range.get('slots', 0)
|
total_slots = termin_range.get('slots', 0)
|
||||||
|
# Ensure numeric fields are cast to int when stored as strings
|
||||||
|
try:
|
||||||
|
total_slots = int(termin_range.get('slots', 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
total_slots = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
slot_lenght = int(termin_range.get('slot_lenght') or 0)
|
||||||
|
except Exception:
|
||||||
|
slot_lenght = termin_range.get('slot_lenght')
|
||||||
|
|
||||||
booked = termin_range.get('slots_booked', []) or []
|
booked = termin_range.get('slots_booked', []) or []
|
||||||
|
|
||||||
# Normalize booked entries to dicts for easier consumption
|
# Normalize booked entries to dicts for easier consumption
|
||||||
@@ -306,7 +446,10 @@ def get_available(id):
|
|||||||
normalized.append({'value': s})
|
normalized.append({'value': s})
|
||||||
|
|
||||||
slots_used = len(normalized)
|
slots_used = len(normalized)
|
||||||
slots_left = max(0, total_slots - slots_used)
|
try:
|
||||||
|
slots_left = max(0, int(total_slots) - slots_used)
|
||||||
|
except Exception:
|
||||||
|
slots_left = max(0, slots_used - slots_used)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'date_start': date_start,
|
'date_start': date_start,
|
||||||
@@ -316,6 +459,7 @@ def get_available(id):
|
|||||||
'slots_total': total_slots,
|
'slots_total': total_slots,
|
||||||
'slots_booked': normalized,
|
'slots_booked': normalized,
|
||||||
'slots_left': slots_left,
|
'slots_left': slots_left,
|
||||||
|
'slot_days': _build_slot_overview(termin_range),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting available slots: {e}")
|
print(f"Error getting available slots: {e}")
|
||||||
|
|||||||
@@ -2,6 +2,73 @@
|
|||||||
|
|
||||||
{% block title %}Termin buchen - Inventarsystem{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="container py-4">
|
<div class="container py-4">
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
@@ -27,6 +94,11 @@
|
|||||||
<div>{{ available.slot_lenght }} Minuten</div>
|
<div>{{ available.slot_lenght }} Minuten</div>
|
||||||
</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 %}
|
{% if available.slots_booked %}
|
||||||
<div class="p-3 rounded-3 bg-light">
|
<div class="p-3 rounded-3 bg-light">
|
||||||
<div class="fw-semibold mb-2">Bereits gebucht</div>
|
<div class="fw-semibold mb-2">Bereits gebucht</div>
|
||||||
@@ -45,15 +117,66 @@
|
|||||||
<div class="card-body p-4 p-md-5 bg-white">
|
<div class="card-body p-4 p-md-5 bg-white">
|
||||||
<h2 class="h4 fw-bold mb-4">Buchung absenden</h2>
|
<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">
|
<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>
|
<div>
|
||||||
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
|
<label for="start_date_picker" class="form-label fw-semibold">Startdatum</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>
|
<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">Tragen Sie Datum und Uhrzeit im Format YYYY-MM-DD HH:MM ein, sofern kein Kalenderfeld genutzt wird.</div>
|
<div class="form-text">Wählen Sie zuerst den Tag. Danach erscheint der passende Zeitbereich.</div>
|
||||||
</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>
|
<div>
|
||||||
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
|
<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>
|
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
|
||||||
</div>
|
</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">
|
<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>
|
<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>
|
<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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user