Uren-filters; roosterblok geldig van/tot (periode + eenmalig); dag leegmaken + eenmalig alternatief (migratie core 0008)

This commit is contained in:
bes-r 2026-06-16 23:12:52 +02:00
parent 2c2c33c116
commit fc651c2f9d
7 changed files with 147 additions and 15 deletions

View file

@ -89,12 +89,19 @@ volgt.
- [x] **Module die berekent hoeveel uur een groep per vak krijgt.** (module `uren`, default uit; endpoint /api/urenoverzicht/) - [x] **Module die berekent hoeveel uur een groep per vak krijgt.** (module `uren`, default uit; endpoint /api/urenoverzicht/)
- [x] Optie om dit **per leerling** te tonen, **inclusief stage-uren**. - [x] Optie om dit **per leerling** te tonen, **inclusief stage-uren**.
- [x] **Filters op de uren-tabellen** (per groep, per vak, zoek op leerling).
- [ ] **Geschiedenis** van dit overzicht **per leerling** tonen (verloop over de - [ ] **Geschiedenis** van dit overzicht **per leerling** tonen (verloop over de
tijd). _(nog te doen: vereist opslag van momentopnames)_ tijd). _(nog te doen: vereist opslag van momentopnames)_
- [x] **Vrije dagen tellen niet mee** in deze urenberekening. - [x] **Vrije dagen tellen niet mee** in deze urenberekening.
## Roostermaker — vrije dagen & blok vervallen ## Roostermaker — vrije dagen & blok vervallen
- [x] **Vak met geldigheidsperiode**: een blok kan gelden **van/tot** een datum
(leeg = altijd). `geldig_van == geldig_tot` = eenmalig op die dag.
- [x] **Dag leegmaken + eenmalig alternatief**: per dag knop 🧹 (alle blokken díe
dag laten vervallen, blijft lesdag) en (eenmalig blok op die dag), zonder
gevolgen voor andere dagen.
- [ ] **Vrije/vakantie-/studiedagen kunnen niet gebruikt worden** in de - [ ] **Vrije/vakantie-/studiedagen kunnen niet gebruikt worden** in de
Roostermaker (geen blokken plannen op die dagen). Roostermaker (geen blokken plannen op die dagen).
- [x] De actie heet nu **"dit vak laten vervallen"** (per blok, juiste tekst). - [x] De actie heet nu **"dit vak laten vervallen"** (per blok, juiste tekst).

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.15 on 2026-06-16 21:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_activiteit_icoon'),
]
operations = [
migrations.AddField(
model_name='roosterblok',
name='geldig_tot',
field=models.DateField(blank=True, null=True, verbose_name='geldig tot en met'),
),
migrations.AddField(
model_name='roosterblok',
name='geldig_van',
field=models.DateField(blank=True, null=True, verbose_name='geldig vanaf'),
),
]

View file

@ -406,6 +406,10 @@ class Roosterblok(models.Model):
Persoon, related_name="begeleider_roosterblokken", blank=True, Persoon, related_name="begeleider_roosterblokken", blank=True,
) )
opmerkingen = models.CharField("opmerkingen", max_length=255, blank=True) opmerkingen = models.CharField("opmerkingen", max_length=255, blank=True)
# Geldigheidsperiode: leeg = altijd (wekelijks). Gevuld = alleen binnen dit
# datumbereik. geldig_van == geldig_tot betekent een eenmalig blok op die dag.
geldig_van = models.DateField("geldig vanaf", null=True, blank=True)
geldig_tot = models.DateField("geldig tot en met", null=True, blank=True)
aangemaakt_op = models.DateTimeField(auto_now_add=True) aangemaakt_op = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
@ -420,6 +424,8 @@ class Roosterblok(models.Model):
raise ValidationError( raise ValidationError(
"Kies precies één doelgroep: groep, subgroep of leerling." "Kies precies één doelgroep: groep, subgroep of leerling."
) )
if self.geldig_van and self.geldig_tot and self.geldig_tot < self.geldig_van:
raise ValidationError("'geldig tot' ligt vóór 'geldig vanaf'.")
@property @property
def doel_type(self) -> str: def doel_type(self) -> str:

