7 Commits
Author SHA1 Message Date
marijn 91a5b8a7f3 Merge pull request 'Donker thema toevoegen via gebruikersmenu rechtsboven' (#3) from donker_thema into main
Reviewed-on: #3
2026-10-14 16:11:54 +00:00
marinostrusandClaude Opus 5.5 8b7fa3f716 Donker thema toevoegen via gebruikersmenu rechtsboven
Klik op je naam rechtsboven om een menu te openen met een schakelaar
voor het donkere thema. De keuze wordt per browser onthouden.

- global.css: donkere kleurvariabelen, alleen op scherm (afdrukken blijft licht)
- base.html: gebruikersmenu met themaschakelaar
- vaste witte achtergronden en pastelkleuren op enkele pagina's
  volgen nu het thema

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:08:30 +02:00
marijn 684db2ec82 Upload files to "static" 2026-10-14 14:39:51 +00:00
marijn 857f9c8f8b Update static/pivot_logo_old.png 2026-10-14 14:38:57 +00:00
marijn a85eff883e Merge pull request 'mijn eerste aanpassing' (#2) from import_eigendom into main
Reviewed-on: #2
2026-10-14 13:28:05 +00:00
marijn 45d0b7f04a Merge pull request 'qr-codes genereren op basis van database tables' (#1) from QR_genereren_vanuit_database into main
Reviewed-on: #1
2026-10-14 13:23:59 +00:00
Kristof 2b4e8de681 qr-codes genereren op basis van database tables 2026-10-14 15:13:40 +02:00
14 changed files with 347 additions and 47 deletions

No files matched your search

+129 -26
View File
@@ -1,42 +1,31 @@
import io import io
import re
import zipfile import zipfile
import qrcode import qrcode
import pandas as pd import pandas as pd
from flask import Blueprint, render_template, request, send_file, flash, redirect, url_for from flask import Blueprint, render_template, request, send_file, flash, redirect, url_for
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from blueprints.auth import login_required from blueprints.auth import login_required
from rechten import recht_vereist # Zorg dat dit pad klopt naar je auth_utils.py from database import execute_query
from rechten import recht_vereist, heeft_recht
# definieren blueprint # definieren blueprint
qr_code_bp = Blueprint('qr_generator', __name__, template_folder='templates') qr_code_bp = Blueprint('qr_generator', __name__, template_folder='templates')
def generate_qr_images(excel_file, show_top, show_bottom): def _lettertype():
# Overslaan van de header rij
df = pd.read_excel(excel_file, header=0)
zip_buffer = io.BytesIO()
try: try:
# Try loading your local or system Arial font # Try loading your local or system Arial font
font = ImageFont.truetype("arial.ttf", 20) return ImageFont.truetype("arial.ttf", 20)
except IOError: except IOError:
try: try:
return ImageFont.load_default_font(size=20)
font = ImageFont.load_default_font(size=20)
except AttributeError: except AttributeError:
# Absolute fallback if Pillow is outdated, but textbbox WILL fail if this hits # Absolute fallback if Pillow is outdated, but textbbox WILL fail if this hits
font = ImageFont.load_default() return ImageFont.load_default()
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zip_file:
for index, row in df.iterrows():
# Gebruik .iloc voor veiligheid tegen kolomnamen
qr_text = str(row.iloc[0]) if not pd.isna(row.iloc[0]) else ""
top_text = str(row.iloc[1]) if not pd.isna(row.iloc[1]) else ""
bottom_text = str(row.iloc[2]) if not pd.isna(row.iloc[2]) else ""
if qr_text.strip() == "":
continue
def _qr_png(qr_text, top_text, bottom_text, font):
"""Eén QR-code als PNG (bytes), met optioneel tekst erboven en eronder."""
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_H) qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_H)
qr.add_data(qr_text) qr.add_data(qr_text)
qr.make(fit=True) qr.make(fit=True)
@@ -53,30 +42,96 @@ def generate_qr_images(excel_file, show_top, show_bottom):
qr_y = padding qr_y = padding
canvas.paste(qr_img, (qr_x, qr_y)) canvas.paste(qr_img, (qr_x, qr_y))
if show_top and top_text: if top_text:
bbox = draw.textbbox((0, 0), top_text, font=font) bbox = draw.textbbox((0, 0), top_text, font=font)
text_w = bbox[2] - bbox[0] text_w = bbox[2] - bbox[0]
draw.text(((canvas.width - text_w) / 2, 5), top_text, fill="black", font=font) draw.text(((canvas.width - text_w) / 2, 5), top_text, fill="black", font=font)
if show_bottom and bottom_text: if bottom_text:
bbox = draw.textbbox((0, 0), bottom_text, font=font) bbox = draw.textbbox((0, 0), bottom_text, font=font)
text_w = bbox[2] - bbox[0] text_w = bbox[2] - bbox[0]
draw.text(((canvas.width - text_w) / 2, qr_y + qr_height + 5), bottom_text, fill="black", font=font) draw.text(((canvas.width - text_w) / 2, qr_y + qr_height + 5), bottom_text, fill="black", font=font)
img_io = io.BytesIO() img_io = io.BytesIO()
canvas.save(img_io, 'PNG') canvas.save(img_io, 'PNG')
img_io.seek(0) return img_io.getvalue()
zip_file.writestr(f"qr_{index+1}.png", img_io.getvalue())
def generate_qr_images(excel_file, show_top, show_bottom):
# Overslaan van de header rij
df = pd.read_excel(excel_file, header=0)
zip_buffer = io.BytesIO()
font = _lettertype()
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zip_file:
for index, row in df.iterrows():
# Gebruik .iloc voor veiligheid tegen kolomnamen
qr_text = str(row.iloc[0]) if not pd.isna(row.iloc[0]) else ""
top_text = str(row.iloc[1]) if not pd.isna(row.iloc[1]) else ""
bottom_text = str(row.iloc[2]) if not pd.isna(row.iloc[2]) else ""
if qr_text.strip() == "":
continue
png = _qr_png(qr_text, top_text if show_top else "", bottom_text if show_bottom else "", font)
zip_file.writestr(f"qr_{index+1}.png", png)
zip_buffer.seek(0) zip_buffer.seek(0)
return zip_buffer return zip_buffer
# ---------------------------------------------------------------------------
# QR-codes uit de database (tabel Toestellen + Signpost_DB)
# ---------------------------------------------------------------------------
# sleutel → (naam op het scherm, kolom in de query)
DB_VELDEN = {
'toestelnummer': ('Toestelnummer (Schooleigen_ID)', 't.Schooleigen_ID'),
'serienummer': ('Serienummer', 't.Serienummer'),
'spb': ('SPB-nummer (Signpost)', 'sp.Signpostnummer'),
'type': ('Type', 't.Type'),
}
QR_VELDEN = ('toestelnummer', 'serienummer', 'spb') # wat in de QR-code zelf mag
STATUSSEN = ('Beschikbaar', 'Uitgeleend', 'Defect')
MAX_DB_CODES = 5000
def _bestandsnaam(tekst, gebruikt):
"""Veilige, unieke bestandsnaam op basis van de QR-inhoud."""
naam = re.sub(r'[^A-Za-z0-9._-]+', '_', tekst).strip('._') or 'qr'
kandidaat, i = naam, 2
while kandidaat.lower() in gebruikt:
kandidaat, i = f"{naam}_{i}", i + 1
gebruikt.add(kandidaat.lower())
return f"{kandidaat}.png"
def toestellen_voor_qr(qr_veld, status='', zoek='', nummers=None):
"""Toestellen met een ingevulde waarde voor qr_veld, optioneel gefilterd. Geeft rijen met alle DB_VELDEN."""
kolommen = ', '.join(f"{kol} AS {sleutel}" for sleutel, (_n, kol) in DB_VELDEN.items())
qr_kol = DB_VELDEN[qr_veld][1]
sql = (f"SELECT {kolommen} FROM Toestellen t LEFT JOIN Signpost_DB sp ON sp.Toestel_ID = t.Toestel_ID "
f"WHERE {qr_kol} IS NOT NULL AND TRIM({qr_kol}) <> ''")
params = []
if status in STATUSSEN:
sql += " AND t.Status = %s"
params.append(status)
if zoek:
sql += " AND CONCAT_WS(' ', t.Schooleigen_ID, t.Serienummer, sp.Signpostnummer, t.Type) LIKE %s"
params.append(f"%{zoek}%")
if nummers:
sql += f" AND {qr_kol} IN ({', '.join(['%s'] * len(nummers))})"
params.extend(nummers)
sql += f" ORDER BY {qr_kol}"
return execute_query(sql, tuple(params)) or []
@qr_code_bp.route('/') @qr_code_bp.route('/')
@login_required @login_required
@recht_vereist('qr_codes') @recht_vereist('qr_codes')
def qr_generator(): def qr_generator():
return render_template('qr_code.html', db_toegang=heeft_recht('toestellen_beheren'), db_velden=DB_VELDEN,
return render_template('qr_code.html') qr_velden=QR_VELDEN, statussen=STATUSSEN, max_db_codes=MAX_DB_CODES)
@qr_code_bp.route('/download-template') @qr_code_bp.route('/download-template')
@login_required @login_required
@@ -123,3 +178,51 @@ def upload_file():
return redirect(url_for('qr_generator.qr_generator')) return redirect(url_for('qr_generator.qr_generator'))
return redirect(url_for('qr_generator.qr_generator')) return redirect(url_for('qr_generator.qr_generator'))
@qr_code_bp.route('/database', methods=['POST'])
@login_required
@recht_vereist('qr_codes')
def database_qr():
"""QR-codes van toestelnummers, serienummers of SPB-nummers uit de database (ZIP met PNG's)."""
terug = redirect(url_for('qr_generator.qr_generator'))
if not heeft_recht('toestellen_beheren'):
flash("Je hebt het recht 'Toestelleninventaris beheren' nodig om QR-codes uit de database te maken.", "error")
return terug
qr_veld = request.form.get('qr_veld')
if qr_veld not in QR_VELDEN:
flash("Kies wat er in de QR-code moet komen.", "warning")
return terug
boven = request.form.get('boven') if request.form.get('boven') in DB_VELDEN else None
onder = request.form.get('onder') if request.form.get('onder') in DB_VELDEN else None
nummers = [n.strip() for n in re.split(r'[\s,;]+', request.form.get('nummers') or '') if n.strip()]
try:
rijen = toestellen_voor_qr(qr_veld, request.form.get('status') or '', (request.form.get('zoek') or '').strip(),
nummers)
except Exception as e:
flash(f"Fout bij het ophalen van de toestellen: {e}", "danger")
return terug
if not rijen:
flash(f"Geen toestellen gevonden met een {DB_VELDEN[qr_veld][0].split(' (')[0].lower()} voor deze keuze.",
"warning")
return terug
if len(rijen) > MAX_DB_CODES:
flash(f"Dat zijn {len(rijen)} toestellen; maximaal {MAX_DB_CODES} per keer. Gebruik een filter.", "warning")
return terug
font = _lettertype()
zip_buffer = io.BytesIO()
gebruikt = set()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for r in rijen:
qr_text = str(r[qr_veld]).strip()
png = _qr_png(qr_text, str(r[boven] or '') if boven else '', str(r[onder] or '') if onder else '', font)
zip_file.writestr(_bestandsnaam(qr_text, gebruikt), png)
zip_buffer.seek(0)
if nummers:
gevonden = {str(r[qr_veld]).strip().upper() for r in rijen}
niet = [n for n in nummers if n.upper() not in gevonden]
if niet:
flash(f"Niet gevonden: {', '.join(niet[:20])}{' ...' if len(niet) > 20 else ''}", "warning")
return send_file(zip_buffer, mimetype='application/zip', as_attachment=True,
download_name=f"qr_{qr_veld}.zip")
+103 -8
View File
@@ -37,11 +37,85 @@
--shadow: 0 1px 3px rgba(15, 23, 42, .08), 0 4px 12px rgba(15, 23, 42, .05); --shadow: 0 1px 3px rgba(15, 23, 42, .08), 0 4px 12px rgba(15, 23, 42, .05);
--shadow-lg: 0 10px 30px rgba(15, 23, 42, .18); --shadow-lg: 0 10px 30px rgba(15, 23, 42, .18);
/* Overige vlakken */
--header-bg: var(--primary-color);
--row-hover: #f8fbfe;
--badge-bg: #e2e8f0; --badge-text: #334155;
--overlay: rgba(255, 255, 255, .85);
--header-height: 64px; --header-height: 64px;
--page-width: 1200px; --page-width: 1200px;
--font: "Segoe UI", system-ui, -apple-system, Roboto, Arial, sans-serif; --font: "Segoe UI", system-ui, -apple-system, Roboto, Arial, sans-serif;
} }
/* ---------- 1b. Donker thema (aan/uit via het gebruikersmenu rechtsboven) ---------- */
/* Alleen op scherm: afdrukken gebeurt altijd in het lichte thema */
@media screen {
html[data-theme="dark"] {
color-scheme: dark;
--primary-color: #3a8cc9;
--primary-dark: #2d73a8;
--primary-light: #17324a;
--background-color: #0f172a;
--surface: #1e293b;
--surface-muted: #172033;
--border: #334155;
--border-strong: #475569;
--text: #e2e8f0;
--text-muted: #94a3b8;
--success-bg: #14361f; --success-text: #86efac;
--warning-bg: #3a300b; --warning-text: #fde047;
--danger-bg: #3f1515; --danger-text: #fca5a5;
--info-bg: #172a4d; --info-text: #93c5fd;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .4);
--shadow: 0 1px 3px rgba(0, 0, 0, .4), 0 4px 12px rgba(0, 0, 0, .25);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, .6);
--header-bg: #13304a;
--row-hover: #243349;
--badge-bg: #334155; --badge-text: #e2e8f0;
--overlay: rgba(15, 23, 42, .85);
}
html[data-theme="dark"] .flash-message,
html[data-theme="dark"] .flash-message.success,
html[data-theme="dark"] .flash-message.error,
html[data-theme="dark"] .flash-message.danger,
html[data-theme="dark"] .flash-message.warning { border-color: var(--border); }
html[data-theme="dark"] .dash-section-title.admin::after { background: var(--danger-bg); }
html[data-theme="dark"] .tile.tile-tools .tile-icon,
html[data-theme="dark"] .tile.tile-tools .tile-links a:hover { background: #123b38; }
html[data-theme="dark"] .tile.tile-tools h3,
html[data-theme="dark"] .tile.tile-tools .tile-links a:hover { color: #5eead4; }
html[data-theme="dark"] .tile.tile-scholengroep .tile-icon,
html[data-theme="dark"] .tile.tile-scholengroep .tile-links a:hover { background: #2e1f52; }
html[data-theme="dark"] .tile.tile-scholengroep h3,
html[data-theme="dark"] .tile.tile-scholengroep .tile-links a:hover { color: #c4b5fd; }
html[data-theme="dark"] .google-btn { background: var(--surface); color: var(--text); border-color: var(--border-strong); }
html[data-theme="dark"] .google-btn:hover { background: var(--surface-muted); }
html[data-theme="dark"] body.auth-page { background: var(--background-color); }
html[data-theme="dark"] mark { background: #854d0e; color: #fef9c3; }
/* Pagina's met eigen pastelkleuren (gemarkeerde rijen, legendes, filters) */
html[data-theme="dark"] .tellers .let,
html[data-theme="dark"] .filters button.let { background: var(--warning-bg); border-color: #854d0e; }
html[data-theme="dark"] .filters button.let[aria-pressed="true"] { background: #854d0e; color: #fef9c3; }
html[data-theme="dark"] td.leeg,
html[data-theme="dark"] tr.flagged td,
html[data-theme="dark"] table.aw .vandaag,
html[data-theme="dark"] td.inv input.gewijzigd,
html[data-theme="dark"] .panel.directie,
html[data-theme="dark"] .panel.vraag { background: #2e2710 !important; border-color: #854d0e; }
html[data-theme="dark"] tr.rij-fout td { background: #3a1a1a; }
html[data-theme="dark"] button.st:not(.aanwezig):not(.afwezig) { background: var(--surface-muted); border-color: var(--border-strong); color: var(--text-muted); }
html[data-theme="dark"] #personeelBody tr.g-account td, html[data-theme="dark"] .legende i.account { background: #14361f; border-color: #166534; }
html[data-theme="dark"] #personeelBody tr.g-alias td, html[data-theme="dark"] .legende i.alias { background: #3a2a10; border-color: #9a5b13; }
html[data-theme="dark"] #personeelBody tr.g-geen td, html[data-theme="dark"] .legende i.geen { background: #3a1a1a; border-color: #991b1b; }
}
/* ---------- 2. Basis ---------- */ /* ---------- 2. Basis ---------- */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
@@ -84,7 +158,7 @@ a { color: var(--primary-color); }
align-items: center; align-items: center;
gap: 16px; gap: 16px;
padding: 0 24px; padding: 0 24px;
background: var(--primary-color); background: var(--header-bg);
color: #fff; color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, .15); box-shadow: 0 2px 8px rgba(0, 0, 0, .15);
} }
@@ -130,10 +204,29 @@ a { color: var(--primary-color); }
} }
.home-btn, .header-btn { background: rgba(255, 255, 255, .16); color: #fff; } .home-btn, .header-btn { background: rgba(255, 255, 255, .16); color: #fff; }
.home-btn:hover, .header-btn:hover { background: rgba(255, 255, 255, .28); } .home-btn:hover, .header-btn:hover { background: rgba(255, 255, 255, .28); }
.logout-btn { background: #fff; color: var(--danger); } .logout-btn { background: var(--surface); color: var(--danger); }
.logout-btn:hover { background: var(--danger-bg); } .logout-btn:hover { background: var(--danger-bg); }
.app-header form { margin: 0; } .app-header form { margin: 0; }
/* Gebruikersmenu (klik op je naam rechtsboven) */
.user-menu { position: relative; }
.user-menu-btn { opacity: 1; font-weight: 500; }
.user-menu-btn .caret { font-size: 10px; opacity: .8; }
.user-menu-panel {
display: none;
position: absolute; right: 0; top: calc(100% + 8px);
z-index: 1100;
min-width: 260px;
padding: 6px 14px;
background: var(--surface); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.user-menu.open .user-menu-panel { display: block; }
.user-menu-head { padding: 8px 0 10px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
.user-menu-head strong { display: block; color: var(--text); font-size: 14px; }
.user-menu-panel .switch-row { font-size: 14px; font-weight: 500; cursor: pointer; }
/* ---------- 4. Pagina-layout ---------- */ /* ---------- 4. Pagina-layout ---------- */
.app-main { .app-main {
flex: 1; flex: 1;
@@ -187,7 +280,7 @@ select, textarea {
font: inherit; font: inherit;
font-size: 15px; font-size: 15px;
color: var(--text); color: var(--text);
background: #fff; background: var(--surface);
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: border-color .15s, box-shadow .15s; transition: border-color .15s, box-shadow .15s;
@@ -260,7 +353,7 @@ button:disabled, .btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-secondary { background: #475569; } .btn-secondary { background: #475569; }
.btn-secondary:hover { background: #334155; } .btn-secondary:hover { background: #334155; }
.btn-light { background: #fff; color: var(--text); border-color: var(--border-strong); } .btn-light { background: var(--surface); color: var(--text); border-color: var(--border-strong); }
.btn-light:hover { background: var(--surface-muted); } .btn-light:hover { background: var(--surface-muted); }
.btn-success { background: var(--success); } .btn-success { background: var(--success); }
.btn-success:hover { background: #15803d; } .btn-success:hover { background: #15803d; }
@@ -291,7 +384,7 @@ button:disabled, .btn:disabled { opacity: .5; cursor: not-allowed; }
border-radius: var(--radius); border-radius: var(--radius);
margin-top: 12px; margin-top: 12px;
} }
table { width: 100%; border-collapse: collapse; background: #fff; text-align: left; } table { width: 100%; border-collapse: collapse; background: var(--surface); text-align: left; }
th, td { padding: 11px 14px; border-bottom: 1px solid var(--border); vertical-align: middle; } th, td { padding: 11px 14px; border-bottom: 1px solid var(--border); vertical-align: middle; }
th { th {
background: var(--surface-muted); background: var(--surface-muted);
@@ -299,7 +392,7 @@ th {
color: var(--text-muted); white-space: nowrap; color: var(--text-muted); white-space: nowrap;
} }
tbody tr:last-child td { border-bottom: 0; } tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #f8fbfe; } tbody tr:hover { background: var(--row-hover); }
td.actions, th.actions { text-align: right; white-space: nowrap; } td.actions, th.actions { text-align: right; white-space: nowrap; }
td.actions form { display: inline; margin: 0; } td.actions form { display: inline; margin: 0; }
td.actions > * + * { margin-left: 6px; } td.actions > * + * { margin-left: 6px; }
@@ -324,7 +417,7 @@ td.center, th.center { text-align: center; }
display: inline-flex; align-items: center; gap: 4px; display: inline-flex; align-items: center; gap: 4px;
padding: 3px 10px; border-radius: 999px; padding: 3px 10px; border-radius: 999px;
font-size: 12.5px; font-weight: 600; white-space: nowrap; font-size: 12.5px; font-weight: 600; white-space: nowrap;
background: #e2e8f0; color: #334155; background: var(--badge-bg); color: var(--badge-text);
} }
.badge-success { background: var(--success-bg); color: var(--success-text); } .badge-success { background: var(--success-bg); color: var(--success-text); }
.badge-warning { background: var(--warning-bg); color: var(--warning-text); } .badge-warning { background: var(--warning-bg); color: var(--warning-text); }
@@ -448,7 +541,7 @@ td.center, th.center { text-align: center; }
position: fixed; inset: 0; position: fixed; inset: 0;
z-index: 9999; z-index: 9999;
flex-direction: column; justify-content: center; align-items: center; gap: 16px; flex-direction: column; justify-content: center; align-items: center; gap: 16px;
background: rgba(255, 255, 255, .85); background: var(--overlay);
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
.spinner { .spinner {
@@ -563,6 +656,8 @@ body.auth-page { background: linear-gradient(160deg, #eaf2f9 0%, var(--backgroun
/* ---------- 17. Responsief ---------- */ /* ---------- 17. Responsief ---------- */
@media (max-width: 900px) { @media (max-width: 900px) {
.brand-school, .header-user { display: none; } .brand-school, .header-user { display: none; }
.user-menu-btn { display: inline-flex; }
.user-menu-btn .user-naam { display: none; }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.app-header { .app-header {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

+1 -1
View File
@@ -7,7 +7,7 @@
<style> <style>
/* Bewerken-knop altijd zichtbaar: laatste kolom blijft rechts staan bij horizontaal scrollen */ /* Bewerken-knop altijd zichtbaar: laatste kolom blijft rechts staan bij horizontaal scrollen */
.lln-tabel td.actions, .lln-tabel th.actions { .lln-tabel td.actions, .lln-tabel th.actions {
position: sticky; right: 0; background: #fff; white-space: nowrap; position: sticky; right: 0; background: var(--surface); white-space: nowrap;
box-shadow: -6px 0 6px -6px rgba(15, 23, 42, .15); box-shadow: -6px 0 6px -6px rgba(15, 23, 42, .15);
} }
.lln-tabel th.actions { background: var(--surface-muted); } .lln-tabel th.actions { background: var(--surface-muted); }
+1 -1
View File
@@ -21,7 +21,7 @@
tr.lln.open td:first-child::before { transform: rotate(90deg); } tr.lln.open td:first-child::before { transform: rotate(90deg); }
tr.detail td { background: var(--surface-muted); padding: 10px 14px 14px 44px; } tr.detail td { background: var(--surface-muted); padding: 10px 14px 14px 44px; }
.day-chips { display: flex; flex-wrap: wrap; gap: 6px; } .day-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.day-chip { display: inline-flex; gap: 6px; align-items: center; padding: 4px 10px; border-radius: 999px; background: #fff; .day-chip { display: inline-flex; gap: 6px; align-items: center; padding: 4px 10px; border-radius: 999px; background: var(--surface);
border: 1px solid var(--border); font-size: 13px; } border: 1px solid var(--border); font-size: 13px; }
.day-chip .dd { font-weight: 700; color: var(--danger-text); } .day-chip .dd { font-weight: 700; color: var(--danger-text); }
.count-badge { display: inline-block; min-width: 34px; text-align: center; padding: 3px 10px; border-radius: 999px; font-weight: 700; .count-badge { display: inline-block; min-width: 34px; text-align: center; padding: 3px 10px; border-radius: 999px; font-weight: 700;
+50 -2
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}PIVOT{% endblock %} – {{ school_name_short|upper or 'PIVOT' }}</title> <title>{% block title %}PIVOT{% endblock %} – {{ school_name_short|upper or 'PIVOT' }}</title>
<script>
/* Thema meteen toepassen (vóór het tekenen) zodat de pagina niet eerst licht oplicht */
try { if (localStorage.getItem('pivot.thema') === 'donker') document.documentElement.dataset.theme = 'dark'; } catch (e) { /* geen opslag */ }
</script>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='pivot_mark.png') }}"> <link rel="icon" type="image/png" href="{{ url_for('static', filename='pivot_mark.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='global.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='global.css') }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
@@ -44,7 +48,7 @@
onchange="if (this.value) location.href = this.value"> onchange="if (this.value) location.href = this.value">
<option value="" selected>🏫 {{ school.naam }} ⇄</option> <option value="" selected>🏫 {{ school.naam }} ⇄</option>
{% for s in andere_scholen %} {% for s in andere_scholen %}
<option value="{{ url_for('scholen.wissel', code=s.code) }}" style="color:#1e293b">{{ s.naam }}</option> <option value="{{ url_for('scholen.wissel', code=s.code) }}" style="color:var(--text)">{{ s.naam }}</option>
{% endfor %} {% endfor %}
</select> </select>
{% else %} {% else %}
@@ -52,7 +56,22 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if session.get('user') %} {% if session.get('user') %}
<span class="header-user">👤 {{ session.get('user') }}</span> <div class="user-menu" id="user-menu">
<button type="button" class="header-btn header-user user-menu-btn" id="user-menu-btn"
aria-haspopup="true" aria-expanded="false" aria-controls="user-menu-panel" title="Gebruikersmenu">
👤 <span class="user-naam">{{ session.get('user') }}</span> <span class="caret">▼</span>
</button>
<div class="user-menu-panel" id="user-menu-panel">
<div class="user-menu-head">Aangemeld als <strong>{{ session.get('user') }}</strong></div>
<label class="switch-row" for="thema-schakelaar">
<span>🌙 Donker thema</span>
<span class="switch">
<input type="checkbox" id="thema-schakelaar">
<span class="slider"></span>
</span>
</label>
</div>
</div>
{% endif %} {% endif %}
<form action="{{ url_for('auth.logout') }}" method="get"> <form action="{{ url_for('auth.logout') }}" method="get">
<button type="submit" class="logout-btn">Uitloggen</button> <button type="submit" class="logout-btn">Uitloggen</button>
@@ -134,6 +153,35 @@
}); });
}, 6000); }, 6000);
// Gebruikersmenu rechtsboven + donker thema aan/uit (onthouden in deze browser)
(function () {
const menu = document.getElementById('user-menu');
if (!menu) return;
const knop = document.getElementById('user-menu-btn');
const schakelaar = document.getElementById('thema-schakelaar');
function zetOpen(open) {
menu.classList.toggle('open', open);
knop.setAttribute('aria-expanded', open);
}
knop.addEventListener('click', function (e) {
e.stopPropagation();
zetOpen(!menu.classList.contains('open'));
});
document.addEventListener('click', function (e) {
if (!menu.contains(e.target)) zetOpen(false);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') zetOpen(false);
});
schakelaar.checked = document.documentElement.dataset.theme === 'dark';
schakelaar.addEventListener('change', function () {
if (schakelaar.checked) document.documentElement.dataset.theme = 'dark';
else delete document.documentElement.dataset.theme;
try { localStorage.setItem('pivot.thema', schakelaar.checked ? 'donker' : 'licht'); } catch (err) { /* negeren */ }
});
})();
// Escape sluit open modals // Escape sluit open modals
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
+1 -1
View File
@@ -23,7 +23,7 @@
.modus-keuze small { display: block; color: var(--text-muted); font-weight: 400; } .modus-keuze small { display: block; color: var(--text-muted); font-weight: 400; }
.tellers { display: flex; flex-wrap: wrap; gap: 10px; margin: 6px 0 14px; } .tellers { display: flex; flex-wrap: wrap; gap: 10px; margin: 6px 0 14px; }
.teller-knop { all: unset; cursor: pointer; display: inline-flex; gap: 8px; align-items: center; padding: 8px 14px; .teller-knop { all: unset; cursor: pointer; display: inline-flex; gap: 8px; align-items: center; padding: 8px 14px;
border-radius: var(--radius); border: 1px solid var(--border); background: #fff; font-weight: 600; font-size: 14px; } border-radius: var(--radius); border: 1px solid var(--border); background: var(--surface); font-weight: 600; font-size: 14px; }
.teller-knop.actief { border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(30,111,165,.18); } .teller-knop.actief { border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(30,111,165,.18); }
.teller-knop .n { font-size: 18px; } .teller-knop .n { font-size: 18px; }
tr.rij-fout td { background: #fff7f7; } tr.rij-fout td { background: #fff7f7; }
+1 -1
View File
@@ -23,7 +23,7 @@
.modus-keuze small { display: block; color: var(--text-muted); font-weight: 400; } .modus-keuze small { display: block; color: var(--text-muted); font-weight: 400; }
.tellers { display: flex; flex-wrap: wrap; gap: 10px; margin: 6px 0 14px; } .tellers { display: flex; flex-wrap: wrap; gap: 10px; margin: 6px 0 14px; }
.teller-knop { all: unset; cursor: pointer; display: inline-flex; gap: 8px; align-items: center; padding: 8px 14px; .teller-knop { all: unset; cursor: pointer; display: inline-flex; gap: 8px; align-items: center; padding: 8px 14px;
border-radius: var(--radius); border: 1px solid var(--border); background: #fff; font-weight: 600; font-size: 14px; } border-radius: var(--radius); border: 1px solid var(--border); background: var(--surface); font-weight: 600; font-size: 14px; }
.teller-knop.actief { border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(30,111,165,.18); } .teller-knop.actief { border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(30,111,165,.18); }
.teller-knop .n { font-size: 18px; } .teller-knop .n { font-size: 18px; }
tr.rij-fout td { background: #fff7f7; } tr.rij-fout td { background: #fff7f7; }
+1 -1
View File
@@ -23,7 +23,7 @@
#personeelBody tr.g-account td { background: #e8f7ee; } #personeelBody tr.g-account td { background: #e8f7ee; }
#personeelBody tr.g-alias td { background: #fff1e0; } #personeelBody tr.g-alias td { background: #fff1e0; }
#personeelBody tr.g-geen td { background: #fdeaea; } #personeelBody tr.g-geen td { background: #fdeaea; }
#personeelBody tr[class*="g-"] input.adres, #personeelBody tr[class*="g-"] select.rol { background: #fff; } #personeelBody tr[class*="g-"] input.adres, #personeelBody tr[class*="g-"] select.rol { background: var(--surface); }
.legende { display: flex; flex-wrap: wrap; gap: 6px 18px; align-items: center; margin: -6px 0 14px; font-size: .88em; color: var(--text-muted); } .legende { display: flex; flex-wrap: wrap; gap: 6px 18px; align-items: center; margin: -6px 0 14px; font-size: .88em; color: var(--text-muted); }
.legende button.kleurfilter { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; .legende button.kleurfilter { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center;
gap: 6px; padding: 4px 10px; border-radius: 999px; border: 1px solid transparent; } gap: 6px; padding: 4px 10px; border-radius: 999px; border: 1px solid transparent; }
+1 -1
View File
@@ -11,7 +11,7 @@
.nav-bar form { display: flex; gap: 6px; margin: 0; } .nav-bar form { display: flex; gap: 6px; margin: 0; }
.nav-bar input[type=date] { width: auto; } .nav-bar input[type=date] { width: auto; }
.seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; } .seg { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
.seg a { padding: 8px 14px; text-decoration: none; color: var(--text); font-weight: 600; font-size: 14px; background: #fff; } .seg a { padding: 8px 14px; text-decoration: none; color: var(--text); font-weight: 600; font-size: 14px; background: var(--surface); }
.seg a.on { background: var(--primary-color); color: #fff; } .seg a.on { background: var(--primary-color); color: #fff; }
.day { margin-bottom: 20px; } .day { margin-bottom: 20px; }
.day-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 10px; } .day-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 10px; }
+3 -3
View File
@@ -8,18 +8,18 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.soort-switch { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; } .soort-switch { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; }
.soort-switch a { padding: 8px 14px; border-radius: 999px; border: 1px solid var(--border); background: #fff; text-decoration: none; color: var(--text); font-weight: 600; font-size: 14px; } .soort-switch a { padding: 8px 14px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface); text-decoration: none; color: var(--text); font-weight: 600; font-size: 14px; }
.soort-switch a.on { background: var(--primary-color); border-color: var(--primary-color); color: #fff; } .soort-switch a.on { background: var(--primary-color); border-color: var(--primary-color); color: #fff; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; } .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0 16px; } .grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0 16px; }
@media (max-width: 640px) { .grid2, .grid3 { grid-template-columns: 1fr; } } @media (max-width: 640px) { .grid2, .grid3 { grid-template-columns: 1fr; } }
.dagdeel { display: flex; gap: 6px; flex-wrap: wrap; } .dagdeel { display: flex; gap: 6px; flex-wrap: wrap; }
.dagdeel label { display: inline-flex; align-items: center; gap: 6px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: #fff; cursor: pointer; font-weight: 500; } .dagdeel label { display: inline-flex; align-items: center; gap: 6px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); cursor: pointer; font-weight: 500; }
.dagdeel input:checked + span { font-weight: 700; color: var(--primary-color); } .dagdeel input:checked + span { font-weight: 700; color: var(--primary-color); }
.klassen-box { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; background: var(--surface-muted); } .klassen-box { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; background: var(--surface-muted); }
.klassen-box input[type=search] { margin-bottom: 8px; } .klassen-box input[type=search] { margin-bottom: 8px; }
.klassen { display: flex; flex-wrap: wrap; gap: 6px; max-height: 210px; overflow-y: auto; } .klassen { display: flex; flex-wrap: wrap; gap: 6px; max-height: 210px; overflow-y: auto; }
.klas { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 999px; background: #fff; font-size: 13px; font-weight: 600; cursor: pointer; user-select: none; } .klas { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); font-size: 13px; font-weight: 600; cursor: pointer; user-select: none; }
.klas input { width: 14px; height: 14px; } .klas input { width: 14px; height: 14px; }
.klas.on { background: var(--primary-light); border-color: var(--primary-color); color: var(--primary-color); } .klas.on { background: var(--primary-light); border-color: var(--primary-color); color: var(--primary-color); }
.gekozen { font-size: 13px; color: var(--text-muted); margin-top: 6px; } .gekozen { font-size: 13px; color: var(--text-muted); margin-top: 6px; }
+55 -1
View File
@@ -26,7 +26,7 @@
<div class="page-head"> <div class="page-head">
<div> <div>
<h2>💡 QR-codes maken</h2> <h2>💡 QR-codes maken</h2>
<p class="muted" style="margin:0">Maak eenvoudig QR-codes aan vanuit een Excel-bestand.</p> <p class="muted" style="margin:0">Maak eenvoudig QR-codes aan vanuit een Excel-bestand{{ ' of uit de database (hieronder)' if db_toegang }}.</p>
</div> </div>
</div> </div>
@@ -50,4 +50,58 @@
</li> </li>
</ol> </ol>
</div> </div>
{% if db_toegang %}
<div class="card">
<div class="page-head">
<div>
<h2>🗄️ QR-codes uit de database</h2>
<p class="muted" style="margin:0">Maak QR-codes van de toestellen in PIVOT: één PNG per toestel, samen in een ZIP.</p>
</div>
</div>
<form action="{{ url_for('qr_generator.database_qr') }}" method="post">
<div class="field">
<label for="qr_veld">In de QR-code</label>
<select id="qr_veld" name="qr_veld" required>
{% for v in qr_velden %}<option value="{{ v }}">{{ db_velden[v][0] }}</option>{% endfor %}
</select>
<div class="field-hint">Toestellen zonder deze waarde worden overgeslagen.</div>
</div>
<div class="options-box">
<div class="field">
<label for="boven">Tekst boven de QR-code</label>
<select id="boven" name="boven">
<option value="">Geen</option>
{% for k, v in db_velden.items() %}<option value="{{ k }}">{{ v[0] }}</option>{% endfor %}
</select>
</div>
<div class="field">
<label for="onder">Tekst onder de QR-code</label>
<select id="onder" name="onder">
<option value="">Geen</option>
{% for k, v in db_velden.items() %}<option value="{{ k }}" {{ 'selected' if k == 'toestelnummer' }}>{{ v[0] }}</option>{% endfor %}
</select>
</div>
</div>
<div class="field">
<label for="status">Status</label>
<select id="status" name="status">
<option value="">Alle toestellen</option>
{% for s in statussen %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
</select>
</div>
<div class="field">
<label for="zoek">Zoeken (optioneel)</label>
<input type="text" id="zoek" name="zoek" placeholder="bv. CB-01 of Lenovo">
<div class="field-hint">Enkel toestellen waarvan het toestelnummer, serienummer, SPB-nummer of type deze tekst bevat.</div>
</div>
<div class="field">
<label for="nummers">Enkel deze nummers (optioneel)</label>
<textarea id="nummers" name="nummers" rows="4" placeholder="Eén per regel, of gescheiden door komma's"></textarea>
<div class="field-hint">Nummers van het gekozen soort (zie "In de QR-code"). Leeg = alle toestellen. Max. {{ max_db_codes }} per keer.</div>
</div>
<button type="submit" class="btn-block btn-lg">🚀 Genereer QR-codes (ZIP)</button>
</form>
</div>
{% endif %}
{% endblock %} {% endblock %}
+1 -1
View File
@@ -21,7 +21,7 @@
.code-panel { margin-top: 18px; border: 2px solid var(--success); border-radius: var(--radius-lg); background: var(--success-bg); padding: 20px; text-align: center; } .code-panel { margin-top: 18px; border: 2px solid var(--success); border-radius: var(--radius-lg); background: var(--success-bg); padding: 20px; text-align: center; }
.big-code { font-family: ui-monospace, Consolas, monospace; font-size: 40px; font-weight: 800; letter-spacing: 6px; color: var(--success-text); margin: 6px 0 4px; } .big-code { font-family: ui-monospace, Consolas, monospace; font-size: 40px; font-weight: 800; letter-spacing: 6px; color: var(--success-text); margin: 6px 0 4px; }
.code-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 6px; margin-top: 12px; } .code-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 6px; margin-top: 12px; }
.code-grid span { font-family: ui-monospace, Consolas, monospace; background: #fff; border: 1px solid var(--border); border-radius: 6px; padding: 6px; font-size: 15px; } .code-grid span { font-family: ui-monospace, Consolas, monospace; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 6px; font-size: 15px; }
details summary { cursor: pointer; color: var(--text-muted); font-size: 14px; margin-top: 12px; } details summary { cursor: pointer; color: var(--text-muted); font-size: 14px; margin-top: 12px; }
.exception-active { margin-top: 14px; } .exception-active { margin-top: 14px; }
.pick-list { list-style: none; margin: 10px 0 0; padding: 0; } .pick-list { list-style: none; margin: 10px 0 0; padding: 0; }