huisstijl-module (logo + kwaliteitscheck) + print-aanvullingen (legenda, pagina per dag, koptekst met logo)

This commit is contained in:
bes-r 2026-06-20 00:00:35 +02:00
parent 1b559dd2c6
commit e8934c6352
15 changed files with 393 additions and 8 deletions

View file

@ -43,8 +43,13 @@ volgt.
- [x] **Printopties-dialog** (🖨 in Rooster/Roostermaker): kies wát (huidige - [x] **Printopties-dialog** (🖨 in Rooster/Roostermaker): kies wát (huidige
selectie / alle klassen) en hóé je afdrukt — indeling (kolommen / dagen selectie / alle klassen) en hóé je afdrukt — indeling (kolommen / dagen
onder elkaar), vakken in kleur, begeleiding/lokaal tonen, optionele onder elkaar), vakken in kleur, begeleiding/lokaal tonen, optionele
koptekst — plus **papierformaat** (A4/A3/A5/Letter) en oriëntatie koptekst, **kleurenlegenda** en **elke dag op nieuwe pagina** — plus
(liggend/staand). Toegepast via body-classes + dynamische `@page`. **papierformaat** (A4/A3/A5/Letter) en oriëntatie (liggend/staand).
Afdruk-koptekst met logo + schooljaar + week + afdrukdatum.
- [x] **Module Huisstijl** (`huisstijl`, default aan): schoollogo uploaden via
Instellingen (PNG/SVG/JPG/GIF/WebP) met **kwaliteitscheck** (bestandstype,
resolutie, transparantie/achtergrond — waarschuwt bij gevulde/witte
achtergrond). Logo verschijnt in elke roosterweergave en op afdrukken.
- [x] **Vakkleur als vlak** (toggle 🎨, onthouden per browser): vak volledig in de - [x] **Vakkleur als vlak** (toggle 🎨, onthouden per browser): vak volledig in de
activiteitskleur i.p.v. een streepje, met automatisch leesbare tekstkleur activiteitskleur i.p.v. een streepje, met automatisch leesbare tekstkleur

View file

@ -78,6 +78,7 @@ MODULE_APPS = [
"modules.blokwizard", # Vak-wizard "modules.blokwizard", # Vak-wizard
"modules.personenwizard", # Personen-wizard & import "modules.personenwizard", # Personen-wizard & import
"modules.autorooster", # Automatisch rooster "modules.autorooster", # Automatisch rooster
"modules.huisstijl", # Schoollogo / huisstijl
] ]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS

View file

@ -17,6 +17,7 @@ urlpatterns = [
path("api/", include("modules.personenwizard.urls")), path("api/", include("modules.personenwizard.urls")),
path("api/", include("modules.autorooster.urls")), path("api/", include("modules.autorooster.urls")),
path("api/", include("modules.accounts.urls")), path("api/", include("modules.accounts.urls")),
path("api/", include("modules.huisstijl.urls")),
] ]
if settings.DEBUG: if settings.DEBUG:

View file

View file

@ -0,0 +1,30 @@
from django.apps import AppConfig
class HuisstijlConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "modules.huisstijl"
label = "module_huisstijl"
verbose_name = "Module: Huisstijl (logo)"
def ready(self):
from plugins.registry import MenuItem, ModuleSpec, registry
registry.register(
ModuleSpec(
key="huisstijl",
name="Huisstijl",
description=(
"Toont het schoollogo in elke roosterweergave en op afdrukken. "
"Logo te uploaden via Instellingen, met een kwaliteitscheck "
"(bestandstype, resolutie en achtergrond/transparantie)."
),
version="0.1.0",
category="Instellingen",
core=False,
default_enabled=True,
menu_items=(
MenuItem("Huisstijl", "/huisstijl", icon="badge", order=74, group="Instellingen"),
),
)
)

View file

@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Huisstijl",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("logo", models.FileField(blank=True, null=True, upload_to="huisstijl/", verbose_name="logo")),
("aangepast_op", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "huisstijl",
"verbose_name_plural": "huisstijl",
},
),
]

