Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e11fbf969 | |||
| 7d13dc264c | |||
| 480bd00a54 | |||
| fdf699363a | |||
| dd495a8b13 | |||
| ca57961d4b |
@@ -4,6 +4,9 @@ import Web.modules.terminplaner.backend_server as appointment_service
|
||||
import Web.modules.database.settings as cfg
|
||||
import Web.modules.database.termine as termin
|
||||
import Web.modules.database.user as us
|
||||
import csv
|
||||
import io
|
||||
from flask import make_response, flash, redirect, url_for, session
|
||||
|
||||
# Create a blueprint instance
|
||||
appoint_bp = Blueprint('terminplaner', __name__)
|
||||
@@ -34,10 +37,71 @@ def _current_tenant_id():
|
||||
pass
|
||||
return str(session.get('tenant_id', '') or '').strip()
|
||||
|
||||
@appoint_bp.route('/client/<appointment_id>/export_csv')
|
||||
def export_csv(appointment_id):
|
||||
"""
|
||||
Generiert einen CSV-Export aller Buchungen für den Ersteller.
|
||||
"""
|
||||
# 1. Berechtigungsprüfung (analog zur Client-Route)
|
||||
current_user = str(session.get('username', '') or '').strip()
|
||||
appointment_item = termin.get_item(appointment_id) or {}
|
||||
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
||||
|
||||
can_view = False
|
||||
if current_user:
|
||||
try:
|
||||
can_view = bool(us.check_admin(current_user) or current_user == appointment_owner)
|
||||
except Exception:
|
||||
can_view = bool(current_user == appointment_owner)
|
||||
|
||||
if not can_view:
|
||||
flash('Nicht autorisiert für diesen Export.', 'error')
|
||||
return redirect(url_for('terminplaner.client', appointment_id=appointment_id))
|
||||
|
||||
custom_fields = appointment_item.get('custom_fields', [])
|
||||
bookings = appointment_item.get('slots_booked', []) or []
|
||||
|
||||
# 2. CSV im Speicher erstellen
|
||||
si = io.StringIO()
|
||||
# Wir nutzen ein Semikolon als Trennzeichen – das öffnet Excel im deutschsprachigen Raum direkt fehlerfrei
|
||||
cw = csv.writer(si, delimiter=';', quoting=csv.QUOTE_MINIMAL)
|
||||
|
||||
# Tabellenkopf schreiben
|
||||
headers = ['Zeitpunkt', 'Name'] + list(custom_fields)
|
||||
cw.writerow(headers)
|
||||
|
||||
# Datenzeilen schreiben
|
||||
for booking in bookings:
|
||||
if isinstance(booking, (list, tuple)):
|
||||
row = [booking[0], booking[1]]
|
||||
|
||||
# Antworten holen und sicherstellen, dass Lücken (falls vorhanden) mit Leerstrings gefüllt werden
|
||||
answers = booking[2] if len(booking) > 2 else []
|
||||
for i in range(len(custom_fields)):
|
||||
if i < len(answers):
|
||||
row.append(answers[i])
|
||||
else:
|
||||
row.append('')
|
||||
cw.writerow(row)
|
||||
|
||||
# 3. Response vorbereiten und UTF-8-BOM für Excel mitsenden
|
||||
response_content = si.getvalue()
|
||||
output = make_response(response_content)
|
||||
|
||||
# Der Byte Order Mark (BOM) zwingt Excel dazu, UTF-8 (Umlaute) direkt richtig zu erkennen
|
||||
output.data = b'\xef\xbb\xbf' + output.data.encode('utf-8')
|
||||
|
||||
# Dateiname generieren
|
||||
title_slug = appointment_item.get('title', 'export').replace(' ', '_')
|
||||
output.headers["Content-Disposition"] = f"attachment; filename=buchungen_{title_slug}.csv"
|
||||
output.headers["Content-Type"] = "text/csv; charset=utf-8"
|
||||
|
||||
return output
|
||||
|
||||
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
||||
def client(appointment_id):
|
||||
"""
|
||||
The Route for the terminplaner to work with the client
|
||||
The Route for the terminplaner to work with the client and allow owners to manage it.
|
||||
"""
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
@@ -74,35 +138,60 @@ def client(appointment_id):
|
||||
available_for_view['slots_booked'] = sanitized_bookings
|
||||
|
||||
if request.method == 'POST':
|
||||
start_daytime = request.form.get('start_day_time')
|
||||
username = request.form.get('client_name')
|
||||
|
||||
custom_answers = request.form.getlist('custom_answers')
|
||||
action = request.form.get('action', 'book')
|
||||
|
||||
if not start_daytime or not username:
|
||||
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
||||
return render_template(
|
||||
'termin_client.html',
|
||||
appointment_id=appointment_id,
|
||||
available=available_for_view,
|
||||
current_user=session.get('username', ''),
|
||||
tenant_id=_current_tenant_id(),
|
||||
can_view_booking_names=can_view_booking_names,
|
||||
custom_fields=custom_fields
|
||||
)
|
||||
# Fall 1: ADMIN/AUTOR storniert einen Termin
|
||||
if action == 'delete' and can_view_booking_names:
|
||||
slot_time = request.form.get('slot_time')
|
||||
client_name = request.form.get('target_client_name')
|
||||
|
||||
# Aktuelle Buchungen direkt aus dem DB-Item holen
|
||||
current_slots = appointment_item.get('slots_booked', []) or []
|
||||
|
||||
# Filtere den zu löschenden Slot heraus (Prüfung auf Zeit und Name)
|
||||
updated_slots = [
|
||||
slot for slot in current_slots
|
||||
if not (isinstance(slot, (list, tuple)) and slot[0] == slot_time and slot[1] == client_name)
|
||||
]
|
||||
|
||||
# In DB schreiben via deiner existierenden termin.update() Funktion
|
||||
if termin.update(appointment_id, updated_slots):
|
||||
flash('Buchung wurde erfolgreich gelöscht.', 'success')
|
||||
else:
|
||||
flash('Fehler beim Löschen der Buchung.', 'error')
|
||||
|
||||
return redirect(url_for('terminplaner.client', appointment_id=appointment_id, tenant=_current_tenant_id() or None))
|
||||
|
||||
if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers):
|
||||
return redirect(
|
||||
url_for(
|
||||
'terminplaner.client_success',
|
||||
# Fall 2: NORMALER CLIENT bucht einen Termin
|
||||
elif action == 'book':
|
||||
start_daytime = request.form.get('start_day_time')
|
||||
username = request.form.get('client_name')
|
||||
custom_answers = request.form.getlist('custom_answers')
|
||||
|
||||
if not start_daytime or not username:
|
||||
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
||||
return render_template(
|
||||
'termin_client.html',
|
||||
appointment_id=appointment_id,
|
||||
tenant=_current_tenant_id() or None,
|
||||
start=start_daytime,
|
||||
name=username,
|
||||
available=available_for_view,
|
||||
current_user=session.get('username', ''),
|
||||
tenant_id=_current_tenant_id(),
|
||||
can_view_booking_names=can_view_booking_names,
|
||||
custom_fields=custom_fields
|
||||
)
|
||||
)
|
||||
|
||||
flash('Der Termin konnte nicht gespeichert werden.', 'error')
|
||||
if appointment_service.book_slot(appointment_id, start_daytime, username, custom=custom_answers):
|
||||
return redirect(
|
||||
url_for(
|
||||
'terminplaner.client_success',
|
||||
appointment_id=appointment_id,
|
||||
tenant=_current_tenant_id() or None,
|
||||
start=start_daytime,
|
||||
name=username,
|
||||
)
|
||||
)
|
||||
|
||||
flash('Der Termin konnte nicht gespeichert werden.', 'error')
|
||||
|
||||
return render_template(
|
||||
'termin_client.html',
|
||||
@@ -111,7 +200,8 @@ def client(appointment_id):
|
||||
current_user=session.get('username', ''),
|
||||
tenant_id=_current_tenant_id(),
|
||||
can_view_booking_names=can_view_booking_names,
|
||||
custom_fields=custom_fields
|
||||
custom_fields=custom_fields,
|
||||
appointment_item=appointment_item
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -39,13 +39,19 @@
|
||||
<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 - {{ 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>
|
||||
|
||||
{% 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>
|
||||
@@ -58,23 +64,24 @@
|
||||
<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">
|
||||
<ul class="mb-0 small text-muted">
|
||||
{% for booking in available.slots_booked %}
|
||||
<li>
|
||||
{{ booking.start }}
|
||||
{% if can_view_booking_names and booking.name %}
|
||||
- {{ booking.name }}
|
||||
{% if booking is mapping %}
|
||||
{{ booking.get('start', '') }} - Belegt
|
||||
{% else %}
|
||||
- Belegt
|
||||
{{ booking[0] }} - {% if can_view_booking_names %}<span class="fw-semibold text-dark">{{ booking[1] }}</span>{% else %}Belegt{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
@@ -84,49 +91,161 @@
|
||||
</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>
|
||||
|
||||
{% if custom_fields %}
|
||||
{% for field_label in custom_fields %}
|
||||
{% 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>
|
||||
<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>
|
||||
<h2 class="h4 fw-bold mb-0">Eingegangene Buchungen</h2>
|
||||
<div class="text-muted small">Verwalten Sie die Termine Ihrer Klienten</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<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="d-flex flex-column flex-sm-row gap-2 pt-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
|
||||
<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>
|
||||
</form>
|
||||
|
||||
{% 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>
|
||||
@@ -152,6 +271,52 @@
|
||||
</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 () {
|
||||
|
||||
Reference in New Issue
Block a user