Pictogram per vak (upload) + media-infra; grote leerling- en ouderweergave met pictogram en vakkleur (migratie core 0009)
This commit is contained in:
parent
6afe007b22
commit
2548b7acd9
16 changed files with 263 additions and 7 deletions
|
|
@ -109,6 +109,15 @@ volgt.
|
||||||
- [x] Een **hele dag laten vervallen** kan direct in het weekrooster (knop in de
|
- [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).
|
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
|
## Later
|
||||||
|
|
||||||
- [ ] **Module: automatisch rooster genereren.** De gebruiker ge
|
- [ ] **Module: automatisch rooster genereren.** De gebruiker ge
|
||||||
|
|
@ -24,7 +24,7 @@ RUN chmod +x /app/entrypoint.sh
|
||||||
# Draai niet als root: een eventueel lek in de app geeft dan geen
|
# Draai niet als root: een eventueel lek in de app geeft dan geen
|
||||||
# root-rechten in de container.
|
# root-rechten in de container.
|
||||||
RUN useradd --create-home --shell /usr/sbin/nologin appuser \
|
RUN useradd --create-home --shell /usr/sbin/nologin appuser \
|
||||||
&& mkdir -p /app/staticfiles \
|
&& mkdir -p /app/staticfiles /app/media \
|
||||||
&& chown -R appuser:appuser /app
|
&& chown -R appuser:appuser /app
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,10 @@ USE_TZ = True
|
||||||
# (o.a. de CSS van de Django-admin).
|
# (o.a. de CSS van de Django-admin).
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
STATIC_ROOT = os.environ.get("DJANGO_STATIC_ROOT", str(BASE_DIR / "staticfiles"))
|
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"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
# --- API ------------------------------------------------------------------
|
# --- API ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"""Hoofd-URL-configuratie."""
|
"""Hoofd-URL-configuratie."""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
@ -14,3 +16,6 @@ urlpatterns = [
|
||||||
path("api/", include("modules.uren.urls")),
|
path("api/", include("modules.uren.urls")),
|
||||||
path("api/", include("modules.accounts.urls")),
|
path("api/", include("modules.accounts.urls")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ class CoreConfig(AppConfig):
|
||||||
# Hoofdniveau: Rooster = weergave, Roostermaker = aanpasser.
|
# Hoofdniveau: Rooster = weergave, Roostermaker = aanpasser.
|
||||||
MenuItem("Rooster", "/rooster", icon="calendar", order=10),
|
MenuItem("Rooster", "/rooster", icon="calendar", order=10),
|
||||||
MenuItem("Roostermaker", "/roostermaker", icon="calendar", order=12),
|
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.
|
# Submenu Organisatie: wie hoort waar.
|
||||||
MenuItem("Personen", "/personen", icon="users", order=20, group="Organisatie"),
|
MenuItem("Personen", "/personen", icon="users", order=20, group="Organisatie"),
|
||||||
MenuItem("Groepsindeling", "/groepsindeling", icon="layers", order=24, group="Organisatie"),
|
MenuItem("Groepsindeling", "/groepsindeling", icon="layers", order=24, group="Organisatie"),
|
||||||
|
|
|
||||||
18
backend/core/migrations/0009_activiteit_pictogram.py
Normal file
18
backend/core/migrations/0009_activiteit_pictogram.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -286,6 +286,10 @@ class Activiteit(models.Model):
|
||||||
naam = models.CharField("naam", max_length=80, unique=True)
|
naam = models.CharField("naam", max_length=80, unique=True)
|
||||||
kleur = models.CharField("kleur", max_length=7, default="#2563eb", help_text="hex, bv. #2563eb")
|
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.")
|
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)
|
omschrijving = models.CharField("omschrijving", max_length=255, blank=True)
|
||||||
theorievak = models.BooleanField(
|
theorievak = models.BooleanField(
|
||||||
"theorievak", default=False,
|
"theorievak", default=False,
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@ class ActiviteitSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Activiteit
|
model = Activiteit
|
||||||
fields = ["id", "naam", "kleur", "icoon", "omschrijving", "theorievak", "actief"]
|
fields = ["id", "naam", "kleur", "icoon", "pictogram", "omschrijving", "theorievak", "actief"]
|
||||||
|
|
||||||
|
|
||||||
class LocatieSerializer(serializers.ModelSerializer):
|
class LocatieSerializer(serializers.ModelSerializer):
|
||||||
|
|
@ -208,6 +208,7 @@ class RoosterblokSerializer(serializers.ModelSerializer):
|
||||||
activiteit_naam = serializers.CharField(source="activiteit.naam", read_only=True)
|
activiteit_naam = serializers.CharField(source="activiteit.naam", read_only=True)
|
||||||
activiteit_kleur = serializers.CharField(source="activiteit.kleur", 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_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)
|
tijdslot_label = serializers.CharField(source="tijdslot.__str__", read_only=True)
|
||||||
locatie_naam = serializers.CharField(source="locatie.naam", read_only=True, default=None)
|
locatie_naam = serializers.CharField(source="locatie.naam", read_only=True, default=None)
|
||||||
lokaal_naam = serializers.CharField(source="lokaal.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
|
model = Roosterblok
|
||||||
fields = [
|
fields = [
|
||||||
"id", "schooljaar", "tijdslot", "tijdslot_label",
|
"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",
|
"locatie", "locatie_naam", "lokaal", "lokaal_naam",
|
||||||
"groep", "subgroep", "leerling", "doel_type", "doel_naam",
|
"groep", "subgroep", "leerling", "doel_type", "doel_naam",
|
||||||
"geldig_van", "geldig_tot",
|
"geldig_van", "geldig_tot",
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,4 @@ django-cors-headers>=4.4
|
||||||
# --- Productie / test-server ---------------------------------------------
|
# --- Productie / test-server ---------------------------------------------
|
||||||
gunicorn>=22.0 # WSGI-server die Django draait
|
gunicorn>=22.0 # WSGI-server die Django draait
|
||||||
psycopg[binary]>=3.2 # PostgreSQL-driver
|
psycopg[binary]>=3.2 # PostgreSQL-driver
|
||||||
|
Pillow>=10.0 # beeldverwerking voor pictogram-upload
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
# Verzamelde statics delen met nginx.
|
# Verzamelde statics delen met nginx.
|
||||||
- static_volume:/app/staticfiles
|
- static_volume:/app/staticfiles
|
||||||
|
# Geüploade media (pictogrammen) delen met nginx.
|
||||||
|
- media_volume:/app/media
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
|
|
@ -52,7 +54,9 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
# Lees de door Django verzamelde statics (alleen-lezen).
|
# Lees de door Django verzamelde statics (alleen-lezen).
|
||||||
- static_volume:/static:ro
|
- static_volume:/static:ro
|
||||||
|
- media_volume:/media:ro
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
static_volume:
|
static_volume:
|
||||||
|
media_volume:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,13 @@ server {
|
||||||
expires 30d;
|
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).
|
# API-verzoeken naar de Django-backend (gunicorn).
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
ActiviteitenPage, LocatiesPage, LokalenPage, TijdslotenPage, SchooljaarPage,
|
ActiviteitenPage, LocatiesPage, LokalenPage, TijdslotenPage, SchooljaarPage,
|
||||||
RoosterWeergavePage, RoostermakerPage,
|
RoosterWeergavePage, RoostermakerPage,
|
||||||
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
|
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
|
||||||
|
LeerlingRoosterPage, OuderRoosterPage,
|
||||||
} from "./pages.jsx";
|
} from "./pages.jsx";
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
|
|
@ -35,6 +36,8 @@ const PAGES = {
|
||||||
"/stage": StagePage,
|
"/stage": StagePage,
|
||||||
"/gebruikers": GebruikersPage,
|
"/gebruikers": GebruikersPage,
|
||||||
"/uren": UrenPage,
|
"/uren": UrenPage,
|
||||||
|
"/leerlingrooster": LeerlingRoosterPage,
|
||||||
|
"/ouderrooster": OuderRoosterPage,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
|
||||||
|
|
@ -61,3 +61,24 @@ export const deleteResource = (resource, id) =>
|
||||||
// --- Lokaal-instelling (singleton; dubbele-boekingbeleid) ------------------
|
// --- Lokaal-instelling (singleton; dubbele-boekingbeleid) ------------------
|
||||||
export const getLokaalinstelling = () => request("GET", "/lokaalinstelling/");
|
export const getLokaalinstelling = () => request("GET", "/lokaalinstelling/");
|
||||||
export const setLokaalinstelling = (data) => request("PUT", "/lokaalinstelling/", data);
|
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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
getModules,
|
getModules,
|
||||||
getLokaalinstelling,
|
getLokaalinstelling,
|
||||||
setLokaalinstelling,
|
setLokaalinstelling,
|
||||||
|
uploadBestand,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
const ROLLEN = [
|
const ROLLEN = [
|
||||||
|
|
@ -88,7 +89,7 @@ function Foutmelding({ msg }) {
|
||||||
|
|
||||||
/* ----------------------- Generieke eenvoudige catalogus ------------------- */
|
/* ----------------------- Generieke eenvoudige catalogus ------------------- */
|
||||||
// Voor lijsten met alleen naam + omschrijving (Functies, Activiteiten, Locaties).
|
// 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 { items, error, loading, reload, setError } = useList(resource);
|
||||||
const leeg = {
|
const leeg = {
|
||||||
naam: "", omschrijving: "",
|
naam: "", omschrijving: "",
|
||||||
|
|
@ -98,7 +99,7 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTh
|
||||||
};
|
};
|
||||||
const [form, setForm] = useState(leeg);
|
const [form, setForm] = useState(leeg);
|
||||||
const [editId, setEditId] = useState(null);
|
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) {
|
async function opslaan(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -121,6 +122,14 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTh
|
||||||
setError(err.message);
|
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) {
|
function bewerk(it) {
|
||||||
setEditId(it.id);
|
setEditId(it.id);
|
||||||
setForm({
|
setForm({
|
||||||
|
|
@ -165,6 +174,7 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTh
|
||||||
<th>Naam</th>
|
<th>Naam</th>
|
||||||
<th>Omschrijving</th>
|
<th>Omschrijving</th>
|
||||||
{metTheorievak && <th>Theorievak</th>}
|
{metTheorievak && <th>Theorievak</th>}
|
||||||
|
{metPictogram && <th>Pictogram</th>}
|
||||||
{telKolom && <th>{telKolom}</th>}
|
{telKolom && <th>{telKolom}</th>}
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -179,6 +189,13 @@ function CatalogusPage({ titel, uitleg, resource, kleur = false, telKolom, metTh
|
||||||
</td>
|
</td>
|
||||||
<td className="muted">{it.omschrijving || "—"}</td>
|
<td className="muted">{it.omschrijving || "—"}</td>
|
||||||
{metTheorievak && <td>{it.theorievak ? "✓" : "—"}</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>}
|
{telKolom && <td>{it.aantal_personen ?? "—"}</td>}
|
||||||
<td className="row-actions">
|
<td className="row-actions">
|
||||||
<button className="link" onClick={() => bewerk(it)}>bewerken</button>
|
<button className="link" onClick={() => bewerk(it)}>bewerken</button>
|
||||||
|
|
@ -216,6 +233,7 @@ export function ActiviteitenPage() {
|
||||||
kleur
|
kleur
|
||||||
metTheorievak
|
metTheorievak
|
||||||
metIcoon
|
metIcoon
|
||||||
|
metPictogram
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2162,7 +2180,7 @@ export function RoosterScherm({ mode }) {
|
||||||
>
|
>
|
||||||
<div className="rb-top">
|
<div className="rb-top">
|
||||||
{edit && <span className="rb-greep" onPointerDown={(e) => startDrag(e, b)} onClick={(e) => e.stopPropagation()} title="Sleep naar ander tijdslot">⠿</span>}
|
{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" 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>}
|
{edit && <button type="button" className="icon-btn danger rb-x" onClick={(e) => { e.stopPropagation(); verwijder(b.id); }} title="Blok verwijderen">×</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2197,7 +2215,7 @@ export function RoosterScherm({ mode }) {
|
||||||
>
|
>
|
||||||
<div className="split-vak-kop">
|
<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>}
|
{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" 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>}
|
{edit && <button type="button" className="icon-btn danger" onClick={(e) => { e.stopPropagation(); verwijder(b.id); }} title="Blok verwijderen">×</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2687,3 +2705,130 @@ export function UrenPage() {
|
||||||
</section>
|
</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" />; }
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
.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 { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
|
||||||
.historie-kop h2 { margin: 0; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export default defineConfig({
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:8000",
|
"/api": "http://localhost:8000",
|
||||||
|
"/media": "http://localhost:8000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue