Pictogram per vak (upload) + media-infra; grote leerling- en ouderweergave met pictogram en vakkleur (migratie core 0009)

This commit is contained in:
bes-r 2026-06-16 23:43:59 +02:00
parent 6afe007b22
commit 2548b7acd9
16 changed files with 263 additions and 7 deletions

View file

@ -109,6 +109,15 @@ volgt.
- [x] Een **hele dag laten vervallen** kan direct in het weekrooster (knop in de
dagkop; maakt een vrije dag aan, met "weer lesdag" om te herstellen).
## Pictogrammen & leerlingweergave (ZML)
- [x] **Pictogram per vak uploaden** (afbeelding) en tonen in het rooster.
- [x] **Grote leerlingweergave** (één dag, groot pictogram + vakkleur) en
**ouderweergave** (weekoverzicht), per leerling of groep.
- [x] Vakkleur duidelijk gebruikt in de weergaven.
- [ ] Later: pictogrammen ook per losse activiteit/stap binnen een dag; dagdeel-
indeling met picto's.
## Later
- [ ] **Module: automatisch rooster genereren.** De gebruiker ge

View file

@ -24,7 +24,7 @@ RUN chmod +x /app/entrypoint.sh
# Draai niet als root: een eventueel lek in de app geeft dan geen
# root-rechten in de container.
RUN useradd --create-home --shell /usr/sbin/nologin appuser \
&& mkdir -p /app/staticfiles \
&& mkdir -p /app/staticfiles /app/media \
&& chown -R appuser:appuser /app
USER appuser

View file

@ -150,6 +150,10 @@ USE_TZ = True
# (o.a. de CSS van de Django-admin).
STATIC_URL = "/static/"
STATIC_ROOT = os.environ.get("DJANGO_STATIC_ROOT", str(BASE_DIR / "staticfiles"))
# Geüploade bestanden (o.a. pictogrammen bij vakken).
MEDIA_URL = "/media/"
MEDIA_ROOT = os.environ.get("DJANGO_MEDIA_ROOT", str(BASE_DIR / "media"))
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# --- API ------------------------------------------------------------------

View file