View file

@ -0,0 +1,21 @@
from django.db import models
class Huisstijl(models.Model):
"""Eén-rij-instelling met het schoollogo dat in elke roosterweergave en op
afdrukken getoond wordt."""
logo = models.FileField("logo", upload_to="huisstijl/", null=True, blank=True)
aangepast_op = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "huisstijl"
verbose_name_plural = "huisstijl"
@classmethod
def actueel(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
def __str__(self):
return "Huisstijl (logo)"

View file

@ -0,0 +1,11 @@
from rest_framework import serializers
from .models import Huisstijl
class HuisstijlSerializer(serializers.ModelSerializer):
logo = serializers.FileField(read_only=True)
class Meta:
model = Huisstijl
fields = ["id", "logo", "aangepast_op"]

View file

@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path("huisstijl/", views.huisstijl, name="huisstijl"),
]

View file

@ -0,0 +1,125 @@
"""Logo-beheer met kwaliteitscheck.
GET /api/huisstijl/ -> huidig logo (alle ingelogde gebruikers).
PUT /api/huisstijl/ -> upload logo (alleen beheerders); voert een
kwaliteitscheck uit en geeft waarschuwingen terug.
DELETE /api/huisstijl/ -> logo verwijderen (alleen beheerders).
"""
from rest_framework.decorators import api_view
from rest_framework.response import Response
from .models import Huisstijl
from .serializers import HuisstijlSerializer
TOEGESTAAN = {"png", "jpg", "jpeg", "gif", "webp", "svg"}
MAX_BYTES = 3 * 1024 * 1024 # 3 MB
def _hex(rgb):
return "#%02x%02x%02x" % (rgb[0], rgb[1], rgb[2])
def analyseer_logo(f):
"""Beoordeel een geüpload logo. Geeft {ok, fouten, waarschuwingen, info}.
'ok' is False bij harde problemen (verkeerd bestandstype, onleesbaar).
Anders wordt het bestand opgeslagen, maar mogelijk met waarschuwingen
(bv. een niet-transparante / gevulde achtergrond, of lage resolutie).
"""
naam = (f.name or "").lower()
ext = naam.rsplit(".", 1)[-1] if "." in naam else ""
fouten, waarsch = [], []
if ext not in TOEGESTAAN:
fouten.append(f"Bestandstype '.{ext}' wordt niet ondersteund. Gebruik PNG, SVG, JPG, GIF of WebP.")
return {"ok": False, "fouten": fouten, "waarschuwingen": waarsch, "info": {}}
if getattr(f, "size", 0) and f.size > MAX_BYTES:
waarsch.append(f"Het bestand is groot ({f.size // 1024} KB); overweeg een kleiner logo.")
if ext == "svg":
return {"ok": True, "fouten": [], "waarschuwingen": waarsch,
"info": {"formaat": "SVG", "transparant": True, "breedte": None, "hoogte": None, "achtergrond": None}}
try:
from PIL import Image
except Exception:
# Zonder Pillow slaan we toch op, maar zonder beeldcontrole.
return {"ok": True, "fouten": [], "waarschuwingen": waarsch + ["Beeldcontrole niet beschikbaar."], "info": {"formaat": ext.upper()}}
try:
f.seek(0)
img = Image.open(f)
img.load()
except Exception:
return {"ok": False, "fouten": ["Kan de afbeelding niet lezen of het bestand is beschadigd."], "waarschuwingen": [], "info": {}}
breedte, hoogte = img.size
transparant = False
achtergrond = None
heeft_alpha = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
if heeft_alpha:
rgba = img.convert("RGBA")
hoeken = [rgba.getpixel((0, 0)), rgba.getpixel((breedte - 1, 0)),
rgba.getpixel((0, hoogte - 1)), rgba.getpixel((breedte - 1, hoogte - 1))]
if all(p[3] < 16 for p in hoeken):
transparant = True
else:
achtergrond = hoeken[0][:3]
waarsch.append("De hoeken zijn niet doorzichtig; het logo lijkt een gevulde achtergrond te hebben. "
"Op een gekleurde balk valt dat lelijk op — gebruik bij voorkeur een transparante PNG of SVG.")
else:
rgb = img.convert("RGB")
hoeken = [rgb.getpixel((0, 0)), rgb.getpixel((breedte - 1, 0)),
rgb.getpixel((0, hoogte - 1)), rgb.getpixel((breedte - 1, hoogte - 1))]
achtergrond = hoeken[0]
eensgezind = len(set(hoeken)) == 1
if eensgezind and min(achtergrond) > 245:
waarsch.append("Effen witte achtergrond zonder transparantie. Op een gekleurd vlak valt het witte blok op; "
"een transparante PNG of SVG is netter.")
elif eensgezind:
waarsch.append(f"Effen achtergrondkleur gedetecteerd ({_hex(achtergrond)}). Gebruik bij voorkeur een transparante PNG of SVG.")
else:
waarsch.append("Geen transparantie (bv. JPG). Voor een nette weergave op gekleurde vlakken is een transparante PNG of SVG beter.")
if breedte < 80 or hoogte < 80:
waarsch.append(f"Lage resolutie ({breedte}×{hoogte}px); dit kan korrelig printen.")
info = {
"formaat": img.format or ext.upper(),
"breedte": breedte,
"hoogte": hoogte,
"transparant": transparant,
"achtergrond": _hex(achtergrond) if achtergrond else None,
}
return {"ok": True, "fouten": [], "waarschuwingen": waarsch, "info": info}
@api_view(["GET", "PUT", "POST", "DELETE"])
def huisstijl(request):
obj = Huisstijl.actueel()
schrijven = request.method in ("PUT", "POST", "DELETE")
if schrijven and not (request.user and request.user.is_staff):
return Response({"detail": "Alleen beheerders kunnen het logo wijzigen."}, status=403)
if request.method == "DELETE":
if obj.logo:
obj.logo.delete(save=True)
return Response(HuisstijlSerializer(obj, context={"request": request}).data)
if request.method in ("PUT", "POST"):
f = request.FILES.get("logo")
if not f:
return Response({"detail": "Geen bestand ontvangen (veld 'logo')."}, status=400)
analyse = analyseer_logo(f)
if not analyse["ok"]:
return Response({"detail": " ".join(analyse["fouten"]), "kwaliteit": analyse}, status=400)
f.seek(0)
if obj.logo:
obj.logo.delete(save=False)
obj.logo = f
obj.save()
data = HuisstijlSerializer(obj, context={"request": request}).data
data["kwaliteit"] = analyse
return Response(data)
return Response(HuisstijlSerializer(obj, context={"request": request}).data)

