524 lines
22 KiB
HTML
524 lines
22 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>
|
|
<h1 class="h3 fw-bold mb-3">Termin buchen</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>
|
|
|
|
<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>
|
|
|
|
<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 %}
|
|
<div class="p-3 rounded-3 bg-light">
|
|
<div class="fw-semibold mb-2">Bereits gebucht</div>
|
|
<ul class="mb-0 small">
|
|
{% for booking in available.slots_booked %}
|
|
<li>
|
|
{{ booking.start }}
|
|
{% if can_view_booking_names and booking.name %}
|
|
- {{ booking.name }}
|
|
{% else %}
|
|
- Belegt
|
|
{% 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">
|
|
<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>
|
|
<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>
|
|
</div>
|
|
</form>
|
|
</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 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 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;
|
|
|
|
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 || ''));
|
|
|
|
// Show the requested UI time window from 08:15 to 20:00 and highlight available slots
|
|
let slotMinTime = '08:15:00';
|
|
let slotMaxTime = '20: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()); });
|
|
|
|
// Keep the computed min/max for gap calculations below
|
|
var computedMinStart = null;
|
|
var computedMaxEnd = null;
|
|
if (starts.length > 0 && ends.length > 0) {
|
|
computedMinStart = starts[0];
|
|
computedMaxEnd = ends[0];
|
|
starts.forEach(function (d) { if (d < computedMinStart) computedMinStart = d; });
|
|
ends.forEach(function (d) { if (d > computedMaxEnd) computedMaxEnd = d; });
|
|
}
|
|
}
|
|
|
|
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 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 || '');
|
|
}
|
|
});
|
|
|
|
// No background greying: show full calendar skeleton and only mark possible slots
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// No background gaps: keep full skeleton/grid visible
|
|
|
|
// Add candidate free slots (blue)
|
|
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: '#0d6efd',
|
|
textColor: '#ffffff',
|
|
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.');
|
|
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]) {
|
|
if (calendar.view.type === 'timeGridDay') {
|
|
calendar.gotoDate(allDays[idx]);
|
|
}
|
|
}
|
|
});
|
|
|
|
calendar.render();
|
|
refreshIcsLink();
|
|
})();
|
|
</script>
|
|
{% endblock %}
|