@ -1,5 +1,7 @@
"""Hoofd-URL-configuratie."""
from django.contrib import admin
from django.conf import settings
from django.conf.urls.static import static
from django.urls import include, path
urlpatterns = [
@ -14,3 +16,6 @@ urlpatterns = [
path("api/", include("modules.uren.urls")),
path("api/", include("modules.accounts.urls")),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -30,6 +30,8 @@ class CoreConfig(AppConfig):
# Hoofdniveau: Rooster = weergave, Roostermaker = aanpasser.
MenuItem("Rooster", "/rooster", icon="calendar", order=10),
MenuItem("Roostermaker", "/roostermaker", icon="calendar", order=12),
MenuItem("Leerlingrooster", "/leerlingrooster", icon="book", order=14),
MenuItem("Ouderrooster", "/ouderrooster", icon="badge", order=16),
# Submenu Organisatie: wie hoort waar.
MenuItem("Personen", "/personen", icon="users", order=20, group="Organisatie"),
MenuItem("Groepsindeling", "/groepsindeling", icon="layers", order=24, group="Organisatie"),

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-06-16 21:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_roosterblok_geldig_tot_roosterblok_geldig_van'),
]
operations = [
migrations.AddField(
model_name='activiteit',
name='pictogram',
field=models.ImageField(blank=True, help_text='Optioneel pictogram (afbeelding) ter ondersteuning van leerlingen.', null=True, upload_to='pictogrammen/', verbose_name='pictogram'),
),
]

View file

@ -286,6 +286,10 @@ class Activiteit(models.Model):
naam = models.CharField("naam", max_length=80, unique=True)
kleur = models.CharField("kleur", max_length=7, default="#2563eb", help_text="hex, bv. #2563eb")
icoon = models.CharField("icoon", max_length=8, blank=True, help_text="Optioneel emoji; leeg = automatisch gekozen op naam.")
pictogram = models.ImageField(
"pictogram", upload_to="pictogrammen/", null=True, blank=True,
help_text="Optioneel pictogram (afbeelding) ter ondersteuning van leerlingen.",
)
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
theorievak = models.BooleanField(
"theorievak", default=False,

View file

@ -183,7 +183,7 @@ class ActiviteitSerializer(serializers.ModelSerializer):
class Meta:
model = Activiteit
fields = ["id", "naam", "kleur", "icoon", "omschrijving", "theorievak", "actief"]
fields = ["id", "naam", "kleur", "icoon", "pictogram", "omschrijving", "theorievak", "actief"]
class LocatieSerializer(serializers.ModelSerializer):
@ -208,6 +208,7 @@ class RoosterblokSerializer(serializers.ModelSerializer):
activiteit_naam = serializers.CharField(source="activiteit.naam", read_only=True)
activiteit_kleur = serializers.CharField(source="activiteit.kleur", read_only=True)
activiteit_icoon = serializers.CharField(source="activiteit.icoon", read_only=True, default="")
activiteit_pictogram = serializers.ImageField(source="activiteit.pictogram", read_only=True)
tijdslot_label = serializers.CharField(source="tijdslot.__str__", read_only=True)
locatie_naam = serializers.CharField(source="locatie.naam", read_only=True, default=None)
lokaal_naam = serializers.CharField(source="lokaal.naam", read_only=True, default=None)
@ -219,7 +220,7 @@ class RoosterblokSerializer(serializers.ModelSerializer):
model = Roosterblok
fields = [
"id", "schooljaar", "tijdslot", "tijdslot_label",
"activiteit", "activiteit_naam", "activiteit_kleur", "activiteit_icoon",
"activiteit", "activiteit_naam", "activiteit_kleur", "activiteit_icoon", "activiteit_pictogram",
"locatie", "locatie_naam", "lokaal", "lokaal_naam",
"groep", "subgroep", "leerling", "doel_type", "doel_naam",
"geldig_van", "geldig_tot",

View file

@ -5,3 +5,4 @@ django-cors-headers>=4.4
# --- Productie / test-server ---------------------------------------------
gunicorn>=22.0 # WSGI-server die Django draait
psycopg[binary]>=3.2 # PostgreSQL-driver
Pillow>=10.0 # beeldverwerking voor pictogram-upload

View file

@ -40,6 +40,8 @@ services:
volumes:
# Verzamelde statics delen met nginx.
- static_volume:/app/staticfiles
# Geüploade media (pictogrammen) delen met nginx.
- media_volume:/app/media
nginx:
build: ./frontend
@ -52,7 +54,9 @@ services:
volumes:
# Lees de door Django verzamelde statics (alleen-lezen).
- static_volume:/static:ro
- media_volume:/media:ro
volumes:
pgdata:
static_volume:
media_volume:

View file

@ -20,6 +20,13 @@ server {
expires 30d;
}
# Geüploade media (pictogrammen), uit het gedeelde volume.
location /media/ {
alias /media/;
access_log off;
expires 7d;
}
# API-verzoeken naar de Django-backend (gunicorn).
location /api/ {
proxy_pass http://backend:8000;

View file

@ -6,6 +6,7 @@ import {
ActiviteitenPage, LocatiesPage, LokalenPage, TijdslotenPage, SchooljaarPage,
RoosterWeergavePage, RoostermakerPage,
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
LeerlingRoosterPage, OuderRoosterPage,
} from "./pages.jsx";
const ICONS = {
@ -35,6 +36,8 @@ const PAGES = {
"/stage": StagePage,
"/gebruikers": GebruikersPage,
"/uren": UrenPage,
"/leerlingrooster": LeerlingRoosterPage,
"/ouderrooster": OuderRoosterPage,
};
export default function App() {

View file

@ -61,3 +61,24 @@ export const deleteResource = (resource, id) =>
// --- Lokaal-instelling (singleton; dubbele-boekingbeleid) ------------------
export const getLokaalinstelling = () => request("GET", "/lokaalinstelling/");
export const setLokaalinstelling = (data) => request("PUT", "/lokaalinstelling/", data);
// --- Bestand uploaden (multipart, bv. pictogram bij een vak) ---------------
export async function uploadBestand(resource, id, veld, file) {
const fd = new FormData();
fd.append(veld, file);
const token = getCookie("csrftoken");
const res = await fetch(`${BASE}/${resource}/${id}/`, {
method: "PATCH",
headers: token ? { "X-CSRFToken": token } : {},
credentials: "same-origin",
body: fd,
});
if (!res.ok) {
let data = null;
try { data = await res.json(); } catch { data = null; }
const err = new Error((data && JSON.stringify(data)) || "Upload mislukt");
err.status = res.status;
throw err;
}
return res.json();
}

View file

@ -7,6 +7,7 @@ import {
getModules,
getLokaalinstelling,
setLokaalinstelling,
uploadBestand,
} from "./api.js";
const ROLLEN = [
@ -88,7 +89,7 @@ function Foutmelding({ msg }) {
/* ----------------------- Generieke eenvoudige catalogus ------------------- */
// Voor lijsten met alleen naam + omschrijving (Functies, Activiteiten, Locaties).
function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTheorievak = false, metIcoon = false }) {
function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTheorievak = false, metIcoon = false, metPictogram = false }) {
const { items, error, loading, reload, setError } = useList(resource);
const leeg = {
naam: "", omschrijving: "",
@ -98,7 +99,7 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTh
};
const [form, setForm] = useState(leeg);
const [editId, setEditId] = useState(null);
const kolommen = 3 + (telKolom ? 1 : 0) + (metTheorievak ? 1 : 0);
const kolommen = 3 + (telKolom ? 1 : 0) + (metTheorievak ? 1 : 0) + (metPictogram ? 1 : 0);
async function opslaan(e) {
e.preventDefault();
@ -121,6 +122,14 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTh
setError(err.message);
}
}
async function uploadPicto(id, file) {
try { await uploadBestand(resource, id, "pictogram", file); await reload(); }
catch (err) { setError(err.message); }
}
async function verwijderPicto(id) {
try { await updateResource(resource, id, { pictogram: null }); await reload(); }
catch (err) { setError(err.message); }
}
function bewerk(it) {
setEditId(it.id);
setForm({
@ -165,6 +174,7 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTh
<th>Naam</th>
<th>Omschrijving</th>
{metTheorievak && <th>Theorievak</th>}
{metPictogram && <th>Pictogram</th>}
{telKolom && <th>{telKolom}</th>}
<th></th>
</tr>
@ -179,6 +189,13 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTh
</td>
<td className="muted">{it.omschrijving || "—"}</td>
{metTheorievak && <td>{it.theorievak ? "✓" : "—"}</td>}
{metPictogram && (
<td className="picto-cel">
{it.pictogram && <img src={it.pictogram} alt="" className="picto-thumb" />}
<label className="link small">{it.pictogram ? "vervang" : "upload"}<input type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { const f = e.target.files[0]; if (f) uploadPicto(it.id, f); e.target.value = ""; }} /></label>
{it.pictogram && <button type="button" className="link small danger" onClick={() => verwijderPicto(it.id)}>verwijder</button>}
</td>
)}
{telKolom && <td>{it.aantal_personen ?? "—"}</td>}
<td className="row-actions">
<button className="link" onClick={() => bewerk(it)}>bewerken</button>
@ -216,6 +233,7 @@ export function ActiviteitenPage() {
kleur
metTheorievak
metIcoon
metPictogram
/>
);
}
@ -2162,7 +2180,7 @@ export function RoosterScherm({ mode }) {
>
<div className="rb-top">
{edit && <span className="rb-greep" onPointerDown={(e) => startDrag(e, b)} onClick={(e) => e.stopPropagation()} title="Sleep naar ander tijdslot"></span>}
<strong>{b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam}</strong>
{b.activiteit_pictogram && <img src={b.activiteit_pictogram} alt="" className="rb-picto" />}<strong>{b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam}</strong>
{edit && <button type="button" className="icon-btn" onClick={(e) => { e.stopPropagation(); toggleOpenBlok(b); }} title="Bewerken"></button>}
{edit && <button type="button" className="icon-btn danger rb-x" onClick={(e) => { e.stopPropagation(); verwijder(b.id); }} title="Blok verwijderen">×</button>}
</div>
@ -2197,7 +2215,7 @@ export function RoosterScherm({ mode }) {
>
<div className="split-vak-kop">
{edit && <span className="rb-greep" onPointerDown={(e) => startDrag(e, b)} onClick={(e) => e.stopPropagation()} title="Sleep naar ander tijdslot"></span>}
<strong>{b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam}</strong>
{b.activiteit_pictogram && <img src={b.activiteit_pictogram} alt="" className="rb-picto" />}<strong>{b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam}</strong>
{edit && <button type="button" className="icon-btn" onClick={(e) => { e.stopPropagation(); toggleOpenBlok(b); }} title="Bewerken"></button>}
{edit && <button type="button" className="icon-btn danger" onClick={(e) => { e.stopPropagation(); verwijder(b.id); }} title="Blok verwijderen">×</button>}
</div>
@ -2687,3 +2705,130 @@ export function UrenPage() {
</section>
);
}
/* ---------------- Eenvoudige weergave (ZML-leerling / ouder) -------------- */
export function EenvoudigRooster({ mode }) {
const ouder = mode === "ouder";
const [schooljaren, setSchooljaren] = useState([]);
const [schooljaar, setSchooljaar] = useState("");
const [tijdsloten, setTijdsloten] = useState([]);
const [blokken, setBlokken] = useState([]);
const [kalender, setKalender] = useState([]);
const [groepen, setGroepen] = useState([]);
const [subgroepen, setSubgroepen] = useState([]);
const [leerpleinen, setLeerpleinen] = useState([]);
const [leerlingen, setLeerlingen] = useState([]);
const [scopeType, setScopeType] = useState("leerling");
const [scopeId, setScopeId] = useState("");
const [maandag, setMaandag] = useState(() => maandagVan(new Date()));
const [dagIdx, setDagIdx] = useState(() => Math.min((new Date().getDay() + 6) % 7, 4));
const [error, setError] = useState(null);
useEffect(() => {
listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar(String(j[0].id)); }).catch((e) => setError(e.message));
listResource("tijdsloten").then(setTijdsloten).catch(() => {});
listResource("groepen").then(setGroepen).catch(() => {});
listResource("subgroepen").then(setSubgroepen).catch(() => {});
listResource("leerpleinen").then(setLeerpleinen).catch(() => setLeerpleinen([]));
listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {});
}, []);
useEffect(() => {
if (!schooljaar) return;
listResource("roosterblokken", `?schooljaar=${schooljaar}`).then(setBlokken).catch((e) => setError(e.message));
listResource("kalenderdagen", `?schooljaar=${schooljaar}`).then(setKalender).catch(() => {});
}, [schooljaar]);
const ctx = { leerlingen, subgroepen, leerpleinen };
const scope = { type: scopeType, id: scopeId ? Number(scopeId) : "" };
const scopeOpties = scopeType === "klas" ? groepen : leerlingen;
const weekDatums = [0, 1, 2, 3, 4].map((i) => plusDagen(maandag, i));
const kalenderOp = (iso) => kalender.find((k) => k.datum === iso);
const blokGeldigOp = (b, iso) => !((b.geldig_van && iso < b.geldig_van) || (b.geldig_tot && iso > b.geldig_tot));
function dagBlokken(datum) {
const iso = fmtISO(datum);
const vrij = kalenderOp(iso);
if (vrij) return { vrij, items: [] };
const wd = (datum.getDay() + 6) % 7;
const slots = tijdsloten.filter((t) => t.dag === wd).sort((a, b) => a.volgorde - b.volgorde);
const items = [];
slots.forEach((t) => {
blokken
.filter((b) => b.tijdslot === t.id && blokGeldigOp(b, iso) && (scopeId ? blokInScope(b, scope, ctx) : false))
.forEach((b) => items.push({ t, b }));
});
return { vrij: null, items };
}
function blokKaart({ t, b }, groot) {
const kleur = b.activiteit_kleur || "#2563eb";
return (
<div className={groot ? "ez-blok groot" : "ez-blok"} key={b.id + "-" + t.id} style={{ borderColor: kleur, background: kleur + "18" }}>
<div className="ez-tijd">{t.start_tijd?.slice(0, 5)}</div>
{b.activiteit_pictogram
? <img className="ez-picto" src={b.activiteit_pictogram} alt="" />
: <div className="ez-emoji">{b.activiteit_icoon || vakIcoon(b.activiteit_naam)}</div>}
<div className="ez-vak" style={{ color: kleur }}>{b.activiteit_naam}</div>
</div>
);
}
return (
<section className="ez">
<h1>{ouder ? "Weekrooster" : "Mijn rooster"}</h1>
<Foutmelding msg={error} />
<div className="form-row ez-bar no-print">
<select value={scopeType} onChange={(e) => { setScopeType(e.target.value); setScopeId(""); }}>
<option value="leerling">Per leerling</option>
<option value="klas">Per groep</option>
</select>
<select value={scopeId} onChange={(e) => setScopeId(e.target.value)}>
<option value=""> kies {scopeType === "klas" ? "groep" : "leerling"} </option>
{scopeOpties.map((o) => <option key={o.id} value={o.id}>{o.naam || o.volledige_naam}</option>)}
</select>
{schooljaren.length > 1 && (
<select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select>
)}
<button type="button" className="btn" onClick={() => setMaandag((m) => plusDagen(m, -7))}> vorige week</button>
<button type="button" className="btn" onClick={() => setMaandag(maandagVan(new Date()))}>deze week</button>
<button type="button" className="btn" onClick={() => setMaandag((m) => plusDagen(m, 7))}>volgende week </button>
</div>
{!scopeId && <p className="muted">Kies hierboven een {scopeType === "klas" ? "groep" : "leerling"} om het rooster te tonen.</p>}
{scopeId && ouder && (
<div className="ez-week">
{weekDatums.map((datum, i) => {
const { vrij, items } = dagBlokken(datum);
return (
<div className="ez-dag-kolom" key={i}>
<div className="ez-dag-kop">{WEEKDAGEN[i].label}<span className="muted"> {fmtDM(datum)}</span></div>
{vrij && <div className="ez-vrij">{vrij.type_label}</div>}
{!vrij && items.length === 0 && <div className="muted small"></div>}
{!vrij && items.map((x) => blokKaart(x, false))}
</div>
);
})}
</div>
)}
{scopeId && !ouder && (() => {
const datum = plusDagen(maandag, dagIdx);
const { vrij, items } = dagBlokken(datum);
return (
<div className="ez-dag-groot">
<div className="ez-dag-nav no-print">
<button type="button" className="btn groot" onClick={() => setDagIdx((d) => Math.max(0, d - 1))} disabled={dagIdx === 0}></button>
<div className="ez-dag-titel">{WEEKDAGEN[dagIdx].label} <span className="muted">{fmtDM(datum)}</span></div>
<button type="button" className="btn groot" onClick={() => setDagIdx((d) => Math.min(4, d + 1))} disabled={dagIdx === 4}></button>
</div>
{vrij && <div className="ez-vrij groot">{vrij.type_label}{vrij.omschrijving ? `: ${vrij.omschrijving}` : ""}</div>}
{!vrij && items.length === 0 && <p className="muted">Geen lessen op deze dag.</p>}
{!vrij && <div className="ez-dag-blokken">{items.map((x) => blokKaart(x, true))}</div>}
</div>
);
})()}
</section>
);
}
export function LeerlingRoosterPage() { return <EenvoudigRooster mode="leerling" />; }
export function OuderRoosterPage() { return <EenvoudigRooster mode="ouder" />; }

View file

@ -429,3 +429,34 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.slot-cel[data-vrij] { background: repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,.03) 6px, rgba(0,0,0,.03) 12px); }
.historie-kop { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
.historie-kop h2 { margin: 0; }
/* Pictogrammen bij vak + in het rooster */
.picto-cel { white-space: nowrap; }
.picto-thumb { height: 28px; width: 28px; object-fit: contain; vertical-align: middle; margin-right: 6px; border-radius: 4px; }
.rb-picto { height: 22px; width: 22px; object-fit: contain; vertical-align: middle; margin-right: 4px; }
/* Eenvoudige ZML-/ouderweergave */
.ez h1 { font-size: 28px; }
.ez-bar { flex-wrap: wrap; align-items: center; gap: 8px; }
.ez-week { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; align-items: start; }
.ez-dag-kolom { display: flex; flex-direction: column; gap: 10px; }
.ez-dag-kop { font-size: 16px; font-weight: 700; text-align: center; padding-bottom: 4px; border-bottom: 2px solid var(--line); }
.ez-blok { display: flex; flex-direction: column; align-items: center; gap: 4px; border: 3px solid; border-radius: 14px; padding: 10px 8px; text-align: center; }
.ez-tijd { font-size: 14px; font-weight: 700; color: var(--muted); }
.ez-picto { width: 64px; height: 64px; object-fit: contain; }
.ez-emoji { font-size: 48px; line-height: 1; }
.ez-vak { font-size: 16px; font-weight: 800; }
.ez-vrij { text-align: center; font-weight: 700; color: var(--muted); padding: 10px; background: var(--bg); border-radius: 10px; }
/* Leerlingmodus: groot, één dag */
.ez-dag-groot { max-width: 760px; margin: 0 auto; }
.ez-dag-nav { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.ez-dag-titel { font-size: 24px; font-weight: 800; }
.btn.groot { font-size: 24px; padding: 8px 20px; }
.ez-dag-blokken { display: flex; flex-direction: column; gap: 14px; }
.ez-dag-blokken .ez-blok.groot { flex-direction: row; justify-content: flex-start; gap: 18px; padding: 16px 20px; border-width: 4px; }
.ez-blok.groot .ez-tijd { font-size: 22px; min-width: 70px; }
.ez-blok.groot .ez-picto { width: 84px; height: 84px; }
.ez-blok.groot .ez-emoji { font-size: 64px; }
.ez-blok.groot .ez-vak { font-size: 26px; }
.ez-vrij.groot { font-size: 22px; padding: 30px; }

View file

@ -8,6 +8,7 @@ export default defineConfig({
port: 5173,
proxy: {
"/api": "http://localhost:8000",
"/media": "http://localhost:8000",
},
},
});