679 lines
34 KiB
HTML
679 lines
34 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Termin buchen - Inventarsystem{% endblock %}
|
|
|
|
{% block head %}
|
|
{{ super() }}
|
|
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet">
|
|
<style>
|
|
#client-slot-calendar {
|
|
min-height: 520px;
|
|
}
|
|
.fc .fc-timegrid-slot-label-cushion,
|
|
.fc .fc-timegrid-axis-cushion,
|
|
.fc .fc-col-header-cell-cushion {
|
|
font-weight: 600;
|
|
}
|
|
.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;
|
|
}
|
|
.day-slider-wrap {
|
|
background: #f8f9fa;
|
|
border: 1px solid #e9ecef;
|
|
border-radius: .9rem;
|
|
padding: .8rem 1rem;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container py-4">
|
|
<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>
|
|
{% 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>
|
|
</div>
|
|
<div class="p-3 rounded-3 bg-light mb-3">
|
|
<div class="fw-semibold">Verfügbare Slots</div>
|
|
<div>{{ available.slots_left }} von {{ available.slots_total }} frei</div>
|
|
</div>
|
|
<div class="p-3 rounded-3 bg-light mb-3">
|
|
<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 text-muted">
|
|
{% for booking in available.slots_booked %}
|
|
<li>
|
|
{% if booking is mapping %}
|
|
{{ booking.get('start', '') }} - Belegt
|
|
{% else %}
|
|
{{ booking[0] }} - {% if can_view_booking_names %}<span class="fw-semibold text-dark">{{ booking[1] }}</span>{% else %}Belegt{% endif %}
|
|
{% endif %}
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
</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">
|
|
|
|
{% 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>
|
|
<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>
|
|
|
|
{% 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 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>
|
|
|
|
{% 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>
|
|
</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">Abbrechen</button>
|
|
<button id="confirm-booking-only" type="button" class="btn btn-primary">Jetzt buchen</button>
|
|
<button id="confirm-booking-with-ics" type="button" class="btn btn-success">Buchen + .ics</button>
|
|
</div>
|
|
</div>
|
|
</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;
|
|
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 confirmBookingOnlyBtn = document.getElementById('confirm-booking-only');
|
|
const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics');
|
|
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
|
|
|
// 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();
|
|
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;
|
|
}
|
|
|
|
const fullyBookedSlots = [];
|
|
|
|
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);
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
|
|
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 = '20:00:00';
|
|
|
|
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);
|
|
|
|
const multiDayDuration = Math.max(1, allDays.length || 1);
|
|
const initialViewName = multiDayDuration === 1 ? 'timeGridDay' : 'timeGridRange';
|
|
let selectedSlot = '';
|
|
let selectedEvent = null;
|
|
let allowImmediateSubmit = false;
|
|
|
|
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 ausgebucht oder nicht 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 || '');
|
|
}
|
|
});
|
|
|
|
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 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;
|
|
|
|
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,
|
|
});
|
|
|
|
refreshIcsLink();
|
|
}
|
|
|
|
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);
|
|
const name = String(clientNameInput.value || '').trim();
|
|
if (name) url.searchParams.set('name', name);
|
|
return url.toString();
|
|
}
|
|
|
|
function refreshIcsLink() {
|
|
const icsUrl = getIcsDownloadUrl();
|
|
if (confirmBookingWithIcsBtn) {
|
|
confirmBookingWithIcsBtn.setAttribute('data-ics-url', icsUrl);
|
|
}
|
|
}
|
|
|
|
// 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: displayTitle,
|
|
start: start,
|
|
end: end,
|
|
color: '#0d6efd',
|
|
textColor: '#ffffff',
|
|
editable: false,
|
|
extendedProps: {
|
|
slotStart: slot.start,
|
|
slotType: 'free',
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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: 'Ausgebucht',
|
|
start: start,
|
|
end: end,
|
|
color: '#dc3545',
|
|
editable: false,
|
|
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;
|
|
ev.preventDefault();
|
|
if (modal) modal.show();
|
|
});
|
|
|
|
if (confirmBookingOnlyBtn) {
|
|
confirmBookingOnlyBtn.addEventListener('click', function () {
|
|
allowImmediateSubmit = true;
|
|
if (modal) modal.hide();
|
|
form.submit();
|
|
});
|
|
}
|
|
|
|
if (confirmBookingWithIcsBtn) {
|
|
confirmBookingWithIcsBtn.addEventListener('click', function () {
|
|
const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl();
|
|
if (icsUrl) window.open(icsUrl, '_blank');
|
|
allowImmediateSubmit = true;
|
|
if (modal) modal.hide();
|
|
form.submit();
|
|
});
|
|
}
|
|
|
|
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] && calendar.view.type === 'timeGridDay') {
|
|
calendar.gotoDate(allDays[idx]);
|
|
}
|
|
});
|
|
|
|
calendar.render();
|
|
refreshIcsLink();
|
|
})();
|
|
</script>
|
|
{% endblock %}
|