Compare commits

...

11 Commits

7 changed files with 363 additions and 93 deletions
+1 -1
View File
@@ -1 +1 @@
v0.8.22
v0.8.26
+67 -40
View File
@@ -37,6 +37,7 @@ if _CURRENT_DIR not in sys.path:
import Web.modules.database.user as us
import Web.modules.database.items as it
import Web.modules.database.ausleihung as au
import Web.modules.database.termine as termin
import Web.modules.log.audit_log as al
import push_notifications as pn
import Web.modules.inventarsystem.pdf_export as pdf_export
@@ -4817,56 +4818,82 @@ def get_user_appointments():
db = client[MONGODB_DB]
items_col = db['items']
# Use the established user-specific route logic for appointments.
bookings = au.get_ausleihung_by_user(
username,
status=['planned', 'active', 'completed'],
use_client_side_verification=True,
)
bookings = sorted(bookings, key=lambda b: b.get('Start') or datetime.datetime.min)
# Use the appointment collection for the user's appointments
appointments = termin.get_upcoming_for_user(username, limit=250)
result = []
for booking in bookings:
start_dt = booking.get('Start')
if not start_dt:
import re as _re
for appt in appointments:
appt_id = str(appt.get('_id') or '')
if not appt_id:
continue
end_dt = booking.get('End')
if not end_dt and isinstance(start_dt, datetime.datetime):
end_dt = start_dt + datetime.timedelta(minutes=45)
elif not end_dt:
end_dt = start_dt
date_start = str(appt.get('date_start') or '')
date_end = str(appt.get('date_end') or date_start)
time_span = appt.get('time_span', []) or []
item_id = str(booking.get('Item') or '')
item_doc = None
if item_id:
try:
item_doc = items_col.find_one({'_id': ObjectId(item_id)})
except Exception:
item_doc = None
# Determine a sensible start and end datetime for the event.
# Prefer a time from the time_span for the first day and a time for the last day when multi-day.
start_dt_str = date_start
end_dt_str = date_end
first_time = None
last_time = None
generic_first = None
generic_last = None
try:
# collect times from spans
for entry in time_span:
s = str(entry or '').strip()
if not s:
continue
m_date = _re.match(r"^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$", s)
if m_date:
d = m_date.group(1)
if d == date_start and not first_time:
first_time = m_date.group(2)
if d == date_end:
last_time = m_date.group(3)
continue
m = _re.match(r"^(\d{2}:\d{2})-(\d{2}:\d{2})$", s)
if m:
if generic_first is None:
generic_first = m.group(1)
generic_last = m.group(2)
item_name = item_id or 'Termin'
if item_doc:
item_name = item_doc.get('Name') or item_doc.get('Code_4') or item_name
if not first_time and generic_first:
first_time = generic_first
if not last_time and generic_last:
last_time = generic_last
period = booking.get('Period')
title = item_name
if period:
title = f"{title} - {period}. Std"
if first_time:
start_dt_str = f"{date_start}T{first_time}"
status = booking.get('VerifiedStatus') or booking.get('Status') or 'unknown'
if status == 'active':
status = 'current'
# If appointment spans multiple days and we have a last_time for the final day, use it
if date_end != date_start:
if last_time:
end_dt_str = f"{date_end}T{last_time}"
else:
# span until end of final day
end_dt_str = f"{date_end}T23:59:59"
else:
if last_time:
end_dt_str = f"{date_start}T{last_time}"
except Exception:
# fallback: use whole-day span
if date_end != date_start:
end_dt_str = f"{date_end}T23:59:59"
title = appt.get('note') or f"Termin von {appt.get('user') or ''}"
result.append({
'id': str(booking.get('_id')),
'id': appt_id,
'title': title,
'start': start_dt.isoformat() if isinstance(start_dt, datetime.datetime) else str(start_dt),
'end': end_dt.isoformat() if isinstance(end_dt, datetime.datetime) else str(end_dt),
'status': status,
'itemId': item_id,
'userName': str(booking.get('User') or ''),
'notes': str(booking.get('Notes') or ''),
'period': period,
'start': start_dt_str,
'end': end_dt_str,
'status': 'planned',
'itemId': appt_id,
'userName': str(appt.get('user') or ''),
'notes': str(appt.get('note') or ''),
'period': None,
'isCurrentUser': True,
'itemBorrower': '',
})
+54 -4
View File
@@ -47,6 +47,28 @@ def client(appointment_id):
if not available:
return _appointment_not_found_response()
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_booking_names = False
if current_user:
try:
can_view_booking_names = bool(us.check_admin(current_user) or current_user == appointment_owner)
except Exception:
can_view_booking_names = bool(current_user == appointment_owner)
available_for_view = dict(available)
if not can_view_booking_names:
sanitized_bookings = []
for booking in (available.get('slots_booked') or []):
if isinstance(booking, dict):
sanitized_bookings.append({'start': booking.get('start', '')})
elif isinstance(booking, (list, tuple)) and len(booking) >= 1:
sanitized_bookings.append({'start': booking[0]})
else:
sanitized_bookings.append({'start': ''})
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')
@@ -55,22 +77,50 @@ def client(appointment_id):
return render_template(
'termin_client.html',
appointment_id=appointment_id,
available=available,
available=available_for_view,
current_user=session.get('username', ''),
tenant_id=_current_tenant_id(),
can_view_booking_names=can_view_booking_names,
)
if appointment_service.book_slot(appointment_id, start_daytime, username):
flash('Der Termin wurde gespeichert.', 'success')
return redirect(url_for('terminplaner.client', appointment_id=appointment_id))
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',
appointment_id=appointment_id,
available=available,
available=available_for_view,
current_user=session.get('username', ''),
tenant_id=_current_tenant_id(),
can_view_booking_names=can_view_booking_names,
)
@appoint_bp.route('/client/success/<appointment_id>', methods=['GET'])
def client_success(appointment_id):
guard = _require_module_enabled()
if guard:
return guard
slot_start = str(request.args.get('start', '') or '').strip()
client_name = str(request.args.get('name', '') or '').strip()
return render_template(
'termin_client_success.html',
appointment_id=appointment_id,
slot_start=slot_start,
client_name=client_name,
tenant_id=_current_tenant_id(),
)
+29 -22
View File
@@ -1392,28 +1392,7 @@
{% endif %}
{% else %}
<nav class="navbar navbar-expand-lg navbar-dark" id="loginNavbar">
<div class="container-fluid">
<a class="navbar-brand py-0" href="{{ url_for('home') }}">
{% set school_logo_thumb = school_info.get('logo_thumb') if school_info else '' %}
{% set school_logo_path = school_info.get('logo_path') if school_info else '' %}
{% if school_logo_thumb or school_logo_path %}
{% if school_logo_thumb %}
<img src="{{ url_for('uploaded_file', filename=school_logo_thumb) }}" alt="{{ school_info.name or 'Schullogo' }}" class="invario-logo">
{% else %}
<img src="{{ url_for('uploaded_file', filename=school_logo_path) }}" alt="{{ school_info.name or 'Schullogo' }}" class="invario-logo">
{% endif %}
{% else %}
<img src="{{ url_for('static', filename='img/invario-logo.png') }}" alt="Invario" class="invario-logo">
{% endif %}
</a>
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('impressum') }}">Impressum</a>
</li>
</ul>
</div>
</nav>
<!-- No navbar for anonymous users on public pages. -->
{% endif %}
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
@@ -1427,6 +1406,11 @@
{% endwith %}
{% block content %}{% endblock %}
</div>
{% if 'username' not in session %}
<div class="guest-impressum-footer">
<a href="{{ url_for('impressum') }}">Impressum</a>
</div>
{% endif %}
<!-- Cookie consent banner -->
<style>
#cookie-banner { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(33,37,41,.98); color: #fff; padding: 14px 16px; display: none; z-index: 2000; box-shadow: 0 -2px 8px rgba(0,0,0,.25); }
@@ -1560,6 +1544,29 @@
margin-bottom: 2px;
}
.guest-impressum-footer {
position: fixed;
left: 0;
right: 0;
bottom: 10px;
text-align: center;
z-index: 1200;
pointer-events: none;
}
.guest-impressum-footer a {
pointer-events: auto;
font-size: 0.76rem;
color: rgba(15, 23, 42, 0.72);
text-decoration: none;
border-bottom: 1px solid rgba(15, 23, 42, 0.28);
}
.guest-impressum-footer a:hover {
color: rgba(15, 23, 42, 0.92);
border-bottom-color: rgba(15, 23, 42, 0.52);
}
.notification-toast.show {
display: block;
}
+74 -25
View File
@@ -69,7 +69,14 @@
<div class="fw-semibold mb-2">Bereits gebucht</div>
<ul class="mb-0 small">
{% for booking in available.slots_booked %}
<li>{{ booking.start }}{% if booking.name %} - {{ booking.name }}{% endif %}</li>
<li>
{{ booking.start }}
{% if can_view_booking_names and booking.name %}
- {{ booking.name }}
{% else %}
- Belegt
{% endif %}
</li>
{% endfor %}
</ul>
</div>
@@ -127,8 +134,9 @@
<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">Später</button>
<a id="download-slot-ics" class="btn btn-primary" href="#">.ics herunterladen</a>
<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>
@@ -148,7 +156,8 @@
const form = document.getElementById('client-booking-form');
const calendarEl = document.getElementById('client-slot-calendar');
const modalEl = document.getElementById('calendarDownloadModal');
const downloadIcsLink = document.getElementById('download-slot-ics');
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;
@@ -258,8 +267,9 @@
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 = '18:00:00';
// 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');
@@ -268,13 +278,14 @@
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) {
let minStart = starts[0];
let maxEnd = ends[0];
starts.forEach(function (d) { if (d < minStart) minStart = d; });
ends.forEach(function (d) { if (d > maxEnd) maxEnd = d; });
slotMinTime = formatTimeForCalendar(minStart);
slotMaxTime = formatTimeForCalendar(maxEnd);
computedMinStart = starts[0];
computedMaxEnd = ends[0];
starts.forEach(function (d) { if (d < computedMinStart) computedMinStart = d; });
ends.forEach(function (d) { if (d > computedMaxEnd) computedMaxEnd = d; });
}
}
@@ -287,7 +298,7 @@
const initialViewName = multiDayDuration === 1 ? 'timeGridDay' : 'timeGridRange';
let selectedSlot = '';
let selectedEvent = null;
let selectedShownOnce = false;
let allowImmediateSubmit = false;
const calendar = new FullCalendar.Calendar(calendarEl, {
initialView: initialViewName,
@@ -346,6 +357,8 @@
}
});
// 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;
@@ -379,18 +392,10 @@
editable: true,
});
if (modal && !selectedShownOnce) {
selectedShownOnce = true;
modal.show();
}
refreshIcsLink();
}
function refreshIcsLink() {
if (!downloadIcsLink) {
return;
}
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) {
@@ -400,9 +405,19 @@
if (name) {
url.searchParams.set('name', name);
}
downloadIcsLink.setAttribute('href', url.toString());
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');
@@ -410,8 +425,8 @@
title: 'Freier Slot',
start: start,
end: end,
color: '#20c997',
textColor: '#062b1f',
color: '#0d6efd',
textColor: '#ffffff',
editable: false,
extendedProps: {
slotStart: slot.start,
@@ -441,9 +456,43 @@
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) {
+121
View File
@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Buchung erfolgreich</title>
<style>
:root {
color-scheme: light;
--bg-a: #0f4c5c;
--bg-b: #16697a;
--ok: #22c55e;
--text: #0f172a;
--muted: #475569;
--card: #ffffff;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Manrope", "Segoe UI", sans-serif;
background: radial-gradient(circle at 20% 20%, rgba(255,255,255,0.14), transparent 45%), linear-gradient(135deg, var(--bg-a), var(--bg-b));
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.success-window {
width: min(900px, 100%);
background: var(--card);
border-radius: 1.25rem;
box-shadow: 0 28px 70px rgba(2, 6, 23, 0.32);
padding: clamp(1.5rem, 4vw, 3rem);
text-align: center;
}
.badge {
width: 84px;
height: 84px;
margin: 0 auto 1.25rem auto;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(34, 197, 94, 0.12);
color: var(--ok);
font-size: 2rem;
font-weight: 800;
}
h1 {
margin: 0 0 0.9rem 0;
font-size: clamp(1.8rem, 5vw, 3rem);
color: var(--text);
line-height: 1.1;
}
p {
margin: 0;
font-size: 1.1rem;
color: var(--muted);
}
.meta {
margin-top: 1.4rem;
padding: 1rem;
border-radius: 0.9rem;
background: #f8fafc;
color: #0f172a;
font-weight: 600;
}
.actions {
margin-top: 1.8rem;
display: flex;
justify-content: center;
gap: .75rem;
flex-wrap: wrap;
}
.btn {
border: 0;
border-radius: 0.8rem;
padding: .85rem 1.25rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-close {
background: #0f4c5c;
color: #fff;
}
.btn-link {
background: #e2e8f0;
color: #0f172a;
}
</style>
</head>
<body>
<main class="success-window" role="main" aria-live="polite">
<div class="badge"></div>
<h1>Buchung erfolgreich</h1>
<p>Sie können das Fenster jetzt schließen.</p>
{% if client_name or slot_start %}
<div class="meta">
{% if client_name %}
<div>Name: {{ client_name }}</div>
{% endif %}
{% if slot_start %}
<div>Termin: {{ slot_start }}</div>
{% endif %}
</div>
{% endif %}
<div class="actions">
<button class="btn btn-close" type="button" onclick="window.close()">Fenster schließen</button>
<a class="btn btn-link" href="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}">Zurück zur Buchungsseite</a>
</div>
</main>
</body>
</html>
+17 -1
View File
@@ -287,7 +287,23 @@ document.addEventListener('DOMContentLoaded', function() {
const filteredEvents = showCompletedBookings
? processedEvents
: processedEvents.filter(event => event.extendedProps.status !== 'completed');
// If any event spans multiple days, switch to week view for better visibility
try {
const multiDay = processedEvents.some(ev => {
try {
const s = new Date(ev.start);
const e = new Date(ev.end);
return s && e && s.toDateString() !== e.toDateString();
} catch (e) { return false; }
});
if (multiDay && calendar && calendar.view && calendar.view.type === 'timeGridDay') {
calendar.changeView('timeGridWeek');
}
} catch (e) {
// ignore
}
// Provide events to calendar
successCallback(filteredEvents);
})