Author SHA1 Message Date
marinostrusandClaude Opus 5.5 8620dfb916 Volledige schoolnaam tonen in de header
De schoolnaam werd afgekapt op 200px. Nu wordt ze volledig getoond
en enkel nog ingekort met … als er geen plaats meer is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:23:51 +02:00
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
Kristof a347c99274 mijn eerste aanpassing 2026-10-14 14:39:01 +02:00
marijn 715740428d Upload files to "/" 2026-10-14 11:42:54 +00:00
21 changed files with 1080 additions and 50 deletions

No files matched your search

+1
View File
@@ -118,6 +118,7 @@ python beheer.py rechten jan sg-antwerpen alles | geen
| `koppelingen` | Koppelingen van deze school |
| `gebruikersbeheer` | Gebruikers & rechten van deze school |
| `database_toestellen` | Beheer › Database: toestellen importeren uit Excel |
| `database_eigendom` | Beheer › Database: eigendom (toestel ↔ leerling) importeren uit Excel |
Een nieuw onderdeel voeg je toe in `rechten.py › ONDERDELEN`. Bij het opstarten komt het automatisch in de tabel `Onderdelen`.
+86
View File
@@ -6,6 +6,8 @@ Toestellen:
GET /beheer/database/toestellen/sjabloon.xlsx → sjabloon downloaden
POST /beheer/database/toestellen/controleren → Excel lezen en voorbeeld tonen (niets bewaard)
POST /beheer/database/toestellen/importeren → bevestigen: het voorbeeld uitvoeren
Eigendom (toekenningen toestel ↔ leerling): dezelfde vier routes onder /beheer/database/eigendom
"""
import json
@@ -14,6 +16,7 @@ from flask import Blueprint, render_template, request, Response, redirect, url_f
from blueprints.auth import login_required
from database import execute_query
from rechten import recht_vereist, log
from services import eigendom_import as ei
from services import toestellen_import as ti
from tenancy import huidige_school
@@ -98,3 +101,86 @@ def toestellen_importeren():
if t['fout']:
flash(f"{t['fout']} rij(en) met een fout werden overgeslagen.", "warning")
return terug
# ---------------------------------------------------------------------------
# Eigendom
# ---------------------------------------------------------------------------
@database_bp.route('/eigendom', methods=['GET'])
@login_required
@recht_vereist('database_eigendom')
def eigendom():
try:
aantal = execute_query("SELECT COUNT(*) AS n FROM Eigendom WHERE Datum_inlevering IS NULL")[0]['n']
except Exception:
aantal = None
return render_template('database_eigendom.html', plan=None, aantal=aantal, kolommen=ei.KOLOMMEN,
max_rijen=ei.MAX_RIJEN)
@database_bp.route('/eigendom/sjabloon.xlsx', methods=['GET'])
@login_required
@recht_vereist('database_eigendom')
def eigendom_sjabloon():
school = huidige_school()
data = ei.maak_sjabloon(school.naam)
return Response(data, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': f"attachment; filename=Sjabloon_eigendom_{school.code}.xlsx"})
@database_bp.route('/eigendom/controleren', methods=['POST'])
@login_required
@recht_vereist('database_eigendom')
def eigendom_controleren():
terug = redirect(url_for('database_beheer.eigendom'))
bestand = request.files.get('bestand')
if not bestand or not bestand.filename:
flash("Kies eerst een Excel-bestand.", "error")
return terug
if not bestand.filename.lower().endswith(('.xlsx', '.xlsm')):
flash("Enkel Excel-bestanden (.xlsx) kunnen ingelezen worden. Sla een .xls of .csv eerst op als .xlsx.", "error")
return terug
modus = 'overschrijven' if request.form.get('modus') == 'overschrijven' else 'aanvullen'
try:
rijen, onbekend = ei.lees_excel(bestand)
except ei.ImportFout as e:
flash(str(e), "error")
return terug
if not rijen:
flash("Het bestand bevat geen toekenningen (enkel kolomkoppen of lege rijen).", "warning")
return terug
try:
plan = ei.maak_plan(rijen, modus)
except Exception as e:
flash(f"Controleren mislukt: {e}", "error")
return terug
return render_template('database_eigendom.html', plan=plan, bestandsnaam=bestand.filename, onbekend=onbekend,
rijen_json=json.dumps(rijen, ensure_ascii=False), kolommen=ei.KOLOMMEN,
max_rijen=ei.MAX_RIJEN, aantal=None)
@database_bp.route('/eigendom/importeren', methods=['POST'])
@login_required
@recht_vereist('database_eigendom')
def eigendom_importeren():
terug = redirect(url_for('database_beheer.eigendom'))
modus = 'overschrijven' if request.form.get('modus') == 'overschrijven' else 'aanvullen'
try:
rijen = json.loads(request.form.get('rijen') or '[]')
if not isinstance(rijen, list) or len(rijen) > ei.MAX_RIJEN or not all(isinstance(r, dict) for r in rijen):
raise ValueError
except ValueError:
flash("De gegevens van het voorbeeld zijn ongeldig. Upload het bestand opnieuw.", "error")
return terug
try:
plan, nieuw, bijgewerkt = ei.voer_uit(rijen, modus, session.get('user'))
except Exception as e:
flash(f"Importeren mislukt, er is niets bewaard: {e}", "error")
return terug
t = plan['tellers']
log(f"Eigendom geïmporteerd uit Excel: {nieuw} nieuw, {bijgewerkt} bijgewerkt ({modus})")
ongewijzigd = f", {t['ongewijzigd']} ongewijzigd" if t['ongewijzigd'] else ""
flash(f"Import klaar: {nieuw} toekenning(en) toegevoegd, {bijgewerkt} bijgewerkt{ongewijzigd}.", "success")
if t['fout']:
flash(f"{t['fout']} rij(en) met een fout werden overgeslagen.", "warning")
return terug
+129 -26
View File
@@ -1,42 +1,31 @@
import io
import re
import zipfile
import qrcode
import pandas as pd
from flask import Blueprint, render_template, request, send_file, flash, redirect, url_for
from PIL import Image, ImageDraw, ImageFont
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
qr_code_bp = Blueprint('qr_generator', __name__, template_folder='templates')
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()
def _lettertype():
try:
# Try loading your local or system Arial font
font = ImageFont.truetype("arial.ttf", 20)
return ImageFont.truetype("arial.ttf", 20)
except IOError:
try:
font = ImageFont.load_default_font(size=20)
return ImageFont.load_default_font(size=20)
except AttributeError:
# 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.add_data(qr_text)
qr.make(fit=True)
@@ -53,30 +42,96 @@ def generate_qr_images(excel_file, show_top, show_bottom):
qr_y = padding
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)
text_w = bbox[2] - bbox[0]
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)
text_w = bbox[2] - bbox[0]
draw.text(((canvas.width - text_w) / 2, qr_y + qr_height + 5), bottom_text, fill="black", font=font)
img_io = io.BytesIO()
canvas.save(img_io, 'PNG')
img_io.seek(0)
zip_file.writestr(f"qr_{index+1}.png", img_io.getvalue())
return 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)
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('/')
@login_required
@recht_vereist('qr_codes')
def qr_generator():
return render_template('qr_code.html')
return render_template('qr_code.html', db_toegang=heeft_recht('toestellen_beheren'), db_velden=DB_VELDEN,
qr_velden=QR_VELDEN, statussen=STATUSSEN, max_db_codes=MAX_DB_CODES)
@qr_code_bp.route('/download-template')
@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'))
@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")
+42
View File
@@ -0,0 +1,42 @@
# =====================================================================
# PIVOT – algemene instellingen (gelden voor ALLE scholen)
#
# Alles wat per school verschilt (Smartschool, Informat, Google, Signpost,
# CO2, personeel, database van de school, ...) staat NIET meer hier, maar
# in het scholenregister: in PIVOT via 🏫 Scholen (superbeheerder) of
# 🔌 Koppelingen van deze school (beheerder van een school).
#
# Geheimen kan je ook als omgevingsvariabele meegeven i.p.v. hier:
# PIVOT_DB_PASSWORD, PIVOT_SSO_CLIENT_SECRET, FLASK_SECRET_KEY, PIVOT_SLEUTEL
# =====================================================================
[database]
# Server en standaardlogin: voor de centrale database én voor elke schooldatabase
# (per school te overschrijven in het scholenregister)
host = localhost
port = 3306
user = marijn
password =8778Zappa4861!
# Database met de lijst van scholen (wordt automatisch ingericht)
centrale_database = pivot
[google_sso]
# Gedeelde 'Inloggen met Google' voor alle scholen (OAuth-client van het type
# 'Webtoepassing', toestemmingsscherm 'Extern'). Leeg laten als elke school een
# eigen client gebruikt (in te stellen per school).
# Toegestane redirect-URI: https://<jouw-pivot-adres>/callback
client_id =
client_secret =
[algemeen]
app_version = 1.1.0
# Superbeheerders van de scholengroep (Google-accounts, komma-gescheiden):
# mogen scholen toevoegen/wijzigen en tussen scholen wisselen.
superbeheerders = ict@kamerksem.be
# Geheime sleutel voor sessies (liever via FLASK_SECRET_KEY)
secret_key =
debug = nee
# Elke bezochte pagina bewaren in de tabel Logs (ja/nee)
logboek = ja
# Aantal reverse proxies vóór PIVOT (nginx, IIS, firewall met https). 0 = PIVOT staat rechtstreeks op het net.
proxy_aantal = 0
+6
View File
@@ -0,0 +1,6 @@
[.ShellClassInfo]
IconResource=C:\Windows\System32\SHELL32.dll,33
[ViewState]
Mode=
Vid=
FolderType=Documents
+1
View File
@@ -88,6 +88,7 @@ ONDERDELEN = [
('nieuw_schooljaar', 'Nieuw schooljaar (accounts & toestellen)', 'Beheer'),
('ip_whitelist', 'IP-adressen toestaan voor deze school', 'Beheer'),
('database_toestellen', 'Database: toestellen importeren uit Excel', 'Beheer'),
('database_eigendom', 'Database: eigendom importeren uit Excel', 'Beheer'),
# --- PIVOT ---
('gebruikersbeheer', 'Gebruikers & rechten van deze school', 'PIVOT'),
('koppelingen', 'Koppelingen van deze school', 'PIVOT'),
+431
View File
@@ -0,0 +1,431 @@
"""
Beheer › Database › Eigendom: toekenningen toestel ↔ leerling (tabel Eigendom) aanvullen vanuit een Excel-bestand.
Zelfde werkwijze als de toestellen-import (services/toestellen_import.py):
1. lees_excel() : het geüploade bestand → ruwe rijen (enkel tekst / ISO-datums)
2. maak_plan() : ruwe rijen → per rij 'nieuw', 'bijwerken', 'ongewijzigd' of 'fout' (niets wordt bewaard)
3. voer_uit() : het plan in één transactie uitvoeren (het plan wordt opnieuw gemaakt, nooit blind vertrouwd)
Elke rij is één toekenning: een leerling (internnummer, Smartschool-nummer of gebruikersnaam) en een toestel
(toestelnummer of serienummer) die al in PIVOT bestaan, met een datum van toekenning en eventueel van inlevering.
- Zonder datum inlevering heeft de leerling het toestel nu (toestel → 'Uitgeleend').
- Met datum inlevering is het geschiedenis (de status van het toestel verandert niet).
- Bestaat dezelfde toekenning al (zelfde leerling, toestel en datum toekenning; of de huidige als die datum
leeg is), dan wordt enkel een lege datum inlevering aangevuld (= inleveren, toestel → 'Beschikbaar').
- Een toestel kan nooit tegelijk bij twee leerlingen zijn. Staat het nu bij een andere leerling, dan is dat
een fout, of (keuze 'overschrijven') wordt die toekenning eerst afgesloten op de nieuwe datum toekenning.
Er wordt nooit iets verwijderd.
"""
import io
from datetime import date, datetime
from database import execute_query, get_db_connection
from services.toestellen_import import ImportFout, _norm, _cel, _datum_tekst, _toon
MAX_RIJEN = 5000
# (sleutel, kolomkop in het sjabloon, max. lengte, uitleg, andere namen die ook herkend worden)
KOLOMMEN = [
('leerling', 'Leerling', 255,
"Internnummer, Smartschool-nummer of gebruikersnaam van een leerling die al in PIVOT staat. Verplicht.",
('internnummer', 'intern nummer', 'leerlingnummer', 'ss nummer', 'smartschool nummer', 'gebruikersnaam')),
('toestel', 'Toestel', 100,
"Toestelnummer (Schooleigen_ID) of serienummer van een toestel dat al in PIVOT staat. Verplicht.",
('toestelnummer', 'schooleigen id', 'kamxm nummer', 'kamxm', 'serienummer', 'serial', 'serial number')),
('datum_toekenning', 'Datum toekenning', None,
"Datum (dd-mm-jjjj) waarop de leerling het toestel kreeg. Leeg = vandaag "
"(verplicht als de datum inlevering ingevuld is).",
('toekenning', 'datum uitdeling', 'uitgedeeld', 'uitgedeeld op', 'toegekend op')),
('datum_inlevering', 'Datum inlevering', None,
"Datum (dd-mm-jjjj) waarop het toestel terug ingeleverd werd. Leeg = de leerling heeft het toestel nu.",
('inlevering', 'ingeleverd', 'ingeleverd op', 'datum inleveren')),
]
SLEUTELS = [k[0] for k in KOLOMMEN]
KOPPEN = {k[0]: k[1] for k in KOLOMMEN}
_HERKEN = {}
for _k, _kop, _m, _u, _alias in KOLOMMEN:
for _naam in (_k, _kop) + tuple(_alias):
_HERKEN[_norm(_naam)] = _k
# ---------------------------------------------------------------------------
# Sjabloon
# ---------------------------------------------------------------------------
def maak_sjabloon(schoolnaam=''):
"""Excel-sjabloon (bytes): tabblad Eigendom (in te vullen) en Uitleg."""
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.worksheet.datavalidation import DataValidation
wb = Workbook()
ws = wb.active
ws.title = 'Eigendom'
ws.append([k[1] for k in KOLOMMEN])
kop_vulling = PatternFill('solid', fgColor='1E6FA5')
for c in ws[1]:
c.font = Font(bold=True, color='FFFFFF')
c.fill = kop_vulling
c.alignment = Alignment(vertical='center')
breedtes = {'Leerling': 20, 'Toestel': 20, 'Datum toekenning': 18, 'Datum inlevering': 18}
for i, k in enumerate(KOLOMMEN):
letter = ws.cell(row=1, column=i + 1).column_letter
ws.column_dimensions[letter].width = breedtes.get(k[1], 16)
ws.freeze_panes = 'A2'
ws.row_dimensions[1].height = 22
kol = {k[0]: ws.cell(row=1, column=i + 1).column_letter for i, k in enumerate(KOLOMMEN)}
# Nummers als tekst (anders maakt Excel van 00123 → 123)
for sleutel in ('leerling', 'toestel'):
for r in range(2, MAX_RIJEN + 2):
ws[f"{kol[sleutel]}{r}"].number_format = '@'
for sleutel in ('datum_toekenning', 'datum_inlevering'):
for r in range(2, MAX_RIJEN + 2):
ws[f"{kol[sleutel]}{r}"].number_format = 'DD-MM-YYYY'
dv_datum = DataValidation(type='date', operator='greaterThan', formula1='1', allow_blank=True,
showErrorMessage=True, errorTitle=KOPPEN[sleutel],
error='Vul een datum in, bv. 01-09-2026.')
dv_datum.add(f"{kol[sleutel]}2:{kol[sleutel]}{MAX_RIJEN + 1}")
ws.add_data_validation(dv_datum)
# Uitleg
wu = wb.create_sheet('Uitleg')
wu.column_dimensions['A'].width = 18
wu.column_dimensions['B'].width = 95
wu.append([f"Eigendom importeren in PIVOT{' – ' + schoolnaam if schoolnaam else ''}"])
wu['A1'].font = Font(bold=True, size=14)
regels = [
"Vul het tabblad 'Eigendom' in: één toekenning (leerling + toestel) per rij, vanaf rij 2. "
"Laat de kolomkoppen in rij 1 staan.",
"De leerling en het toestel moeten al in PIVOT staan. Nieuwe toestellen voeg je eerst toe via "
"Database › Toestellen importeren.",
"Zonder datum inlevering heeft de leerling het toestel nu: het toestel krijgt de status Uitgeleend.",
"Met een datum inlevering wordt de toekenning als geschiedenis bewaard.",
"Bestaat de toekenning al, dan wordt enkel een lege datum inlevering aangevuld (= het toestel inleveren).",
"Een toestel kan niet tegelijk bij twee leerlingen zijn. Staat het nu bij een andere leerling, dan kies je bij "
"het uploaden of dat een fout is of dat die toekenning eerst afgesloten wordt.",
f"Er wordt nooit iets verwijderd. Je krijgt eerst een voorbeeld te zien. Maximaal {MAX_RIJEN} rijen per bestand.",
]
for t in regels:
wu.append(['', t])
wu.append([])
wu.append(['Kolom', 'Uitleg'])
for c in wu[wu.max_row]:
c.font = Font(bold=True)
for _k, kop, maxlen, uitleg, _a in KOLOMMEN:
wu.append([kop, uitleg])
for rij in wu.iter_rows(min_row=2):
for c in rij:
c.alignment = Alignment(wrap_text=True, vertical='top')
wu.append([])
wu.append(['Voorbeeld', 'Leerling 12345 · Toestel CB-0123 · Datum toekenning 01-09-2026 · Datum inlevering leeg'])
wb.active = 0
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
# ---------------------------------------------------------------------------
# Excel lezen
# ---------------------------------------------------------------------------
def lees_excel(bestand):
"""Leest een .xlsx (bestandsobject of bytes). Geeft (rijen, onbekende_kolommen).
rijen = [{'rij': <rijnummer in Excel>, 'leerling': '...', ...}] (enkel niet-lege rijen)."""
from openpyxl import load_workbook
data = bestand if isinstance(bestand, (bytes, bytearray)) else bestand.read()
if not data:
raise ImportFout("Het bestand is leeg.")
try:
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
except Exception:
raise ImportFout("Dit bestand kan niet gelezen worden. Gebruik een Excel-bestand (.xlsx), "
"bv. het sjabloon van deze pagina.")
try:
ws = wb['Eigendom'] if 'Eigendom' in wb.sheetnames else wb.worksheets[0]
rijen_iter = ws.iter_rows(values_only=True)
# Kopregel zoeken in de eerste 10 rijen: de eerste rij met een herkende kolom
kop_index, onbekend, rijnr = None, [], 0
for rij in rijen_iter:
rijnr += 1
gevonden = {i: _HERKEN[_norm(v)] for i, v in enumerate(rij or ()) if _norm(v) in _HERKEN}
if gevonden:
kop_index = {}
for i, sleutel in gevonden.items():
kop_index.setdefault(sleutel, i) # dubbele kolom: de eerste telt
onbekend = [_cel(v) for i, v in enumerate(rij) if _cel(v) and i not in gevonden]
break
if rijnr >= 10:
break
if not kop_index:
raise ImportFout("Geen kolomkoppen gevonden. Rij 1 moet de kolomnamen van het sjabloon bevatten "
f"({', '.join(KOPPEN.values())}).")
ontbreekt = [KOPPEN[s] for s in ('leerling', 'toestel') if s not in kop_index]
if ontbreekt:
raise ImportFout(f"De kolom {' en '.join(ontbreekt)} ontbreekt.")
uit = []
for rij in rijen_iter:
rijnr += 1
waarden = {s: _cel(rij[i]) if rij and i < len(rij) else '' for s, i in kop_index.items()}
if not any(waarden.values()):
continue
if len(uit) >= MAX_RIJEN:
raise ImportFout(f"Het bestand bevat meer dan {MAX_RIJEN} rijen. Splits het op in kleinere bestanden.")
uit.append({'rij': rijnr, **{s: waarden.get(s, '') for s in SLEUTELS}})
return uit, onbekend
finally:
wb.close()
# ---------------------------------------------------------------------------
# Plan maken (niets bewaren)
# ---------------------------------------------------------------------------
def _d(w):
return w.date() if isinstance(w, datetime) else w
def _overlapt(begin1, einde1, begin2, einde2):
"""Overlappen twee periodes? Einde None = nog niet ingeleverd. Inleveren en opnieuw uitdelen op dezelfde dag mag."""
return (einde2 is None or begin1 < einde2) and (einde1 is None or begin2 < einde1)
def _naam(l):
naam = ' '.join(x for x in (l.get('Voornaam'), l.get('Naam')) if x)
return f"{naam} ({l['Internnummer']})" if naam else l['Internnummer']
def _toestel_label(t):
return t.get('Schooleigen_ID') or t.get('Serienummer') or str(t['Toestel_ID'])
def maak_plan(rijen, modus='aanvullen', vandaag=None):
"""Bepaalt per rij wat er zou gebeuren. modus: 'aanvullen' (toestel bij een andere leerling = fout) of
'overschrijven' (die toekenning eerst afsluiten; ook een andere datum inlevering wordt dan overschreven).
Geeft {'rijen': [...], 'tellers': {...}}; elke rij heeft 'actie', 'meldingen', 'wijzigingen' en wat er in de
database moet gebeuren ('waarden', 'eigendom_id', 'sluit', 'toestel_status')."""
overschrijven = modus == 'overschrijven'
vandaag = vandaag or date.today()
# Leerlingen: internnummer gaat voor Smartschool-nummer, dat gaat voor gebruikersnaam
leerlingen = execute_query("SELECT Internnummer, SS_nummer, Gebruikersnaam, Naam, Voornaam, Vertrokken_op "
"FROM Leerlingen") or []
op_leerling = {}
for veld in ('Gebruikersnaam', 'SS_nummer', 'Internnummer'):
for l in leerlingen:
w = str(l[veld] or '').strip().upper()
if w:
op_leerling[w] = l
toestellen = [dict(t) for t in execute_query("SELECT * FROM Toestellen") or []]
op_nummer = {(t['Schooleigen_ID'] or '').strip().upper(): t for t in toestellen if (t['Schooleigen_ID'] or '').strip()}
op_serie = {}
for t in toestellen:
s = (t['Serienummer'] or '').strip().upper()
if s:
op_serie.setdefault(s, t)
toestel_op_id = {t['Toestel_ID']: t for t in toestellen}
# Bestaande toekenningen per toestel (kopie: het plan past ze aan zodat latere rijen ermee rekening houden)
per_toestel, actief_per_leerling = {}, {}
for e in execute_query("SELECT Eigendom_ID, Internnummer, Toestel_ID, Datum_toekenning, Datum_inlevering "
"FROM Eigendom") or []:
e = {**e, 'Datum_toekenning': _d(e['Datum_toekenning']), 'Datum_inlevering': _d(e['Datum_inlevering']),
'rij': None}
per_toestel.setdefault(e['Toestel_ID'], []).append(e)
if e['Datum_inlevering'] is None:
actief_per_leerling.setdefault(e['Internnummer'], set()).add(e['Toestel_ID'])
uit = []
tellers = {'nieuw': 0, 'bijwerken': 0, 'ongewijzigd': 0, 'fout': 0}
for invoer in rijen:
r = {'rij': invoer.get('rij'), 'invoer': {s: str(invoer.get(s) or '').strip() for s in SLEUTELS},
'meldingen': [], 'wijzigingen': [], 'actie': None, 'label': None,
'waarden': {}, 'toestel_id': None, 'eigendom_id': None, 'sluit': None, 'toestel_status': None}
v = r['invoer']
fouten = []
# --- waarden controleren ---
leerling = toestel = None
if not v['leerling']:
fouten.append("Leerling is verplicht.")
else:
leerling = op_leerling.get(v['leerling'].upper())
if not leerling:
fouten.append(f"Leerling '{v['leerling']}' staat niet in PIVOT.")
if not v['toestel']:
fouten.append("Toestel is verplicht.")
else:
toestel = op_nummer.get(v['toestel'].upper()) or op_serie.get(v['toestel'].upper())
if not toestel:
fouten.append(f"Toestel '{v['toestel']}' staat niet in PIVOT. Voeg het eerst toe via "
f"Database › Toestellen.")
begin = einde = None
for s in ('datum_toekenning', 'datum_inlevering'):
if v[s]:
try:
d = _datum_tekst(v[s])
except ValueError:
fouten.append(f"{KOPPEN[s]} '{v[s]}' is geen geldige datum (gebruik dd-mm-jjjj).")
continue
if d > vandaag:
fouten.append(f"{KOPPEN[s]} {_toon(d)} ligt in de toekomst.")
elif s == 'datum_toekenning':
begin = d
else:
einde = d
if v['datum_inlevering'] and not v['datum_toekenning']:
fouten.append("Datum toekenning is verplicht als de datum inlevering ingevuld is.")
if begin and einde and einde < begin:
fouten.append("Datum inlevering ligt vóór de datum toekenning.")
if not fouten:
intern = leerling['Internnummer']
tid = r['toestel_id'] = toestel['Toestel_ID']
r['label'] = f"{_naam(leerling)} · toestel {_toestel_label(toestel)}"
lijst = per_toestel.setdefault(tid, [])
# --- bestaat deze toekenning al? ---
if begin:
bestaand = next((e for e in lijst if e['Internnummer'] == intern and e['Datum_toekenning'] == begin),
None)
else:
bestaand = next((e for e in lijst if e['Internnummer'] == intern and e['Datum_inlevering'] is None),
None)
if not bestaand and not einde:
# de leerling heeft dit toestel al (sinds een andere datum)
bestaand = next((e for e in lijst if e['Internnummer'] == intern and e['Datum_inlevering'] is None),
None)
if bestaand:
if bestaand['rij']:
fouten.append(f"Deze toekenning staat al op rij {bestaand['rij']}.")
elif einde and einde != bestaand['Datum_inlevering'] and (bestaand['Datum_inlevering'] is None
or overschrijven):
andere = [e for e in lijst if e is not bestaand]
botsing = next((e for e in andere if _overlapt(bestaand['Datum_toekenning'], einde,
e['Datum_toekenning'], e['Datum_inlevering'])), None)
if botsing:
fouten.append(f"Het toestel was vanaf {_toon(botsing['Datum_toekenning'])} al bij "
f"leerling {botsing['Internnummer']}.")
else:
r['actie'] = 'bijwerken'
r['eigendom_id'] = bestaand['Eigendom_ID']
r['waarden'] = {'Datum_inlevering': einde}
r['wijzigingen'].append(f"Datum inlevering: {_toon(bestaand['Datum_inlevering']) or '–'} "
f"→ {_toon(einde)}")
if bestaand['Datum_inlevering'] is None:
actief_per_leerling.get(intern, set()).discard(tid)
if toestel['Status'] == 'Uitgeleend':
r['toestel_status'] = 'Beschikbaar'
r['wijzigingen'].append("Toestel: Uitgeleend → Beschikbaar")
toestel['Status'] = 'Beschikbaar'
bestaand['Datum_inlevering'] = einde
bestaand['rij'] = r['rij']
else:
r['actie'] = 'ongewijzigd'
if einde and bestaand['Datum_inlevering'] and einde != bestaand['Datum_inlevering']:
r['meldingen'].append(f"Niet overschreven: datum inlevering "
f"({_toon(bestaand['Datum_inlevering'])}).")
elif bestaand['Datum_inlevering'] is None:
r['meldingen'].append(f"Heeft dit toestel al sinds {_toon(bestaand['Datum_toekenning'])}.")
else:
r['meldingen'].append("Deze toekenning staat al in PIVOT.")
# --- nieuwe toekenning ---
else:
begin = begin or vandaag
sluit = None
botsingen = [e for e in lijst if _overlapt(begin, einde, e['Datum_toekenning'], e['Datum_inlevering'])]
if (overschrijven and not einde and len(botsingen) == 1 and botsingen[0]['Datum_inlevering'] is None
and botsingen[0]['Datum_toekenning'] <= begin and botsingen[0]['rij'] is None):
sluit = botsingen.pop()
for e in botsingen:
waar = f"op rij {e['rij']}" if e['rij'] else "in PIVOT"
if e['Datum_inlevering'] is None:
tip = '' if overschrijven or e['rij'] else ' Kies "Vorige toekenning afsluiten" of lever het eerst in.'
fouten.append(f"Het toestel staat {waar} sinds {_toon(e['Datum_toekenning'])} bij leerling "
f"{e['Internnummer']}.{tip}")
else:
fouten.append(f"Het toestel was {waar} van {_toon(e['Datum_toekenning'])} tot "
f"{_toon(e['Datum_inlevering'])} bij leerling {e['Internnummer']}.")
break
if not fouten:
r['actie'] = 'nieuw'
r['waarden'] = {'Internnummer': intern, 'Toestel_ID': tid,
'Status': 1 if int(toestel.get('Geblokkeerd') or 0) else 0,
'Datum_toekenning': begin, 'Datum_inlevering': einde}
if not v['datum_toekenning']:
r['wijzigingen'].append(f"Datum toekenning: vandaag ({_toon(begin)})")
if sluit:
r['sluit'] = {'eigendom_id': sluit['Eigendom_ID'], 'datum': begin}
r['wijzigingen'].append(f"Vorige toekenning bij leerling {sluit['Internnummer']} afgesloten "
f"op {_toon(begin)}")
actief_per_leerling.get(sluit['Internnummer'], set()).discard(tid)
sluit['Datum_inlevering'] = begin
sluit['rij'] = r['rij']
if not einde:
andere = [toestel_op_id[t] for t in actief_per_leerling.get(intern, ()) if t in toestel_op_id]
if andere:
r['meldingen'].append("Heeft ook nog toestel " +
', '.join(_toestel_label(t) for t in andere) + '.')
actief_per_leerling.setdefault(intern, set()).add(tid)
if toestel['Status'] != 'Uitgeleend':
r['toestel_status'] = 'Uitgeleend'
r['wijzigingen'].append(f"Toestel: {toestel['Status'] or '–'} → Uitgeleend")
toestel['Status'] = 'Uitgeleend'
lijst.append({'Eigendom_ID': None, 'Internnummer': intern, 'Toestel_ID': tid,
'Datum_toekenning': begin, 'Datum_inlevering': einde, 'rij': r['rij']})
if not fouten and leerling.get('Vertrokken_op'):
r['meldingen'].append(f"Let op: leerling vertrokken op {_toon(_d(leerling['Vertrokken_op']))}.")
if fouten:
r['actie'] = 'fout'
r['meldingen'] = fouten
r['wijzigingen'] = []
r['waarden'], r['sluit'], r['toestel_status'], r['eigendom_id'] = {}, None, None, None
tellers[r['actie']] += 1
uit.append(r)
return {'rijen': uit, 'tellers': tellers, 'modus': 'overschrijven' if overschrijven else 'aanvullen'}
# ---------------------------------------------------------------------------
# Uitvoeren
# ---------------------------------------------------------------------------
def voer_uit(rijen, modus, gebruiker=None):
"""Maakt het plan opnieuw en voert 'nieuw' en 'bijwerken' uit in één transactie.
Geeft (plan, aantal_nieuw, aantal_bijgewerkt). Bij een databasefout wordt niets bewaard."""
plan = maak_plan(rijen, modus)
conn = get_db_connection()
cur = conn.cursor()
nieuw = bijgewerkt = 0
try:
conn.start_transaction()
for r in plan['rijen']:
if r['actie'] not in ('nieuw', 'bijwerken'):
continue
if r['sluit']:
cur.execute("UPDATE Eigendom SET Datum_inlevering = %s WHERE Eigendom_ID = %s",
(r['sluit']['datum'], r['sluit']['eigendom_id']))
w = r['waarden']
if r['actie'] == 'nieuw':
kolommen = list(w.keys())
cur.execute(f"INSERT INTO Eigendom ({', '.join(kolommen)}) "
f"VALUES ({', '.join(['%s'] * len(kolommen))})", tuple(w[k] for k in kolommen))
nieuw += 1
else:
cur.execute("UPDATE Eigendom SET Datum_inlevering = %s WHERE Eigendom_ID = %s",
(w['Datum_inlevering'], r['eigendom_id']))
bijgewerkt += 1
if r['toestel_status']:
cur.execute("UPDATE Toestellen SET Status = %s WHERE Toestel_ID = %s",
(r['toestel_status'], r['toestel_id']))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
cur.close()
conn.close()
return plan, nieuw, bijgewerkt
+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-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;
--page-width: 1200px;
--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 ---------- */
*, *::before, *::after { box-sizing: border-box; }
@@ -84,7 +158,7 @@ a { color: var(--primary-color); }
align-items: center;
gap: 16px;
padding: 0 24px;
background: var(--primary-color);
background: var(--header-bg);
color: #fff;
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: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); }
.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 ---------- */
.app-main {
flex: 1;
@@ -187,7 +280,7 @@ select, textarea {
font: inherit;
font-size: 15px;
color: var(--text);
background: #fff;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
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: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-success { background: var(--success); }
.btn-success:hover { background: #15803d; }
@@ -291,7 +384,7 @@ button:disabled, .btn:disabled { opacity: .5; cursor: not-allowed; }
border-radius: var(--radius);
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 {
background: var(--surface-muted);
@@ -299,7 +392,7 @@ th {
color: var(--text-muted); white-space: nowrap;
}
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 form { display: inline; margin: 0; }
td.actions > * + * { margin-left: 6px; }
@@ -324,7 +417,7 @@ td.center, th.center { text-align: center; }
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 10px; border-radius: 999px;
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-warning { background: var(--warning-bg); color: var(--warning-text); }
@@ -448,7 +541,7 @@ td.center, th.center { text-align: center; }
position: fixed; inset: 0;
z-index: 9999;
flex-direction: column; justify-content: center; align-items: center; gap: 16px;
background: rgba(255, 255, 255, .85);
background: var(--overlay);
backdrop-filter: blur(2px);
}
.spinner {
@@ -563,6 +656,8 @@ body.auth-page { background: linear-gradient(160deg, #eaf2f9 0%, var(--backgroun
/* ---------- 17. Responsief ---------- */
@media (max-width: 900px) {
.brand-school, .header-user { display: none; }
.user-menu-btn { display: inline-flex; }
.user-menu-btn .user-naam { display: none; }
}
@media (max-width: 720px) {
.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>
/* Bewerken-knop altijd zichtbaar: laatste kolom blijft rechts staan bij horizontaal scrollen */
.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);
}
.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.detail td { background: var(--surface-muted); padding: 10px 14px 14px 44px; }
.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; }
.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;
+51 -3
View File
@@ -4,10 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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="stylesheet" href="{{ url_for('static', filename='global.css') }}">
{% block head %}{% endblock %}
<style>.header-school { display: inline-block; max-width: 200px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; }</style>
<style>.header-school { display: inline-block; min-width: 0; flex-shrink: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; vertical-align: middle; }</style>
{% block extra_css %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
@@ -44,7 +48,7 @@
onchange="if (this.value) location.href = this.value">
<option value="" selected>🏫 {{ school.naam }} ⇄</option>
{% 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 %}
</select>
{% else %}
@@ -52,7 +56,22 @@
{% endif %}
{% endif %}
{% 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 %}
<form action="{{ url_for('auth.logout') }}" method="get">
<button type="submit" class="logout-btn">Uitloggen</button>
@@ -134,6 +153,35 @@
});
}, 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
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
+4 -3
View File
@@ -210,7 +210,7 @@
{% endif %}
</div>
{% if mag('co2', 'koppelingen', 'gebruikersbeheer', 'nieuw_schooljaar', 'ip_whitelist', 'database_toestellen') or is_admin or is_superbeheerder %}
{% if mag('co2', 'koppelingen', 'gebruikersbeheer', 'nieuw_schooljaar', 'ip_whitelist', 'database_toestellen', 'database_eigendom') or is_admin or is_superbeheerder %}
<div class="dash-section-title admin">⚙️ Beheer</div>
<div class="tile-grid">
@@ -255,11 +255,12 @@
</section>
{% endif %}
{% if mag('database_toestellen') %}
{% if mag('database_toestellen', 'database_eigendom') %}
<section class="tile tile-admin">
<div class="tile-head"><span class="tile-icon">🗄️</span><h3>Database</h3></div>
<ul class="tile-links">
<li><a href="{{ url_for('database_beheer.toestellen') }}">💻 Toestellen importeren (Excel)</a></li>
{% if mag('database_toestellen') %}<li><a href="{{ url_for('database_beheer.toestellen') }}">💻 Toestellen importeren (Excel)</a></li>{% endif %}
{% if mag('database_eigendom') %}<li><a href="{{ url_for('database_beheer.eigendom') }}">🎒 Eigendom importeren (Excel)</a></li>{% endif %}
</ul>
</section>
{% endif %}
+162
View File
@@ -0,0 +1,162 @@
{% extends "base.html" %}
{% block title %}Eigendom importeren{% endblock %}
{% block page_title %}Database › Eigendom{% endblock %}
{% block loader_text %}Bestand controleren...{% endblock %}
{% block extra_css %}
<style>
.stappen { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
.stappen > .card + .card { margin-top: 0; }
@media (max-width: 860px) { .stappen { grid-template-columns: 1fr; } }
.stap-nr { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 50%;
background: var(--primary-color); color: #fff; font-size: 14px; margin-right: 6px; }
.kolom-lijst { margin: 10px 0 0; padding: 0; list-style: none; font-size: 14px; }
.kolom-lijst li { padding: 6px 0; border-bottom: 1px solid var(--border); }
.kolom-lijst li:last-child { border-bottom: 0; }
.kolom-lijst strong { display: inline-block; min-width: 120px; }
.modus-keuze { display: grid; gap: 8px; margin: 4px 0 16px; }
.modus-keuze label { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--border);
border-radius: var(--radius-sm); font-weight: 500; cursor: pointer; background: var(--surface-muted); }
.modus-keuze label:has(input:checked) { border-color: var(--primary-color); background: var(--primary-light); }
.modus-keuze input { margin-top: 2px; flex-shrink: 0; }
.modus-keuze small { display: block; color: var(--text-muted); font-weight: 400; }
.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;
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 .n { font-size: 18px; }
tr.rij-fout td { background: #fff7f7; }
td.melding { font-size: 13px; color: var(--text-muted); min-width: 220px; }
td.melding .fout { color: var(--danger-text); }
td.melding .wijz { color: var(--text); }
.bevestig { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: space-between; margin-top: 16px; }
</style>
{% endblock %}
{% block content %}
{% if not plan %}
<div class="stappen">
<div class="card">
<h2 class="card-title"><span class="stap-nr">1</span>Sjabloon downloaden</h2>
<p class="muted">Vul het sjabloon in met één toekenning (leerling + toestel) per rij. Leerling en toestel moeten al
in PIVOT staan{% if mag('database_toestellen') %}; nieuwe toestellen voeg je eerst toe via
<a href="{{ url_for('database_beheer.toestellen') }}">Toestellen importeren</a>{% endif %}.</p>
<a class="btn" href="{{ url_for('database_beheer.eigendom_sjabloon') }}">⬇️ Sjabloon downloaden (.xlsx)</a>
<ul class="kolom-lijst">
{% for k in kolommen %}
<li><strong>{{ k[1] }}</strong> <span class="muted">{{ k[3] }}</span></li>
{% endfor %}
</ul>
</div>
<div class="card">
<h2 class="card-title"><span class="stap-nr">2</span>Excel uploaden</h2>
<p class="muted">Je krijgt eerst een voorbeeld te zien. Er wordt pas iets bewaard als je bevestigt.
{% if aantal is not none %}Er zijn nu <strong>{{ aantal }}</strong> toestellen uitgeleend.{% endif %}</p>
<form method="POST" action="{{ url_for('database_beheer.eigendom_controleren') }}" enctype="multipart/form-data"
onsubmit="showLoader('Bestand controleren...')">
<div class="field">
<label for="bestand">Excel-bestand (.xlsx)</label>
<input type="file" id="bestand" name="bestand" accept=".xlsx,.xlsm,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" required>
<div class="field-hint">Max. {{ max_rijen }} rijen. Het tabblad <em>Eigendom</em> wordt gelezen (of anders het eerste tabblad).</div>
</div>
<label>Toestel staat nu bij een andere leerling</label>
<div class="modus-keuze">
<label><input type="radio" name="modus" value="aanvullen" checked>
<span>Als fout tonen <small>De rij wordt overgeslagen; de huidige toekenning blijft staan. (Aanbevolen)</small></span></label>
<label><input type="radio" name="modus" value="overschrijven">
<span>Vorige toekenning afsluiten <small>Het toestel wordt bij de vorige leerling ingeleverd op de datum toekenning uit Excel. Ook een andere datum inlevering wordt dan overschreven.</small></span></label>
</div>
<button type="submit">🔎 Controleren</button>
</form>
</div>
</div>
{% else %}
{% set t = plan.tellers %}
<div class="card">
<div class="page-head">
<h2>🔎 Voorbeeld: {{ bestandsnaam }}</h2>
<a class="btn btn-light" href="{{ url_for('database_beheer.eigendom') }}">↩️ Ander bestand</a>
</div>
<p class="muted" style="margin-top:-8px">Nog niets bewaard. Toestel bij een andere leerling:
<strong>{{ 'vorige toekenning afsluiten' if plan.modus == 'overschrijven' else 'als fout tonen' }}</strong>.</p>
{% if onbekend %}
<div class="alert alert-warning">Deze kolommen worden genegeerd: {{ onbekend|join(', ') }}.</div>
{% endif %}
<div class="tellers" role="group" aria-label="Filter">
<button type="button" class="teller-knop actief" data-filter="">Alle <span class="n">{{ plan.rijen|length }}</span></button>
<button type="button" class="teller-knop" data-filter="nieuw"><span class="badge badge-success">➕ Nieuw</span> <span class="n">{{ t.nieuw }}</span></button>
<button type="button" class="teller-knop" data-filter="bijwerken"><span class="badge badge-info">✏️ Bijwerken</span> <span class="n">{{ t.bijwerken }}</span></button>
<button type="button" class="teller-knop" data-filter="ongewijzigd"><span class="badge">= Ongewijzigd</span> <span class="n">{{ t.ongewijzigd }}</span></button>
<button type="button" class="teller-knop" data-filter="fout"><span class="badge badge-danger">⚠️ Fout</span> <span class="n">{{ t.fout }}</span></button>
</div>
<div class="table-wrap">
<table id="voorbeeld">
<thead>
<tr>
<th>Rij</th><th>Actie</th>
{% for k in kolommen %}<th>{{ k[1] }}</th>{% endfor %}
<th>Wat gebeurt er</th>
</tr>
</thead>
<tbody>
{% for r in plan.rijen %}
<tr data-actie="{{ r.actie }}" class="{{ 'rij-fout' if r.actie == 'fout' }}">
<td class="muted">{{ r.rij }}</td>
<td>
{% if r.actie == 'nieuw' %}<span class="badge badge-success">➕ Nieuw</span>
{% elif r.actie == 'bijwerken' %}<span class="badge badge-info">✏️ Bijwerken</span>
{% elif r.actie == 'ongewijzigd' %}<span class="badge">= Ongewijzigd</span>
{% else %}<span class="badge badge-danger">⚠️ Fout</span>{% endif %}
</td>
{% for k in kolommen %}<td class="{{ 'mono' if k[0] in ('leerling', 'toestel') }}">{{ r.invoer[k[0]] }}</td>{% endfor %}
<td class="melding">
{% if r.actie == 'fout' %}{% for m in r.meldingen %}<div class="fout">{{ m }}</div>{% endfor %}
{% else %}
{% if r.label %}<div>{{ r.label }}</div>{% endif %}
{% if r.actie == 'nieuw' %}<div class="wijz">Wordt toegevoegd{{ ' als geschiedenis' if r.invoer.datum_inlevering }}.</div>{% endif %}
{% for w in r.wijzigingen %}<div class="wijz">{{ w }}</div>{% endfor %}
{% for m in r.meldingen %}<div>{{ m }}</div>{% endfor %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% set te_doen = t.nieuw + t.bijwerken %}
<form method="POST" action="{{ url_for('database_beheer.eigendom_importeren') }}" class="bevestig"
onsubmit="return confirm({{ ('Nu ' ~ t.nieuw ~ ' toekenning(en) toevoegen en ' ~ t.bijwerken ~ ' bijwerken?' ~ (' De ' ~ t.fout ~ ' rij(en) met een fout worden overgeslagen.' if t.fout else ''))|tojson|forceescape }}) && (showLoader('Importeren...'), true)">
<input type="hidden" name="modus" value="{{ plan.modus }}">
<input type="hidden" name="rijen" value="{{ rijen_json }}">
<span class="muted">
{% if te_doen %}{{ t.nieuw }} nieuw en {{ t.bijwerken }} bij te werken toekenning(en).
{% if t.fout %}Rijen met een fout worden overgeslagen; verbeter ze in Excel en upload opnieuw.{% endif %}
{% else %}Er is niets te importeren.{% endif %}
</span>
<span style="display:flex;gap:8px">
<a class="btn btn-light" href="{{ url_for('database_beheer.eigendom') }}">Annuleren</a>
<button type="submit" class="btn-success" {{ 'disabled' if not te_doen }}>✅ Importeren</button>
</span>
</form>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// Voorbeeld filteren op actie (nieuw / bijwerken / ongewijzigd / fout)
document.querySelectorAll('.teller-knop').forEach(knop => knop.addEventListener('click', () => {
document.querySelectorAll('.teller-knop').forEach(k => k.classList.toggle('actief', k === knop));
const f = knop.dataset.filter;
document.querySelectorAll('#voorbeeld tbody tr').forEach(tr => {
tr.style.display = !f || tr.dataset.actie === f ? '' : 'none';
});
}));
</script>
{% endblock %}
+1 -1
View File
@@ -23,7 +23,7 @@
.modus-keuze small { display: block; color: var(--text-muted); font-weight: 400; }
.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;
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 .n { font-size: 18px; }
tr.rij-fout td { background: #fff7f7; }
+1 -1
View File
@@ -23,7 +23,7 @@
#personeelBody tr.g-account td { background: #e8f7ee; }
#personeelBody tr.g-alias td { background: #fff1e0; }
#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 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; }
+1 -1
View File
@@ -11,7 +11,7 @@
.nav-bar form { display: flex; gap: 6px; margin: 0; }
.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 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; }
.day { margin-bottom: 20px; }
.day-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 10px; }
+3 -3
View File
@@ -8,18 +8,18 @@
{% block extra_css %}
<style>
.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; }
.grid2 { display: grid; grid-template-columns: 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; } }
.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); }
.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 { 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.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; }
+55 -1
View File
@@ -26,7 +26,7 @@
<div class="page-head">
<div>
<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>
@@ -50,4 +50,58 @@
</li>
</ol>
</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 %}
+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; }
.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 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; }
.exception-active { margin-top: 14px; }
.pick-list { list-style: none; margin: 10px 0 0; padding: 0; }