Enhance admin dashboard with block day functionality, improve appointment details, and update footer animations

- Added a section in the admin dashboard for blocking calendar days with a form to submit block dates and reasons.
- Implemented a list of blocked days with the ability to unblock them.
- Enhanced appointment details to include meeting type and location information.
- Updated styles for blocked days in the appointment calendar.
- Improved footer design with animations and responsive adjustments.
- Removed unused license transfer forms from admin and user license templates.
- Changed database name references from 'Inventarsystem' to 'Invario_Website' in user management functions.
This commit is contained in:
2026-03-24 16:18:39 +01:00
parent 7e45cec5f8
commit 40db9e9576
8 changed files with 704 additions and 313 deletions
-58
View File
@@ -1,58 +0,0 @@
[
{
"id": "a-1774281965267",
"username": "Aiirondev",
"display_name": "Maximilian Gr\u00fcndinger",
"date": "2026-03-24",
"time": "10:00",
"subject": "Test",
"note": "",
"status": "Abgelehnt",
"created_at": "2026-03-23T16:06:05Z",
"response": "Es geht leider nicht um die von ihnen angefragte Uhrzeit, bitte versuchen sie es zwei Tage sp\u00e4ter",
"responded_at": "2026-03-23T16:06:52Z",
"responded_by": "Aiirondev"
},
{
"id": "a-1774287654152",
"username": "Aiirondev",
"display_name": "Maximilian Gr\u00fcndinger",
"date": "2026-03-26",
"time": "10:00",
"subject": "Test",
"note": "Test",
"status": "Bestaetigt",
"created_at": "2026-03-23T17:40:54Z",
"response": "Wunderbar bis dann!",
"responded_at": "2026-03-23T17:41:10Z",
"responded_by": "Aiirondev"
},
{
"id": "a-1774296722216",
"username": "Aiirondev",
"display_name": "Maximilian Gr\u00fcndinger",
"date": "2026-03-27",
"time": "14:30",
"subject": "Erst Gespr\u00e4ch f\u00fcr Grunschule ...",
"note": "Erste kontakt aufnahme",
"status": "Bestaetigt",
"created_at": "2026-03-23T20:12:02Z",
"response": "Ja passt so, wir kommen vorbei",
"responded_at": "2026-03-23T20:12:43Z",
"responded_by": "Aiirondev"
},
{
"id": "a-1774335231538",
"username": "Aiirondev",
"display_name": "Maximilian Gr\u00fcndinger",
"date": "2026-03-26",
"time": "13:00",
"subject": "Erst Gespr\u00e4ch",
"note": "Test",
"status": "Bestaetigt",
"created_at": "2026-03-24T06:53:51Z",
"response": "Es w\u00fcrde an dem von ihnen andgefragten Datum eine Stunde gehen",
"responded_at": "2026-03-24T06:54:42Z",
"responded_by": "Aiirondev"
}
]
+292 -187
View File
@@ -1,10 +1,8 @@
from flask_jwt_extended import JWTManager, create_access_token, jwt_required
from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, get_flashed_messages, session
import os
import json
import calendar
from datetime import timedelta, datetime, date
from pathlib import Path
from functools import wraps
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename
@@ -36,14 +34,10 @@ def set_security_headers(response):
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; img-src 'self' data:; connect-src 'self';"
return response
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"
INVOICE_UPLOAD_DIR = BASE_DIR / "static" / "uploads" / "invoices"
USERS_FILE = DATA_DIR / "users.json"
APPOINTMENTS_FILE = DATA_DIR / "appointments.json"
POSTS_FILE = DATA_DIR / "posts.json"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
INVOICE_UPLOAD_DIR = os.path.join(BASE_DIR, "static", "uploads", "invoices")
MONGO_URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017")
MONGO_DB_NAME = os.environ.get("MONGO_DB_NAME", "Inventarsystem")
MONGO_DB_NAME = os.environ.get("MONGO_DB_NAME", "Invario_Website")
def _issue_access_token() -> str:
@@ -64,10 +58,10 @@ def _save_invoice_pdf(file_obj, invoice_number: str) -> str | None:
original_name = secure_filename(file_obj.filename)
if not _is_allowed_invoice_filename(original_name):
return None
INVOICE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
os.makedirs(INVOICE_UPLOAD_DIR, exist_ok=True)
safe_invoice = secure_filename(invoice_number or "invoice")
unique_name = f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}_{safe_invoice}_{original_name}"
target = INVOICE_UPLOAD_DIR / unique_name
target = os.path.join(INVOICE_UPLOAD_DIR, unique_name)
file_obj.save(target)
return f"uploads/invoices/{unique_name}"
@@ -108,78 +102,6 @@ def _list_users_for_admin() -> list:
return users
def _ensure_user_license(username: str, display_name: str) -> None:
client = None
try:
client, licenses = _get_collection("licenses")
if licenses.find_one({"username": username}):
return
licenses.insert_one(
{
"username": username,
"school_name": f"{display_name} Schule",
"license_key": f"LIC-{int(datetime.utcnow().timestamp())}-{username[:3].upper()}",
"plan": "Standard",
"status": "Aktiv",
"valid_until": "2027-12-31",
"created_at": _utc_now_iso(),
}
)
except PyMongoError:
return
finally:
if client:
client.close()
def _ensure_user_invoice(username: str) -> None:
client = None
try:
client, invoices = _get_collection("invoices")
if invoices.find_one({"username": username}):
return
invoices.insert_one(
{
"username": username,
"invoice_number": f"INV-{datetime.utcnow().strftime('%Y%m')}-{username[:3].upper()}",
"period": datetime.utcnow().strftime("%m/%Y"),
"amount_eur": 79.0,
"status": "Offen",
"due_date": "2026-12-31",
"pdf_path": "",
"created_at": _utc_now_iso(),
}
)
except PyMongoError:
return
finally:
if client:
client.close()
def _ensure_data_files() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not USERS_FILE.exists():
USERS_FILE.write_text("[]", encoding="utf-8")
if not APPOINTMENTS_FILE.exists():
APPOINTMENTS_FILE.write_text("[]", encoding="utf-8")
if not POSTS_FILE.exists():
POSTS_FILE.write_text("[]", encoding="utf-8")
def _read_json(file_path: Path) -> list:
_ensure_data_files()
try:
return json.loads(file_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return []
def _write_json(file_path: Path, payload: list) -> None:
_ensure_data_files()
file_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True), encoding="utf-8")
def _sanitize_text(text: str, max_length: int = 255) -> str:
"""Sanitize user text input: strip, limit length, and escape HTML."""
text = (text or "").strip()
@@ -188,6 +110,74 @@ def _sanitize_text(text: str, max_length: int = 255) -> str:
return text
def _with_public_id(doc: dict | None) -> dict | None:
if not doc:
return doc
if not doc.get("id") and doc.get("_id") is not None:
doc["id"] = str(doc.get("_id"))
return doc
def _appointment_query_from_id(appointment_id: str) -> dict | None:
lookup = (appointment_id or "").strip()
if not lookup:
return None
if lookup.startswith("a-"):
return {"id": lookup}
try:
return {"_id": ObjectId(lookup)}
except Exception:
return {"id": lookup}
def _post_query_from_id(post_id: str) -> dict | None:
lookup = (post_id or "").strip()
if not lookup:
return None
if lookup.startswith("p-"):
return {"id": lookup}
try:
return {"_id": ObjectId(lookup)}
except Exception:
return {"id": lookup}
def _get_blocked_days() -> list:
client = None
entries = []
try:
client, col = _get_collection("blocked_days")
entries = list(col.find({}, {"_id": 0}).sort("date", 1))
except PyMongoError:
return []
finally:
if client:
client.close()
normalized = []
for item in entries:
day = (item.get("date") or "").strip()
if not day:
continue
normalized.append(
{
"date": day,
"reason": (item.get("reason") or "").strip()[:200],
"blocked_by": (item.get("blocked_by") or "").strip()[:80],
"created_at": item.get("created_at") or "",
}
)
normalized.sort(key=lambda value: value.get("date", ""))
return normalized
def _get_blocked_day_map() -> dict:
blocked = {}
for item in _get_blocked_days():
blocked[item.get("date", "")] = item
return blocked
def _sanitize_html(html_content: str, max_length: int = 50000) -> str:
"""Sanitize HTML content: allow safe tags only."""
if not html_content:
@@ -321,8 +311,6 @@ def login():
session['display_name'] = stored_user.get("display_name") or stored_user.get("username")
session['is_admin'] = stored_user.get("is_admin", False)
session['access_token'] = _issue_access_token()
_ensure_user_license(session['username'], session['display_name'])
_ensure_user_invoice(session['username'])
if request.is_json:
return jsonify({"access_token": session['access_token'], "token_type": "Bearer"}), 200
@@ -400,6 +388,11 @@ def appointments():
appointment_time = (request.form.get("appointment_time") or "").strip()
subject = (request.form.get("subject") or "").strip()
note = (request.form.get("note") or "").strip()
meeting_type = (request.form.get("meeting_type") or "digital").strip().lower()
location_name = _sanitize_text(request.form.get("location_name") or "", 200)
location_maps_url = _sanitize_text(request.form.get("location_maps_url") or "", 1000)
blocked_day_map = _get_blocked_day_map()
try:
date.fromisoformat(selected_date)
@@ -407,15 +400,33 @@ def appointments():
flash("Bitte einen gueltigen Termin im Kalender auswaehlen.", "error")
return redirect(url_for("appointments", month=month, year=year))
if selected_date in blocked_day_map:
flash("Dieser Tag ist im Kalender gesperrt. Bitte einen anderen Termin waehlen.", "error")
return redirect(url_for("appointments", month=month, year=year))
if not appointment_time or not subject:
flash("Bitte Uhrzeit und Betreff ausfuellen.", "error")
return redirect(url_for("appointments", month=month, year=year))
if meeting_type not in ["digital", "vor_ort"]:
flash("Bitte eine gueltige Terminart waehlen.", "error")
return redirect(url_for("appointments", month=month, year=year))
if meeting_type == "vor_ort" and not location_name:
flash("Bei Vor-Ort-Terminen ist ein Ort erforderlich.", "error")
return redirect(url_for("appointments", month=month, year=year))
if meeting_type == "digital":
location_name = ""
location_maps_url = ""
subject = _sanitize_text(subject, 200)
note = _sanitize_text(note, 2000)
entries = _read_json(APPOINTMENTS_FILE)
entries.append(
client = None
try:
client, col = _get_collection("appointments")
col.insert_one(
{
"id": f"a-{int(datetime.utcnow().timestamp() * 1000)}",
"username": session.get("username"),
@@ -423,12 +434,21 @@ def appointments():
"date": selected_date,
"time": appointment_time,
"subject": subject,
"meeting_type": meeting_type,
"meeting_label": "Digital" if meeting_type == "digital" else "Vor Ort",
"location_name": location_name,
"location_maps_url": location_maps_url,
"note": note,
"status": "Angefragt",
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
}
)
_write_json(APPOINTMENTS_FILE, entries)
except PyMongoError:
flash("Termin konnte nicht gespeichert werden.", "error")
return redirect(url_for("appointments", month=month, year=year))
finally:
if client:
client.close()
flash("Termin erfolgreich angefragt.", "success")
selected = date.fromisoformat(selected_date)
@@ -450,9 +470,20 @@ def appointments():
next_month = 1
next_year += 1
all_appointments = _read_json(APPOINTMENTS_FILE)
user_appointments = [item for item in all_appointments if item.get("username") == session.get("username")]
user_appointments.sort(key=lambda item: (item.get("date", ""), item.get("time", "")), reverse=False)
user_appointments = []
client = None
try:
client, col = _get_collection("appointments")
user_appointments = list(col.find({"username": session.get("username")}).sort([("date", 1), ("time", 1)]))
for item in user_appointments:
_with_public_id(item)
except PyMongoError:
flash("Termine konnten nicht geladen werden.", "error")
finally:
if client:
client.close()
blocked_day_map = _get_blocked_day_map()
return render_template(
"appointments.html",
@@ -465,6 +496,7 @@ def appointments():
previous_year=previous_year,
next_month=next_month,
next_year=next_year,
blocked_day_map=blocked_day_map,
user_appointments=user_appointments,
)
@@ -472,8 +504,20 @@ def appointments():
@app.route('/admin/dashboard')
@admin_required
def admin_dashboard():
all_appointments = _read_json(APPOINTMENTS_FILE)
all_appointments.sort(key=lambda x: (x.get("date", ""), x.get("time", "")), reverse=True)
all_appointments = []
client = None
try:
client, col = _get_collection("appointments")
all_appointments = list(col.find().sort([("date", -1), ("time", -1)]))
for item in all_appointments:
_with_public_id(item)
except PyMongoError:
flash("Terminanfragen konnten nicht geladen werden.", "error")
finally:
if client:
client.close()
blocked_days = _get_blocked_days()
status_counts = {
"Angefragt": len([a for a in all_appointments if a.get("status") == "Angefragt"]),
@@ -481,17 +525,75 @@ def admin_dashboard():
"Abgelehnt": len([a for a in all_appointments if a.get("status") == "Abgelehnt"]),
}
posts = _read_json(POSTS_FILE)
total_posts = len(posts)
total_posts = 0
client = None
try:
client, col = _get_collection("posts")
total_posts = col.count_documents({})
except PyMongoError:
total_posts = 0
finally:
if client:
client.close()
return render_template(
"admin_dashboard.html",
appointments=all_appointments,
blocked_days=blocked_days,
status_counts=status_counts,
total_posts=total_posts,
)
@app.route('/admin/appointments/block-day', methods=['POST'])
@admin_required
def admin_block_day():
action = _sanitize_text(request.form.get("action") or "", 30)
block_date = (request.form.get("block_date") or "").strip()
reason = _sanitize_text(request.form.get("reason") or "", 200)
client = None
try:
client, col = _get_collection("blocked_days")
if action == "add":
try:
date.fromisoformat(block_date)
except ValueError:
flash("Bitte ein gueltiges Datum zum Sperren waehlen.", "error")
return redirect(url_for("admin_dashboard"))
if col.find_one({"date": block_date}):
flash("Der Tag ist bereits gesperrt.", "error")
return redirect(url_for("admin_dashboard"))
col.insert_one(
{
"date": block_date,
"reason": reason,
"blocked_by": session.get("username") or "admin",
"created_at": _utc_now_iso(),
}
)
flash("Tag im Kalender gesperrt.", "success")
elif action == "remove":
result = col.delete_one({"date": block_date})
if not result.deleted_count:
flash("Sperrtag nicht gefunden.", "error")
return redirect(url_for("admin_dashboard"))
flash("Sperrtag entfernt.", "success")
else:
flash("Ungueltige Aktion fuer Kalendersperre.", "error")
return redirect(url_for("admin_dashboard"))
except PyMongoError:
flash("Kalendersperre konnte nicht gespeichert werden.", "error")
finally:
if client:
client.close()
return redirect(url_for("admin_dashboard"))
@app.route('/admin/appointment/<appointment_id>', methods=['POST'])
@admin_required
def update_appointment(appointment_id):
@@ -502,27 +604,37 @@ def update_appointment(appointment_id):
flash("Ungueltige Aktion.", "error")
return redirect(url_for("admin_dashboard"))
all_appointments = _read_json(APPOINTMENTS_FILE)
appointment = None
for item in all_appointments:
if item.get("id") == appointment_id:
appointment = item
break
if not appointment:
query = _appointment_query_from_id(appointment_id)
if not query:
flash("Termin nicht gefunden.", "error")
return redirect(url_for("admin_dashboard"))
if action == "confirm":
appointment["status"] = "Bestaetigt"
else:
appointment["status"] = "Abgelehnt"
new_status = "Bestaetigt" if action == "confirm" else "Abgelehnt"
appointment["response"] = response_text
appointment["responded_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
appointment["responded_by"] = session.get("username")
client = None
try:
client, col = _get_collection("appointments")
result = col.update_one(
query,
{
"$set": {
"status": new_status,
"response": response_text,
"responded_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
"responded_by": session.get("username"),
}
},
)
if result.matched_count == 0:
flash("Termin nicht gefunden.", "error")
return redirect(url_for("admin_dashboard"))
except PyMongoError:
flash("Termin konnte nicht aktualisiert werden.", "error")
return redirect(url_for("admin_dashboard"))
finally:
if client:
client.close()
_write_json(APPOINTMENTS_FILE, all_appointments)
flash(f"Termin wurde {('bestaetigt' if action == 'confirm' else 'abgelehnt')}.", "success")
return redirect(url_for("admin_dashboard"))
@@ -541,9 +653,11 @@ def admin_blog():
if not title or not content:
flash("Bitte Titel und inhalt ausfuellen.", "error")
return redirect(url_for("admin_blog"))
posts = _read_json(POSTS_FILE)
posts.append({
client = None
try:
client, col = _get_collection("posts")
col.insert_one(
{
"id": f"p-{int(datetime.utcnow().timestamp() * 1000)}",
"title": title,
"excerpt": excerpt or (content[:150] + "...") if len(content) > 150 else content,
@@ -551,50 +665,93 @@ def admin_blog():
"author": session.get("username"),
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
"published": True,
})
_write_json(POSTS_FILE, posts)
}
)
except PyMongoError:
flash("Beitrag konnte nicht gespeichert werden.", "error")
return redirect(url_for("admin_blog"))
finally:
if client:
client.close()
flash("Beitrag veroeffentlicht.", "success")
return redirect(url_for("admin_blog"))
elif action == "delete":
post_id = (request.form.get("post_id") or "").strip()
if not post_id or not post_id.startswith("p-"):
query = _post_query_from_id(post_id)
if not query:
flash("Ungueltige Beitrag-ID.", "error")
return redirect(url_for("admin_blog"))
posts = _read_json(POSTS_FILE)
original_count = len(posts)
posts = [p for p in posts if p.get("id") != post_id]
if len(posts) == original_count:
client = None
try:
client, col = _get_collection("posts")
result = col.delete_one(query)
except PyMongoError:
flash("Beitrag konnte nicht geloescht werden.", "error")
return redirect(url_for("admin_blog"))
finally:
if client:
client.close()
if not result.deleted_count:
flash("Beitrag nicht gefunden.", "error")
return redirect(url_for("admin_blog"))
_write_json(POSTS_FILE, posts)
flash("Beitrag geloescht.", "success")
return redirect(url_for("admin_blog"))
posts = _read_json(POSTS_FILE)
posts.sort(key=lambda x: x.get("created_at", ""), reverse=True)
posts = []
client = None
try:
client, col = _get_collection("posts")
posts = list(col.find().sort("created_at", -1))
for item in posts:
_with_public_id(item)
except PyMongoError:
flash("Blogbeitraege konnten nicht geladen werden.", "error")
finally:
if client:
client.close()
return render_template("admin_blog.html", posts=posts)
@app.route('/blog')
def blog():
posts = _read_json(POSTS_FILE)
posts.sort(key=lambda x: x.get("created_at", ""), reverse=True)
posts = []
client = None
try:
client, col = _get_collection("posts")
posts = list(col.find({"published": True}).sort("created_at", -1))
for item in posts:
_with_public_id(item)
except PyMongoError:
posts = []
finally:
if client:
client.close()
return render_template("blog.html", posts=posts)
@app.route('/blog/<post_id>')
def blog_post(post_id):
if not post_id or not post_id.startswith("p-"):
query = _post_query_from_id(post_id)
if not query:
flash("Ungueltige Beitrag-ID.", "error")
return redirect(url_for("blog"))
posts = _read_json(POSTS_FILE)
client = None
post = None
for p in posts:
if p.get("id") == post_id:
post = p
break
try:
client, col = _get_collection("posts")
post = col.find_one(query)
_with_public_id(post)
except PyMongoError:
post = None
finally:
if client:
client.close()
if not post:
flash("Beitrag nicht gefunden.", "error")
@@ -607,40 +764,7 @@ def blog_post(post_id):
@login_required
def my_licenses():
if request.method == 'POST':
action = _sanitize_text(request.form.get("action") or "", 30)
license_id = _sanitize_text(request.form.get("license_id") or "", 64)
target_username = _sanitize_text(request.form.get("target_username") or "", 80)
if action != "transfer" or not license_id or not target_username:
flash("Ungueltige Weitergabe-Angaben.", "error")
return redirect(url_for("my_licenses"))
if target_username == session.get("username"):
flash("Bitte einen anderen Nutzer waehlen.", "error")
return redirect(url_for("my_licenses"))
client = None
try:
client, col = _get_collection("licenses")
result = col.update_one(
{"_id": ObjectId(license_id), "username": session.get("username")},
{
"$set": {
"username": target_username,
"transferred_at": _utc_now_iso(),
"transferred_by": session.get("username"),
}
},
)
if result.modified_count:
flash("Lizenz wurde erfolgreich weitergegeben.", "success")
else:
flash("Lizenz konnte nicht weitergegeben werden.", "error")
except Exception:
flash("Weitergabe fehlgeschlagen.", "error")
finally:
if client:
client.close()
flash("Weitergabe von Lizenzen ist deaktiviert.", "error")
return redirect(url_for("my_licenses"))
licenses = []
@@ -656,8 +780,7 @@ def my_licenses():
if client:
client.close()
transfer_users = [u for u in _list_users_for_admin() if u.get("username") != session.get("username")]
return render_template("my_licenses.html", licenses=licenses, transfer_users=transfer_users)
return render_template("my_licenses.html", licenses=licenses)
@app.route('/my/invoices')
@@ -945,24 +1068,6 @@ def admin_licenses():
)
flash("Lizenz aktualisiert.", "success")
elif action == "transfer" and license_id:
target_username = _sanitize_text(request.form.get("target_username") or "", 80)
if not target_username:
flash("Bitte Zielnutzer waehlen.", "error")
return redirect(url_for("admin_licenses"))
col.update_one(
{"_id": ObjectId(license_id)},
{
"$set": {
"username": target_username,
"transferred_at": _utc_now_iso(),
"transferred_by": session.get("username"),
}
},
)
flash("Lizenz weitergegeben.", "success")
elif action == "delete" and license_id:
col.delete_one({"_id": ObjectId(license_id)})
flash("Lizenz geloescht.", "success")
+111
View File
@@ -48,6 +48,76 @@
margin-bottom: 0.8rem;
}
.block-days-section {
margin-bottom: 1.2rem;
padding: 1rem;
border: 1px solid #d8e3ec;
border-radius: 12px;
background: #f9fbfd;
}
.block-days-section h3 {
margin: 0 0 0.6rem;
color: #143a55;
font-size: 1.05rem;
}
.block-form {
display: grid;
grid-template-columns: 180px 1fr auto;
gap: 0.5rem;
margin-bottom: 0.8rem;
}
.block-form input {
width: 100%;
border: 1px solid #c4d4e0;
border-radius: 8px;
padding: 0.45rem 0.55rem;
font: inherit;
}
.block-btn,
.unblock-btn {
border: none;
border-radius: 8px;
padding: 0.45rem 0.65rem;
font-weight: 700;
cursor: pointer;
}
.block-btn {
color: #ffffff;
background: linear-gradient(120deg, #0b5b89 0%, #0b4262 100%);
}
.blocked-list {
display: grid;
gap: 0.45rem;
}
.blocked-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
border: 1px solid #d5dee6;
border-radius: 9px;
padding: 0.5rem 0.65rem;
background: #ffffff;
}
.blocked-item p {
margin: 0;
font-size: 0.88rem;
}
.unblock-btn {
color: #7e2d2d;
background: #fff3f3;
border: 1px solid #e8b8b8;
}
.appointment-card {
border: 1px solid #d8e3ec;
border-radius: 12px;
@@ -141,6 +211,10 @@
.admin-grid {
grid-template-columns: 1fr 1fr;
}
.block-form {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
@@ -175,12 +249,49 @@
<section class="appointments-section">
<h2>Terminanfragen verwalten</h2>
<div class="block-days-section">
<h3>Kalender-Tage sperren</h3>
<form method="POST" action="{{ url_for('admin_block_day') }}" class="block-form">
<input type="hidden" name="action" value="add">
<input type="date" name="block_date" required>
<input type="text" name="reason" placeholder="Grund (optional)">
<button type="submit" class="block-btn">Tag sperren</button>
</form>
<div class="blocked-list">
{% if blocked_days %}
{% for blocked in blocked_days %}
<div class="blocked-item">
<p>
<strong>{{ blocked.date }}</strong>
{% if blocked.reason %}
- {{ blocked.reason }}
{% endif %}
</p>
<form method="POST" action="{{ url_for('admin_block_day') }}">
<input type="hidden" name="action" value="remove">
<input type="hidden" name="block_date" value="{{ blocked.date }}">
<button type="submit" class="unblock-btn">Entsperren</button>
</form>
</div>
{% endfor %}
{% else %}
<p>Aktuell sind keine Tage gesperrt.</p>
{% endif %}
</div>
</div>
{% if appointments %}
{% for appointment in appointments %}
<article class="appointment-card">
<div class="appointment-info">
<strong>{{ appointment.display_name }} - {{ appointment.subject }}</strong>
<p><strong>Datum:</strong> {{ appointment.date }} um {{ appointment.time }}</p>
<p><strong>Terminart:</strong> {{ appointment.meeting_label or ('Vor Ort' if appointment.meeting_type == 'vor_ort' else 'Digital') }}</p>
{% if appointment.location_name %}
<p><strong>Ort:</strong> {{ appointment.location_name }}</p>
{% endif %}
{% if appointment.location_maps_url %}
<p><strong>Maps:</strong> <a href="{{ appointment.location_maps_url }}" target="_blank" rel="noopener noreferrer">Link öffnen</a></p>
{% endif %}
<p><strong>Angefragt:</strong> {{ appointment.created_at[:10] }}</p>
{% if appointment.note %}
<p><strong>Notiz:</strong> {{ appointment.note }}</p>
-13
View File
@@ -45,19 +45,6 @@
<input type="text" name="valid_until" value="{{ item.valid_until }}" placeholder="Gültig bis">
<button type="submit">Aktualisieren</button>
</form>
<form method="post" class="inline-form" style="margin-top:0.45rem;">
<input type="hidden" name="action" value="transfer">
<input type="hidden" name="license_id" value="{{ item.id }}">
<select name="target_username" required>
<option value="">Lizenz weitergeben an...</option>
{% for user in users %}
{% if user.username != item.username %}
<option value="{{ user.username }}">{{ user.username }} ({{ user.display_name }})</option>
{% endif %}
{% endfor %}
</select>
<button type="submit">Weitergeben</button>
</form>
<form method="post">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="license_id" value="{{ item.id }}">
+103 -2
View File
@@ -94,6 +94,18 @@
color: #ffffff;
}
.day.blocked {
background: #f3f4f7;
color: #8b96a1;
border-color: #d2d8de;
cursor: not-allowed;
}
.day.blocked:hover {
transform: none;
border-color: #d2d8de;
}
.booking-form h2,
.appointment-list h2 {
font-size: 1.2rem;
@@ -124,7 +136,8 @@
}
.field input,
.field textarea {
.field textarea,
.field select {
width: 100%;
border: 1px solid #c4d4e0;
border-radius: 10px;
@@ -180,6 +193,41 @@
background: #eef4f8;
}
.meeting-note {
margin: 0 0 0.75rem;
color: #27526d;
font-weight: 600;
}
.helper-line {
margin-top: 0.35rem;
font-size: 0.84rem;
color: #4f6f83;
}
.maps-tools {
display: flex;
gap: 0.45rem;
margin-top: 0.45rem;
}
.maps-btn {
border: 1px solid #8fb1c8;
background: #f6fbff;
color: #1d5575;
border-radius: 999px;
padding: 0.35rem 0.7rem;
font-size: 0.84rem;
font-weight: 700;
cursor: pointer;
}
.entry-meta {
margin-top: 0.2rem;
color: #345e78;
font-size: 0.9rem;
}
@media (max-width: 960px) {
.appointments-grid {
grid-template-columns: 1fr;
@@ -216,10 +264,15 @@
<button type="button" class="day empty" disabled></button>
{% else %}
{% set day_iso = "%04d-%02d-%02d"|format(year, month, day) %}
{% set blocked_day = blocked_day_map.get(day_iso) %}
<button
type="button"
class="day {% if day_iso == today_iso %}today{% endif %}"
class="day {% if day_iso == today_iso %}today{% endif %} {% if blocked_day %}blocked{% endif %}"
data-date="{{ day_iso }}"
{% if blocked_day %}
disabled
title="Gesperrt{% if blocked_day.reason %}: {{ blocked_day.reason }}{% endif %}"
{% endif %}
>
{{ day }}
</button>
@@ -231,6 +284,7 @@
<aside class="panel booking-form">
<h2>Termin anfragen</h2>
<p class="meeting-note">Persoenliches Gespraechstermin: Digital oder Vor Ort.</p>
<span class="selected-date-badge" id="selectedDateBadge">Kein Datum gewählt</span>
<form method="POST" action="{{ url_for('appointments', month=month, year=year) }}">
<input type="hidden" id="selectedDateInput" name="selected_date" required>
@@ -238,10 +292,26 @@
<label for="appointment_time">Uhrzeit</label>
<input id="appointment_time" name="appointment_time" type="time" required>
</div>
<div class="field">
<label for="meeting_type">Terminart</label>
<select id="meeting_type" name="meeting_type" required>
<option value="digital">Digital</option>
<option value="vor_ort">Vor Ort</option>
</select>
</div>
<div class="field">
<label for="subject">Betreff</label>
<input id="subject" name="subject" type="text" placeholder="z. B. Projekt Kickoff" required>
</div>
<div class="field" id="locationField" style="display: none;">
<label for="location_name">Ort (bei Vor-Ort Pflicht)</label>
<input id="location_name" name="location_name" type="text" placeholder="Adresse oder Ort">
<div class="maps-tools">
<button type="button" id="mapsSearchBtn" class="maps-btn">In Google Maps suchen</button>
</div>
<div class="helper-line">Optional: Google-Maps-Link eintragen.</div>
<input id="location_maps_url" name="location_maps_url" type="url" placeholder="https://maps.google.com/..." style="margin-top: 0.4rem;">
</div>
<div class="field">
<label for="note">Notiz (optional)</label>
<textarea id="note" name="note" placeholder="Kurzbeschreibung der Anfrage"></textarea>
@@ -260,6 +330,13 @@
{% if item.note %}
<p>{{ item.note }}</p>
{% endif %}
<p class="entry-meta"><strong>Terminart:</strong> {{ item.meeting_label or ('Vor Ort' if item.meeting_type == 'vor_ort' else 'Digital') }}</p>
{% if item.location_name %}
<p class="entry-meta"><strong>Ort:</strong> {{ item.location_name }}</p>
{% endif %}
{% if item.location_maps_url %}
<p class="entry-meta"><a href="{{ item.location_maps_url }}" target="_blank" rel="noopener noreferrer">Google Maps anzeigen</a></p>
{% endif %}
{% if item.response %}
<p><strong>Admin-Antwort:</strong> {{ item.response }}</p>
{% endif %}
@@ -276,6 +353,10 @@
const dayGrid = document.getElementById("dayGrid");
const selectedDateInput = document.getElementById("selectedDateInput");
const selectedDateBadge = document.getElementById("selectedDateBadge");
const meetingType = document.getElementById("meeting_type");
const locationField = document.getElementById("locationField");
const locationNameInput = document.getElementById("location_name");
const mapsSearchBtn = document.getElementById("mapsSearchBtn");
function setSelection(dateValue, button) {
selectedDateInput.value = dateValue;
@@ -290,6 +371,16 @@
}
}
function updateLocationRequirement() {
const isOnsite = meetingType.value === "vor_ort";
locationField.style.display = isOnsite ? "block" : "none";
locationNameInput.required = isOnsite;
if (!isOnsite) {
locationNameInput.value = "";
document.getElementById("location_maps_url").value = "";
}
}
dayGrid.addEventListener("click", function (event) {
const target = event.target.closest(".day[data-date]");
if (!target) {
@@ -297,6 +388,16 @@
}
setSelection(target.dataset.date, target);
});
meetingType.addEventListener("change", updateLocationRequirement);
mapsSearchBtn.addEventListener("click", function () {
const query = (locationNameInput.value || "").trim();
const base = "https://www.google.com/maps/search/?api=1&query=";
window.open(base + encodeURIComponent(query || "meeting location"), "_blank", "noopener");
});
updateLocationRequirement();
})();
</script>
{% endblock %}
+162 -3
View File
@@ -42,6 +42,12 @@
var(--bg-main);
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
h1 {
margin: 0;
font-family: "Space Grotesk", system-ui, sans-serif;
@@ -210,6 +216,7 @@
}
.main-wrap {
flex: 1 0 auto;
padding: 2rem 0 3.4rem;
}
@@ -354,22 +361,66 @@
.site-footer {
background: linear-gradient(135deg, var(--brand-strong) 0%, #0a4c74 100%);
color: #ffffff;
margin-top: 4rem;
margin-top: auto;
padding: 3rem 0 1.5rem;
border-top: 1px solid var(--line);
position: relative;
overflow: hidden;
animation: footerEnter 0.7s ease both;
will-change: transform, opacity;
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.45s ease, box-shadow 0.45s ease;
}
body.footer-scroll-mode .site-footer {
opacity: 0.9;
transform: translateY(26px);
box-shadow: 0 -8px 22px rgba(5, 27, 41, 0.14);
}
body.footer-scroll-mode.footer-reveal .site-footer {
opacity: 1;
transform: translateY(0);
box-shadow: 0 -14px 34px rgba(5, 27, 41, 0.2);
}
.site-footer::before {
content: "";
position: absolute;
inset: -40% -10% auto -10%;
height: 180px;
background: radial-gradient(circle at 20% 50%, rgba(242, 140, 40, 0.25) 0%, rgba(242, 140, 40, 0) 62%);
transform: translateX(-12%);
animation: footerGlow 7s ease-in-out infinite;
pointer-events: none;
}
.footer-inner {
position: relative;
z-index: 1;
display: grid;
gap: 2rem;
margin-bottom: 2rem;
}
.footer-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.footer-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
animation: footerSectionRise 0.65s ease both;
}
.footer-section:nth-child(2) {
animation-delay: 0.08s;
}
.footer-section:nth-child(3) {
animation-delay: 0.16s;
}
.footer-section h4 {
@@ -395,11 +446,12 @@
.footer-links a {
color: rgba(255, 255, 255, 0.8);
font-size: 0.95rem;
transition: color 0.2s ease;
transition: color 0.2s ease, transform 0.2s ease;
}
.footer-links a:hover {
color: #ffffff;
transform: translateX(4px);
}
.footer-bottom {
@@ -414,8 +466,63 @@
margin: 0;
}
@keyframes footerEnter {
from {
opacity: 0;
transform: translateY(22px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes footerSectionRise {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes footerGlow {
0%,
100% {
transform: translateX(-12%);
opacity: 0.65;
}
50% {
transform: translateX(12%);
opacity: 0.95;
}
}
@media (prefers-reduced-motion: reduce) {
.site-footer,
.site-footer::before,
.footer-section,
.footer-links a {
animation: none;
transition: none;
}
body.footer-scroll-mode .site-footer,
body.footer-scroll-mode.footer-reveal .site-footer {
opacity: 1;
transform: none;
box-shadow: none;
}
}
@media (max-width: 700px) {
.footer-inner {
gap: 1.5rem;
}
.footer-content {
grid-template-columns: 1fr;
gap: 1.5rem;
}
@@ -425,5 +532,57 @@
<!-- Mobile compatibility scripts -->
<script src="{{ url_for('static', filename='js/mobile_compatibility.js') }}"></script>
<script src="{{ url_for('static', filename='js/ios_fixes.js') }}"></script>
<script>
(function () {
var footer = document.querySelector(".site-footer");
if (!footer) {
return;
}
var body = document.body;
var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
var ticking = false;
function updateFooterMode() {
ticking = false;
var maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
body.classList.remove("footer-scroll-mode", "footer-reveal");
if (reduceMotion.matches || maxScroll < 140) {
return;
}
body.classList.add("footer-scroll-mode");
var currentScroll = window.scrollY || window.pageYOffset || 0;
var distanceToBottom = maxScroll - currentScroll;
var progress = maxScroll > 0 ? currentScroll / maxScroll : 1;
var shouldReveal = progress >= 0.78 || distanceToBottom <= 320;
body.classList.toggle("footer-reveal", shouldReveal);
}
function requestUpdate() {
if (ticking) {
return;
}
ticking = true;
window.requestAnimationFrame(updateFooterMode);
}
window.addEventListener("scroll", requestUpdate, { passive: true });
window.addEventListener("resize", requestUpdate);
window.addEventListener("load", requestUpdate);
if (typeof reduceMotion.addEventListener === "function") {
reduceMotion.addEventListener("change", requestUpdate);
} else if (typeof reduceMotion.addListener === "function") {
reduceMotion.addListener(requestUpdate);
}
requestUpdate();
})();
</script>
</body>
</html>
-14
View File
@@ -17,17 +17,6 @@
<p><strong>Lizenzschlüssel:</strong> {{ item.license_key }}</p>
<p><strong>Status:</strong> {{ item.status }}</p>
<p><strong>Gültig bis:</strong> {{ item.valid_until }}</p>
<form method="post" class="transfer-form">
<input type="hidden" name="action" value="transfer">
<input type="hidden" name="license_id" value="{{ item.id }}">
<select name="target_username" required>
<option value="">Lizenz weitergeben an...</option>
{% for user in transfer_users %}
<option value="{{ user.username }}">{{ user.username }} ({{ user.display_name }})</option>
{% endfor %}
</select>
<button type="submit">Weitergeben</button>
</form>
</article>
{% endfor %}
{% else %}
@@ -43,8 +32,5 @@
.grid { display: grid; gap: 0.8rem; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
.card { background: #fff; border: 1px solid #d8e1e8; border-radius: 14px; padding: 1rem; }
.card p { margin-top: 0.35rem; }
.transfer-form { margin-top: 0.8rem; display: grid; gap: 0.45rem; }
select, button { border: 1px solid #c9d8e3; border-radius: 10px; padding: 0.52rem; font: inherit; }
button { border-radius: 999px; color: #fff; background: linear-gradient(120deg, #0c5a86 0%, #08486c 100%); border-color: #0a4c74; font-weight: 700; }
</style>
{% endblock %}
+12 -12
View File
@@ -43,7 +43,7 @@ def check_nm_pwd(username, password):
dict: User document if credentials are valid, None otherwise
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
db = client['Invario_Website']
users = db['users']
hashed_password = hashlib.sha512(password.encode()).hexdigest()
user = users.find_one({'Username': username, 'Password': hashed_password})
@@ -63,7 +63,7 @@ def add_user(username, password, name, last_name):
bool: True if user was added successfully, False if password was too weak
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
db = client['Invario_Website']
users = db['users']
if not check_password_strength(password):
return False
@@ -83,7 +83,7 @@ def make_admin(username):
bool: True if user was promoted successfully
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
db = client['Invario_Website']
users = db['users']
users.update_one({'Username': username}, {'$set': {'Admin': True}})
client.close()
@@ -100,7 +100,7 @@ def remove_admin(username):
bool: True if user was demoted successfully
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
db = client['Invario_Website']
users = db['users']
users.update_one({'Username': username}, {'$set': {'Admin': False}})
client.close()
@@ -117,7 +117,7 @@ def get_user(username):
dict: User document or None if not found
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
db = client['Invario_Website']
users = db['users']
users_return = users.find_one({'Username': username})
client.close()
@@ -135,7 +135,7 @@ def check_admin(username):
bool: True if user is an administrator, False otherwise
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
db = client['Invario_Website']
users = db['users']
user = users.find_one({'Username': username})
client.close()
@@ -153,7 +153,7 @@ def delete_user(username):
bool: True if user was deleted successfully, False otherwise
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
db = client['Invario_Website']
users = db['users']
result = users.delete_one({'username': username})
client.close()
@@ -175,7 +175,7 @@ def get_name(username):
str: String of name
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
db = client['Invario_Website']
users = db['users']
user = users.find_one({'Username': username})
name = user.get("name")
@@ -189,7 +189,7 @@ def get_last_name(username):
str: String of last_name
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
db = client['Invario_Website']
users = db['users']
user = users.find_one({'Username': username})
name = user.get("last_name")
@@ -206,7 +206,7 @@ def get_all_users():
"""
try:
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
db = client['Invario_Website']
users = db['users']
all_users = list(users.find())
client.close()
@@ -230,7 +230,7 @@ def update_password(username, new_password):
return False
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
db = client['Invario_Website']
users = db['users']
# Hash the new password
@@ -262,7 +262,7 @@ def update_user_name(username, name, last_name):
"""
try:
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
db = client['Invario_Website']
users = db['users']
result = users.update_one(