View file

@ -8,6 +8,7 @@ import {
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage, AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
LeerlingRoosterPage, OuderRoosterPage, LeerlingRoosterPage, OuderRoosterPage,
VakWizardPage, PersonenWizardPage, AutoRoosterPage, WachtwoordResetPage, VakWizardPage, PersonenWizardPage, AutoRoosterPage, WachtwoordResetPage,
HuisstijlPage,
} from "./pages.jsx"; } from "./pages.jsx";
const ICONS = { const ICONS = {
@ -46,6 +47,7 @@ const PAGES = {
"/vak-wizard": VakWizardPage, "/vak-wizard": VakWizardPage,
"/personen-wizard": PersonenWizardPage, "/personen-wizard": PersonenWizardPage,
"/auto-rooster": AutoRoosterPage, "/auto-rooster": AutoRoosterPage,
"/huisstijl": HuisstijlPage,
}; };
const RESET_PARAMS = (() => { const RESET_PARAMS = (() => {

View file

@ -43,6 +43,30 @@ export const login = (username, password) =>
request("POST", "/auth/login/", { username, password }); request("POST", "/auth/login/", { username, password });
export const logout = () => request("POST", "/auth/logout/"); export const logout = () => request("POST", "/auth/logout/");
// --- Huisstijl (schoollogo) -----------------------------------------------
export const getHuisstijl = () => request("GET", "/huisstijl/");
export const verwijderLogo = () => request("DELETE", "/huisstijl/");
export async function uploadLogo(file) {
const fd = new FormData();
fd.append("logo", file);
const token = getCookie("csrftoken");
const res = await fetch(`${BASE}/huisstijl/`, {
method: "PUT",
headers: token ? { "X-CSRFToken": token } : {},
credentials: "same-origin",
body: fd,
});
let data = null;
try { data = await res.json(); } catch { data = null; }
if (!res.ok) {
const err = new Error((data && (data.detail || JSON.stringify(data))) || "Upload mislukt");
err.status = res.status;
err.data = data;
throw err;
}
return data;
}
// --- Plugin-framework ----------------------------------------------------- // --- Plugin-framework -----------------------------------------------------
export const getModules = () => request("GET", "/modules/"); export const getModules = () => request("GET", "/modules/");
export const setModuleState = (key, enabled) => export const setModuleState = (key, enabled) =>

View file

@ -11,6 +11,9 @@ import {
wachtwoordReset, wachtwoordReset,
adminWachtwoordReset, adminWachtwoordReset,
gebruikersImport, gebruikersImport,
getHuisstijl,
uploadLogo,
verwijderLogo,
} from "./api.js"; } from "./api.js";
const ROLLEN = [ const ROLLEN = [
@ -2077,7 +2080,8 @@ export function RoosterScherm({ mode }) {
const [printPending, setPrintPending] = useState(false); const [printPending, setPrintPending] = useState(false);
const [printAllesActief, setPrintAllesActief] = useState(false); const [printAllesActief, setPrintAllesActief] = useState(false);
const [printKop, setPrintKop] = useState(""); const [printKop, setPrintKop] = useState("");
const [printOpts, setPrintOpts] = useState({ bereik: "huidig", indeling: "kolom", kleur: false, begeleiding: true, lokaal: true, papier: "A4", orientatie: "landscape" }); const [printLegenda, setPrintLegenda] = useState(false);
const [printOpts, setPrintOpts] = useState({ bereik: "huidig", indeling: "kolom", kleur: false, begeleiding: true, lokaal: true, legenda: false, paginaPerDag: false, papier: "A4", orientatie: "landscape" });
useEffect(() => { useEffect(() => {
listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar((s) => s || String(j[0].id)); }).catch((e) => setError(e.message)); listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar((s) => s || String(j[0].id)); }).catch((e) => setError(e.message));
@ -2105,6 +2109,12 @@ export function RoosterScherm({ mode }) {
// Bij printen "alle klassen" tijdelijk de scope negeren. // Bij printen "alle klassen" tijdelijk de scope negeren.
const effScope = printAllesActief ? { type: "alles", id: "" } : scope; const effScope = printAllesActief ? { type: "alles", id: "" } : scope;
const zichtbareBlokken = blokken.filter((b) => blokInScope(b, effScope, scopeCtx)); const zichtbareBlokken = blokken.filter((b) => blokInScope(b, effScope, scopeCtx));
// Distinct vakken (voor de optionele kleurenlegenda bij het afdrukken).
const legendaItems = (() => {
const seen = new Set(); const out = [];
zichtbareBlokken.forEach((b) => { if (!seen.has(b.activiteit_naam)) { seen.add(b.activiteit_naam); out.push({ naam: b.activiteit_naam, kleur: b.activiteit_kleur, icoon: b.activiteit_icoon }); } });
return out.sort((a, x) => a.naam.localeCompare(x.naam));
})();
// Conflicten over álle blokken (niet alleen de zichtbare scope), zodat de // Conflicten over álle blokken (niet alleen de zichtbare scope), zodat de
// melding compleet is en de betrokken blokken in het rooster omlijnd worden. // melding compleet is en de betrokken blokken in het rooster omlijnd worden.
const conflictInfo = berekenRoosterConflicten(blokken, scopeCtx, tijdsloten); const conflictInfo = berekenRoosterConflicten(blokken, scopeCtx, tijdsloten);
@ -2215,8 +2225,10 @@ export function RoosterScherm({ mode }) {
b.toggle("print-kleur", !!printOpts.kleur); b.toggle("print-kleur", !!printOpts.kleur);
b.toggle("print-geen-begeleiding", !printOpts.begeleiding); b.toggle("print-geen-begeleiding", !printOpts.begeleiding);
b.toggle("print-geen-lokaal", !printOpts.lokaal); b.toggle("print-geen-lokaal", !printOpts.lokaal);
b.toggle("print-paginadag", !!printOpts.paginaPerDag);
zetPaginaStijl(printOpts.papier, printOpts.orientatie); zetPaginaStijl(printOpts.papier, printOpts.orientatie);
setPrintKop(printOpts.kop || ""); setPrintKop(printOpts.kop || "");
setPrintLegenda(!!printOpts.legenda);
if (printOpts.bereik === "alles") setPrintAllesActief(true); if (printOpts.bereik === "alles") setPrintAllesActief(true);
setPrintOpen(false); setPrintOpen(false);
setPrintPending(true); setPrintPending(true);
@ -2510,8 +2522,17 @@ export function RoosterScherm({ mode }) {
return ( return (
<section className="rooster-scherm"> <section className="rooster-scherm">
{printKop && <div className="print-only print-kop">{printKop}</div>} <div className="print-only print-kop">
<h1>{edit ? "Roostermaker" : "Rooster"}</h1> <RoosterLogo print />
<div className="print-kop-tekst">
<strong>{printKop || (edit ? "Roostermaker" : "Rooster")}</strong>
<span>{(schooljaren.find((j) => String(j.id) === String(schooljaar)) || {}).naam || ""}{schooljaar ? " · " : ""}{schooljaar ? weekTitel(maandag) : ""} · afgedrukt {fmtDM(new Date())}</span>
</div>
</div>
<div className="rooster-kop">
<h1>{edit ? "Roostermaker" : "Rooster"}</h1>
<RoosterLogo />
</div>
<p className="muted no-print"> <p className="muted no-print">
{edit {edit
? "Sleep een blok naar een ander tijdslot om het te verplaatsen. Vrije dagen, afwezigheid en uitzonderingen worden meegenomen." ? "Sleep een blok naar een ander tijdslot om het te verplaatsen. Vrije dagen, afwezigheid en uitzonderingen worden meegenomen."
@ -2770,6 +2791,17 @@ export function RoosterScherm({ mode }) {
</div> </div>
)} )}
{schooljaar && tab === "week" && printLegenda && legendaItems.length > 0 && (
<div className="print-only kleur-legenda">
<strong>Legenda</strong>
<div className="legenda-items">
{legendaItems.map((v) => (
<span className="legenda-item" key={v.naam}><span className="legenda-swatch" style={{ background: v.kleur }} />{v.icoon || vakIcoon(v.naam)} {v.naam}</span>
))}
</div>
</div>
)}
{edit && tab === "wizard" && blokwizardAan && <VakWizardPage />} {edit && tab === "wizard" && blokwizardAan && <VakWizardPage />}
{edit && tab === "auto" && autoroosterAan && <AutoRoosterPage />} {edit && tab === "auto" && autoroosterAan && <AutoRoosterPage />}
@ -2797,6 +2829,10 @@ export function RoosterScherm({ mode }) {
<summary><span className="ico">📍</span> Locaties</summary> <summary><span className="ico">📍</span> Locaties</summary>
<div className="inst-body"><p className="muted small">Vrije locatie-aanduidingen (bv. gymzaal, zwembad).</p><LocatiesPage /></div> <div className="inst-body"><p className="muted small">Vrije locatie-aanduidingen (bv. gymzaal, zwembad).</p><LocatiesPage /></div>
</details> </details>
<details className="inst-sectie">
<summary><span className="ico">🖼</span> Huisstijl / logo</summary>
<div className="inst-body"><HuisstijlPage /></div>
</details>
</div> </div>
)} )}
@ -2850,6 +2886,8 @@ export function RoosterScherm({ mode }) {
<label className="check-item"><input type="checkbox" checked={printOpts.kleur} onChange={(e) => setPrintOpts({ ...printOpts, kleur: e.target.checked })} /><span>Vakken in kleur</span></label> <label className="check-item"><input type="checkbox" checked={printOpts.kleur} onChange={(e) => setPrintOpts({ ...printOpts, kleur: e.target.checked })} /><span>Vakken in kleur</span></label>
<label className="check-item"><input type="checkbox" checked={printOpts.begeleiding} onChange={(e) => setPrintOpts({ ...printOpts, begeleiding: e.target.checked })} /><span>Begeleiding tonen</span></label> <label className="check-item"><input type="checkbox" checked={printOpts.begeleiding} onChange={(e) => setPrintOpts({ ...printOpts, begeleiding: e.target.checked })} /><span>Begeleiding tonen</span></label>
<label className="check-item"><input type="checkbox" checked={printOpts.lokaal} onChange={(e) => setPrintOpts({ ...printOpts, lokaal: e.target.checked })} /><span>Lokaal/locatie tonen</span></label> <label className="check-item"><input type="checkbox" checked={printOpts.lokaal} onChange={(e) => setPrintOpts({ ...printOpts, lokaal: e.target.checked })} /><span>Lokaal/locatie tonen</span></label>
<label className="check-item"><input type="checkbox" checked={printOpts.legenda} onChange={(e) => setPrintOpts({ ...printOpts, legenda: e.target.checked })} /><span>Kleurenlegenda toevoegen</span></label>
<label className="check-item"><input type="checkbox" checked={printOpts.paginaPerDag} onChange={(e) => setPrintOpts({ ...printOpts, paginaPerDag: e.target.checked })} /><span>Elke dag op nieuwe pagina</span></label>
</div> </div>
<div className="editor-actions"> <div className="editor-actions">
<button type="button" className="btn primary" onClick={startPrint}>Afdrukken</button> <button type="button" className="btn primary" onClick={startPrint}>Afdrukken</button>
@ -3048,7 +3086,7 @@ export function PeriodeRoosterPage() {
return ( return (
<section className="weekmaker"> <section className="weekmaker">
<h1>Perioderooster</h1> <div className="rooster-kop"><h1>Perioderooster</h1><RoosterLogo /></div>
<p className="muted no-print">Geef alle klassen in één overzicht hun weekvakken. Je bouwt de weekopzet hier eerst los van de kalender; bij <b>voltooien</b> kies je de periode waarin dit rooster gaat gelden. Dezelfde begeleider of hetzelfde lokaal op hetzelfde moment wordt meteen als conflict gemarkeerd.</p> <p className="muted no-print">Geef alle klassen in één overzicht hun weekvakken. Je bouwt de weekopzet hier eerst los van de kalender; bij <b>voltooien</b> kies je de periode waarin dit rooster gaat gelden. Dezelfde begeleider of hetzelfde lokaal op hetzelfde moment wordt meteen als conflict gemarkeerd.</p>
<Foutmelding msg={error} /> <Foutmelding msg={error} />
@ -3237,6 +3275,75 @@ export function PeriodeRoosterPage() {
); );
} }
/* -------------------------------- Huisstijl / logo ------------------------ */
// Klein logo dat in elke roosterweergave en op afdrukken verschijnt.
export function RoosterLogo({ print = false }) {
const [logo, setLogo] = useState(null);
useEffect(() => { getHuisstijl().then((h) => setLogo(h && h.logo ? h.logo : null)).catch(() => {}); }, []);
if (!logo) return null;
return <img src={logo} alt="Logo" className={print ? "rooster-logo print-logo" : "rooster-logo no-print"} />;
}
export function HuisstijlPage() {
const [huis, setHuis] = useState(null);
const [kwaliteit, setKwaliteit] = useState(null);
const [bezig, setBezig] = useState(false);
const [error, setError] = useState(null);
useEffect(() => { getHuisstijl().then(setHuis).catch((e) => setError(e.message)); }, []);
async function kies(e) {
const f = e.target.files && e.target.files[0];
e.target.value = "";
if (!f) return;
setBezig(true); setError(null); setKwaliteit(null);
try {
const r = await uploadLogo(f);
setHuis(r);
setKwaliteit(r.kwaliteit || null);
} catch (err) {
setError(err.message);
if (err.data && err.data.kwaliteit) setKwaliteit(err.data.kwaliteit);
}
setBezig(false);
}
async function wis() {
setBezig(true); setError(null);
try { const r = await verwijderLogo(); setHuis(r); setKwaliteit(null); } catch (e) { setError(e.message); }
setBezig(false);
}
return (
<div className="huisstijl">
<Foutmelding msg={error} />
<p className="muted small">Het logo verschijnt in elke roosterweergave en op afdrukken. Gebruik bij voorkeur een <b>transparante PNG</b> of <b>SVG</b>; een logo met een gevulde (witte) achtergrond valt op een gekleurde balk lelijk op.</p>
<div className="logo-vak">
<div className="logo-preview" title="Voorbeeld op een geruite achtergrond (zo zie je transparantie)">
{huis && huis.logo ? <img src={huis.logo} alt="Logo" /> : <span className="muted small">Nog geen logo</span>}
</div>
<div className="logo-acties">
<label className="btn primary">{bezig ? "Bezig…" : (huis && huis.logo ? "Ander logo kiezen" : "Logo kiezen")}
<input type="file" accept=".png,.svg,.jpg,.jpeg,.gif,.webp,image/*" style={{ display: "none" }} onChange={kies} disabled={bezig} />
</label>
{huis && huis.logo && <button type="button" className="btn danger" onClick={wis} disabled={bezig}>Verwijderen</button>}
<div className="muted small">Toegestaan: PNG, SVG, JPG, GIF, WebP.</div>
</div>
</div>
{kwaliteit && (
<div className={kwaliteit.waarschuwingen && kwaliteit.waarschuwingen.length ? "banner waarschuwing" : "banner ok"}>
<b>Kwaliteitscheck:</b>
{kwaliteit.info && (kwaliteit.info.breedte || kwaliteit.info.formaat) && (
<div className="small">Formaat {kwaliteit.info.formaat}{kwaliteit.info.breedte ? `, ${kwaliteit.info.breedte}×${kwaliteit.info.hoogte}px` : ""}{kwaliteit.info.transparant ? ", transparante achtergrond ✓" : ""}{kwaliteit.info.achtergrond ? `, achtergrond ${kwaliteit.info.achtergrond}` : ""}.</div>
)}
{kwaliteit.waarschuwingen && kwaliteit.waarschuwingen.length > 0
? <ul className="conflict-lijst">{kwaliteit.waarschuwingen.map((w, i) => <li key={i} className="waarschuwing">{w}</li>)}</ul>
: <div className="small">Geen problemen gevonden mooi en netjes. </div>}
</div>
)}
</div>
);
}
/* -------------------------------- Urenoverzicht --------------------------- */ /* -------------------------------- Urenoverzicht --------------------------- */
export function UrenPage() { export function UrenPage() {
const [schooljaren, setSchooljaren] = useState([]); const [schooljaren, setSchooljaren] = useState([]);
@ -3457,7 +3564,7 @@ export function EenvoudigRooster({ mode }) {
return ( return (
<section className="ez"> <section className="ez">
<h1>{ouder ? "Weekrooster" : "Mijn rooster"}</h1> <div className="rooster-kop"><h1>{ouder ? "Weekrooster" : "Mijn rooster"}</h1><RoosterLogo /></div>
<Foutmelding msg={error} /> <Foutmelding msg={error} />
<div className="form-row ez-bar no-print"> <div className="form-row ez-bar no-print">
<select value={scopeType} onChange={(e) => { setScopeType(e.target.value); setScopeId(""); }}> <select value={scopeType} onChange={(e) => { setScopeType(e.target.value); setScopeId(""); }}>

View file

@ -346,6 +346,10 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
/* Vakken in kleur afdrukken */ /* Vakken in kleur afdrukken */
body.print-kleur .vak-kaart { background: var(--vk-kleur) !important; color: var(--vk-tekst) !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } body.print-kleur .vak-kaart { background: var(--vk-kleur) !important; color: var(--vk-tekst) !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body.print-kleur .vak-kaart .vk-naam, body.print-kleur .vak-kaart .muted { color: inherit !important; } body.print-kleur .vak-kaart .vk-naam, body.print-kleur .vak-kaart .muted { color: inherit !important; }
/* Elke dag op een nieuwe pagina (in 'dagen onder elkaar') */
body.print-paginadag.print-lijst .rooster-kolom { break-after: page; page-break-after: always; }
body.print-paginadag.print-lijst .rooster-kolom:last-child { break-after: auto; page-break-after: auto; }
} }
/* --- Onderkant zijbalk: subtiel Instellingen-blok --- */ /* --- Onderkant zijbalk: subtiel Instellingen-blok --- */
@ -580,7 +584,31 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.vak-kaart.kleur-vlak { background: var(--vk-kleur); color: var(--vk-tekst); } .vak-kaart.kleur-vlak { background: var(--vk-kleur); color: var(--vk-tekst); }
.vak-kaart.kleur-vlak .vk-naam { color: inherit; } .vak-kaart.kleur-vlak .vk-naam { color: inherit; }
.print-only { display: none; } .print-only { display: none; }
.print-kop { font-size: 16px; font-weight: 700; margin: 0 0 8px; } .print-kop { display: flex; align-items: center; gap: 12px; margin: 0 0 8px; }
.print-kop-tekst { display: flex; flex-direction: column; }
.print-kop-tekst strong { font-size: 16px; }
.print-kop-tekst span { font-size: 12px; color: #444; }
.print-logo { max-height: 46px; max-width: 180px; object-fit: contain; }
/* Schoollogo in de roosterweergave */
.rooster-kop { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.rooster-logo { max-height: 40px; max-width: 150px; object-fit: contain; }
/* Huisstijl-pagina: logo-upload + voorbeeld op geruite achtergrond */
.huisstijl .logo-vak { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin: 10px 0; }
.logo-preview { width: 220px; height: 120px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 10px;
background-color: #fff;
background-image: linear-gradient(45deg, #e7e8ec 25%, transparent 25%), linear-gradient(-45deg, #e7e8ec 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e7e8ec 75%), linear-gradient(-45deg, transparent 75%, #e7e8ec 75%);
background-size: 16px 16px; background-position: 0 0, 0 8px, 8px -8px, -8px 0; }
.logo-preview img { max-width: 200px; max-height: 104px; object-fit: contain; }
.logo-acties { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.logo-acties .btn { cursor: pointer; }
/* Kleurenlegenda (alleen bij afdrukken) */
.kleur-legenda { margin-top: 10px; padding-top: 8px; border-top: 1px solid #ccc; }
.legenda-items { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 4px; }
.legenda-item { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; }
.legenda-swatch { width: 12px; height: 12px; border-radius: 3px; display: inline-block; border: 1px solid rgba(0,0,0,.15); -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.vak-kaart.kleur-vlak .rb-greep, .vak-kaart.kleur-vlak .icon-btn { color: inherit; opacity: .85; } .vak-kaart.kleur-vlak .rb-greep, .vak-kaart.kleur-vlak .icon-btn { color: inherit; opacity: .85; }
.vak-kaart.kleur-vlak .rb-status, .vak-kaart.kleur-vlak .rb-verv { color: inherit; } .vak-kaart.kleur-vlak .rb-status, .vak-kaart.kleur-vlak .rb-verv { color: inherit; }
.wm-vak.kleur-vlak .wm-vak-naam { color: inherit; } .wm-vak.kleur-vlak .wm-vak-naam { color: inherit; }