View file

@ -222,6 +222,7 @@ class RoosterblokSerializer(serializers.ModelSerializer):
"activiteit", "activiteit_naam", "activiteit_kleur", "activiteit_icoon", "activiteit", "activiteit_naam", "activiteit_kleur", "activiteit_icoon",
"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",
"begeleiders", "begeleiders_namen", "opmerkingen", "aangemaakt_op", "begeleiders", "begeleiders_namen", "opmerkingen", "aangemaakt_op",
] ]
read_only_fields = ["aangemaakt_op"] read_only_fields = ["aangemaakt_op"]

View file

@ -46,12 +46,29 @@ def urenoverzicht(request):
sub_leden = {sid: set(s.leden.values_list("id", flat=True)) for sid, s in subgroepen.items()} sub_leden = {sid: set(s.leden.values_list("id", flat=True)) for sid, s in subgroepen.items()}
leerlingen = list(Persoon.objects.filter(rol=Rol.LEERLING).select_related("groep")) leerlingen = list(Persoon.objects.filter(rol=Rol.LEERLING).select_related("groep"))
def _tel_weekdag(van, tot, wd):
n = 0
d = van
while d <= tot:
if d.weekday() == wd and d not in vrije:
n += 1
d += timedelta(days=1)
return n
def occurrences(blok): def occurrences(blok):
wd = blok.tijdslot.dag wd = blok.tijdslot.dag
basis = lesdagen.get(wd, 0) # Geldigheidsperiode binnen het schooljaar clampen.
van = max(blok.geldig_van or sj.start_datum, sj.start_datum)
tot = min(blok.geldig_tot or sj.eind_datum, sj.eind_datum)
if van > tot:
return 0
if blok.geldig_van is None and blok.geldig_tot is None:
basis = lesdagen.get(wd, 0)
else:
basis = _tel_weekdag(van, tot, wd)
weg = 0 weg = 0
for u in blok.uitzonderingen.all(): for u in blok.uitzonderingen.all():
if u.vervalt and sj.start_datum <= u.datum <= sj.eind_datum and u.datum.weekday() == wd and u.datum not in vrije: if u.vervalt and van <= u.datum <= tot and u.datum.weekday() == wd and u.datum not in vrije:
weg += 1 weg += 1
return max(basis - weg, 0) return max(basis - weg, 0)

View file

