diff --git a/ROADMAP.md b/ROADMAP.md index ac5fac2..f1c4c9b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 1b72d03..1d67c46 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/config/settings.py b/backend/config/settings.py index a26e417..624702e 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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 ------------------------------------------------------------------ diff --git a/backend/config/urls.py b/backend/config/urls.py index e680b99..2f0c244 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -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) diff --git a/backend/core/apps.py b/backend/core/apps.py index e1fe18e..1c51211 100644 --- a/backend/core/apps.py +++ b/backend/core/apps.py @@ -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"), diff --git a/backend/core/migrations/0009_activiteit_pictogram.py b/backend/core/migrations/0009_activiteit_pictogram.py new file mode 100644 index 0000000..5a5d447 --- /dev/null +++ b/backend/core/migrations/0009_activiteit_pictogram.py @@ -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'), + ), + ] diff --git a/backend/core/models.py b/backend/core/models.py index 29550df..37745d3 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -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, diff --git a/backend/core/serializers.py b/backend/core/serializers.py index 90e4772..40cbb6f 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -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", diff --git a/backend/requirements.txt b/backend/requirements.txt index 2108e13..1777fea 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 188c86c..702f0c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 070cefe..e6791f7 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index aa2afbd..de741d8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { diff --git a/frontend/src/api.js b/frontend/src/api.js index 797f78f..4a25fb5 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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(); +} diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 5a953e4..2e7b3f6 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -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 Naam Omschrijving {metTheorievak && Theorievak} + {metPictogram && Pictogram} {telKolom && {telKolom}} @@ -179,6 +189,13 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTh {it.omschrijving || "—"} {metTheorievak && {it.theorievak ? "✓" : "—"}} + {metPictogram && ( + + {it.pictogram && } + + {it.pictogram && } + + )} {telKolom && {it.aantal_personen ?? "—"}} @@ -216,6 +233,7 @@ export function ActiviteitenPage() { kleur metTheorievak metIcoon + metPictogram /> ); } @@ -2162,7 +2180,7 @@ export function RoosterScherm({ mode }) { >
{edit && startDrag(e, b)} onClick={(e) => e.stopPropagation()} title="Sleep naar ander tijdslot">⠿} - {b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam} + {b.activiteit_pictogram && }{b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam} {edit && } {edit && }
@@ -2197,7 +2215,7 @@ export function RoosterScherm({ mode }) { >
{edit && startDrag(e, b)} onClick={(e) => e.stopPropagation()} title="Sleep naar ander tijdslot">⠿} - {b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam} + {b.activiteit_pictogram && }{b.activiteit_icoon || vakIcoon(b.activiteit_naam)} {b.activiteit_naam} {edit && } {edit && }
@@ -2687,3 +2705,130 @@ export function UrenPage() { ); } + +/* ---------------- 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 ( +
+
{t.start_tijd?.slice(0, 5)}
+ {b.activiteit_pictogram + ? + :
{b.activiteit_icoon || vakIcoon(b.activiteit_naam)}
} +
{b.activiteit_naam}
+
+ ); + } + + return ( +
+

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

+ +
+ + + {schooljaren.length > 1 && ( + + )} + + + +
+ {!scopeId &&

Kies hierboven een {scopeType === "klas" ? "groep" : "leerling"} om het rooster te tonen.

} + {scopeId && ouder && ( +
+ {weekDatums.map((datum, i) => { + const { vrij, items } = dagBlokken(datum); + return ( +
+
{WEEKDAGEN[i].label} {fmtDM(datum)}
+ {vrij &&
{vrij.type_label}
} + {!vrij && items.length === 0 &&
} + {!vrij && items.map((x) => blokKaart(x, false))} +
+ ); + })} +
+ )} + {scopeId && !ouder && (() => { + const datum = plusDagen(maandag, dagIdx); + const { vrij, items } = dagBlokken(datum); + return ( +
+
+ +
{WEEKDAGEN[dagIdx].label} {fmtDM(datum)}
+ +
+ {vrij &&
{vrij.type_label}{vrij.omschrijving ? `: ${vrij.omschrijving}` : ""}
} + {!vrij && items.length === 0 &&

Geen lessen op deze dag.

} + {!vrij &&
{items.map((x) => blokKaart(x, true))}
} +
+ ); + })()} +
+ ); +} +export function LeerlingRoosterPage() { return ; } +export function OuderRoosterPage() { return ; } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3eb7049..0aeed8e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 3628615..c1fe25d 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -8,6 +8,7 @@ export default defineConfig({ port: 5173, proxy: { "/api": "http://localhost:8000", + "/media": "http://localhost:8000", }, }, });