conflicten in rooster tonen + tabbalk logischer
This commit is contained in:
parent
d2905dba71
commit
19862ae8aa
3 changed files with 121 additions and 7 deletions
|
|
@ -37,6 +37,14 @@ volgt.
|
||||||
en **Modulebeheer** verdwijnen.
|
en **Modulebeheer** verdwijnen.
|
||||||
- [x] De hint **"Menu's verschijnen alleen voor ingeschakelde modules."** uit de
|
- [x] De hint **"Menu's verschijnen alleen voor ingeschakelde modules."** uit de
|
||||||
zijbalk verwijderd.
|
zijbalk verwijderd.
|
||||||
|
- [x] **Roostermaker-tabbalk logischer**: de losse tab "Conflicten" is vervallen
|
||||||
|
(conflicten staan nu in het weekrooster zelf) en Vak-wizard + Auto-rooster
|
||||||
|
zitten onder één **"Genereren ▾"**-knop. Overgebleven: Week · Jaar ·
|
||||||
|
Genereren · Instellingen.
|
||||||
|
- [x] **Conflicten direct in het rooster**: een melding boven het weekrooster
|
||||||
|
(uitklapbaar met details) én een rode omlijning rond de betrokken blokken.
|
||||||
|
Client-side berekend uit de geladen blokken (begeleider/lokaal/leerling
|
||||||
|
dubbel op hetzelfde tijdslot), met respect voor geldigheidsperiodes.
|
||||||
|
|
||||||
## Vakken
|
## Vakken
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1604,6 +1604,67 @@ export function blokInScope(blok, scope, ctx) {
|
||||||
return leerlingenVanBlok(blok, ctx).some((id) => set.has(id));
|
return leerlingenVanBlok(blok, ctx).some((id) => set.has(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bereken conflicten direct uit de geladen blokken (zelfde tijdslot): begeleider
|
||||||
|
// dubbel, lokaal dubbel of een leerling dubbel ingeroosterd. Houdt rekening met
|
||||||
|
// geldigheidsperiodes (blokken die elkaar niet in de tijd overlappen botsen niet).
|
||||||
|
export function berekenRoosterConflicten(blokken, ctx, tijdsloten) {
|
||||||
|
const blokIds = new Set();
|
||||||
|
const teksten = new Map(); // tekst -> ernst (dedupe)
|
||||||
|
const slotLabel = (id) => {
|
||||||
|
const t = (tijdsloten || []).find((x) => x.id === id);
|
||||||
|
if (!t) return "";
|
||||||
|
const dag = (WEEKDAGEN.find((d) => d.value === t.dag) || {}).label || "";
|
||||||
|
return `${dag} ${(t.start_tijd || "").slice(0, 5)}`.trim();
|
||||||
|
};
|
||||||
|
const leerlingNaam = (id) => {
|
||||||
|
const l = (ctx.leerlingen || []).find((x) => x.id === id);
|
||||||
|
return l ? l.volledige_naam : "Leerling";
|
||||||
|
};
|
||||||
|
const overlapt = (a, b) => {
|
||||||
|
const at = a.geldig_tot || null, av = a.geldig_van || null;
|
||||||
|
const bt = b.geldig_tot || null, bv = b.geldig_van || null;
|
||||||
|
if (at && bv && at < bv) return false;
|
||||||
|
if (bt && av && bt < av) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const perSlot = new Map();
|
||||||
|
blokken.forEach((b) => {
|
||||||
|
if (!perSlot.has(b.tijdslot)) perSlot.set(b.tijdslot, []);
|
||||||
|
perSlot.get(b.tijdslot).push(b);
|
||||||
|
});
|
||||||
|
perSlot.forEach((lijst, slotId) => {
|
||||||
|
if (lijst.length < 2) return;
|
||||||
|
const lab = slotLabel(slotId);
|
||||||
|
const flag = (a, b, tekst, ernst) => {
|
||||||
|
if (!overlapt(a, b)) return;
|
||||||
|
blokIds.add(a.id);
|
||||||
|
blokIds.add(b.id);
|
||||||
|
if (!teksten.has(tekst)) teksten.set(tekst, ernst);
|
||||||
|
};
|
||||||
|
for (let i = 0; i < lijst.length; i++) {
|
||||||
|
for (let j = i + 1; j < lijst.length; j++) {
|
||||||
|
const a = lijst[i], b = lijst[j];
|
||||||
|
(a.begeleiders_namen || []).forEach((p) => {
|
||||||
|
if ((b.begeleiders_namen || []).some((q) => q.id === p.id)) {
|
||||||
|
flag(a, b, `${p.naam} staat dubbel ingepland — ${lab}`, "fout");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (a.lokaal && b.lokaal && a.lokaal === b.lokaal) {
|
||||||
|
flag(a, b, `Lokaal ${a.lokaal_naam} dubbel geboekt — ${lab}`, "waarschuwing");
|
||||||
|
}
|
||||||
|
const lb = new Set(leerlingenVanBlok(b, ctx));
|
||||||
|
leerlingenVanBlok(a, ctx)
|
||||||
|
.filter((id) => lb.has(id))
|
||||||
|
.forEach((id) => flag(a, b, `${leerlingNaam(id)} is dubbel ingeroosterd — ${lab}`, "fout"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const meldingen = [...teksten.entries()]
|
||||||
|
.map(([tekst, ernst]) => ({ tekst, ernst }))
|
||||||
|
.sort((x, y) => (x.ernst === y.ernst ? 0 : x.ernst === "fout" ? -1 : 1));
|
||||||
|
return { blokIds, meldingen };
|
||||||
|
}
|
||||||
|
|
||||||
function groepVanSubgroep(subgroepen, subgroepId) {
|
function groepVanSubgroep(subgroepen, subgroepId) {
|
||||||
const sub = subgroepen.find((s) => s.id === subgroepId);
|
const sub = subgroepen.find((s) => s.id === subgroepId);
|
||||||
return sub ? sub.groep : null;
|
return sub ? sub.groep : null;
|
||||||
|
|
@ -1992,6 +2053,8 @@ export function RoosterScherm({ mode }) {
|
||||||
const laadRef = useRef(() => {});
|
const laadRef = useRef(() => {});
|
||||||
const [ghost, setGhost] = useState(null);
|
const [ghost, setGhost] = useState(null);
|
||||||
const [hoverSlot, setHoverSlot] = useState(null);
|
const [hoverSlot, setHoverSlot] = useState(null);
|
||||||
|
const [conflictOpen, setConflictOpen] = useState(false);
|
||||||
|
const [genOpen, setGenOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar((s) => s || String(j[0].id)); }).catch((e) => setError(e.message));
|
listResource("schooljaren").then((j) => { setSchooljaren(j); if (j.length) setSchooljaar((s) => s || String(j[0].id)); }).catch((e) => setError(e.message));
|
||||||
|
|
@ -2017,6 +2080,9 @@ export function RoosterScherm({ mode }) {
|
||||||
const scopeCtx = { leerlingen, subgroepen, leerpleinen };
|
const scopeCtx = { leerlingen, subgroepen, leerpleinen };
|
||||||
const scope = { type: scopeType, id: scopeId ? Number(scopeId) : "" };
|
const scope = { type: scopeType, id: scopeId ? Number(scopeId) : "" };
|
||||||
const zichtbareBlokken = blokken.filter((b) => blokInScope(b, scope, scopeCtx));
|
const zichtbareBlokken = blokken.filter((b) => blokInScope(b, scope, scopeCtx));
|
||||||
|
// Conflicten over álle blokken (niet alleen de zichtbare scope), zodat de
|
||||||
|
// melding compleet is en de betrokken blokken in het rooster omlijnd worden.
|
||||||
|
const conflictInfo = berekenRoosterConflicten(blokken, scopeCtx, tijdsloten);
|
||||||
const scopeOpties = scopeType === "klas" ? groepen
|
const scopeOpties = scopeType === "klas" ? groepen
|
||||||
: scopeType === "leerplein" ? leerpleinen
|
: scopeType === "leerplein" ? leerpleinen
|
||||||
: scopeType === "leerling" ? leerlingen
|
: scopeType === "leerling" ? leerlingen
|
||||||
|
|
@ -2240,7 +2306,7 @@ export function RoosterScherm({ mode }) {
|
||||||
const vervallen = !!kal || (uit && uit.vervalt);
|
const vervallen = !!kal || (uit && uit.vervalt);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={(vervallen ? "rooster-blok vervallen" : "rooster-blok") + (edit ? " klikbaar" : "") + (isOpenBlok(b) ? " open" : "")}
|
className={(vervallen ? "rooster-blok vervallen" : "rooster-blok") + (edit ? " klikbaar" : "") + (isOpenBlok(b) ? " open" : "") + (conflictInfo.blokIds.has(b.id) ? " in-conflict" : "")}
|
||||||
key={b.id}
|
key={b.id}
|
||||||
style={{ borderLeftColor: b.activiteit_kleur }}
|
style={{ borderLeftColor: b.activiteit_kleur }}
|
||||||
onClick={edit ? () => toggleOpenBlok(b) : undefined}
|
onClick={edit ? () => toggleOpenBlok(b) : undefined}
|
||||||
|
|
@ -2275,7 +2341,7 @@ export function RoosterScherm({ mode }) {
|
||||||
const vervallen = !!kal || (uit && uit.vervalt);
|
const vervallen = !!kal || (uit && uit.vervalt);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={(vervallen ? "split-vak vervallen" : "split-vak") + (edit ? " klikbaar" : "") + (isOpenBlok(b) ? " open" : "")}
|
className={(vervallen ? "split-vak vervallen" : "split-vak") + (edit ? " klikbaar" : "") + (isOpenBlok(b) ? " open" : "") + (conflictInfo.blokIds.has(b.id) ? " in-conflict" : "")}
|
||||||
key={b.id}
|
key={b.id}
|
||||||
style={{ borderLeftColor: b.activiteit_kleur }}
|
style={{ borderLeftColor: b.activiteit_kleur }}
|
||||||
onClick={edit ? () => toggleOpenBlok(b) : undefined}
|
onClick={edit ? () => toggleOpenBlok(b) : undefined}
|
||||||
|
|
@ -2445,10 +2511,18 @@ export function RoosterScherm({ mode }) {
|
||||||
{edit && (
|
{edit && (
|
||||||
<div className="tabbar no-print">
|
<div className="tabbar no-print">
|
||||||
<button className={tab === "week" ? "tab on" : "tab"} title="Weekrooster" onClick={() => setTab("week")}><span className="ico">▦</span><span className="tab-tekst">Week</span></button>
|
<button className={tab === "week" ? "tab on" : "tab"} title="Weekrooster" onClick={() => setTab("week")}><span className="ico">▦</span><span className="tab-tekst">Week</span></button>
|
||||||
<button className={tab === "conflicten" ? "tab on" : "tab"} title="Conflicten" onClick={() => setTab("conflicten")}><span className="ico">⚠</span><span className="tab-tekst">Conflicten</span></button>
|
|
||||||
<button className={tab === "schooljaar" ? "tab on" : "tab"} title="Schooljaar" onClick={() => setTab("schooljaar")}><span className="ico">◷</span><span className="tab-tekst">Jaar</span></button>
|
<button className={tab === "schooljaar" ? "tab on" : "tab"} title="Schooljaar" onClick={() => setTab("schooljaar")}><span className="ico">◷</span><span className="tab-tekst">Jaar</span></button>
|
||||||
{blokwizardAan && <button className={tab === "wizard" ? "tab on" : "tab"} title="Vak-wizard" onClick={() => setTab("wizard")}><span className="ico">✨</span><span className="tab-tekst">Wizard</span></button>}
|
{(blokwizardAan || autoroosterAan) && (
|
||||||
{autoroosterAan && <button className={tab === "auto" ? "tab on" : "tab"} title="Automatisch rooster" onClick={() => setTab("auto")}><span className="ico">🪄</span><span className="tab-tekst">Auto-rooster</span></button>}
|
<div className="tab-dropdown">
|
||||||
|
<button className={(tab === "wizard" || tab === "auto") ? "tab on" : "tab"} title="Rooster genereren" onClick={() => setGenOpen((v) => !v)}><span className="ico">✨</span><span className="tab-tekst">Genereren ▾</span></button>
|
||||||
|
{genOpen && (
|
||||||
|
<div className="tab-menu" onMouseLeave={() => setGenOpen(false)}>
|
||||||
|
{blokwizardAan && <button type="button" className="tab-menu-item" onClick={() => { setTab("wizard"); setGenOpen(false); }}>✨ Vak-wizard</button>}
|
||||||
|
{autoroosterAan && <button type="button" className="tab-menu-item" onClick={() => { setTab("auto"); setGenOpen(false); }}>🪄 Auto-rooster</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button className={tab === "instellingen" ? "tab on" : "tab"} title="Instellingen" onClick={() => setTab("instellingen")}><span className="ico">⚙</span><span className="tab-tekst">Instellingen</span></button>
|
<button className={tab === "instellingen" ? "tab on" : "tab"} title="Instellingen" onClick={() => setTab("instellingen")}><span className="ico">⚙</span><span className="tab-tekst">Instellingen</span></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -2555,6 +2629,23 @@ export function RoosterScherm({ mode }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{schooljaar && tab === "week" && conflictInfo.meldingen.length > 0 && (
|
||||||
|
<div className="conflict-banner no-print">
|
||||||
|
<button type="button" className="conflict-kop" onClick={() => setConflictOpen((v) => !v)}>
|
||||||
|
<span className="ico">⚠</span>
|
||||||
|
<span className="conflict-aantal">{conflictInfo.meldingen.length} conflict{conflictInfo.meldingen.length === 1 ? "" : "en"} in dit rooster</span>
|
||||||
|
<span className="muted small">{conflictOpen ? "▲ verberg" : "▼ toon details"}</span>
|
||||||
|
</button>
|
||||||
|
{conflictOpen && (
|
||||||
|
<ul className="conflict-lijst">
|
||||||
|
{conflictInfo.meldingen.map((m, i) => (
|
||||||
|
<li key={i} className={m.ernst === "fout" ? "fout" : "waarschuwing"}>{m.tekst}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{schooljaar && tab === "week" && (
|
{schooljaar && tab === "week" && (
|
||||||
<div className="rooster-grid">
|
<div className="rooster-grid">
|
||||||
{WEEKDAGEN.map((d, i) => {
|
{WEEKDAGEN.map((d, i) => {
|
||||||
|
|
@ -2597,8 +2688,6 @@ export function RoosterScherm({ mode }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{schooljaar && tab === "conflicten" && <Conflicten schooljaar={schooljaar} />}
|
|
||||||
|
|
||||||
{tab === "schooljaar" && (
|
{tab === "schooljaar" && (
|
||||||
<div className="instellingen">
|
<div className="instellingen">
|
||||||
<p className="muted">Maak hier snel een schooljaar aan en beheer de kalender (studiedagen, vrije dagen, vakanties).</p>
|
<p className="muted">Maak hier snel een schooljaar aan en beheer de kalender (studiedagen, vrije dagen, vakanties).</p>
|
||||||
|
|
|
||||||
|
|
@ -508,3 +508,20 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px;
|
||||||
.tabbar .tab .tab-tekst { display: none; }
|
.tabbar .tab .tab-tekst { display: none; }
|
||||||
.tabbar .tab { padding-left: 12px; padding-right: 12px; }
|
.tabbar .tab { padding-left: 12px; padding-right: 12px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "Genereren"-uitklap in de tabbalk (wizard + auto-rooster gebundeld) */
|
||||||
|
.tab-dropdown { position: relative; }
|
||||||
|
.tab-menu { position: absolute; left: 0; top: 100%; margin-top: 4px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,.15); min-width: 180px; padding: 6px; z-index: 60; display: flex; flex-direction: column; }
|
||||||
|
.tab-menu-item { background: transparent; border: 0; text-align: left; padding: 8px 10px; border-radius: 8px; font: inherit; color: var(--ink); cursor: pointer; }
|
||||||
|
.tab-menu-item:hover { background: var(--line); }
|
||||||
|
|
||||||
|
/* Conflict-melding direct boven het weekrooster + omlijning van betrokken blokken */
|
||||||
|
.conflict-banner { border: 1px solid #fca5a5; background: #fef2f2; border-radius: 10px; margin: 0 0 14px; overflow: hidden; }
|
||||||
|
.conflict-kop { display: flex; align-items: center; gap: 10px; width: 100%; background: transparent; border: 0; padding: 10px 14px; font: inherit; color: #991b1b; cursor: pointer; text-align: left; }
|
||||||
|
.conflict-kop .ico { font-size: 16px; }
|
||||||
|
.conflict-kop .conflict-aantal { font-weight: 600; flex: 1; }
|
||||||
|
.conflict-lijst { list-style: none; margin: 0; padding: 0 14px 12px; }
|
||||||
|
.conflict-lijst li { padding: 4px 0 4px 16px; position: relative; color: #7f1d1d; font-size: 14px; }
|
||||||
|
.conflict-lijst li::before { content: "•"; position: absolute; left: 2px; }
|
||||||
|
.conflict-lijst li.waarschuwing { color: #92400e; }
|
||||||
|
.rooster-blok.in-conflict, .split-vak.in-conflict { outline: 2px solid #ef4444; outline-offset: 1px; box-shadow: 0 0 0 4px rgba(239,68,68,.15); }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue