Compare commits
10
Commits
715740428d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beaa5b2e5c | ||
|
|
8620dfb916 | ||
|
|
91a5b8a7f3 | ||
|
|
8b7fa3f716 | ||
|
|
684db2ec82 | ||
|
|
857f9c8f8b | ||
|
|
a85eff883e | ||
|
|
45d0b7f04a | ||
|
|
2b4e8de681 | ||
|
|
a347c99274 |
No files matched your search
@@ -118,6 +118,7 @@ python beheer.py rechten jan sg-antwerpen alles | geen
|
|||||||
| `koppelingen` | Koppelingen van deze school |
|
| `koppelingen` | Koppelingen van deze school |
|
||||||
| `gebruikersbeheer` | Gebruikers & rechten van deze school |
|
| `gebruikersbeheer` | Gebruikers & rechten van deze school |
|
||||||
| `database_toestellen` | Beheer › Database: toestellen importeren uit Excel |
|
| `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`.
|
Een nieuw onderdeel voeg je toe in `rechten.py › ONDERDELEN`. Bij het opstarten komt het automatisch in de tabel `Onderdelen`.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Toestellen:
|
|||||||
GET /beheer/database/toestellen/sjabloon.xlsx → sjabloon downloaden
|
GET /beheer/database/toestellen/sjabloon.xlsx → sjabloon downloaden
|
||||||
POST /beheer/database/toestellen/controleren → Excel lezen en voorbeeld tonen (niets bewaard)
|
POST /beheer/database/toestellen/controleren → Excel lezen en voorbeeld tonen (niets bewaard)
|
||||||
POST /beheer/database/toestellen/importeren → bevestigen: het voorbeeld uitvoeren
|
POST /beheer/database/toestellen/importeren → bevestigen: het voorbeeld uitvoeren
|
||||||
|
|
||||||
|
Eigendom (toekenningen toestel ↔ leerling): dezelfde vier routes onder /beheer/database/eigendom
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ from flask import Blueprint, render_template, request, Response, redirect, url_f
|
|||||||
from blueprints.auth import login_required
|
from blueprints.auth import login_required
|
||||||
from database import execute_query
|
from database import execute_query
|
||||||
from rechten import recht_vereist, log
|
from rechten import recht_vereist, log
|
||||||
|
from services import eigendom_import as ei
|
||||||
from services import toestellen_import as ti
|
from services import toestellen_import as ti
|
||||||
from tenancy import huidige_school
|
from tenancy import huidige_school
|
||||||
|
|
||||||
@@ -98,3 +101,86 @@ def toestellen_importeren():
|
|||||||
if t['fout']:
|
if t['fout']:
|
||||||
flash(f"{t['fout']} rij(en) met een fout werden overgeslagen.", "warning")
|
flash(f"{t['fout']} rij(en) met een fout werden overgeslagen.", "warning")
|
||||||
return terug
|
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
|
||||||
+147
-44
@@ -1,31 +1,68 @@
|
|||||||
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 _lettertype():
|
||||||
|
try:
|
||||||
|
# Try loading your local or system Arial font
|
||||||
|
return ImageFont.truetype("arial.ttf", 20)
|
||||||
|
except IOError:
|
||||||
|
try:
|
||||||
|
return ImageFont.load_default_font(size=20)
|
||||||
|
except AttributeError:
|
||||||
|
# Absolute fallback if Pillow is outdated, but textbbox WILL fail if this hits
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
|
||||||
|
|
||||||
|
qr_width, qr_height = qr_img.size
|
||||||
|
padding = 40
|
||||||
|
total_height = qr_height + (2 * padding)
|
||||||
|
|
||||||
|
canvas = Image.new("RGB", (qr_width + 40, total_height + 40), "white")
|
||||||
|
draw = ImageDraw.Draw(canvas)
|
||||||
|
|
||||||
|
qr_x = (canvas.width - qr_width) // 2
|
||||||
|
qr_y = padding
|
||||||
|
canvas.paste(qr_img, (qr_x, qr_y))
|
||||||
|
|
||||||
|
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 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')
|
||||||
|
return img_io.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def generate_qr_images(excel_file, show_top, show_bottom):
|
def generate_qr_images(excel_file, show_top, show_bottom):
|
||||||
# Overslaan van de header rij
|
# Overslaan van de header rij
|
||||||
df = pd.read_excel(excel_file, header=0)
|
df = pd.read_excel(excel_file, header=0)
|
||||||
|
|
||||||
zip_buffer = io.BytesIO()
|
zip_buffer = io.BytesIO()
|
||||||
|
font = _lettertype()
|
||||||
try:
|
|
||||||
# Try loading your local or system Arial font
|
|
||||||
font = ImageFont.truetype("arial.ttf", 20)
|
|
||||||
except IOError:
|
|
||||||
try:
|
|
||||||
|
|
||||||
font = 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()
|
|
||||||
|
|
||||||
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zip_file:
|
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
for index, row in df.iterrows():
|
for index, row in df.iterrows():
|
||||||
@@ -37,46 +74,64 @@ def generate_qr_images(excel_file, show_top, show_bottom):
|
|||||||
if qr_text.strip() == "":
|
if qr_text.strip() == "":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_H)
|
png = _qr_png(qr_text, top_text if show_top else "", bottom_text if show_bottom else "", font)
|
||||||
qr.add_data(qr_text)
|
zip_file.writestr(f"qr_{index+1}.png", png)
|
||||||
qr.make(fit=True)
|
|
||||||
qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
|
|
||||||
|
|
||||||
qr_width, qr_height = qr_img.size
|
|
||||||
padding = 40
|
|
||||||
total_height = qr_height + (2 * padding)
|
|
||||||
|
|
||||||
canvas = Image.new("RGB", (qr_width + 40, total_height + 40), "white")
|
|
||||||
draw = ImageDraw.Draw(canvas)
|
|
||||||
|
|
||||||
qr_x = (canvas.width - qr_width) // 2
|
|
||||||
qr_y = padding
|
|
||||||
canvas.paste(qr_img, (qr_x, qr_y))
|
|
||||||
|
|
||||||
if show_top and 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:
|
|
||||||
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())
|
|
||||||
|
|
||||||
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")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[.ShellClassInfo]
|
||||||
|
IconResource=C:\Windows\System32\SHELL32.dll,33
|
||||||
|
[ViewState]
|
||||||
|
Mode=
|
||||||
|
Vid=
|
||||||
|
FolderType=Documents
|
||||||
@@ -88,6 +88,7 @@ ONDERDELEN = [
|
|||||||
('nieuw_schooljaar', 'Nieuw schooljaar (accounts & toestellen)', 'Beheer'),
|
('nieuw_schooljaar', 'Nieuw schooljaar (accounts & toestellen)', 'Beheer'),
|
||||||
('ip_whitelist', 'IP-adressen toestaan voor deze school', 'Beheer'),
|
('ip_whitelist', 'IP-adressen toestaan voor deze school', 'Beheer'),
|
||||||
('database_toestellen', 'Database: toestellen importeren uit Excel', 'Beheer'),
|
('database_toestellen', 'Database: toestellen importeren uit Excel', 'Beheer'),
|
||||||
|
('database_eigendom', 'Database: eigendom importeren uit Excel', 'Beheer'),
|
||||||
# --- PIVOT ---
|
# --- PIVOT ---
|
||||||
('gebruikersbeheer', 'Gebruikers & rechten van deze school', 'PIVOT'),
|
('gebruikersbeheer', 'Gebruikers & rechten van deze school', 'PIVOT'),
|
||||||
('koppelingen', 'Koppelingen van deze school', 'PIVOT'),
|
('koppelingen', 'Koppelingen van deze school', 'PIVOT'),
|
||||||
|
|||||||
@@ -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
@@ -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 |
@@ -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); }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+51
-3
@@ -4,10 +4,14 @@
|
|||||||
<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 %}
|
||||||
<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 %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="{% block body_class %}{% endblock %}">
|
<body class="{% block body_class %}{% 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;
|
||||||
|
|||||||
@@ -210,7 +210,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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="dash-section-title admin">⚙️ Beheer</div>
|
||||||
|
|
||||||
<div class="tile-grid">
|
<div class="tile-grid">
|
||||||
@@ -255,11 +255,12 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if mag('database_toestellen') %}
|
{% if mag('database_toestellen', 'database_eigendom') %}
|
||||||
<section class="tile tile-admin">
|
<section class="tile tile-admin">
|
||||||
<div class="tile-head"><span class="tile-icon">🗄️</span><h3>Database</h3></div>
|
<div class="tile-head"><span class="tile-icon">🗄️</span><h3>Database</h3></div>
|
||||||
<ul class="tile-links">
|
<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>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
@@ -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 %}
|
||||||
@@ -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; }
|
||||||
|
|||||||
Reference in new issue
Block a user