Compare commits

...

43 Commits

Author SHA1 Message Date
Aiirondev_dev 95e02b7a75 changes to descriptors 2026-09-23 16:53:59 +02:00
Aiirondev_dev 3b0d499958 changes to implement a filter option for the student cards 2026-09-23 16:25:30 +02:00
Aiirondev_dev a7494648d8 changes to implement an option to view the password beeing typed in 2026-09-23 16:11:12 +02:00
Aiirondev_dev 8d8609fd70 removal of the debug 2026-09-21 15:57:54 +02:00
Aiirondev_dev 18c831eb42 debugging for the next function 2026-09-21 15:39:55 +02:00
Aiirondev_dev 8895283904 debugging for the user generation from the csv import 2026-09-21 15:26:57 +02:00
Aiirondev_dev a357881a20 changes to the upload of users process. 2026-09-21 15:12:07 +02:00
Aiirondev_dev b151dae3a8 changes to the descriptor 2026-09-21 14:51:15 +02:00
Aiirondev_dev 6167bfca82 changes to the main page 2026-09-21 14:36:03 +02:00
Aiirondev_dev c44a481382 changes to the beschriftungs presets 2026-09-20 13:02:54 +02:00
Aiirondev_dev 904972af53 changes to the item filter describtor 2026-09-20 12:33:02 +02:00
Aiirondev_dev de4098ebb2 Changes to the Timing of the removal of the Small Confirm 2026-09-20 11:42:39 +02:00
Aiirondev_dev 53b33afa80 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-09-20 11:34:00 +02:00
Aiirondev_dev 0e9c7c6eb2 changes to the Name importing 2026-09-20 11:33:22 +02:00
Aiirondev_dev f442648dc9 changes to the mobile design to make it better fr the mobile view 2026-09-18 22:01:22 +02:00
Aiirondev_dev d2e2339d21 removal of test emial sending service 2026-09-18 16:01:37 +02:00
Aiirondev_dev e42d82fb62 allow to oush to 1.0.0 2026-09-18 15:53:21 +02:00
Aiirondev_dev c5726756d0 implementation of needet clarification 2026-09-17 21:29:24 +02:00
Aiirondev_dev 7d0147061a changes to the Email processing 2026-09-17 21:04:03 +02:00
Aiirondev_dev 76efa85419 changes to the sending test_mail profile 2026-09-17 20:39:10 +02:00
Aiirondev_dev a4cf9626cd changes to the send testmail 2026-09-17 20:30:48 +02:00
Aiirondev_dev fa55717f7f changes to have the test email function 2026-09-17 20:22:30 +02:00
Aiirondev_dev 4943aff944 changes to the email processing 2026-09-17 20:13:26 +02:00
Aiirondev_dev ee1683abe1 fix of faulty spelling 2026-09-17 19:59:35 +02:00
Aiirondev_dev 320acb5256 changes to the email sending 2026-09-17 19:36:48 +02:00
Aiirondev_dev 7b2a6d7bc1 test mail sender 2026-09-17 19:28:24 +02:00
Aiirondev_dev 027cef257d changes to the client building for the email requests 2026-09-17 17:42:00 +02:00
Aiirondev_dev cbacda34ca temporary removal of the tutorial Page 2026-09-17 16:46:31 +02:00
Aiirondev_dev cbc805919a changes to the Email behaivior 2026-09-16 17:45:16 +02:00
Aiirondev 31e8b7ccb4 revert 92c86334e9
revert Refactor email configuration handling and improve logging for email delivery
2026-09-16 15:37:25 +00:00
Aiirondev 5d908b6142 revert 92c86334e9
revert Refactor email configuration handling and improve logging for email delivery
2026-09-16 15:37:12 +00:00
Aiirondev aa9eb4996a revert eba20b7e2e
revert Fixes for the Secret deployment
2026-09-16 15:36:35 +00:00
Aiirondev 43f963d0d3 revert 7ab2899e92
revert changes to the Secret changes
2026-09-16 15:36:10 +00:00
Aiirondev 6589b8d275 revert 8673c0f05c
revert secrets processibg chanbged
2026-09-16 15:35:55 +00:00
Aiirondev 612ce2e6e6 revert d49ec9fd34
revert changes to the env processing
2026-09-16 15:35:36 +00:00
Aiirondev 210483d250 revert 6b3c35f895
revert changes tp implement the env file
2026-09-16 15:35:21 +00:00
Aiirondev 943b48ce16 revert 1dbe5709f5
revert changes to some settings
2026-09-16 15:35:12 +00:00
Aiirondev 476dfee0ff revert d002c5f9da
revert changes
2026-09-16 15:35:04 +00:00
Aiirondev_dev d002c5f9da changes 2026-09-16 17:14:37 +02:00
Aiirondev_dev 1dbe5709f5 changes to some settings 2026-09-16 15:43:17 +02:00
Aiirondev_dev 6b3c35f895 changes tp implement the env file 2026-09-16 15:27:07 +02:00
Aiirondev_dev d49ec9fd34 changes to the env processing 2026-09-16 15:16:31 +02:00
Aiirondev_dev 8673c0f05c secrets processibg chanbged 2026-09-16 15:02:00 +02:00
16 changed files with 449 additions and 253 deletions
+9 -13
View File
@@ -150,10 +150,7 @@ jobs:
LATEST_MAJOR="0" LATEST_MAJOR="0"
fi fi
if [ "${BUMP_TYPE:-}" != "major" ] && [ "$TAG_MAJOR" != "$LATEST_MAJOR" ]; then
echo "Error: major version must stay v$LATEST_MAJOR.x.x (got $TAG)"
exit 1
fi
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
i=1 i=1
@@ -250,15 +247,14 @@ jobs:
INVENTAR_MONGODB_DB: Inventarsystem INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_BACKUP_FOLDER: /data/backups INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs INVENTAR_LOGS_FOLDER: /data/logs
INVENTAR_SECRET_KEY: ${{ secrets.INVENTAR_SECRET_KEY }} INVENTAR_SECRET_KEY: ${{secrets.INVENTAR_SECRET_KEY}}
INVENTAR_DATA_ENCRYPTION_KEY: ${{ secrets.INVENTAR_DATA_ENCRYPTION_KEY }} INVENTAR_DATA_ENCRYPTION_KEY: ${{secrets.INVENTAR_DATA_ENCRYPTION_KEY}}
INVENTAR_MONGODB_PASSWORD: ${{ secrets.INVENTAR_MONGODB_PASSWORD }} INVENTAR_MONGODB_PASSWORD: ${{secrets.INVENTAR_MONGODB_PASSWORD}}
EMAIL_ENABLED: ${{ secrets.EMAIL_ENABLED }} EMAIL_ENABLED: ${{secrets.EMAIL_ENABLED}}
EMAIL_SMTP_HOST: mai.invario-software.de EMAIL_SMTP_HOST: ${{secrets.EMAIL_SMTP_HOST}}
EMAIL_SMTP_PORT: 587 EMAIL_SMTP_PORT: ${{secrets.EMAIL_SMTP_PORT}}
EMAIL_USERNAME: no-reply@invario-software.de EMAIL_USERNAME: ${{secrets.EMAIL_USERNAME}}
EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }} EMAIL_PASSWORD: ${{secrets.EMAIL_PASSWORD}}
EMAIL_FROM_ADDRESS: ${{ secrets.EMAIL_FROM_ADDRESS }}
expose: expose:
- "8000" - "8000"
volumes: volumes:
+47 -21
View File
@@ -2224,8 +2224,8 @@ def _upload_student_cards_excel():
'ausweis_id': ['ausweis_id', 'ausweisid', 'ausweis-id', 'karte', 'kartennummer', 'card_id', 'id'], 'ausweis_id': ['ausweis_id', 'ausweisid', 'ausweis-id', 'karte', 'kartennummer', 'card_id', 'id'],
'ausweis_ident': ['lokales differenzierungsmerkmal', 'lokales_differenzierungsmerkmal', 'ausweis_ident', 'ausweis_ident': ['lokales differenzierungsmerkmal', 'lokales_differenzierungsmerkmal', 'ausweis_ident',
'differenzierungsmerkmal'], 'differenzierungsmerkmal'],
'first_name': ['vorname', 'first_name', 'firstname', 'rufname'], 'first_name': ['name', 'vorname', 'first_name', 'firstname', 'rufname'],
'last_name': ['nachname', 'last_name', 'lastname'], 'last_name': ['nachname', 'last_name', 'lastname', 'familienname'],
'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe', 'asv_klasse'], 'class_name': ['klasse', 'class', 'class_name', 'jahrgang', 'jahrgangsstufe', 'stufe', 'gruppe', 'asv_klasse'],
'notes': ['notizen', 'notes', 'bemerkungen', 'bemerkung', 'hinweis', 'hinweise'], 'notes': ['notizen', 'notes', 'bemerkungen', 'bemerkung', 'hinweis', 'hinweise'],
'default_borrow_days': ['standard_ausleihdauer', 'ausleihdauer', 'borrow_days', 'tage', 'leihtage', 'default_borrow_days': ['standard_ausleihdauer', 'ausleihdauer', 'borrow_days', 'tage', 'leihtage',
@@ -3490,6 +3490,8 @@ def library_loans_admin():
damaged_items=damaged_items, damaged_items=damaged_items,
library_module_enabled=cfg.MODULES.is_enabled('library'), library_module_enabled=cfg.MODULES.is_enabled('library'),
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'), student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
email_service_enabled=cfg.MODULES.is_enabled('mail')
) )
except Exception as e: except Exception as e:
app.logger.error(f"Error loading library loans admin view: {e}") app.logger.error(f"Error loading library loans admin view: {e}")
@@ -8527,20 +8529,18 @@ def parse_csv_users(file_bytes):
return parsed_users return parsed_users
def generate_compliant_password(length=16): def generate_compliant_password(length=6):
lowers = string.ascii_lowercase lowers = string.ascii_lowercase
uppers = string.ascii_uppercase uppers = string.ascii_uppercase
digits = string.digits digits = string.digits
symbols = "!@#$%^&*()_+~|}{[]:;?><,.-="
# Ensure at least one character from each required category # Ensure at least one character from each required category
pwd = [ pwd = [
secrets.choice(lowers), secrets.choice(lowers),
secrets.choice(uppers), secrets.choice(uppers),
secrets.choice(digits), secrets.choice(digits)
secrets.choice(symbols)
] ]
all_chars = lowers + uppers + digits + symbols all_chars = lowers + uppers + digits
pwd += [secrets.choice(all_chars) for _ in range(length - 4)] pwd += [secrets.choice(all_chars) for _ in range(length - 4)]
# Shuffle so guaranteed types aren't always at the start # Shuffle so guaranteed types aren't always at the start
@@ -8664,19 +8664,31 @@ def register_csv():
# Benutzernamen & Passwort generieren # Benutzernamen & Passwort generieren
username = us.build_unique_username_from_name(name, last_name) username = us.build_unique_username_from_name(name, last_name)
password = generate_compliant_password(16) password = generate_compliant_password(6)
# In DB speichern password = us.check_password_strength(password) and password or generate_compliant_password(6)
success = us.add_user(
username=username, action_permissions = None
password=password, page_permissions = None
name=name,
last_name=last_name, try:
is_student=False, us.add_user(
student_card_id=None, username,
max_borrow_days=None, password,
permission_preset=permission_preset, name,
) last_name,
is_student=False,
student_card_id=None,
max_borrow_days=None,
permission_preset=permission_preset,
action_permissions=action_permissions,
page_permissions=page_permissions,
)
success = True
except Exception as e:
app.logger.error(f"Fehler beim Erstellen des Benutzers {username}: {e}")
success = False
continue
if success: if success:
created_users.append({ created_users.append({
@@ -10377,7 +10389,7 @@ def admin_reset_user_password():
# Reset the password # Reset the password
try: try:
us.update_password(username, new_password) us.update_password(username, new_password)
flash(f'Passwort für {encrypt_text(username)} wurde erfolgreich zurückgesetzt auf: {new_password}', 'success') flash(f'Passwort für {username} wurde erfolgreich zurückgesetzt auf: {new_password}', 'success')
except Exception as e: except Exception as e:
app.logger.error(f'Error resetting password for {encrypt_text(username)}: {e}') app.logger.error(f'Error resetting password for {encrypt_text(username)}: {e}')
flash('Fehler beim Zurücksetzen des Passworts', 'error') flash('Fehler beim Zurücksetzen des Passworts', 'error')
@@ -13755,4 +13767,18 @@ def upload_csv_batch():
"images_processed": processed_count, "images_processed": processed_count,
"images_deduplicated": dedup_count, "images_deduplicated": dedup_count,
"images_failed": error_count "images_failed": error_count
}), 200 }), 200
"""
@app.route('/test_email')
def test_email():
#Test endpoint to send a sample email.
#This is for development purposes only.
try:
send(email="maximiliangruendinger@gmail.com", subject="Test Email from Inventarsystem", note="This is a test email sent from the Inventarsystem application.", sender="Inventarsystem")
return "Test email sent successfully."
except Exception as e:
return f"Failed to send test email: {str(e)}", 500
"""
+5 -17
View File
@@ -220,25 +220,13 @@ SSL_CERT = _get(_conf, ['ssl', 'cert'], DEFAULTS['ssl']['cert'])
SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key']) SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
# Email settings # Email settings
def _get_email_env(name, default=''): EMAIL_ENABLED = bool(os.getenv('EMAIL_ENABLED', False))
value = os.getenv(name) EMAIL_SMTP_HOST = str(os.getenv('EMAIL_SMTP_HOST', False))
if value is None:
return default
value = value.strip()
return '' if value.lower() in {'', 'false', 'none', 'null'} else value
EMAIL_ENABLED = _get_email_env('EMAIL_ENABLED').lower() in {'1', 'true', 'yes', 'on'}
EMAIL_SMTP_HOST = _get_email_env('EMAIL_SMTP_HOST')
EMAIL_SMTP_PORT = int(os.getenv('EMAIL_SMTP_PORT', 587)) EMAIL_SMTP_PORT = int(os.getenv('EMAIL_SMTP_PORT', 587))
EMAIL_USE_TLS = True EMAIL_USE_TLS = True
EMAIL_USERNAME = _get_email_env('EMAIL_USERNAME') EMAIL_USERNAME = str(os.getenv('EMAIL_USERNAME', False))
EMAIL_PASSWORD = _get_email_env('EMAIL_PASSWORD') EMAIL_PASSWORD = str(os.getenv('EMAIL_PASSWORD', False))
EMAIL_FROM_ADDRESS = ( EMAIL_FROM_ADDRESS = _get(_conf, ['email', 'from_address'], EMAIL_USERNAME)
_get_email_env('EMAIL_FROM_ADDRESS')
or _get(_conf, ['email', 'from_address'], '')
or EMAIL_USERNAME
)
EMAIL_DEFAULT_SENDER_NAME = "Invario Inventarsystem Sender" EMAIL_DEFAULT_SENDER_NAME = "Invario Inventarsystem Sender"
EMAIL_TIMEOUT_SECONDS = 20 EMAIL_TIMEOUT_SECONDS = 20
+2 -3
View File
@@ -431,15 +431,14 @@ def check_password_strength(password):
if password is None: if password is None:
return False return False
if len(password) < 12: if len(password) < 5:
return False return False
has_lower = any(char.islower() for char in password) has_lower = any(char.islower() for char in password)
has_upper = any(char.isupper() for char in password) has_upper = any(char.isupper() for char in password)
has_digit = any(char.isdigit() for char in password) has_digit = any(char.isdigit() for char in password)
has_symbol = any(not char.isalnum() for char in password)
if not (has_lower and has_upper and has_digit and has_symbol): if not (has_lower and has_upper and has_digit):
return False return False
return True return True
+9 -17
View File
@@ -3,32 +3,25 @@ from email.mime.text import MIMEText
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication
import smtplib import smtplib
import time import time
import logging
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
logger = logging.getLogger(__name__)
def _build_smtp_client(): def _build_smtp_client():
if not cfg.EMAIL_SMTP_HOST:
raise RuntimeError('EMAIL_SMTP_HOST ist nicht konfiguriert')
smtp = smtplib.SMTP( smtp = smtplib.SMTP(
cfg.EMAIL_SMTP_HOST, "mail.invario-software.de",
cfg.EMAIL_SMTP_PORT, 587,
timeout=cfg.EMAIL_TIMEOUT_SECONDS, timeout=10,
) )
smtp.ehlo() smtp.ehlo()
if cfg.EMAIL_USE_TLS: if True:
smtp.starttls() smtp.starttls()
smtp.ehlo() smtp.ehlo()
if cfg.EMAIL_USERNAME: smtp.login("no-reply@invario-software.de", "eSpage,65,{")
smtp.login(cfg.EMAIL_USERNAME, cfg.EMAIL_PASSWORD or "")
return smtp return smtp
def _normalize_recipients(email: list | str) -> list[str]: def _normalize_recipients(email: list | str) -> list[str]:
if isinstance(email, str): if isinstance(email, str):
email = email.replace(';', ',').split(',') email = email.replace(';', ',').split(',')
@@ -38,12 +31,11 @@ def _normalize_recipients(email: list | str) -> list[str]:
def _send_message(email: list | str, subject: str, note: str, sender: str, attachment=None) -> bool: def _send_message(email: list | str, subject: str, note: str, sender: str, attachment=None) -> bool:
"""Send a plain/HTML message, optionally with one PDF attachment.""" """Send a plain/HTML message, optionally with one PDF attachment."""
if not cfg.MODULES.is_enabled("mail"): if not cfg.MODULES.is_enabled("mail"):
logger.info("Email delivery skipped because the mail module is disabled") print("Debug: Module not enabled")
return False return False
recipients = _normalize_recipients(email) recipients = _normalize_recipients(email)
if not recipients: if not recipients:
logger.warning("Email delivery skipped because no recipients were provided")
return False return False
body_message = note body_message = note
@@ -84,7 +76,7 @@ def _send_message(email: list | str, subject: str, note: str, sender: str, attac
msg = MIMEMultipart("mixed" if attachment else "alternative") msg = MIMEMultipart("mixed" if attachment else "alternative")
msg["Subject"] = str(subject) msg["Subject"] = str(subject)
from_address = cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME or "no-reply@invario-software.de" from_address = "no-reply@invario-software.de"
msg["From"] = f"{sender} <{from_address}>" msg["From"] = f"{sender} <{from_address}>"
msg["To"] = str(recipient) msg["To"] = str(recipient)
@@ -112,7 +104,7 @@ def _send_message(email: list | str, subject: str, note: str, sender: str, attac
return True return True
except Exception as e: except Exception as e:
logger.exception("Email delivery failed: %s", e) print(f"Debug: Fehler beim Senden der E-Mail: {e}")
return False return False
finally: finally:
if smtp: if smtp:
+1 -1
View File
@@ -3,7 +3,7 @@ import Web.modules.terminplaner.backend_server as appointment_service
import Web.modules.database.settings as cfg import Web.modules.database.settings as cfg
import Web.modules.database.termine as termin import Web.modules.database.termine as termin
import Web.modules.database.user as us import Web.modules.database.user as us
from Web.modules.emailservice.email import send_pdf from Web.modules.emailservice.email import send, send_pdf
from Web.modules.terminplaner.backend_server import _resolve_public_base_url from Web.modules.terminplaner.backend_server import _resolve_public_base_url
import csv import csv
import io import io
+4
View File
@@ -227,6 +227,10 @@ def send_push_notification(username, title, body, icon=None, url='/', reference=
sent_count = 0 sent_count = 0
for subscription in subscriptions: for subscription in subscriptions:
from Web.modules.emailservice.email import send, send_pdf
if cfg.MODULES.is_enabled('mail'):
#implement the sending
pass
success = _send_to_subscription( success = _send_to_subscription(
subscription, subscription,
title, title,
+41 -36
View File
@@ -1346,11 +1346,13 @@
</li> </li>
{% endif %} {% endif %}
{% if 'username' in session %} {% if 'username' in session %}
<!--
{% if current_permissions.pages.get('tutorial_page', False) %} {% if current_permissions.pages.get('tutorial_page', False) %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a> <a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
</li> </li>
{% endif %} {% endif %}
-->
{% endif %} {% endif %}
{% if 'username' in session and current_permissions.actions.get('can_insert', False) and current_permissions.pages.get('library_admin', False) %} {% if 'username' in session and current_permissions.actions.get('can_insert', False) and current_permissions.pages.get('library_admin', False) %}
<li class="nav-item"> <li class="nav-item">
@@ -1630,6 +1632,8 @@
</div> </div>
</div> </div>
<!--
<div id="onboarding-overlay" role="dialog" aria-modal="true" aria-label="Tutorial Vorschlag"> <div id="onboarding-overlay" role="dialog" aria-modal="true" aria-label="Tutorial Vorschlag">
<div id="onboarding-modal"> <div id="onboarding-modal">
<h3>Wollen Sie eine Vorstellung des Produkts?</h3> <h3>Wollen Sie eine Vorstellung des Produkts?</h3>
@@ -1643,6 +1647,7 @@
</div> </div>
</div> </div>
</div> </div>
-->
<div id="notification-toast" class="notification-toast" role="status" aria-live="polite"> <div id="notification-toast" class="notification-toast" role="status" aria-live="polite">
<strong id="notification-toast-title">Neue Benachrichtigung</strong> <strong id="notification-toast-title">Neue Benachrichtigung</strong>
@@ -1662,7 +1667,7 @@
<option value="Benachrichtigungen"></option> <option value="Benachrichtigungen"></option>
{% endif %} {% endif %}
{% if current_permissions.pages.get('tutorial_page', False) %} {% if current_permissions.pages.get('tutorial_page', False) %}
<option value="Tutorial"></option> <!--<option value="Tutorial"></option>-->
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -1765,10 +1770,10 @@
}); });
const username = {{ (session['username'] if 'username' in session else '')|tojson }}; const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }}; //const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }};
const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }}; const isLoginPage = window.location.pathname === {{ url_for('login')|tojson }};
const notificationsPagePath = {{ url_for('notifications_view')|tojson }}; const notificationsPagePath = {{ url_for('notifications_view')|tojson }};
const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null; //const onboardingKey = username ? ('inventarsystem_tutorial_prompt_v1_' + username) : null;
const onboardingOverlay = document.getElementById('onboarding-overlay'); const onboardingOverlay = document.getElementById('onboarding-overlay');
const notificationButtons = Array.from(document.querySelectorAll('[data-notification-button="true"]')); const notificationButtons = Array.from(document.querySelectorAll('[data-notification-button="true"]'));
const notificationToast = document.getElementById('notification-toast'); const notificationToast = document.getElementById('notification-toast');
@@ -1789,9 +1794,9 @@
{% if current_permissions.pages.get('notifications_view', False) %} {% if current_permissions.pages.get('notifications_view', False) %}
{ label: 'Benachrichtigungen', keywords: ['benachrichtigungen', 'nachrichten', 'notifications'], url: {{ url_for('notifications_view')|tojson }} }, { label: 'Benachrichtigungen', keywords: ['benachrichtigungen', 'nachrichten', 'notifications'], url: {{ url_for('notifications_view')|tojson }} },
{% endif %} {% endif %}
{% if current_permissions.pages.get('tutorial_page', False) %} //{% if current_permissions.pages.get('tutorial_page', False) %}
{ label: 'Tutorial', keywords: ['tutorial', 'hilfe', 'anleitung'], url: {{ url_for('tutorial_page')|tojson }} }, //{ label: 'Tutorial', keywords: ['tutorial', 'hilfe', 'anleitung'], url: {{ url_for('tutorial_page')|tojson }} },
{% endif %} //{% endif %}
{% endif %} {% endif %}
{ label: 'Impressum', keywords: ['impressum'], url: {{ url_for('impressum')|tojson }} }, { label: 'Impressum', keywords: ['impressum'], url: {{ url_for('impressum')|tojson }} },
@@ -2042,11 +2047,11 @@
window.setInterval(pollNotificationStatus, 30000); window.setInterval(pollNotificationStatus, 30000);
} }
function showOnboarding(){ //function showOnboarding(){
if (onboardingOverlay) { // if (onboardingOverlay) {
onboardingOverlay.style.display = 'flex'; // onboardingOverlay.style.display = 'flex';
} // }
} //}
function hideOnboarding(){ function hideOnboarding(){
if (onboardingOverlay) { if (onboardingOverlay) {
@@ -2054,19 +2059,19 @@
} }
} }
if (onboardingKey && !isTutorialPage && !isLoginPage) { //if (onboardingKey && !isTutorialPage && !isLoginPage) {
const decision = localStorage.getItem(onboardingKey); // const decision = localStorage.getItem(onboardingKey);
if (!decision || decision === 'later') { // if (!decision || decision === 'later') {
showOnboarding(); // showOnboarding();
} // }
} //}
document.getElementById('onboarding-start')?.addEventListener('click', function(){ //document.getElementById('onboarding-start')?.addEventListener('click', function(){
if (onboardingKey) { // if (onboardingKey) {
localStorage.setItem(onboardingKey, 'started'); // localStorage.setItem(onboardingKey, 'started');
} // }
window.location.href = {{ url_for('tutorial_page')|tojson }}; // window.location.href = {{ url_for('tutorial_page')|tojson }};
}); //});
document.getElementById('onboarding-later')?.addEventListener('click', function(){ document.getElementById('onboarding-later')?.addEventListener('click', function(){
if (onboardingKey) { if (onboardingKey) {
@@ -2399,21 +2404,21 @@
<script> <script>
(function () { (function () {
const username = {{ (session['username'] if 'username' in session else '')|tojson }}; const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const tooltipModeKey = username ? ('inventarsystem_tutorial_tooltips_enabled_' + username) : 'inventarsystem_tutorial_tooltips_enabled'; //const tooltipModeKey = username ? ('inventarsystem_tutorial_tooltips_enabled_' + username) : 'inventarsystem_tutorial_tooltips_enabled';
const enabled = localStorage.getItem(tooltipModeKey) === '1'; const enabled = localStorage.getItem(tooltipModeKey) === '1';
function applyTooltipMode(active) { //function applyTooltipMode(active) {
document.body.classList.toggle('tutorial-tooltips-active', active); // document.body.classList.toggle('tutorial-tooltips-active', active);
let indicator = document.getElementById('tutorialTooltipIndicator'); // let indicator = document.getElementById('tutorialTooltipIndicator');
if (!indicator) { // if (!indicator) {
indicator = document.createElement('div'); // indicator = document.createElement('div');
indicator.id = 'tutorialTooltipIndicator'; // indicator.id = 'tutorialTooltipIndicator';
indicator.className = 'tutorial-tooltip-indicator'; // indicator.className = 'tutorial-tooltip-indicator';
indicator.textContent = 'Tutorial-Modus aktiv: Tooltips sind eingeschaltet'; // indicator.textContent = 'Tutorial-Modus aktiv: Tooltips sind eingeschaltet';
document.body.appendChild(indicator); // document.body.appendChild(indicator);
} // }
indicator.style.display = active ? 'inline-flex' : 'none'; // indicator.style.display = active ? 'inline-flex' : 'none';
} //}
applyTooltipMode(enabled); applyTooltipMode(enabled);
})(); })();
+2 -2
View File
@@ -274,7 +274,7 @@
{% if not show_library_features %} {% if not show_library_features %}
<!-- ================= SYSTEM FILTERS 1-3 (INVENTORY / OTHER ITEMS ONLY) ================= --> <!-- ================= SYSTEM FILTERS 1-3 (INVENTORY / OTHER ITEMS ONLY) ================= -->
<div class="filter-inputs"> <div class="filter-inputs">
<h3>{{ filter_names.get('1', 'Jahrgangsstufe') }}</h3> <h3>{{ filter_names.get('1', 'Fach') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
{% for idx in range(4) %} {% for idx in range(4) %}
<div class="form-group"> <div class="form-group">
@@ -286,7 +286,7 @@
{% endfor %} {% endfor %}
</div> </div>
<h3>{{ filter_names.get('2', 'Fachgebiet') }}</h3> <h3>{{ filter_names.get('2', 'Jahrgangsstufe') }}</h3>
<div class="multi-filter"> <div class="multi-filter">
{% for idx in range(4) %} {% for idx in range(4) %}
<div class="form-group"> <div class="form-group">
+19 -38
View File
@@ -374,7 +374,7 @@
<div class="filter-bar"> <div class="filter-bar">
<input id="library-search" type="text" placeholder="Nach Element, Benutzer, Klasse, Ausweis oder Rechnung suchen..."> <input id="library-search" type="text" placeholder="Nach Element, Benutzer, Klasse, Ausweis oder Rechnung suchen...">
<!-- NEU: Klassen-Filter --> <!-- Klassen-Filter -->
<select id="class-filter"> <select id="class-filter">
<option value="all">Alle Klassen</option> <option value="all">Alle Klassen</option>
{% set classes = [] %} {% set classes = [] %}
@@ -407,7 +407,6 @@
<table class="library-table" id="loans-table"> <table class="library-table" id="loans-table">
<thead> <thead>
<tr> <tr>
<!-- NEU: onClick Handler zum Sortieren hinzugefügt -->
<th style="cursor: pointer;" onclick="sortTable('loans-table', 0)">Status ↕</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 0)">Status ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 1)">Element ↕</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 1)">Element ↕</th>
<th style="cursor: pointer;" onclick="sortTable('loans-table', 2)">Benutzer ↕</th> <th style="cursor: pointer;" onclick="sortTable('loans-table', 2)">Benutzer ↕</th>
@@ -419,7 +418,6 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<!-- NEU: data-klasse hinzugefügt -->
{% for e in loan_entries %} {% for e in loan_entries %}
<tr class="loan-row" <tr class="loan-row"
data-borrow-id="{{ e.id }}" data-borrow-id="{{ e.id }}"
@@ -452,7 +450,6 @@
<td> <td>
<div><strong>{{ e.user }}</strong></div> <div><strong>{{ e.user }}</strong></div>
</td> </td>
<!-- NEU: Klasse Spalte -->
<td> <td>
{% if e.klasse %} {% if e.klasse %}
<span class="badge-pill badge-class">{{ e.klasse }}</span> <span class="badge-pill badge-class">{{ e.klasse }}</span>
@@ -469,9 +466,6 @@
{% if e.invoice_number %} {% if e.invoice_number %}
<div class="mono">{{ e.invoice_number }}</div> <div class="mono">{{ e.invoice_number }}</div>
<div class="muted">{{ e.invoice_amount }}</div> <div class="muted">{{ e.invoice_amount }}</div>
<!--{% if e.invoice_corrections_count %}
<div class="muted" style="color:#7c2d12;">{{ e.invoice_corrections_count }} Korrektur(en)</div>
{% endif %} -->
<div style="margin-top:6px;"> <div style="margin-top:6px;">
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a> <a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_view_invoice_pdf', borrow_id=e.id) }}" target="_blank" rel="noopener">PDF öffnen</a>
</div> </div>
@@ -506,18 +500,9 @@
{% endif %} {% endif %}
{% if e.has_damage %} {% if e.has_damage %}
<!-- Aufruf des Reparatur-Auswahl-Modals -->
<button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ e.item_id }}', '{{ e.item_code }}')">Reparieren / Ersetzen</button> <button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ e.item_id }}', '{{ e.item_code }}')">Reparieren / Ersetzen</button>
{% endif %} {% endif %}
<!--{% if e.invoice_number %}
<form method="post" action="{{ url_for('admin_add_invoice_correction', borrow_id=e.id) }}" onsubmit="return confirm('Korrekturbuchung hinzufügen?');" style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
<input type="text" name="correction_reason" value="Korrektur zu {{ e.invoice_number }}" placeholder="Korrekturgrund" required style="padding:6px; border:1px solid #ddd; border-radius:6px; min-width:160px; max-width:180px;">
<input type="text" name="amount_delta" placeholder="z.B. -{{ e.invoice_amount }}" style="padding:6px; border:1px solid #ddd; border-radius:6px; width:120px;">
<button type="submit" class="btn btn-outline-danger btn-sm">Korrektur</button>
</form>
{% endif %}-->
{% if e.status in ['active', 'planned'] %} {% if e.status in ['active', 'planned'] %}
<form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');"> <form method="post" action="{{ url_for('admin_reset_borrowing', borrow_id=e.id) }}" onsubmit="return confirm('Ausleihe zurücksetzen?');">
<button type="submit" class="btn btn-secondary btn-sm">Zurücksetzen</button> <button type="submit" class="btn btn-secondary btn-sm">Zurücksetzen</button>
@@ -541,7 +526,6 @@
<table class="library-table" id="damaged-table"> <table class="library-table" id="damaged-table">
<thead> <thead>
<tr> <tr>
<!-- NEU: onClick Handler zum Sortieren hinzugefügt -->
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 0)">Element ↕</th> <th style="cursor: pointer;" onclick="sortTable('damaged-table', 0)">Element ↕</th>
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 1)">Code ↕</th> <th style="cursor: pointer;" onclick="sortTable('damaged-table', 1)">Code ↕</th>
<th style="cursor: pointer;" onclick="sortTable('damaged-table', 2)">Schaden ↕</th> <th style="cursor: pointer;" onclick="sortTable('damaged-table', 2)">Schaden ↕</th>
@@ -574,7 +558,6 @@
<div class="row-actions" style="margin-bottom:8px;"> <div class="row-actions" style="margin-bottom:8px;">
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=item.id) }}">Rechnungen</a> <a class="btn btn-outline-secondary btn-sm" href="{{ url_for('library_item_invoices', item_id=item.id) }}">Rechnungen</a>
</div> </div>
<!-- Aufruf des Reparatur-Auswahl-Modals -->
<button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ item.id }}', '{{ item.code }}')">Reparieren / Ersetzen</button> <button type="button" class="btn btn-warning btn-sm" onclick="openRepairModal('{{ item.id }}', '{{ item.code }}')">Reparieren / Ersetzen</button>
</td> </td>
</tr> </tr>
@@ -627,6 +610,13 @@
<textarea id="damage-invoice-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea> <textarea id="damage-invoice-reason" name="damage_reason" rows="5" required style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px; resize:vertical;" placeholder="Beschreiben Sie kurz den Schaden oder die Zerstörung."></textarea>
</div> </div>
{% if email_service_enabled %}
<div style="margin-bottom:16px;">
<label for="damage-invoice-recipient-email" style="display:block; font-weight:700; margin-bottom:6px;">Empfänger-E-Mail für den PDF-Versand</label>
<input id="damage-invoice-recipient-email" name="recipient_email" type="email" style="width:100%; padding:10px; border:1px solid #ddd; border-radius:6px;" placeholder="name@beispiel.de">
</div>
{% endif %}
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;"> <div style="display:flex; flex-wrap:wrap; gap:16px; align-items:center; margin-bottom:18px;">
<label style="display:flex; align-items:center; gap:8px;"> <label style="display:flex; align-items:center; gap:8px;">
<input id="damage-invoice-destroyed" type="checkbox" name="mark_destroyed" checked> <input id="damage-invoice-destroyed" type="checkbox" name="mark_destroyed" checked>
@@ -640,13 +630,16 @@
<div style="display:flex; justify-content:flex-end; gap:10px;"> <div style="display:flex; justify-content:flex-end; gap:10px;">
<button type="button" class="btn btn-secondary" onclick="closeDamageInvoiceModal()">Abbrechen</button> <button type="button" class="btn btn-secondary" onclick="closeDamageInvoiceModal()">Abbrechen</button>
<button type="submit" class="btn btn-danger">PDF-Rechnung erstellen</button> <button type="submit" class="btn btn-outline-danger" name="delivery" value="pdf">Nur PDF herunterladen</button>
{% if email_service_enabled %}
<button type="submit" class="btn btn-danger" name="delivery" value="email">PDF per E-Mail senden</button>
{% endif %}
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- Neues Modal: Reparatur-Optionen (Reparieren, Ersetzen mit neuem Code_4, Löschen) --> <!-- Modal: Reparatur-Optionen -->
<div id="repair-action-modal" role="dialog" aria-modal="true" aria-labelledby="repair-modal-title" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;"> <div id="repair-action-modal" role="dialog" aria-modal="true" aria-labelledby="repair-modal-title" style="display:none; position:fixed; inset:0; background:rgba(15,23,42,0.72); z-index:9999; padding:20px; overflow:auto;">
<div style="max-width:540px; margin:60px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);"> <div style="max-width:540px; margin:60px auto; background: var(--ui-surface); border-radius:12px; padding:24px; box-shadow:0 20px 60px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:16px;"> <div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:16px;">
@@ -676,7 +669,7 @@
<script> <script>
(function() { (function() {
const searchInput = document.getElementById('library-search'); const searchInput = document.getElementById('library-search');
const classFilter = document.getElementById('class-filter'); // NEU: Klassenfilter hinzugefügt const classFilter = document.getElementById('class-filter');
const statusFilter = document.getElementById('loan-status-filter'); const statusFilter = document.getElementById('loan-status-filter');
const damageFilter = document.getElementById('damage-filter'); const damageFilter = document.getElementById('damage-filter');
const loanRows = Array.from(document.querySelectorAll('.loan-row')); const loanRows = Array.from(document.querySelectorAll('.loan-row'));
@@ -693,10 +686,8 @@
const damageInvoiceReason = document.getElementById('damage-invoice-reason'); const damageInvoiceReason = document.getElementById('damage-invoice-reason');
const damageInvoiceReplaceBtn = document.getElementById('damage-invoice-replace-btn'); const damageInvoiceReplaceBtn = document.getElementById('damage-invoice-replace-btn');
// NEU: Globale Sortier-Richtungsobjekte
let sortDirections = {}; let sortDirections = {};
// NEU: Sortier-Funktion
window.sortTable = function(tableId, columnIndex) { window.sortTable = function(tableId, columnIndex) {
const table = document.getElementById(tableId); const table = document.getElementById(tableId);
const tbody = table.tBodies[0]; const tbody = table.tBodies[0];
@@ -714,7 +705,6 @@
const isAscending = sortDirections[sortKey]; const isAscending = sortDirections[sortKey];
const multiplier = isAscending ? 1 : -1; const multiplier = isAscending ? 1 : -1;
// Pfeil im Header aktualisieren
const headers = table.querySelectorAll("th"); const headers = table.querySelectorAll("th");
headers.forEach(th => { headers.forEach(th => {
if(th.innerHTML.includes('↕') || th.innerHTML.includes('▲') || th.innerHTML.includes('▼')) { if(th.innerHTML.includes('↕') || th.innerHTML.includes('▲') || th.innerHTML.includes('▼')) {
@@ -726,19 +716,15 @@
currentTh.innerHTML = currentTh.innerHTML.replace('↕', isAscending ? '▲' : '▼'); currentTh.innerHTML = currentTh.innerHTML.replace('↕', isAscending ? '▲' : '▼');
} }
// Zeilen sortieren
rows.sort((a, b) => { rows.sort((a, b) => {
const cellA = a.cells[columnIndex].textContent.trim(); const cellA = a.cells[columnIndex].textContent.trim();
const cellB = b.cells[columnIndex].textContent.trim(); const cellB = b.cells[columnIndex].textContent.trim();
return cellA.localeCompare(cellB, 'de', { numeric: true, sensitivity: 'base' }) * multiplier; return cellA.localeCompare(cellB, 'de', { numeric: true, sensitivity: 'base' }) * multiplier;
}); });
// Neu einfügen
rows.forEach(row => tbody.appendChild(row)); rows.forEach(row => tbody.appendChild(row));
}; };
// Funktionen für das Reparatur-Modal
window.openRepairModal = function(itemId, currentCode) { window.openRepairModal = function(itemId, currentCode) {
const modal = document.getElementById('repair-action-modal'); const modal = document.getElementById('repair-action-modal');
const form = document.getElementById('repair-action-form'); const form = document.getElementById('repair-action-form');
@@ -871,26 +857,25 @@
}); });
} }
// NEU: Kombinierte Filter-Funktion (Suche + Status + Schaden + Klasse)
function applyFilters() { function applyFilters() {
const search = (searchInput.value || '').trim().toLowerCase(); const search = (searchInput.value || '').trim().toLowerCase();
const status = statusFilter.value; const status = statusFilter.value;
const damage = damageFilter.value; const damage = damageFilter.value;
const klasse = classFilter.value; // NEU const klasse = classFilter.value;
let visibleLoans = 0; let visibleLoans = 0;
loanRows.forEach(row => { loanRows.forEach(row => {
const haystack = row.dataset.search || ''; const haystack = row.dataset.search || '';
const rowStatus = row.dataset.status || ''; const rowStatus = row.dataset.status || '';
const rowKlasse = row.dataset.klasse || ''; // NEU const rowKlasse = row.dataset.klasse || '';
const hasDamage = row.dataset.hasDamage === '1'; const hasDamage = row.dataset.hasDamage === '1';
const searchMatch = !search || haystack.includes(search); const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status; const statusMatch = !status || rowStatus === status;
const classMatch = klasse === 'all' || rowKlasse === klasse; // NEU const classMatch = klasse === 'all' || rowKlasse === klasse;
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage); const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
const show = searchMatch && statusMatch && classMatch && damageMatch; // NEU const show = searchMatch && statusMatch && classMatch && damageMatch;
row.style.display = show ? '' : 'none'; row.style.display = show ? '' : 'none';
if (show) visibleLoans++; if (show) visibleLoans++;
}); });
@@ -905,10 +890,6 @@
const searchMatch = !search || haystack.includes(search); const searchMatch = !search || haystack.includes(search);
const statusMatch = !status || rowStatus === status || status === ''; const statusMatch = !status || rowStatus === status || status === '';
const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage); const damageMatch = damage === 'all' || (damage === 'damage' && hasDamage) || (damage === 'clean' && !hasDamage);
// Defekte-Medien-Tabelle hat keine verknüpfte "Klasse", deshalb blenden wir sie nur bei Klassen-Filter "all" ein,
// oder wenn gar nicht nach Klasse gefiltert wird, damit sie nicht verschwindet.
// Falls sie bei aktiver Klassensuche komplett verschwinden soll, passe die Bedingung an:
const classMatch = klasse === 'all'; const classMatch = klasse === 'all';
const show = searchMatch && statusMatch && damageMatch && classMatch; const show = searchMatch && statusMatch && damageMatch && classMatch;
@@ -919,7 +900,7 @@
} }
searchInput.addEventListener('input', applyFilters); searchInput.addEventListener('input', applyFilters);
classFilter.addEventListener('change', applyFilters); // NEU classFilter.addEventListener('change', applyFilters);
statusFilter.addEventListener('change', applyFilters); statusFilter.addEventListener('change', applyFilters);
damageFilter.addEventListener('change', applyFilters); damageFilter.addEventListener('change', applyFilters);
applyFilters(); applyFilters();
+145 -72
View File
@@ -8,9 +8,9 @@
1. Modals & Overlays 1. Modals & Overlays
========================================= */ ========================================= */
.modal { .modal {
display: none; /* Managed by JS (none/flex) */ display: none;
position: fixed; position: fixed;
inset: 0; /* Modern shorthand for top: 0, right: 0, bottom: 0, left: 0 */ inset: 0;
z-index: 1000; z-index: 1000;
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.6);
align-items: center; align-items: center;
@@ -23,7 +23,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
max-width: 900px; /* Consolidated from conflicting 600px/900px rules */ max-width: 900px;
max-height: 90vh; max-height: 90vh;
background-color: var(--ui-surface, #fff); background-color: var(--ui-surface, #fff);
border-radius: 8px; border-radius: 8px;
@@ -419,31 +419,136 @@
} }
/* ========================================= /* =========================================
7. Mobile Responsiveness 7. Mobile Responsiveness (Updated for Cards)
========================================= */ ========================================= */
@media (max-width: 768px) { @media (max-width: 768px) {
.library-table-container { padding: 10px; } .library-table-container { padding: 10px; }
.library-search-bar { flex-direction: column; } .library-search-bar { flex-direction: column; }
.library-search-input { width: 100%; } .library-search-input { width: 100%; }
.library-items-table { font-size: 0.85em; }
/* Enlarge touch targets */
.library-search-input,
.library-scan-controls select,
.library-scan-controls input,
.library-filter-toggle-btn,
.filter-item input,
.filter-item select,
.button {
min-height: 44px;
}
/* Stack Scan & Controls */
.library-scan-controls {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.library-scan-controls > * {
width: 100% !important;
margin-left: 0 !important;
}
.filter-row {
grid-template-columns: 1fr;
}
.filter-buttons {
flex-direction: column;
align-items: stretch;
}
.filter-buttons .button {
width: 100%;
justify-content: center;
margin-bottom: 8px;
}
.filter-buttons > div[style*="flex-grow"] {
display: none;
}
/* Convert Table to Cards */
.library-items-table,
.library-items-table thead,
.library-items-table tbody,
.library-items-table th, .library-items-table th,
.library-items-table td,
.library-items-table tr {
display: block;
width: 100%;
}
.library-items-table thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
.library-items-table tr {
margin-bottom: 16px;
border: 1px solid var(--ui-border, #ddd);
border-radius: 12px;
background: #fff;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
overflow: hidden;
}
.library-items-table td { .library-items-table td {
padding: 8px 10px; position: relative;
padding: 12px 16px 12px 35% !important;
text-align: right;
border-bottom: 1px solid #f4f4f4;
font-size: 0.95em;
min-height: auto;
display: flex;
justify-content: flex-end;
align-items: center;
}
.library-items-table td::before {
content: attr(data-label);
position: absolute;
left: 16px;
width: 30%;
text-align: left;
font-weight: 600;
color: #555;
white-space: nowrap;
}
/* Styles specific for the Actions cell */
.library-items-table td:last-child {
border-bottom: none;
padding: 16px !important;
background: #f8fafc;
display: flex;
flex-direction: column;
gap: 10px;
align-items: stretch;
}
.library-items-table td:last-child::before {
display: none;
}
.table-actions {
flex-direction: column;
width: 100%;
gap: 10px;
} }
.table-actions .button { .table-actions .button {
padding: 4px 8px; width: 100%;
font-size: 0.75em; margin: 0 !important;
} padding: 12px;
font-size: 1em;
.filter-row, justify-content: center;
.library-scan-controls {
grid-template-columns: 1fr;
} }
.library-scan-reader-wrap { max-width: 100%; } .library-scan-reader-wrap { max-width: 100%; }
} }
.library-scan-reader-wrap { .library-scan-reader-wrap {
display: none; display: none;
margin-top: 15px; margin-top: 15px;
@@ -534,13 +639,12 @@
<div class="library-table-container" id="libraryTableContainer" data-can-edit="{{ 1 if current_permissions.actions.get('can_edit', False) else 0 }}"> <div class="library-table-container" id="libraryTableContainer" data-can-edit="{{ 1 if current_permissions.actions.get('can_edit', False) else 0 }}">
<!-- Header --> <!-- Header -->
<!-- Search and Filter Toggle -->
<!-- Customizable Filter Panel -->
<div class="library-header"> <div class="library-header">
<h1>📚 Bibliothek</h1> <h1>📚 Bibliothek</h1>
<p>Bücher, CDs, DVDs und weitere Medien</p> <p>Bücher, CDs, DVDs und weitere Medien</p>
</div> </div>
<!-- Student card / quick scan workflow -->
<!-- Search and Filter Toggle -->
<div class="library-search-bar"> <div class="library-search-bar">
<input <input
type="text" type="text"
@@ -556,26 +660,31 @@
🔍 Filter 🔍 Filter
</button> </button>
</div> </div>
<!-- Controls -->
<div class="library-scan-controls"> <div class="library-scan-controls">
<select id="scanModeSelect" aria-label="Scan-Modus"> <select id="scanModeSelect" aria-label="Scan-Modus">
<option value="card_only">Nur Ausweis erfassen</option> <option value="card_only">Nur Ausweis erfassen</option>
<option value="quick_toggle">Schnellmodus: Ausweis + Mediencode</option> <option value="quick_toggle">Schnellmodus: 1x Ausweis + 1x Mediencode</option>
<option value="continuous">Dauermodus: 1x Ausweis, dann N Medien</option> <option value="continuous">Dauermodus: 1x Ausweis, dann N Medien</option>
<option value="return_only">Nur Rückgabe (nur Mediencode)</option> <option value="return_only">Nur Rückgabe (nur Mediencode)</option>
</select> </select>
<input type="text" id="activeStudentCard" placeholder="Aktiver Ausweis (gescannt)"> <input type="text" id="activeStudentCard" placeholder="Aktiver Ausweis (gescannt)">
<input type="text" id="manualItemCode" placeholder="Manueller Mediencode (optional)" style="min-width:180px;"> <input type="text" id="manualItemCode" placeholder="Manueller Mediencode (optional)" style="min-width:180px;">
<button id="resetCardBtn" class="button" type="button">Feld zurücksetzen</button> <button id="resetCardBtn" class="button" type="button">Feld zurücksetzen</button>
<button id="toggleScannerBtn" class="button" type="button">Scanner starten</button> <button id="toggleScannerBtn" class="button" type="button">Handyscanner starten</button>
<button id="physicalScannerBtn" class="button" type="button" style="margin-left:6px;"> <button id="physicalScannerBtn" class="button" type="button" style="margin-left:6px;">
Physischer Scanner starten Physischer Scanner starten
</button> </button>
<button id="manualActionBtn" class="button" type="button" style="margin-left:6px; background:#4f46e5; color:white;">Code verarbeiten</button> <button id="manualActionBtn" class="button" type="button" style="margin-left:6px; background:#4f46e5; color:white;">Code verarbeiten</button>
</div> </div>
<div class="library-scan-reader-wrap" id="scanReaderWrap" style="display: none; margin-top: 15px;"> <div class="library-scan-reader-wrap" id="scanReaderWrap" style="display: none; margin-top: 15px;">
<div class="library-scan-reader" style="width: 100%; max-width: 640px; margin: 0 auto; overflow: hidden; border-radius: 8px; border: 2px solid #ccc;"> <div class="library-scan-reader" style="width: 100%; max-width: 640px; margin: 0 auto; overflow: hidden; border-radius: 8px; border: 2px solid #ccc;">
</div> </div>
</div> </div>
<!-- Customizable Filter Panel -->
<div id="filterPanel" class="library-filter-panel"> <div id="filterPanel" class="library-filter-panel">
<div class="filter-row"> <div class="filter-row">
<div class="filter-item"> <div class="filter-item">
@@ -674,7 +783,7 @@
<script> <script>
// ========================================================================= // =========================================================================
// CUSTOM MODAL HELPERS (Replaces native browser alert, confirm & prompt) // CUSTOM MODAL HELPERS
// ========================================================================= // =========================================================================
function createModalOverlay() { function createModalOverlay() {
const overlay = document.createElement('div'); const overlay = document.createElement('div');
@@ -867,6 +976,7 @@
input.select(); input.select();
}); });
} }
// ========================================================================= // =========================================================================
// 1. GLOBAL STATE DEFINITIONS // 1. GLOBAL STATE DEFINITIONS
// ========================================================================= // =========================================================================
@@ -891,19 +1001,18 @@
let renderedCount = INITIAL_RENDER_COUNT; let renderedCount = INITIAL_RENDER_COUNT;
let filterPanelOpen = false; let filterPanelOpen = false;
// Scanner Related State Variables
let scannerInstance = null; let scannerInstance = null;
let scannerRunning = false; let scannerRunning = false;
let activeScannerCallback = null; let activeScannerCallback = null;
let activeStudentCardId = ''; let activeStudentCardId = '';
let lastScanValue = ''; let lastScanValue = '';
let lastScanAt = 0; let lastScanAt = 0;
// Keyboard-scanner support (physical scanners that act as keyboard wedges)
let keyboardScannerEnabled = false; let keyboardScannerEnabled = false;
let keyboardScanBuffer = ''; let keyboardScanBuffer = '';
let keyboardLastKeyAt = 0; let keyboardLastKeyAt = 0;
let physicalScannerModalOpen = false; let physicalScannerModalOpen = false;
const KEYBOARD_SCAN_INTERCHAR_MS = 500; // max time between slower scanner keystrokes const KEYBOARD_SCAN_INTERCHAR_MS = 500;
const physicalScanQueue = []; const physicalScanQueue = [];
let keyboardScanProcessing = false; let keyboardScanProcessing = false;
let editLibraryState = { let editLibraryState = {
@@ -1040,13 +1149,14 @@
const actionLabel = statusKey === 'available' ? 'Ausleihen' : (statusKey === 'borrowed' ? 'Reservieren' : 'Nicht ausleihbar'); const actionLabel = statusKey === 'available' ? 'Ausleihen' : (statusKey === 'borrowed' ? 'Reservieren' : 'Nicht ausleihbar');
const actionDisabled = statusKey === 'damaged' ? 'disabled' : ''; const actionDisabled = statusKey === 'damaged' ? 'disabled' : '';
// Using data-label injection for accurate mobile rendering mapping
return ` return `
<tr> <tr>
<td class="table-title">${escapeHtml(item.Name || 'Untitled')}</td> <td data-label="Titel" class="table-title">${escapeHtml(item.Name || 'Untitled')}</td>
<td>${escapeHtml(item.ISBN || '-')}</td> <td data-label="ISBN/Code">${escapeHtml(item.ISBN || '-')}</td>
<td>${getItemTypeLabel(item.ItemType || 'book')}</td> <td data-label="Typ">${getItemTypeLabel(item.ItemType || 'book')}</td>
<td style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td> <td data-label="Anzahl" style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td>
<td> <td data-label="Status">
<span class="table-status ${statusClass}"> <span class="table-status ${statusClass}">
${statusText} ${statusText}
</span> </span>
@@ -1056,7 +1166,7 @@
<button class="button" style="background: #28a745; color: white;" onclick="borrowItem('${item._id}')" ${actionDisabled}> <button class="button" style="background: #28a745; color: white;" onclick="borrowItem('${item._id}')" ${actionDisabled}>
${actionLabel} ${actionLabel}
</button> </button>
${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''} ${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff; margin-left:6px;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''}
${canEditLibraryItems ? `<button class="button" style="background:#dc2626;color:#fff; margin-left:6px;" onclick="confirmDeleteLibraryItem('${item._id}')">Löschen</button>` : ''} ${canEditLibraryItems ? `<button class="button" style="background:#dc2626;color:#fff; margin-left:6px;" onclick="confirmDeleteLibraryItem('${item._id}')">Löschen</button>` : ''}
</td> </td>
</tr>`; </tr>`;
@@ -1125,26 +1235,22 @@
const scannerWrap = document.querySelector('.library-scan-reader-wrap'); const scannerWrap = document.querySelector('.library-scan-reader-wrap');
const toggleBtn = document.getElementById('toggleScannerBtn'); const toggleBtn = document.getElementById('toggleScannerBtn');
// 1. CRITICAL FIX: Make the container visible BEFORE Quagga initializes
// Quagga cannot calculate video dimensions if display is 'none'
if (scannerWrap) { if (scannerWrap) {
scannerWrap.style.display = 'block'; scannerWrap.style.display = 'block';
} }
setScanStatus('Starte Kamera...', 'warn'); setScanStatus('Starte Kamera...', 'warn');
// 2. Initialize Quagga with a slight delay to allow the DOM to render the block
setTimeout(() => { setTimeout(() => {
Quagga.init({ Quagga.init({
inputStream: { inputStream: {
name: "Live", name: "Live",
type: "LiveStream", type: "LiveStream",
// This MUST match the inner div where the video should appear
target: document.querySelector('.library-scan-reader'), target: document.querySelector('.library-scan-reader'),
constraints: { constraints: {
width: 640, width: 640,
height: 480, height: 480,
facingMode: "environment" // Prefer back camera on mobile facingMode: "environment"
} }
}, },
locator: { locator: {
@@ -1152,13 +1258,11 @@
halfSample: true halfSample: true
}, },
decoder: { decoder: {
// Keep only the barcode types you actually use to improve performance
readers: ["code_128_reader", "ean_reader", "code_39_reader"] readers: ["code_128_reader", "ean_reader", "code_39_reader"]
}, },
locate: true locate: true
}, function(err) { }, function(err) {
if (err) { if (err) {
// This will finally log the actual error (e.g., NotAllowedError) if permissions fail
console.error("Quagga initialization failed:", err); console.error("Quagga initialization failed:", err);
setScanStatus('Kamera-Fehler: ' + (err.name || err), 'error'); setScanStatus('Kamera-Fehler: ' + (err.name || err), 'error');
@@ -1190,7 +1294,6 @@
scannerRunning = false; scannerRunning = false;
activeScannerCallback = null; activeScannerCallback = null;
// Hide the container to free up UI space
const scannerWrap = document.querySelector('.library-scan-reader-wrap'); const scannerWrap = document.querySelector('.library-scan-reader-wrap');
if (scannerWrap) { if (scannerWrap) {
scannerWrap.style.display = 'none'; scannerWrap.style.display = 'none';
@@ -1198,7 +1301,7 @@
const toggleBtn = document.getElementById('toggleScannerBtn'); const toggleBtn = document.getElementById('toggleScannerBtn');
if (toggleBtn) { if (toggleBtn) {
toggleBtn.textContent = 'Kamera Scanner'; // Reset button text toggleBtn.textContent = 'Kamera Scanner';
} }
} }
@@ -1210,7 +1313,6 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
// Falls du in Flask CSRF-Protect nutzt, muss der Token mitgesendet werden:
'X-CSRFToken': '{{ csrf_token }}', 'X-CSRFToken': '{{ csrf_token }}',
'X-CSRF-Token': '{{ csrf_token }}' 'X-CSRF-Token': '{{ csrf_token }}'
} }
@@ -1218,7 +1320,6 @@
if (!response.ok) return false; if (!response.ok) return false;
// Wir parsen die Antwort. Flask jsonify({"is_student_card": true/false})
const data = await response.json(); const data = await response.json();
return data === true || data.is_student_card === true; return data === true || data.is_student_card === true;
@@ -1245,7 +1346,7 @@
}); });
// ========================================================================= // =========================================================================
// Keyboard scanner handling (physical scanners that send chars then Enter) // Keyboard scanner handling
// ========================================================================= // =========================================================================
function getPhysicalScannerInstruction() { function getPhysicalScannerInstruction() {
const mode = document.getElementById('scanModeSelect')?.value || 'card_only'; const mode = document.getElementById('scanModeSelect')?.value || 'card_only';
@@ -1299,7 +1400,6 @@
} }
function keyboardScanKeydownHandler(e) { function keyboardScanKeydownHandler(e) {
// Only active when explicitly enabled
if (!keyboardScannerEnabled || !physicalScannerModalOpen) return; if (!keyboardScannerEnabled || !physicalScannerModalOpen) return;
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -1310,7 +1410,6 @@
const now = Date.now(); const now = Date.now();
// If Enter/Return pressed -> finalize buffer
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
const code = keyboardScanBuffer.trim(); const code = keyboardScanBuffer.trim();
@@ -1318,7 +1417,6 @@
keyboardLastKeyAt = 0; keyboardLastKeyAt = 0;
if (!code) return; if (!code) return;
// Scanner keystrokes must not be submitted into the focused form field.
const displayCode = document.getElementById('physicalScannerCode'); const displayCode = document.getElementById('physicalScannerCode');
if (displayCode) displayCode.textContent = code; if (displayCode) displayCode.textContent = code;
updatePhysicalScannerModal('Enter erkannt. Code wird verarbeitet...', 'warn'); updatePhysicalScannerModal('Enter erkannt. Code wird verarbeitet...', 'warn');
@@ -1326,9 +1424,7 @@
return; return;
} }
// Accept all printable scanner characters, including letters and punctuation.
if (e.key.length === 1) { if (e.key.length === 1) {
// If time gap too big, start new buffer
if (keyboardLastKeyAt && (now - keyboardLastKeyAt) > KEYBOARD_SCAN_INTERCHAR_MS) { if (keyboardLastKeyAt && (now - keyboardLastKeyAt) > KEYBOARD_SCAN_INTERCHAR_MS) {
keyboardScanBuffer = ''; keyboardScanBuffer = '';
} }
@@ -1336,7 +1432,6 @@
keyboardLastKeyAt = now; keyboardLastKeyAt = now;
const displayCode = document.getElementById('physicalScannerCode'); const displayCode = document.getElementById('physicalScannerCode');
if (displayCode) displayCode.textContent = keyboardScanBuffer; if (displayCode) displayCode.textContent = keyboardScanBuffer;
// Prevent default so scanner input is captured even while an input has focus.
e.preventDefault(); e.preventDefault();
} }
} }
@@ -1365,21 +1460,18 @@
const now = Date.now(); const now = Date.now();
if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) { if (scannedCode === lastScanValue && (now - lastScanAt) < 1500) {
return; // Verhindert doppeltes Scannen in kurzer Zeit return;
} }
lastScanValue = scannedCode; lastScanValue = scannedCode;
lastScanAt = now; lastScanAt = now;
// Den aktuellen Modus aus dem Dropdown auslesen
const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only'; const mode = (document.getElementById('scanModeSelect') || {}).value || 'card_only';
// 1. Modus: Nur Rückgabe
if (mode === 'return_only') { if (mode === 'return_only') {
await returnByCode(scannedCode); await returnByCode(scannedCode);
return; return;
} }
// 2. Modus: Nur Ausweis
if (mode === 'card_only') { if (mode === 'card_only') {
setActiveStudentCard(scannedCode); setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}. Bitte den nächsten Ausweis scannen.`, 'ok'); setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}. Bitte den nächsten Ausweis scannen.`, 'ok');
@@ -1388,13 +1480,11 @@
return; return;
} }
// 3. Modus: Schnellmodus (1x Ausweis, 1x Buch)
if (mode === 'quick_toggle') { if (mode === 'quick_toggle') {
await processQuickToggleScan(scannedCode); await processQuickToggleScan(scannedCode);
return; return;
} }
// 4. Modus: Dauermodus (1x Ausweis, Nx Bücher)
if (mode === 'continuous') { if (mode === 'continuous') {
await processContinuousScan(scannedCode); await processContinuousScan(scannedCode);
return; return;
@@ -1471,7 +1561,6 @@
updatePhysicalScannerModal('Aktion erfolgreich. Bereit für den nächsten Mediencode.', 'ok'); updatePhysicalScannerModal('Aktion erfolgreich. Bereit für den nächsten Mediencode.', 'ok');
} }
// Tabellen-Ansicht aktualisieren
if (typeof loadLibraryItems === "function") { if (typeof loadLibraryItems === "function") {
await loadLibraryItems(); await loadLibraryItems();
} }
@@ -1491,7 +1580,6 @@
async function processQuickToggleScan(scannedCode) { async function processQuickToggleScan(scannedCode) {
// 2. Wenn kein Ausweis gesetzt ist, wird der Code als Ausweis interpretiert
if (!activeStudentCardId) { if (!activeStudentCardId) {
setActiveStudentCard(scannedCode); setActiveStudentCard(scannedCode);
setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}. Bitte den nächsten Mediencode scannen.`, 'ok'); setScanStatus(`Ausweis gesetzt: ${activeStudentCardId}. Bitte den nächsten Mediencode scannen.`, 'ok');
@@ -1500,7 +1588,6 @@
return; return;
} }
// 3. Ausleihe/Rückgabe verarbeiten (wenn Ausweis vorhanden)
try { try {
setScanStatus('Verarbeite Mediencode...', 'warn'); setScanStatus('Verarbeite Mediencode...', 'warn');
const response = await fetch('/api/library_scan_action', { const response = await fetch('/api/library_scan_action', {
@@ -1695,7 +1782,6 @@
} }
function showSmallConfirm(message, kind='ok', action = null) { function showSmallConfirm(message, kind='ok', action = null) {
// Append the small helper text in German
const helper = 'Sie können fortfahren. Dies ist nur eine kleine Benachrichtigung.'; const helper = 'Sie können fortfahren. Dies ist nur eine kleine Benachrichtigung.';
const el = document.createElement('div'); const el = document.createElement('div');
el.className = `small-popup ${kind === 'error' ? 'error' : 'ok'}`; el.className = `small-popup ${kind === 'error' ? 'error' : 'ok'}`;
@@ -1720,12 +1806,10 @@
closeButton.innerHTML = '&times;'; closeButton.innerHTML = '&times;';
el.appendChild(closeButton); el.appendChild(closeButton);
document.body.appendChild(el); document.body.appendChild(el);
// close handler
closeButton.addEventListener('click', () => { closeButton.addEventListener('click', () => {
if (el && el.parentNode) el.parentNode.removeChild(el); if (el && el.parentNode) el.parentNode.removeChild(el);
}); });
// auto remove after 3 seconds setTimeout(() => { try { if (el && el.parentNode) el.parentNode.removeChild(el); } catch(e){} }, 15000);
setTimeout(() => { try { if (el && el.parentNode) el.parentNode.removeChild(el); } catch(e){} }, 3000);
} }
async function startNextStudentCardScan() { async function startNextStudentCardScan() {
@@ -1764,11 +1848,9 @@
const detailContent = document.getElementById('detailContent'); const detailContent = document.getElementById('detailContent');
const detailModal = document.getElementById('detailModal'); const detailModal = document.getElementById('detailModal');
// Lade-Status anzeigen und Modal öffnen
detailContent.innerHTML = '<p>Lade Details...</p>'; detailContent.innerHTML = '<p>Lade Details...</p>';
detailModal.style.display = 'flex'; detailModal.style.display = 'flex';
// Daten vom Backend-API-Endpoint abrufen
fetch(`/api/item_detail/${itemId}`) fetch(`/api/item_detail/${itemId}`)
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) {
@@ -1832,15 +1914,12 @@
}); });
} }
// Schließt das Modal über den 'x'-Button
function closeDetailModal() { function closeDetailModal() {
document.getElementById('detailModal').style.display = 'none'; document.getElementById('detailModal').style.display = 'none';
} }
// Closes the modal if the user clicks the dark background overlay
window.onclick = function(event) { window.onclick = function(event) {
const modal = document.getElementById('detailModal'); const modal = document.getElementById('detailModal');
// If the click happened directly on the dark overlay (not the white box)
if (event.target === modal) { if (event.target === modal) {
closeDetailModal(); closeDetailModal();
} }
@@ -1905,12 +1984,10 @@
} }
} }
// Run when DOM structure is entirely ready
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
wireScannerUi(); // Setup scanner control buttons wireScannerUi();
loadLibraryItems(); // Fetch your database items right away! loadLibraryItems();
// Safely connect standard Filters and Search inputs inside DOMContentLoaded
const filterToggleBtn = document.getElementById('filterToggleBtn'); const filterToggleBtn = document.getElementById('filterToggleBtn');
if (filterToggleBtn) { if (filterToggleBtn) {
filterToggleBtn.addEventListener('click', () => { filterToggleBtn.addEventListener('click', () => {
@@ -1968,15 +2045,11 @@
return; return;
} }
// Leitet den manuellen Code an die zentrale Logik weiter,
// die prüft, welcher Modus im Dropdown aktiv ist.
handleScanSuccess(code); handleScanSuccess(code);
// Feld nach Eingabe leeren, um direkt den nächsten Code bereitzuhaben
manualItemCode.value = ''; manualItemCode.value = '';
}); });
// Optional: Auch auf "Enter" im Textfeld reagieren
manualItemCode.addEventListener('keypress', (e) => { manualItemCode.addEventListener('keypress', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
+5 -5
View File
@@ -2420,8 +2420,8 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div id="table-view-header" class="table-view-header" aria-hidden="true"> <div id="table-view-header" class="table-view-header" aria-hidden="true">
<span>Name</span> <span>Name</span>
<span>Ort</span> <span>Ort</span>
<span>{{ filter_names.get('2', 'Fach') }}</span> <span>{{ filter_names.get('1', 'Fach') }}</span>
<span>{{ filter_names.get('1', 'Jahrgangsstufe') }}</span> <span>{{ filter_names.get('2', 'Jahrgangsstufe') }}</span>
<span>{{ filter_names.get('3', 'Schlagwort') }}</span> <span>{{ filter_names.get('3', 'Schlagwort') }}</span>
<span>Barcode</span> <span>Barcode</span>
<span>Anzahl</span> <span>Anzahl</span>
@@ -3394,9 +3394,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="card-content" data-item-id="${item._id}"> <div class="card-content" data-item-id="${item._id}">
<h3 class="item-col-name">${item.Name}</h3> <h3 class="item-col-name">${item.Name}</h3>
<p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p> <p class="item-col-location"><strong>Ort:</strong> ${item.Ort || '-'}</p>
<p class="item-col-filter1"><strong>Unterrichtsfach:</strong> ${filter1Display}${filter1More}</p> <p class="item-col-filter1"><strong>{{ filter_names.get('1', 'Jahrgangsstufe') }}:</strong> ${filter1Display}${filter1More}</p>
<p class="item-col-filter2"><strong>Jahrgangsstufe:</strong> ${filter2Display}${filter2More}</p> <p class="item-col-filter2"><strong>{{ filter_names.get('2', 'Jahrgangsstufe') }}:</strong> ${filter2Display}${filter2More}</p>
<p class="item-col-filter3"><strong>Schlagwort:</strong> ${filter3Display}${filter3More}</p> <p class="item-col-filter3"><strong>{{ filter_names.get('3', 'Jahrgangsstufe') }}:</strong> ${filter3Display}${filter3More}</p>
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p> <p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p> <p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''} ${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''}
+7 -8
View File
@@ -88,11 +88,10 @@
<div class="password-rules" id="password-rules" aria-live="polite"> <div class="password-rules" id="password-rules" aria-live="polite">
<p class="password-rules-title">Passwort-Anforderungen:</p> <p class="password-rules-title">Passwort-Anforderungen:</p>
<ul> <ul>
<li id="pw-rule-length" class="pw-rule">Mindestens 12 Zeichen</li> <li id="pw-rule-length" class="pw-rule">Mindestens 5 Zeichen</li>
<li id="pw-rule-lower" class="pw-rule">Mindestens ein Kleinbuchstabe</li> <li id="pw-rule-lower" class="pw-rule">Mindestens ein Kleinbuchstabe</li>
<li id="pw-rule-upper" class="pw-rule">Mindestens ein Grossbuchstabe</li> <li id="pw-rule-upper" class="pw-rule">Mindestens ein Grossbuchstabe</li>
<li id="pw-rule-digit" class="pw-rule">Mindestens eine Zahl</li> <li id="pw-rule-digit" class="pw-rule">Mindestens eine Zahl</li>
<li id="pw-rule-symbol" class="pw-rule">Mindestens ein Sonderzeichen</li>
</ul> </ul>
</div> </div>
@@ -105,7 +104,7 @@
name="password" name="password"
placeholder="Geben Sie ein sicheres Passwort ein" placeholder="Geben Sie ein sicheres Passwort ein"
required required
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9]).{12,}"> pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{5,}">
<button type="button" id="toggle-pw-btn" class="toggle-pw-btn" onclick="togglePasswordVisibility()" style="background:none; border:none; cursor:pointer; padding-right:10px;">👁️</button> <button type="button" id="toggle-pw-btn" class="toggle-pw-btn" onclick="togglePasswordVisibility()" style="background:none; border:none; cursor:pointer; padding-right:10px;">👁️</button>
</div> </div>
</div> </div>
@@ -201,11 +200,11 @@ function generateUsername() {
// PASSWORT GENERATOR // PASSWORT GENERATOR
function generateSecurePassword() { function generateSecurePassword() {
const length = 16; const length = 5;
const charsetLower = "abcdefghijklmnopqrstuvwxyz"; const charsetLower = "abcdefghijklmnpqrstuvwxyz";
const charsetUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const charsetUpper = "ABCDEFGHIJKLMNPQRSTUVWXYZ";
const charsetNum = "0123456789"; const charsetNum = "123456789";
const charsetSym = "!@#$%^&*()_+~|}{[]:;?><,.-="; const charsetSym = "!#$(),.=";
let password = ""; let password = "";
// Garantiert mindestens 1 Zeichen aus jeder Kategorie // Garantiert mindestens 1 Zeichen aus jeder Kategorie
+121 -15
View File
@@ -1,11 +1,3 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Bibliotheksausweise - Inventarsystem{% endblock %} {% block title %}Bibliotheksausweise - Inventarsystem{% endblock %}
@@ -21,7 +13,6 @@
gap: 15px; gap: 15px;
} }
/* Neu strukturiertes Layout für Formular & Import */
.dashboard-grid { .dashboard-grid {
display: grid; display: grid;
grid-template-columns: 2fr 1fr; grid-template-columns: 2fr 1fr;
@@ -145,6 +136,38 @@
background: #5a6268; background: #5a6268;
} }
/* Filter Controls Styling */
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 15px;
flex-wrap: wrap;
align-items: center;
background: var(--ui-bg);
padding: 12px;
border-radius: 8px;
border: 1px solid #ddd;
}
.filter-bar input,
.filter-bar select {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.filter-bar input {
flex: 1;
min-width: 200px;
}
.filter-counter {
font-size: 13px;
color: #666;
margin-left: auto;
}
.cards-table { .cards-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -255,7 +278,8 @@
} }
:root[data-theme="dark"] .student-card-form, :root[data-theme="dark"] .student-card-form,
:root[data-theme="dark"] .import-card { :root[data-theme="dark"] .import-card,
:root[data-theme="dark"] .filter-bar {
background: var(--ui-surface) !important; background: var(--ui-surface) !important;
color: var(--ui-text) !important; color: var(--ui-text) !important;
border-color: var(--ui-border) !important; border-color: var(--ui-border) !important;
@@ -269,7 +293,8 @@
:root[data-theme="dark"] .import-card p, :root[data-theme="dark"] .import-card p,
:root[data-theme="dark"] .rollover-hint, :root[data-theme="dark"] .rollover-hint,
:root[data-theme="dark"] .empty-state, :root[data-theme="dark"] .empty-state,
:root[data-theme="dark"] .empty-state p { :root[data-theme="dark"] .empty-state p,
:root[data-theme="dark"] .filter-counter {
color: var(--ui-text-muted) !important; color: var(--ui-text-muted) !important;
} }
@@ -286,6 +311,8 @@
:root[data-theme="dark"] .form-group input, :root[data-theme="dark"] .form-group input,
:root[data-theme="dark"] .form-group select, :root[data-theme="dark"] .form-group select,
:root[data-theme="dark"] .form-group textarea, :root[data-theme="dark"] .form-group textarea,
:root[data-theme="dark"] .filter-bar input,
:root[data-theme="dark"] .filter-bar select,
:root[data-theme="dark"] .export-buttons form, :root[data-theme="dark"] .export-buttons form,
:root[data-theme="dark"] .export-buttons select { :root[data-theme="dark"] .export-buttons select {
background: var(--ui-bg) !important; background: var(--ui-bg) !important;
@@ -297,6 +324,8 @@
:root[data-theme="dark"] .form-group input:focus, :root[data-theme="dark"] .form-group input:focus,
:root[data-theme="dark"] .form-group select:focus, :root[data-theme="dark"] .form-group select:focus,
:root[data-theme="dark"] .form-group textarea:focus, :root[data-theme="dark"] .form-group textarea:focus,
:root[data-theme="dark"] .filter-bar input:focus,
:root[data-theme="dark"] .filter-bar select:focus,
:root[data-theme="dark"] .export-buttons select:focus { :root[data-theme="dark"] .export-buttons select:focus {
border-color: #60a5fa !important; border-color: #60a5fa !important;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.22) !important; box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.22) !important;
@@ -334,6 +363,16 @@
.export-buttons > a { .export-buttons > a {
flex: 1 1 100%; flex: 1 1 100%;
} }
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.filter-counter {
margin-left: 0;
text-align: right;
}
} }
@media (max-width: 992px) { @media (max-width: 992px) {
@@ -473,7 +512,8 @@
<div> <div>
<h3 style="margin:0 0 8px 0;">Excel-Import</h3> <h3 style="margin:0 0 8px 0;">Excel-Import</h3>
<p style="margin:0 0 12px 0; color:#555; font-size:13px; line-height:1.4;"> <p style="margin:0 0 12px 0; color:#555; font-size:13px; line-height:1.4;">
Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch (z. B. aus <strong>ASV</strong>). Erkannt werden Name, Nachname, Klasse, Ausweis-ID, lokales Differenzierungsmerkmal, Notizen & Ausleihdauer. Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch (z. B. aus <strong>ASV</strong>). Erkannt werden Rufame, Nachname, Klasse*, Ausweis-ID*, lokales Differenzierungsmerkmal*, Notizen* & Ausleihdauer*.
Hinweis: Die Daten werden Validiert und müssen dann noch einmal aus sicherheits gründen hochgeladen werden!
</p> </p>
</div> </div>
@@ -503,7 +543,20 @@
<div> <div>
<h2 style="margin-bottom: 15px;">Registrierte Ausweise</h2> <h2 style="margin-bottom: 15px;">Registrierte Ausweise</h2>
{% if student_cards %} {% if student_cards %}
<table class="cards-table"> <!-- Filter Options -->
<div class="filter-bar">
<input type="text" id="cardSearchInput" placeholder="🔍 Name oder Ausweis-ID suchen...">
<select id="classFilterSelect">
<option value="">Alle Klassen</option>
{% for cls in available_classes %}
<option value="{{ cls }}">{{ cls }}</option>
{% endfor %}
</select>
<button type="button" id="clearFilterBtn" class="btn-cancel" style="padding: 8px 12px; font-size: 13px;">Zurücksetzen</button>
<span class="filter-counter" id="filterCounter">Angezeigt: {{ student_cards|length }} / {{ student_cards|length }}</span>
</div>
<table class="cards-table" id="cardsTable">
<thead> <thead>
<tr> <tr>
<th>Ausweis-ID</th> <th>Ausweis-ID</th>
@@ -516,7 +569,7 @@
</thead> </thead>
<tbody> <tbody>
{% for card in student_cards %} {% for card in student_cards %}
<tr> <tr class="card-row" data-id="{{ card.AusweisId|lower }}" data-name="{{ card.SchülerName|lower }}" data-class="{{ card.Klasse or '' }}">
<td><strong>{{ card.AusweisId }}</strong></td> <td><strong>{{ card.AusweisId }}</strong></td>
<td>{{ card.SchülerName }}</td> <td>{{ card.SchülerName }}</td>
<td>{{ card.Klasse or '—' }}</td> <td>{{ card.Klasse or '—' }}</td>
@@ -546,6 +599,10 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<div id="noResultsMsg" class="empty-state" style="display: none;">
<p>Keine passende Ausweise für diesen Filter gefunden.</p>
</div>
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
<p>Keine Bibliotheksausweise registriert.</p> <p>Keine Bibliotheksausweise registriert.</p>
@@ -557,7 +614,56 @@
<script src="https://cdn.jsdelivr.net/npm/qrcode.js"></script> <script src="https://cdn.jsdelivr.net/npm/qrcode.js"></script>
<script> <script>
// All PDF exports now go through backend routes document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('cardSearchInput');
const classSelect = document.getElementById('classFilterSelect');
const clearBtn = document.getElementById('clearFilterBtn');
const filterCounter = document.getElementById('filterCounter');
const rows = document.querySelectorAll('.card-row');
const noResultsMsg = document.getElementById('noResultsMsg');
const totalRows = rows.length;
function filterCards() {
const query = searchInput ? searchInput.value.toLowerCase().trim() : '';
const selectedClass = classSelect ? classSelect.value : '';
let visibleCount = 0;
rows.forEach(row => {
const id = row.getAttribute('data-id') || '';
const name = row.getAttribute('data-name') || '';
const cardClass = row.getAttribute('data-class') || '';
const matchesQuery = !query || id.includes(query) || name.includes(query);
const matchesClass = !selectedClass || cardClass === selectedClass;
if (matchesQuery && matchesClass) {
row.style.display = '';
visibleCount++;
} else {
row.style.display = 'none';
}
});
if (filterCounter) {
filterCounter.textContent = `Angezeigt: ${visibleCount} / ${totalRows}`;
}
if (noResultsMsg) {
noResultsMsg.style.display = (visibleCount === 0 && totalRows > 0) ? 'block' : 'none';
}
}
if (searchInput) searchInput.addEventListener('input', filterCards);
if (classSelect) classSelect.addEventListener('change', filterCards);
if (clearBtn) {
clearBtn.addEventListener('click', function() {
if (searchInput) searchInput.value = '';
if (classSelect) classSelect.value = '';
filterCards();
});
}
});
</script> </script>
{% endblock %} {% endblock %}
+2 -2
View File
@@ -738,7 +738,7 @@
{% if show_library_features %} {% if show_library_features %}
<div class="upload-import-panel" style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;"> <div class="upload-import-panel" style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Bibliothek (Mehrere Bücher)</h3> <h3 style="margin:0 0 8px 0;">Excel-Import Bibliothek (Mehrere Bücher)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p> <p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich. Hinweis: Die Daten werden Validiert und müssen dann noch einmal aus sicherheits gründen hochgeladen werden!</p>
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;"> <form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="library_excel" accept=".xlsx,.csv" required> <input type="file" name="library_excel" accept=".xlsx,.csv" required>
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('library')">Beispiel-CSV herunterladen</button> <button type="button" class="btn btn-link" onclick="downloadSampleCsv('library')">Beispiel-CSV herunterladen</button>
@@ -750,7 +750,7 @@
{% else %} {% else %}
<div class="upload-import-panel" style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;"> <div class="upload-import-panel" style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
<h3 style="margin:0 0 8px 0;">Excel-Import Inventar (Mehrere Artikel)</h3> <h3 style="margin:0 0 8px 0;">Excel-Import Inventar (Mehrere Artikel)</h3>
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p> <p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl). Hinweis: Die Daten werden Validiert und müssen dann noch einmal aus sicherheits gründen hochgeladen werden!</p>
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;"> <form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="inventory_excel" accept=".xlsx,.csv" required> <input type="file" name="inventory_excel" accept=".xlsx,.csv" required>
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('inventory')">Beispiel-CSV herunterladen</button> <button type="button" class="btn btn-link" onclick="downloadSampleCsv('inventory')">Beispiel-CSV herunterladen</button>
+30 -3
View File
@@ -228,7 +228,12 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="new-password" class="form-label">Neues Passwort:</label> <label for="new-password" class="form-label">Neues Passwort:</label>
<input type="password" class="form-control" id="new-password" name="new_password" required> <div class="input-group">
<input type="password" class="form-control" id="new-password" name="new_password" required>
<button class="btn btn-outline-secondary" type="button" id="togglePasswordBtn" onclick="togglePasswordVisibility()">
Anzeigen
</button>
</div>
<div class="password-requirements small text-muted mt-1"> <div class="password-requirements small text-muted mt-1">
<p>Das Passwort muss mindestens 6 Zeichen lang sein.</p> <p>Das Passwort muss mindestens 6 Zeichen lang sein.</p>
</div> </div>
@@ -397,14 +402,36 @@
modal.show(); modal.show();
} }
function togglePasswordVisibility() {
var passwordInput = document.getElementById('new-password');
var toggleBtn = document.getElementById('togglePasswordBtn');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggleBtn.textContent = 'Verbergen';
} else {
passwordInput.type = 'password';
toggleBtn.textContent = 'Anzeigen';
}
}
function openResetPasswordModal(username) { function openResetPasswordModal(username) {
document.getElementById('reset-username').value = username; document.getElementById('reset-username').value = username;
document.getElementById('username-display').value = username; document.getElementById('username-display').value = username;
var passwordInput = document.getElementById('new-password');
var toggleBtn = document.getElementById('togglePasswordBtn');
// Reset password field state on modal open
passwordInput.value = '';
passwordInput.type = 'password';
if (toggleBtn) {
toggleBtn.textContent = 'Anzeigen';
}
var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal')); var modal = new bootstrap.Modal(document.getElementById('resetPasswordModal'));
modal.show(); modal.show();
} }
function openPermissionsModal(button) { function openPermissionsModal(button) {
var username = button.getAttribute('data-username') || ''; var username = button.getAttribute('data-username') || '';
var preset = button.getAttribute('data-preset') || 'standard_user'; var preset = button.getAttribute('data-preset') || 'standard_user';