Compare commits

...

9 Commits

9 changed files with 509 additions and 29 deletions
+1 -1
View File
@@ -1 +1 @@
v0.8.10
v0.8.14
+8 -4
View File
@@ -626,14 +626,18 @@ def handle_unexpected_exception(e):
if request.is_json or request.path.startswith('/api/'):
return jsonify({'error': 'Internal server error', 'status': 500}), 500
if 'username' in session:
try:
return render_template(
'maintenance.html',
error_code=500,
error_message='Das System führt gerade Wartungsarbeiten durch.',
), 500
except Exception:
try:
return render_template('error.html', error_code=500, error_message='Interner Serverfehler.'), 500
return render_template('error.html', error_code=500, error_message='Das System führt gerade Wartungsarbeiten durch.'), 500
except Exception:
return 'Internal Server Error', 500
return redirect(url_for('login'))
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
+15 -6
View File
@@ -22,6 +22,14 @@ from Web.modules.database.settings import MongoClient
from bson.objectid import ObjectId
import datetime
def _get_tenant_db(client):
try:
from tenant import get_tenant_db
return get_tenant_db(client)
except Exception:
return client[cfg.MONGODB_DB]
def _active_record_query(extra_query=None):
"""Build a query that excludes logically deleted records."""
base_query = {'Deleted': {'$ne': True}}
@@ -30,10 +38,10 @@ def _active_record_query(extra_query=None):
return base_query
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str=""):
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False):
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
items = db['appointments']
item = {
@@ -45,6 +53,7 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
'user': user,
'mail': mail,
'note': note,
'calendar_enabled': bool(calendar_enabled),
'slots_booked': [], # -> [(start_time, name), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
'Created': datetime.datetime.now(),
'LastUpdated': datetime.datetime.now()
@@ -67,7 +76,7 @@ def get_item(id):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
items = db['appointments']
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
client.close()
@@ -89,7 +98,7 @@ def update(id,slots_used: list):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
items = db['appointments']
update_data = {
@@ -123,7 +132,7 @@ def remove_slot(id, date_start_time, name):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
items = db['appointments']
# Attempt to pull the exact element (stored as an array/tuple)
@@ -151,7 +160,7 @@ def remove(id):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
items = db['appointments']
result = items.delete_one({'_id': ObjectId(id)})
+97 -4
View File
@@ -2,6 +2,7 @@
Class for all funktions of the executive -> Lehrer
"""
import datetime
from datetime import timedelta
from flask import url_for
import Web.modules.emailservice.email as mail_service
import Web.modules.database.termine as termin
@@ -33,7 +34,78 @@ def _normalize_mail_list(mail):
return [entry.strip() for entry in mail.replace(';', ',').split(',') if entry.strip()]
return []
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="") -> str:
def _escape_ics_text(value):
return str(value or '').replace('\\', '\\\\').replace(';', '\\;').replace(',', '\\,').replace('\r\n', '\n').replace('\n', '\\n').replace('\r', '')
def _format_ics_date(date_value):
if isinstance(date_value, datetime.datetime):
return date_value.strftime('%Y%m%dT%H%M%SZ')
if isinstance(date_value, datetime.date):
return date_value.strftime('%Y%m%d')
return ''
def build_calendar_ics(appointment_id: str) -> str | None:
item = termin.get_item(appointment_id)
if not item:
return None
date_start = item.get('date_start')
date_end = item.get('date_end')
time_span = item.get('time_span', []) or []
creator = item.get('user', 'Terminplaner')
note = item.get('note', '') or ''
try:
link = url_for('terminplaner.client', appointment_id=str(appointment_id), _external=True)
except Exception:
tenant_context = get_tenant_context()
subdomain = ''
if tenant_context:
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
host = f"https://{subdomain}.invario.eu" if subdomain else "https://invario.eu"
link = host + "/terminplaner/client/" + str(appointment_id)
try:
start_date = datetime.datetime.strptime(str(date_start), '%Y-%m-%d').date()
end_date = datetime.datetime.strptime(str(date_end), '%Y-%m-%d').date()
except Exception:
return None
uid = f"terminplaner-{appointment_id}@invario.eu"
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
summary = f"Terminplan für {creator}"
description_lines = [
f"Buchungslink: {link}",
f"Zeitraum: {date_start} bis {date_end}",
]
if time_span:
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
if note:
description_lines.append('Notiz: ' + str(note))
ics_lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Inventarsystem//Terminplaner//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
f'UID:{uid}',
f'DTSTAMP:{created_at}',
f'SUMMARY:{_escape_ics_text(summary)}',
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
f'URL:{_escape_ics_text(link)}',
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
'END:VEVENT',
'END:VCALENDAR',
'',
]
return '\r\n'.join(ics_lines)
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False) -> dict:
"""
Generates a link for the executive to send to his clients to book a time Slot
@@ -49,7 +121,7 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
"""
normalized_time_span = _normalize_time_span(time_span)
normalized_mail = _normalize_mail_list(mail)
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note)
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note, calendar_enabled=calendar_enabled)
id_str = str(id)
tenant_context = get_tenant_context()
@@ -64,9 +136,30 @@ def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
link = host + "/terminplaner/client/" + id_str
subject = f"Terminanfrage von {user}"
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
calendar_link = None
if calendar_enabled:
try:
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, _external=True)
except Exception:
tenant_context = get_tenant_context()
subdomain = ''
if tenant_context:
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
host = f"https://{subdomain}.invario.eu" if subdomain else "https://invario.eu"
calendar_link = host + "/terminplaner/calendar/" + id_str + ".ics"
email_body = note_link
if calendar_link:
email_body += f"\n\nKalendereintrag: {calendar_link}"
if normalized_mail and cfg.EMAIL_ENABLED:
mail_service.send(normalized_mail, subject, note_link)
return link
mail_service.send(normalized_mail, subject, email_body)
return {
'appointment_id': id_str,
'link': link,
'calendar_link': calendar_link,
}
def book_slot(id, date_start_time, name):
+32 -4
View File
@@ -1,4 +1,5 @@
from flask import Blueprint, render_template, request, session, url_for, redirect, flash
from flask import Response
import Web.modules.terminplaner.backend_server as appointment_service
import Web.modules.database.settings as cfg
@@ -12,6 +13,14 @@ def _require_module_enabled():
return redirect(url_for('home'))
return None
def _appointment_not_found_response():
return render_template(
'terminplaner_not_found.html',
error_code=404,
error_message='Der Termin wurde nicht gefunden.',
), 404
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
def client(appointment_id):
"""
@@ -23,8 +32,7 @@ def client(appointment_id):
available = appointment_service.get_available(appointment_id)
if not available:
flash('Der Termin wurde nicht gefunden.', 'error')
return redirect(url_for('terminplan'))
return _appointment_not_found_response()
if request.method == 'POST':
start_daytime = request.form.get('start_day_time')
@@ -72,6 +80,7 @@ def configure():
slot_lenght = request.form.get('slot_lenght')
mail = request.form.get('mail', '')
note = request.form.get('note', '')
add_to_calendar = request.form.get('add_to_calendar') == 'on'
if not start or not end or not time or not slots_amount or not slot_lenght:
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
@@ -82,12 +91,14 @@ def configure():
email_service_enabled=cfg.EMAIL_ENABLED,
)
link = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note)
result = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note, calendar_enabled=add_to_calendar)
flash('Der Terminplan wurde angelegt.', 'success')
return render_template(
'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS,
generated_link=link,
generated_link=result['link'],
calendar_link=result.get('calendar_link'),
add_to_calendar=add_to_calendar,
email_service_enabled=cfg.EMAIL_ENABLED,
)
elif request.method == "GET":
@@ -95,9 +106,26 @@ def configure():
'termin_configure.html',
school_periods=cfg.SCHOOL_PERIODS,
generated_link=None,
calendar_link=None,
add_to_calendar=False,
email_service_enabled=cfg.EMAIL_ENABLED,
)
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
def calendar_export(appointment_id):
guard = _require_module_enabled()
if guard:
return guard
ics_content = appointment_service.build_calendar_ics(appointment_id)
if not ics_content:
return _appointment_not_found_response()
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics'
return response
@appoint_bp.route('/')
def main():
guard = _require_module_enabled()
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}Wartungsarbeiten - Inventarsystem{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-8 col-xl-7">
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
<p class="text-uppercase small fw-semibold mb-1 opacity-75">Systemstatus</p>
<h1 class="h3 mb-0 fw-bold">Wartungsarbeiten</h1>
</div>
<div class="card-body p-4 p-md-5 bg-white text-center">
<div class="display-3 fw-bold text-warning mb-3">500</div>
<p class="lead mb-2">Das System führt gerade Wartungsarbeiten durch.</p>
<p class="text-muted mb-4">Bitte versuchen Sie es in wenigen Minuten erneut. Falls das Problem bestehen bleibt, informieren Sie bitte die Administration.</p>
<div class="d-flex flex-column flex-sm-row justify-content-center gap-2">
<a href="{{ url_for('login') }}" class="btn btn-primary btn-lg">Zum Login</a>
<a href="javascript:history.back()" class="btn btn-outline-secondary btn-lg">Zurück</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+184 -4
View File
@@ -24,10 +24,33 @@
</div>
</div>
<div>
<label for="time_frame" class="form-label fw-semibold">Zeitfenster</label>
<textarea id="time_frame" name="time_frame" class="form-control" rows="5" placeholder="Beispiele:\nMontag 08:00-12:00\nDienstag 08:00-12:00" required></textarea>
<div class="form-text">Jede Zeile kann ein eigenes Zeitfenster enthalten.</div>
<div class="border rounded-4 p-3 p-md-4 bg-light-subtle">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
<div>
<label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label>
<div class="form-text mb-0">Sobald Start- und Enddatum gesetzt sind, wird für jeden Tag automatisch ein eigener Eintrag erzeugt.</div>
</div>
<button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button>
</div>
<div class="row g-3 mb-3">
<div class="col-12 col-md-6 col-xl-4">
<label for="default_day_start" class="form-label fw-semibold">Standard-Startzeit</label>
<input type="time" id="default_day_start" class="form-control" value="08:00">
</div>
<div class="col-12 col-md-6 col-xl-4">
<label for="default_day_end" class="form-label fw-semibold">Standard-Endzeit</label>
<input type="time" id="default_day_end" class="form-control" value="12:00">
</div>
</div>
<div id="time_frame_days" class="vstack gap-2"></div>
<div class="mt-3">
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster</label>
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" required></textarea>
<div class="form-text">Das Formular überträgt die erzeugten Tageszeilen an das Backend. Sie können die Liste hier bei Bedarf noch anpassen.</div>
</div>
</div>
<div class="row g-3">
@@ -51,6 +74,12 @@
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
<div class="form-text">Erzeugt zusätzlich eine .ics-Datei, die in gängige Kalender importiert werden kann.</div>
</div>
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
<button type="submit" class="btn btn-primary btn-lg">Terminplan erzeugen</button>
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main') }}">Zur Übersicht</a>
@@ -70,9 +99,160 @@
{% endif %}
</div>
<a href="{{ generated_link }}" class="d-inline-block text-break">{{ generated_link }}</a>
{% if calendar_link %}
<div class="mt-3">
<div class="fw-semibold mb-1">Kalendereintrag</div>
<a href="{{ calendar_link }}" class="btn btn-outline-primary btn-sm">.ics herunterladen</a>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<script>
(function () {
const startDateInput = document.getElementById('start_date');
const endDateInput = document.getElementById('end_date');
const buildButton = document.getElementById('build_time_frame');
const daysContainer = document.getElementById('time_frame_days');
const timeFrameTextarea = document.getElementById('time_frame');
const defaultStartInput = document.getElementById('default_day_start');
const defaultEndInput = document.getElementById('default_day_end');
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea || !defaultStartInput || !defaultEndInput) {
return;
}
const weekdayFormatter = new Intl.DateTimeFormat('de-DE', {
weekday: 'long',
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
function parseDate(value) {
if (!value) {
return null;
}
const parts = value.split('-').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
return null;
}
return new Date(parts[0], parts[1] - 1, parts[2]);
}
function formatDateForRow(date) {
return weekdayFormatter.format(date);
}
function formatDateForValue(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function syncTextarea() {
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
const lines = rows.map(function (row) {
const dayValue = row.getAttribute('data-day-value') || '';
const startTime = row.querySelector('[data-time-start]')?.value || '';
const endTime = row.querySelector('[data-time-end]')?.value || '';
return `${dayValue} ${startTime}-${endTime}`.trim();
}).filter(Boolean);
timeFrameTextarea.value = lines.join('\n');
}
function renderRows() {
const startDate = parseDate(startDateInput.value);
const endDate = parseDate(endDateInput.value);
if (!startDate || !endDate) {
daysContainer.innerHTML = '<div class="text-muted small">Bitte Start- und Enddatum auswählen, damit die Tageszeilen erzeugt werden.</div>';
timeFrameTextarea.value = '';
endDateInput.setCustomValidity('');
return;
}
if (endDate < startDate) {
daysContainer.innerHTML = '<div class="text-danger small">Das Enddatum muss nach dem Startdatum liegen.</div>';
timeFrameTextarea.value = '';
endDateInput.setCustomValidity('Das Enddatum muss nach dem Startdatum liegen.');
return;
}
endDateInput.setCustomValidity('');
const existingValues = new Map();
Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) {
const dayValue = row.getAttribute('data-day-value');
const startTime = row.querySelector('[data-time-start]')?.value || '';
const endTime = row.querySelector('[data-time-end]')?.value || '';
if (dayValue) {
existingValues.set(dayValue, { startTime, endTime });
}
});
const defaultStart = defaultStartInput.value || '08:00';
const defaultEnd = defaultEndInput.value || '12:00';
const rows = [];
for (let current = startDate; current <= endDate; current = addDays(current, 1)) {
const dayValue = formatDateForValue(current);
const preserved = existingValues.get(dayValue) || {};
const rowStart = preserved.startTime || defaultStart;
const rowEnd = preserved.endTime || defaultEnd;
rows.push(`
<div class="border rounded-3 bg-white p-3" data-day-row data-day-value="${dayValue}">
<div class="row g-2 align-items-end">
<div class="col-12 col-lg-5">
<label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label>
<div class="text-muted small">${dayValue}</div>
</div>
<div class="col-6 col-lg-3">
<label class="form-label mb-1">Von</label>
<input type="time" class="form-control" value="${rowStart}" data-time-start>
</div>
<div class="col-6 col-lg-3">
<label class="form-label mb-1">Bis</label>
<input type="time" class="form-control" value="${rowEnd}" data-time-end>
</div>
</div>
</div>
`);
}
daysContainer.innerHTML = rows.join('');
daysContainer.querySelectorAll('input[type="time"]').forEach(function (input) {
input.addEventListener('input', syncTextarea);
input.addEventListener('change', syncTextarea);
});
syncTextarea();
}
buildButton.addEventListener('click', renderRows);
startDateInput.addEventListener('input', renderRows);
startDateInput.addEventListener('change', renderRows);
endDateInput.addEventListener('input', renderRows);
endDateInput.addEventListener('change', renderRows);
defaultStartInput.addEventListener('change', syncTextarea);
defaultEndInput.addEventListener('change', syncTextarea);
if (startDateInput.value && endDateInput.value) {
renderRows();
}
})();
</script>
{% endblock %}
+116 -6
View File
@@ -41,7 +41,37 @@
</label>
</div>
<div id="calendar"></div>
<div class="calendar-stage">
<div id="calendar-skeleton" class="calendar-skeleton" aria-hidden="true">
<div class="skeleton-toolbar">
<span class="skeleton-pill"></span>
<span class="skeleton-pill"></span>
<span class="skeleton-pill"></span>
</div>
<div class="skeleton-grid">
<div class="skeleton-day-header"></div>
<div class="skeleton-day-header"></div>
<div class="skeleton-day-header"></div>
<div class="skeleton-day-header"></div>
<div class="skeleton-day-header"></div>
<div class="skeleton-day-header"></div>
<div class="skeleton-day-header"></div>
<div class="skeleton-slot"></div>
<div class="skeleton-slot"></div>
<div class="skeleton-slot"></div>
<div class="skeleton-slot"></div>
<div class="skeleton-slot"></div>
<div class="skeleton-slot"></div>
<div class="skeleton-slot"></div>
<div class="skeleton-slot"></div>
<div class="skeleton-slot"></div>
<div class="skeleton-slot"></div>
<div class="skeleton-slot"></div>
<div class="skeleton-slot"></div>
</div>
</div>
<div id="calendar"></div>
</div>
<!-- Modal for new bookings -->
<div id="booking-modal" class="modal">
@@ -172,6 +202,8 @@
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/daygrid/main.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/timegrid/main.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/locales/de.js"></script>
<script>
@@ -278,6 +310,12 @@ document.addEventListener('DOMContentLoaded', function() {
minute: '2-digit',
hour12: false
},
loading: function(isLoading) {
const skeleton = document.getElementById('calendar-skeleton');
if (skeleton) {
skeleton.classList.toggle('is-hidden', !isLoading);
}
},
selectAllow: function(info) {
const startHour = info.start.getHours();
const endHour = info.end.getHours();
@@ -428,7 +466,10 @@ document.addEventListener('DOMContentLoaded', function() {
eventClick: function(info) {
showEventDetails(info.event);
},
datesSet: function(dateInfo) {
datesSet: function() {
updateCurrentDayDisplay();
}
});
function setCalendarView(viewName) {
activeCalendarView = viewName;
@@ -442,15 +483,16 @@ document.addEventListener('DOMContentLoaded', function() {
button.addEventListener('click', function() {
setCalendarView(this.dataset.calendarView);
});
});
updateCurrentDayDisplay();
}
setCalendarView(activeCalendarView);
});
// Render calendar
try {
const calendarSkeleton = document.getElementById('calendar-skeleton');
if (calendarSkeleton) {
calendarSkeleton.classList.remove('is-hidden');
}
calendar.render();
setCalendarView(activeCalendarView);
setTimeout(() => {
calendar.updateSize();
@@ -1256,6 +1298,74 @@ document.addEventListener('DOMContentLoaded', function() {
padding: 20px;
}
.calendar-stage {
position: relative;
min-height: 620px;
}
.calendar-skeleton {
position: absolute;
inset: 0;
z-index: 5;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(247, 250, 252, 0.96));
padding: 20px;
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.calendar-skeleton.is-hidden {
display: none;
}
.skeleton-toolbar {
display: flex;
gap: 10px;
margin-bottom: 18px;
}
.skeleton-pill,
.skeleton-day-header,
.skeleton-slot {
position: relative;
overflow: hidden;
background: linear-gradient(90deg, #e9eef5 0%, #f7f9fc 50%, #e9eef5 100%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
}
.skeleton-pill {
width: 90px;
height: 34px;
border-radius: 999px;
}
.skeleton-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 10px;
}
.skeleton-day-header {
height: 28px;
border-radius: 10px;
}
.skeleton-slot {
height: 72px;
border-radius: 14px;
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.calendar-header {
margin-bottom: 20px;
}
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}Termin nicht gefunden - Inventarsystem{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-8 col-xl-7">
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
<p class="text-uppercase small fw-semibold mb-1 opacity-75">Terminplaner</p>
<h1 class="h3 mb-0 fw-bold">Termin nicht gefunden</h1>
</div>
<div class="card-body p-4 p-md-5 bg-white text-center">
<div class="display-3 fw-bold text-danger mb-3">404</div>
<p class="lead mb-2">Der angeforderte Termin existiert nicht mehr oder der Link ist ungültig.</p>
<p class="text-muted mb-4">Bitte prüfen Sie den Link erneut oder öffnen Sie den Terminplaner von der Startseite aus.</p>
<div class="d-flex flex-column flex-sm-row justify-content-center gap-2">
<a href="{{ url_for('home') }}" class="btn btn-primary btn-lg">Zur Startseite</a>
<a href="javascript:history.back()" class="btn btn-outline-secondary btn-lg">Zurück</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}