diff --git a/ROADMAP.md b/ROADMAP.md index 6aa9ca6..0b5fee6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,8 +43,13 @@ volgt. - [x] **Printopties-dialog** (🖨 in Rooster/Roostermaker): kies wát (huidige selectie / alle klassen) en hóé je afdrukt — indeling (kolommen / dagen onder elkaar), vakken in kleur, begeleiding/lokaal tonen, optionele - koptekst — plus **papierformaat** (A4/A3/A5/Letter) en oriëntatie - (liggend/staand). Toegepast via body-classes + dynamische `@page`. + koptekst, **kleurenlegenda** en **elke dag op nieuwe pagina** — plus + **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 activiteitskleur i.p.v. een streepje, met automatisch leesbare tekstkleur diff --git a/backend/config/settings.py b/backend/config/settings.py index d4763bb..c36645c 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -78,6 +78,7 @@ MODULE_APPS = [ "modules.blokwizard", # Vak-wizard "modules.personenwizard", # Personen-wizard & import "modules.autorooster", # Automatisch rooster + "modules.huisstijl", # Schoollogo / huisstijl ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CORE_APPS + MODULE_APPS diff --git a/backend/config/urls.py b/backend/config/urls.py index 8afb46d..f5df69f 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ path("api/", include("modules.personenwizard.urls")), path("api/", include("modules.autorooster.urls")), path("api/", include("modules.accounts.urls")), + path("api/", include("modules.huisstijl.urls")), ] if settings.DEBUG: diff --git a/backend/modules/huisstijl/__init__.py b/backend/modules/huisstijl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/huisstijl/apps.py b/backend/modules/huisstijl/apps.py new file mode 100644 index 0000000..1b11941 --- /dev/null +++ b/backend/modules/huisstijl/apps.py @@ -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"), + ), + ) + ) diff --git a/backend/modules/huisstijl/migrations/0001_initial.py b/backend/modules/huisstijl/migrations/0001_initial.py new file mode 100644 index 0000000..abe62d9 --- /dev/null +++ b/backend/modules/huisstijl/migrations/0001_initial.py @@ -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", + }, + ), + ] diff --git a/backend/modules/huisstijl/migrations/__init__.py b/backend/modules/huisstijl/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/huisstijl/models.py b/backend/modules/huisstijl/models.py new file mode 100644 index 0000000..e616798 --- /dev/null +++ b/backend/modules/huisstijl/models.py @@ -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)" diff --git a/backend/modules/huisstijl/serializers.py b/backend/modules/huisstijl/serializers.py new file mode 100644 index 0000000..6a368dd --- /dev/null +++ b/backend/modules/huisstijl/serializers.py @@ -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"] diff --git a/backend/modules/huisstijl/urls.py b/backend/modules/huisstijl/urls.py new file mode 100644 index 0000000..b6648c3 --- /dev/null +++ b/backend/modules/huisstijl/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("huisstijl/", views.huisstijl, name="huisstijl"), +] diff --git a/backend/modules/huisstijl/views.py b/backend/modules/huisstijl/views.py new file mode 100644 index 0000000..cfce0a8 --- /dev/null +++ b/backend/modules/huisstijl/views.py @@ -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) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 190e873..0f1e3b6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,7 @@ import { AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage, LeerlingRoosterPage, OuderRoosterPage, VakWizardPage, PersonenWizardPage, AutoRoosterPage, WachtwoordResetPage, + HuisstijlPage, } from "./pages.jsx"; const ICONS = { @@ -46,6 +47,7 @@ const PAGES = { "/vak-wizard": VakWizardPage, "/personen-wizard": PersonenWizardPage, "/auto-rooster": AutoRoosterPage, + "/huisstijl": HuisstijlPage, }; const RESET_PARAMS = (() => { diff --git a/frontend/src/api.js b/frontend/src/api.js index 044ebcc..b9f4d70 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -43,6 +43,30 @@ export const login = (username, password) => request("POST", "/auth/login/", { username, password }); 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 ----------------------------------------------------- export const getModules = () => request("GET", "/modules/"); export const setModuleState = (key, enabled) => diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index b57d748..391c70d 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -11,6 +11,9 @@ import { wachtwoordReset, adminWachtwoordReset, gebruikersImport, + getHuisstijl, + uploadLogo, + verwijderLogo, } from "./api.js"; const ROLLEN = [ @@ -2077,7 +2080,8 @@ export function RoosterScherm({ mode }) { const [printPending, setPrintPending] = useState(false); const [printAllesActief, setPrintAllesActief] = useState(false); 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(() => { 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. const effScope = printAllesActief ? { type: "alles", id: "" } : scope; 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 // melding compleet is en de betrokken blokken in het rooster omlijnd worden. const conflictInfo = berekenRoosterConflicten(blokken, scopeCtx, tijdsloten); @@ -2215,8 +2225,10 @@ export function RoosterScherm({ mode }) { b.toggle("print-kleur", !!printOpts.kleur); b.toggle("print-geen-begeleiding", !printOpts.begeleiding); b.toggle("print-geen-lokaal", !printOpts.lokaal); + b.toggle("print-paginadag", !!printOpts.paginaPerDag); zetPaginaStijl(printOpts.papier, printOpts.orientatie); setPrintKop(printOpts.kop || ""); + setPrintLegenda(!!printOpts.legenda); if (printOpts.bereik === "alles") setPrintAllesActief(true); setPrintOpen(false); setPrintPending(true); @@ -2510,8 +2522,17 @@ export function RoosterScherm({ mode }) { return (
- {printKop &&
{printKop}
} -

{edit ? "Roostermaker" : "Rooster"}

+
+ +
+ {printKop || (edit ? "Roostermaker" : "Rooster")} + {(schooljaren.find((j) => String(j.id) === String(schooljaar)) || {}).naam || ""}{schooljaar ? " · " : ""}{schooljaar ? weekTitel(maandag) : ""} · afgedrukt {fmtDM(new Date())} +
+
+
+

{edit ? "Roostermaker" : "Rooster"}

+ +

{edit ? "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 }) { )} + {schooljaar && tab === "week" && printLegenda && legendaItems.length > 0 && ( +

+ Legenda +
+ {legendaItems.map((v) => ( + {v.icoon || vakIcoon(v.naam)} {v.naam} + ))} +
+
+ )} + {edit && tab === "wizard" && blokwizardAan && } {edit && tab === "auto" && autoroosterAan && } @@ -2797,6 +2829,10 @@ export function RoosterScherm({ mode }) { 📍 Locaties

Vrije locatie-aanduidingen (bv. gymzaal, zwembad).

+
+ 🖼️ Huisstijl / logo +
+
)} @@ -2850,6 +2886,8 @@ export function RoosterScherm({ mode }) { + +
@@ -3048,7 +3086,7 @@ export function PeriodeRoosterPage() { return (
-

Perioderooster

+

Perioderooster

Geef alle klassen in één overzicht hun weekvakken. Je bouwt de weekopzet hier eerst los van de kalender; bij voltooien kies je de periode waarin dit rooster gaat gelden. Dezelfde begeleider of hetzelfde lokaal op hetzelfde moment wordt meteen als conflict gemarkeerd.

@@ -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 Logo; +} + +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 ( +
+ +

Het logo verschijnt in elke roosterweergave en op afdrukken. Gebruik bij voorkeur een transparante PNG of SVG; een logo met een gevulde (witte) achtergrond valt op een gekleurde balk lelijk op.

+
+
+ {huis && huis.logo ? Logo : Nog geen logo} +
+
+ + {huis && huis.logo && } +
Toegestaan: PNG, SVG, JPG, GIF, WebP.
+
+
+ {kwaliteit && ( +
+ Kwaliteitscheck: + {kwaliteit.info && (kwaliteit.info.breedte || kwaliteit.info.formaat) && ( +
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}` : ""}.
+ )} + {kwaliteit.waarschuwingen && kwaliteit.waarschuwingen.length > 0 + ?
    {kwaliteit.waarschuwingen.map((w, i) =>
  • {w}
  • )}
+ :
Geen problemen gevonden — mooi en netjes. ✓
} +
+ )} +
+ ); +} + /* -------------------------------- Urenoverzicht --------------------------- */ export function UrenPage() { const [schooljaren, setSchooljaren] = useState([]); @@ -3457,7 +3564,7 @@ export function EenvoudigRooster({ mode }) { return (
-

{ouder ? "Weekrooster" : "Mijn rooster"}

+

{ouder ? "Weekrooster" : "Mijn rooster"}