@ -1547,6 +1547,8 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
doel: blok.groep || blok.subgroep || blok.leerling || "", doel: blok.groep || blok.subgroep || blok.leerling || "",
begeleiders: [...(blok.begeleiders || [])], begeleiders: [...(blok.begeleiders || [])],
opmerkingen: blok.opmerkingen || "", opmerkingen: blok.opmerkingen || "",
geldig_van: blok.geldig_van || "",
geldig_tot: blok.geldig_tot || "",
}); });
const [kopieDoelen, setKopieDoelen] = useState([]); const [kopieDoelen, setKopieDoelen] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@ -1593,7 +1595,7 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
if (splitSubs.length === 0) return; if (splitSubs.length === 0) return;
try { try {
for (const sid of splitSubs) { for (const sid of splitSubs) {
await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, subgroep: sid, begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, subgroep: sid, begeleiders: f.begeleiders, opmerkingen: f.opmerkingen , geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null });
} }
if (verwijderOrig) await deleteResource("roosterblokken", blok.id); if (verwijderOrig) await deleteResource("roosterblokken", blok.id);
await onKlaar(); await onKlaar();
@ -1607,13 +1609,13 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
leerling: f.doel_type === "leerling" ? f.doel : null, leerling: f.doel_type === "leerling" ? f.doel : null,
}); });
async function opslaan() { async function opslaan() {
try { await updateResource("roosterblokken", blok.id, { tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); await onKlaar(); } try { await updateResource("roosterblokken", blok.id, { tijdslot: f.tijdslot, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen , geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null }); await onKlaar(); }
catch (e) { setError(e.message); } catch (e) { setError(e.message); }
} }
async function kopieer() { async function kopieer() {
try { try {
for (const ts of kopieDoelen) { for (const ts of kopieDoelen) {
await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: ts, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: ts, activiteit: f.activiteit, locatie: f.locatie || null, lokaal: f.lokaal || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen , geldig_van: f.geldig_van || null, geldig_tot: f.geldig_tot || null });
} }
await onKlaar(); await onKlaar();
} catch (e) { setError(e.message); } } catch (e) { setError(e.message); }
@ -1654,6 +1656,8 @@ function BlokBewerker({ blok, ctx, onSluit, onKlaar }) {
{lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)} {lokalen.map((l) => <option key={l.id} value={l.id}>{l.naam}</option>)}
</select> </select>
<input placeholder="Opmerking" value={f.opmerkingen} onChange={(e) => setF({ ...f, opmerkingen: e.target.value })} /> <input placeholder="Opmerking" value={f.opmerkingen} onChange={(e) => setF({ ...f, opmerkingen: e.target.value })} />
<input type="date" title="Geldig vanaf (leeg = altijd)" value={f.geldig_van} onChange={(e) => setF({ ...f, geldig_van: e.target.value })} />
<input type="date" title="Geldig t/m (leeg = altijd)" value={f.geldig_tot} onChange={(e) => setF({ ...f, geldig_tot: e.target.value })} />
</div> </div>
<details className="editor-detail"> <details className="editor-detail">
@ -1883,7 +1887,7 @@ export function RoosterScherm({ mode }) {
// Blok bewerken/kopiëren (alleen in de Roostermaker). // Blok bewerken/kopiëren (alleen in de Roostermaker).
const [bewerkBlok, setBewerkBlok] = useState(null); const [bewerkBlok, setBewerkBlok] = useState(null);
const leeg = { tijdslot: "", activiteit: "", locatie: "", lokaal: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" }; const leeg = { tijdslot: "", activiteit: "", locatie: "", lokaal: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "", geldig_van: "", geldig_tot: "" };
const [form, setForm] = useState(leeg); const [form, setForm] = useState(leeg);
const [splitNieuwBlok, setSplitNieuwBlok] = useState(false); const [splitNieuwBlok, setSplitNieuwBlok] = useState(false);
const [splitNieuwDoelen, setSplitNieuwDoelen] = useState([]); const [splitNieuwDoelen, setSplitNieuwDoelen] = useState([]);
@ -2025,7 +2029,7 @@ export function RoosterScherm({ mode }) {
} }
async function opslaan(e) { async function opslaan(e) {
e.preventDefault(); e.preventDefault();
const basis = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, lokaal: form.lokaal || null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen }; const basis = { schooljaar, tijdslot: form.tijdslot, activiteit: form.activiteit, locatie: form.locatie || null, lokaal: form.lokaal || null, begeleiders: form.begeleiders, opmerkingen: form.opmerkingen, geldig_van: form.geldig_van || null, geldig_tot: form.geldig_tot || null };
try { try {
if (form.doel_type === "groep" && splitNieuwBlok && splitNieuwDoelen.length > 0) { if (form.doel_type === "groep" && splitNieuwBlok && splitNieuwDoelen.length > 0) {
for (const subgroep of splitNieuwDoelen) { for (const subgroep of splitNieuwDoelen) {
@ -2064,6 +2068,37 @@ export function RoosterScherm({ mode }) {
catch (err) { setError(err.message); } catch (err) { setError(err.message); }
} }
// Geldt dit blok op deze datum (periode / eenmalig)?
function blokGeldigOp(b, iso) {
if (b.geldig_van && iso < b.geldig_van) return false;
if (b.geldig_tot && iso > b.geldig_tot) return false;
return true;
}
// Maak één specifieke dag leeg: laat alle blokken die dag vervallen (alleen
// deze datum), zonder gevolgen voor andere dagen.
async function leegDag(iso) {
const ourWd = (new Date(iso).getDay() + 6) % 7;
const dagBlokken = blokken.filter((b) => {
const t = tijdsloten.find((x) => x.id === b.tijdslot);
if (!t || t.dag !== ourWd || !blokGeldigOp(b, iso)) return false;
const u = uitzOp(b.id, iso);
return !(u && u.vervalt);
});
if (dagBlokken.length === 0) { setError("Geen blokken om te laten vervallen op deze dag."); return; }
if (!confirm(`Alle ${dagBlokken.length} blok(ken) op ${iso} laten vervallen? Dit geldt alleen voor deze dag.`)) return;
try {
for (const b of dagBlokken) await createResource("blok-uitzonderingen", { roosterblok: b.id, datum: iso, vervalt: true });
await laadWeek();
} catch (err) { setError(err.message); }
}
// Voeg een eenmalig (alleen vandaag geldig) blok toe op deze dag.
function openEenmalig(iso) {
setBewerkBlok(null);
setForm({ ...leeg, geldig_van: iso, geldig_tot: iso });
resetNieuweSplit();
setToonForm(true);
}
// Hulpfuncties voor het effectieve rooster. // Hulpfuncties voor het effectieve rooster.
const kalenderOp = (iso) => kalender.find((k) => k.datum === iso); const kalenderOp = (iso) => kalender.find((k) => k.datum === iso);
const uitzOp = (blokId, iso) => uitzonderingen.find((u) => u.roosterblok === blokId && u.datum === iso); const uitzOp = (blokId, iso) => uitzonderingen.find((u) => u.roosterblok === blokId && u.datum === iso);
@ -2357,6 +2392,12 @@ export function RoosterScherm({ mode }) {
</select> </select>
<input placeholder="Opmerking (optioneel)" value={form.opmerkingen} onChange={(e) => setForm({ ...form, opmerkingen: e.target.value })} /> <input placeholder="Opmerking (optioneel)" value={form.opmerkingen} onChange={(e) => setForm({ ...form, opmerkingen: e.target.value })} />
</div> </div>
<div className="form-row periode-row">
<label className="veld-kol"><span className="veld-kop">Geldig vanaf (optioneel)</span><input type="date" value={form.geldig_van} onChange={(e) => setForm({ ...form, geldig_van: e.target.value })} /></label>
<label className="veld-kol"><span className="veld-kop">Geldig t/m (optioneel)</span><input type="date" value={form.geldig_tot} onChange={(e) => setForm({ ...form, geldig_tot: e.target.value })} /></label>
{form.geldig_van && form.geldig_van === form.geldig_tot && <span className="muted small periode-hint">eenmalig op {form.geldig_van}</span>}
{(form.geldig_van || form.geldig_tot) && <button type="button" className="link small" onClick={() => setForm({ ...form, geldig_van: "", geldig_tot: "" })}>wis periode</button>}
</div>
<details className="editor-detail" open> <details className="editor-detail" open>
<summary>Doelgroep</summary> <summary>Doelgroep</summary>
@ -2430,12 +2471,16 @@ export function RoosterScherm({ mode }) {
{kal && <div className={`tag dag-${kal.type}`}>{kal.type_label}{kal.omschrijving ? `: ${kal.omschrijving}` : ""}</div>} {kal && <div className={`tag dag-${kal.type}`}>{kal.type_label}{kal.omschrijving ? `: ${kal.omschrijving}` : ""}</div>}
{edit && (kal {edit && (kal
? <button type="button" className="icon-btn no-print dag-actie" title="Weer lesdag maken" onClick={() => dagHerstel(kal.id)}></button> ? <button type="button" className="icon-btn no-print dag-actie" title="Weer lesdag maken" onClick={() => dagHerstel(kal.id)}></button>
: <button type="button" className="icon-btn danger no-print dag-actie" title="Dag laten vervallen" onClick={() => dagVervallen(iso)}></button> : <>
<button type="button" className="icon-btn danger no-print dag-actie" title="Hele dag laten vervallen (vrije dag)" onClick={() => dagVervallen(iso)}></button>
<button type="button" className="icon-btn no-print dag-actie" title="Dag leegmaken (alleen deze dag)" onClick={() => leegDag(iso)}>🧹</button>
<button type="button" className="icon-btn no-print dag-actie" title="Eenmalig blok op deze dag toevoegen" onClick={() => openEenmalig(iso)}></button>
</>
)} )}
</div> </div>
{slots.length === 0 && <div className="muted small"></div>} {slots.length === 0 && <div className="muted small"></div>}
{slots.map((t) => { {slots.map((t) => {
const slotBlokken = zichtbareBlokken.filter((b) => b.tijdslot === t.id); const slotBlokken = zichtbareBlokken.filter((b) => b.tijdslot === t.id && blokGeldigOp(b, iso));
return ( return (
<div <div
className={hoverSlot === t.id ? "slot-cel hover" : "slot-cel"} className={hoverSlot === t.id ? "slot-cel hover" : "slot-cel"}
@ -2496,6 +2541,9 @@ export function UrenPage() {
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [groepFilter, setGroepFilter] = useState("");
const [vakFilter, setVakFilter] = useState("");
const [leerlingFilter, setLeerlingFilter] = useState("");
useEffect(() => { useEffect(() => {
listResource("schooljaren") listResource("schooljaren")
@ -2511,16 +2559,43 @@ export function UrenPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [schooljaar]); }, [schooljaar]);
const r1 = (n) => Math.round(n * 10) / 10;
const groepNamen = data ? [...new Set(data.per_groep.map((g) => g.groep))].sort() : [];
const vakNamen = data ? [...new Set(data.per_groep.flatMap((g) => g.vakken.map((v) => v.vak)))].sort() : [];
const perGroep = (data ? data.per_groep : [])
.filter((g) => !groepFilter || g.groep === groepFilter)
.map((g) => { const vakken = vakFilter ? g.vakken.filter((v) => v.vak === vakFilter) : g.vakken; return { ...g, vakken, totaal: r1(vakken.reduce((a, v) => a + v.uren, 0)) }; })
.filter((g) => g.vakken.length > 0);
const perLeerling = (data ? data.per_leerling : [])
.filter((l) => !leerlingFilter || l.leerling.toLowerCase().includes(leerlingFilter.toLowerCase()))
.filter((l) => !groepFilter || l.groep === groepFilter)
.filter((l) => !vakFilter || l.vakken.some((v) => v.vak === vakFilter))
.map((l) => { const vakken = vakFilter ? l.vakken.filter((v) => v.vak === vakFilter) : l.vakken; return { ...l, vakkenWeergave: vakken }; });
const filterActief = groepFilter || vakFilter || leerlingFilter;
return ( return (
<section> <section>
<h1>Urenoverzicht</h1> <h1>Urenoverzicht</h1>
<p className="muted">Lesuren per groep en per leerling over het hele schooljaar. Vrije dagen, vakanties en studiedagen tellen niet mee; vervallen blokken zijn afgetrokken. Stage-uren tellen mee bij de leerling.</p> <p className="muted">Lesuren per groep en per leerling over het hele schooljaar. Vrije dagen, vakanties en studiedagen tellen niet mee; vervallen blokken zijn afgetrokken. Stage-uren tellen mee bij de leerling.</p>
<Foutmelding msg={error} /> <Foutmelding msg={error} />
<div className="form-row"> <div className="form-row uren-filters">
<select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}> <select value={schooljaar} onChange={(e) => setSchooljaar(e.target.value)}>
<option value=""> schooljaar </option> <option value=""> schooljaar </option>
{schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)} {schooljaren.map((j) => <option key={j.id} value={j.id}>{j.naam}</option>)}
</select> </select>
<select value={groepFilter} onChange={(e) => setGroepFilter(e.target.value)}>
<option value="">alle groepen</option>
{groepNamen.map((n) => <option key={n} value={n}>{n}</option>)}
</select>
<select value={vakFilter} onChange={(e) => setVakFilter(e.target.value)}>
<option value="">alle vakken</option>
{vakNamen.map((n) => <option key={n} value={n}>{n}</option>)}
</select>
<input placeholder="zoek leerling…" value={leerlingFilter} onChange={(e) => setLeerlingFilter(e.target.value)} />
{filterActief && <button type="button" className="link small" onClick={() => { setGroepFilter(""); setVakFilter(""); setLeerlingFilter(""); }}>wis filters</button>}
</div> </div>
{loading ? <p>Laden</p> : data && ( {loading ? <p>Laden</p> : data && (
<> <>
@ -2528,8 +2603,8 @@ export function UrenPage() {
<table className="grid"> <table className="grid">
<thead><tr><th>Groep</th><th>Vak</th><th>Uren (schooljaar)</th></tr></thead> <thead><tr><th>Groep</th><th>Vak</th><th>Uren (schooljaar)</th></tr></thead>
<tbody> <tbody>
{data.per_groep.length === 0 && <tr><td colSpan="3" className="muted">Geen lesblokken gevonden.</td></tr>} {perGroep.length === 0 && <tr><td colSpan="3" className="muted">Geen resultaten.</td></tr>}
{data.per_groep.flatMap((g) => [ {perGroep.flatMap((g) => [
...g.vakken.map((v, i) => ( ...g.vakken.map((v, i) => (
<tr key={g.groep + "-" + v.vak}> <tr key={g.groep + "-" + v.vak}>
<td>{i === 0 ? <strong>{g.groep}</strong> : ""}</td> <td>{i === 0 ? <strong>{g.groep}</strong> : ""}</td>
@ -2547,12 +2622,12 @@ export function UrenPage() {
<table className="grid"> <table className="grid">
<thead><tr><th>Leerling</th><th>Groep</th><th>Vakken</th><th>Stage</th><th>Totaal</th></tr></thead> <thead><tr><th>Leerling</th><th>Groep</th><th>Vakken</th><th>Stage</th><th>Totaal</th></tr></thead>
<tbody> <tbody>
{data.per_leerling.length === 0 && <tr><td colSpan="5" className="muted">Geen leerlingen met uren.</td></tr>} {perLeerling.length === 0 && <tr><td colSpan="5" className="muted">Geen resultaten.</td></tr>}
{data.per_leerling.map((l) => ( {perLeerling.map((l) => (
<tr key={l.leerling}> <tr key={l.leerling}>
<td>{l.leerling}</td> <td>{l.leerling}</td>
<td className="muted">{l.groep || "—"}</td> <td className="muted">{l.groep || "—"}</td>
<td className="muted small">{l.vakken.map((v) => `${v.vak} ${v.uren}u`).join(", ") || "—"}</td> <td className="muted small">{l.vakkenWeergave.map((v) => `${v.vak} ${v.uren}u`).join(", ") || "—"}</td>
<td>{l.stage_uren ? `${l.stage_uren} u` : "—"}</td> <td>{l.stage_uren ? `${l.stage_uren} u` : "—"}</td>
<td><strong>{l.totaal} u</strong></td> <td><strong>{l.totaal} u</strong></td>
</tr> </tr>

View file

@ -422,3 +422,6 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
.icoon-input { width: 60px; text-align: center; } .icoon-input { width: 60px; text-align: center; }
.uren-totaal td { background: var(--bg); } .uren-totaal td { background: var(--bg); }
.uren-filters { flex-wrap: wrap; align-items: center; gap: 8px; }
.periode-row { align-items: flex-end; gap: 12px; flex-wrap: wrap; }
.periode-hint { color: #b45309; font-weight: 600; }