From 87c155955361d4f85ae3d7f3b61ab1a2d03562ae Mon Sep 17 00:00:00 2001 From: bes-r <27369668+bes-r@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:35:19 +0200 Subject: [PATCH] =?UTF-8?q?Gebruikersmodule=20(rollen=20+=20persoonskoppel?= =?UTF-8?q?ing),=20blok=20bewerken/kopi=C3=ABren=20in=20Roostermaker,=20pr?= =?UTF-8?q?intopties=20per=20klas/leerplein/leerling/personeel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 +- ROADMAP.md | 18 +- backend/config/urls.py | 1 + backend/modules/accounts/admin.py | 10 + .../accounts/migrations/0001_initial.py | 31 ++ backend/modules/accounts/models.py | 36 +++ backend/modules/accounts/serializers.py | 80 +++++ backend/modules/accounts/urls.py | 9 + backend/modules/accounts/views.py | 17 ++ frontend/src/App.jsx | 3 +- frontend/src/pages.jsx | 280 +++++++++++++++++- frontend/src/styles.css | 10 + 12 files changed, 490 insertions(+), 19 deletions(-) create mode 100644 backend/modules/accounts/admin.py create mode 100644 backend/modules/accounts/migrations/0001_initial.py create mode 100644 backend/modules/accounts/models.py create mode 100644 backend/modules/accounts/serializers.py create mode 100644 backend/modules/accounts/urls.py create mode 100644 backend/modules/accounts/views.py diff --git a/README.md b/README.md index 41e2253..e6da926 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,17 @@ Modulebeheer. Er zijn twee rooster-schermen: **Rooster** (alleen-lezen weergave, om te bekijken en af te drukken) en **Roostermaker** (de aanpasser met drag-and-drop, tabs en -conflictdetectie). De module **Printen/Exporteren** voegt — als die aanstaat — een -subtiele **Afdrukken**-knop toe aan beide; die drukt het weekrooster -printvriendelijk af (of bewaart als PDF via de printdialoog). +conflictdetectie). In de Roostermaker open je een blok met een **klik** om het te +bewerken (vak, locatie, doelgroep, begeleiders, opmerking) of te **kopiëren** naar +andere tijden/dagen. Beide schermen hebben een **scope-filter** (alles / per klas / +per leerplein / per leerling / per personeel); de module **Printen/Exporteren** +voegt — als die aanstaat — een subtiele **Afdrukken**-knop toe die het (gefilterde) +weekrooster printvriendelijk afdrukt of als PDF bewaart. + +Optionele module **Multi-user & toegang**: een **Gebruikers**-scherm (onder +Instellingen, alleen voor beheerders) om inloggers te beheren — gebruikersnaam, +wachtwoord, rol (beheerder/medewerker/ouder/leerling), koppeling aan een persoon, +en actief/beheerder-status. Optionele module **Leerplein**: koppelt subgroepen aan groepen en bundelt groepen, subgroepen én losse leerlingen onder een overkoepelend leerplein. diff --git a/ROADMAP.md b/ROADMAP.md index c57c7e6..15a582a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,12 +6,12 @@ volgt. ## Fase 6 — Rooster bewerken (snel & overzichtelijk) -- [ ] **Blokken kopiëren** naar andere tijden en dagen (dupliceren naar een ander - tijdslot, evt. meerdere tegelijk). -- [ ] **Blok aanpassen bij klik**: op een blok klikken opent direct een - bewerkscherm (nu kan alleen slepen/verwijderen). -- [ ] **Snelle inline-aanpassing per blok**: groep, vak/activiteit, gekoppeld - personeel én individuele leerlingen wijzigen vanuit het blok zelf. +- [x] **Blokken kopiëren** naar andere tijden en dagen (via het bewerkpaneel naar + meerdere tijdsloten tegelijk). +- [x] **Blok aanpassen bij klik**: klik op een blok in de Roostermaker opent een + bewerkpaneel. +- [x] **Snelle inline-aanpassing per blok**: vak/activiteit, locatie, doelgroep + (groep/subgroep/leerling), begeleiders en opmerking wijzigen in het paneel. - [ ] Vanuit het vak/blok direct een **subgroep aanmaken** (zonder naar het Subgroepen-scherm te gaan). - [ ] **Sneltoetsen** voor veelgebruikte acties (bv. nieuw blok, kopiëren, @@ -24,9 +24,9 @@ volgt. ## Fase 6 — Overzicht & navigatie -- [ ] **Alle klassen verwerkbaar in het rooster**, overzichtelijk gemaakt — - bijvoorbeeld **per leerplein een eigen overzicht** in plaats van alles door - elkaar. +- [~] **Alle klassen verwerkbaar in het rooster**, overzichtelijk gemaakt — een + **scope-filter** (per klas/leerplein/leerling/personeel) is toegevoegd in + Rooster en Roostermaker; een vast **per-leerplein-overzicht** kan nog volgen. - [ ] **Snelmenu** om per klas of per leerplein direct het rooster te tonen. - [x] **Schooljaar uit het hoofdmenu** halen en **onder Rooster** plaatsen (tab "Schooljaar") met snelle invoer + kalenderbeheer. diff --git a/backend/config/urls.py b/backend/config/urls.py index 6b8d9b0..06167ea 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -11,4 +11,5 @@ urlpatterns = [ # Module-API's. path("api/", include("modules.leerplein.urls")), path("api/", include("modules.stage.urls")), + path("api/", include("modules.accounts.urls")), ] diff --git a/backend/modules/accounts/admin.py b/backend/modules/accounts/admin.py new file mode 100644 index 0000000..d55ebf8 --- /dev/null +++ b/backend/modules/accounts/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import Gebruikersprofiel + + +@admin.register(Gebruikersprofiel) +class GebruikersprofielAdmin(admin.ModelAdmin): + list_display = ("user", "rol", "persoon") + list_filter = ("rol",) + search_fields = ("user__username",) diff --git a/backend/modules/accounts/migrations/0001_initial.py b/backend/modules/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..4844c20 --- /dev/null +++ b/backend/modules/accounts/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.15 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('core', '0004_activiteit_theorievak_persoon_geboortedatum_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Gebruikersprofiel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rol', models.CharField(choices=[('beheerder', 'Beheerder'), ('medewerker', 'Medewerker'), ('ouder', 'Ouder'), ('leerling', 'Leerling')], default='medewerker', max_length=16, verbose_name='rol')), + ('persoon', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='gebruikers', to='core.persoon')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='gebruikersprofiel', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'gebruikersprofiel', + 'verbose_name_plural': 'gebruikersprofielen', + }, + ), + ] diff --git a/backend/modules/accounts/models.py b/backend/modules/accounts/models.py new file mode 100644 index 0000000..5581017 --- /dev/null +++ b/backend/modules/accounts/models.py @@ -0,0 +1,36 @@ +""" +Accounts-module: gebruikersbeheer. + +Bovenop Django's ingebouwde User leggen we een profiel met een rol en een +optionele koppeling aan een persoon (leerling/medewerker) uit de kern. +""" +from django.conf import settings +from django.db import models + + +class Gebruikersrol(models.TextChoices): + BEHEERDER = "beheerder", "Beheerder" + MEDEWERKER = "medewerker", "Medewerker" + OUDER = "ouder", "Ouder" + LEERLING = "leerling", "Leerling" + + +class Gebruikersprofiel(models.Model): + user = models.OneToOneField( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="gebruikersprofiel" + ) + rol = models.CharField( + "rol", max_length=16, choices=Gebruikersrol.choices, default=Gebruikersrol.MEDEWERKER + ) + persoon = models.ForeignKey( + "core.Persoon", on_delete=models.SET_NULL, null=True, blank=True, + related_name="gebruikers", + help_text="Koppeling aan een leerling of medewerker uit de kern.", + ) + + class Meta: + verbose_name = "gebruikersprofiel" + verbose_name_plural = "gebruikersprofielen" + + def __str__(self) -> str: + return f"{self.user.get_username()} ({self.get_rol_display()})" diff --git a/backend/modules/accounts/serializers.py b/backend/modules/accounts/serializers.py new file mode 100644 index 0000000..87393ff --- /dev/null +++ b/backend/modules/accounts/serializers.py @@ -0,0 +1,80 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from core.models import Persoon + +from .models import Gebruikersprofiel, Gebruikersrol + +User = get_user_model() + + +class GebruikerSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, required=False, allow_blank=True, style={"input_type": "password"}) + rol = serializers.ChoiceField(choices=Gebruikersrol.choices, required=False) + persoon = serializers.PrimaryKeyRelatedField( + queryset=Persoon.objects.all(), required=False, allow_null=True + ) + persoon_naam = serializers.SerializerMethodField() + rol_label = serializers.SerializerMethodField() + + class Meta: + model = User + fields = [ + "id", "username", "password", "first_name", "last_name", "email", + "is_staff", "is_active", "rol", "rol_label", "persoon", "persoon_naam", + "last_login", + ] + read_only_fields = ["last_login"] + + # --- profiel-helpers --- + def _profiel(self, user): + return getattr(user, "gebruikersprofiel", None) + + def get_persoon_naam(self, user): + p = self._profiel(user) + return p.persoon.volledige_naam if (p and p.persoon_id) else None + + def get_rol_label(self, user): + p = self._profiel(user) + return p.get_rol_display() if p else None + + def to_representation(self, user): + data = super().to_representation(user) + p = self._profiel(user) + data["rol"] = p.rol if p else None + data["persoon"] = p.persoon_id if p else None + return data + + def validate(self, data): + if not self.instance and not data.get("password"): + raise serializers.ValidationError({"password": "Een wachtwoord is verplicht voor een nieuwe gebruiker."}) + return data + + def create(self, validated_data): + rol = validated_data.pop("rol", Gebruikersrol.MEDEWERKER) + persoon = validated_data.pop("persoon", None) + password = validated_data.pop("password", None) + user = User(**validated_data) + user.set_password(password) + user.save() + Gebruikersprofiel.objects.create(user=user, rol=rol, persoon=persoon) + return user + + def update(self, user, validated_data): + rol = validated_data.pop("rol", None) + persoon = validated_data.pop("persoon", "__keep__") + password = validated_data.pop("password", None) + for k, v in validated_data.items(): + setattr(user, k, v) + if password: + user.set_password(password) + user.save() + profiel, _ = Gebruikersprofiel.objects.get_or_create(user=user) + if rol is not None: + profiel.rol = rol + if persoon != "__keep__": + profiel.persoon = persoon + profiel.save() + # Ververs de gecachete reverse-relatie, anders toont de respons de oude rol. + user.gebruikersprofiel = profiel + return user diff --git a/backend/modules/accounts/urls.py b/backend/modules/accounts/urls.py new file mode 100644 index 0000000..7d560c1 --- /dev/null +++ b/backend/modules/accounts/urls.py @@ -0,0 +1,9 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from . import views + +router = DefaultRouter() +router.register("gebruikers", views.GebruikerViewSet, basename="gebruiker") + +urlpatterns = [path("", include(router.urls))] diff --git a/backend/modules/accounts/views.py b/backend/modules/accounts/views.py new file mode 100644 index 0000000..d785aa0 --- /dev/null +++ b/backend/modules/accounts/views.py @@ -0,0 +1,17 @@ +from django.contrib.auth import get_user_model +from rest_framework import viewsets +from rest_framework.permissions import IsAdminUser + +from .serializers import GebruikerSerializer + +User = get_user_model() + + +class GebruikerViewSet(viewsets.ModelViewSet): + """Gebruikersbeheer — alleen voor beheerders (is_staff).""" + + serializer_class = GebruikerSerializer + permission_classes = [IsAdminUser] + + def get_queryset(self): + return User.objects.select_related("gebruikersprofiel", "gebruikersprofiel__persoon").order_by("username") diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8b6f9ed..11142af 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,7 +5,7 @@ import { GroepenPage, PersonenPage, SubgroepenPage, FunctiesPage, ActiviteitenPage, LocatiesPage, TijdslotenPage, SchooljaarPage, RoosterWeergavePage, RoostermakerPage, - AfwezigheidPage, LeerpleinPage, StagePage, + AfwezigheidPage, LeerpleinPage, StagePage, GebruikersPage, } from "./pages.jsx"; const ICONS = { @@ -30,6 +30,7 @@ const PAGES = { "/afwezigheid": AfwezigheidPage, "/leerplein": LeerpleinPage, "/stage": StagePage, + "/gebruikers": GebruikersPage, }; export default function App() { diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 5a9d60d..429350a 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -981,6 +981,103 @@ export function StagePage() { ); } +/* -------------------------------- Gebruikers ------------------------------ */ +const GEBRUIKERSROLLEN = [ + { value: "beheerder", label: "Beheerder" }, + { value: "medewerker", label: "Medewerker" }, + { value: "ouder", label: "Ouder" }, + { value: "leerling", label: "Leerling" }, +]; + +export function GebruikersPage() { + const { items, error, loading, reload, setError } = useList("gebruikers"); + const [personen, setPersonen] = useState([]); + const leeg = { username: "", password: "", first_name: "", last_name: "", email: "", rol: "medewerker", persoon: "", is_staff: false, is_active: true }; + const [form, setForm] = useState(leeg); + const [editId, setEditId] = useState(null); + + useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []); + + async function opslaan(e) { + e.preventDefault(); + const payload = { + username: form.username, first_name: form.first_name, last_name: form.last_name, + email: form.email, rol: form.rol, persoon: form.persoon || null, + is_staff: form.is_staff, is_active: form.is_active, + }; + if (form.password) payload.password = form.password; + try { + if (editId) await updateResource("gebruikers", editId, payload); + else await createResource("gebruikers", payload); + setForm(leeg); + setEditId(null); + await reload(); + } catch (err) { setError(err.message); } + } + async function verwijder(id) { + if (!confirm("Deze gebruiker verwijderen?")) return; + try { await deleteResource("gebruikers", id); await reload(); } catch (err) { setError(err.message); } + } + function bewerk(u) { + setEditId(u.id); + setForm({ username: u.username, password: "", first_name: u.first_name || "", last_name: u.last_name || "", email: u.email || "", rol: u.rol || "medewerker", persoon: u.persoon || "", is_staff: u.is_staff, is_active: u.is_active }); + } + + return ( +
+

Gebruikers

+

Beheer inloggers en hun rol. Alleen beheerders kunnen dit scherm gebruiken.

+ +
+
+ setForm({ ...form, username: e.target.value })} /> + setForm({ ...form, password: e.target.value })} /> + +
+
+ setForm({ ...form, first_name: e.target.value })} /> + setForm({ ...form, last_name: e.target.value })} /> + setForm({ ...form, email: e.target.value })} /> +
+
+ + + +
+
+ + {editId && } +
+
+ {loading ?

Laden…

: ( + + + + {items.map((u) => ( + + + + + + + + ))} + {items.length === 0 && } + +
GebruikerRolPersoonStatus
{u.username}{(u.first_name || u.last_name) &&
{u.first_name} {u.last_name}
}
{u.rol_label || "—"}{u.is_staff && beheer}{u.persoon_naam || "—"}{u.is_active ? "actief" : "inactief"} + + +
Geen gebruikers.
+ )} +
+ ); +} + /* -------------------------------- Conflicten ------------------------------ */ function Conflicten({ schooljaar }) { const [data, setData] = useState(null); @@ -1013,6 +1110,128 @@ function fmtDM(d) { return `${pad2(d.getDate())}-${pad2(d.getMonth() + 1)}`; } function maandagVan(d) { const x = new Date(d); x.setHours(0, 0, 0, 0); const wd = (x.getDay() + 6) % 7; x.setDate(x.getDate() - wd); return x; } function plusDagen(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; } +/* ------------------------- Rooster-scope (filter/print) ------------------- */ +// Welke leerlingen raakt een blok (via groep, subgroep of individueel)? +export function leerlingenVanBlok(blok, ctx) { + if (blok.groep) return ctx.leerlingen.filter((l) => l.groep === blok.groep).map((l) => l.id); + if (blok.subgroep) { + const s = ctx.subgroepen.find((x) => x.id === blok.subgroep); + return s ? s.leden_namen.map((m) => m.id) : []; + } + if (blok.leerling) return [blok.leerling]; + return []; +} +// Welke leerlingen vallen binnen een gekozen scope (klas/leerplein/leerling)? +export function scopeLeerlingen(scope, ctx) { + if (scope.type === "leerling") return [scope.id]; + if (scope.type === "klas") return ctx.leerlingen.filter((l) => l.groep === scope.id).map((l) => l.id); + if (scope.type === "leerplein") { + const p = ctx.leerpleinen.find((x) => x.id === scope.id); + if (!p) return []; + const ids = new Set(p.leerlingen || []); + (p.groepen || []).forEach((g) => ctx.leerlingen.filter((l) => l.groep === g).forEach((l) => ids.add(l.id))); + (p.subgroepen || []).forEach((sid) => { + const s = ctx.subgroepen.find((x) => x.id === sid); + if (s) s.leden_namen.forEach((m) => ids.add(m.id)); + }); + return [...ids]; + } + return []; +} +// Valt een blok binnen de gekozen scope? +export function blokInScope(blok, scope, ctx) { + if (scope.type === "alles" || !scope.id) return true; + if (scope.type === "personeel") return (blok.begeleiders || []).includes(scope.id); + const set = new Set(scopeLeerlingen(scope, ctx)); + return leerlingenVanBlok(blok, ctx).some((id) => set.has(id)); +} + +/* ----------------------------- Blok bewerken/kopiëren --------------------- */ +function BlokBewerker({ blok, ctx, onSluit, onKlaar }) { + const { activiteiten, locaties, groepen, subgroepen, leerlingen, medewerkers, tijdsloten } = ctx; + const [f, setF] = useState({ + activiteit: blok.activiteit, + locatie: blok.locatie || "", + doel_type: blok.doel_type, + doel: blok.groep || blok.subgroep || blok.leerling || "", + begeleiders: [...(blok.begeleiders || [])], + opmerkingen: blok.opmerkingen || "", + }); + const [kopieDoelen, setKopieDoelen] = useState([]); + const [error, setError] = useState(null); + const doelOpties = f.doel_type === "groep" ? groepen : f.doel_type === "subgroep" ? subgroepen : leerlingen; + const toggleBeg = (id) => setF((s) => s.begeleiders.includes(id) ? { ...s, begeleiders: s.begeleiders.filter((x) => x !== id) } : { ...s, begeleiders: [...s.begeleiders, id] }); + const toggleKopie = (id) => setKopieDoelen((d) => d.includes(id) ? d.filter((x) => x !== id) : [...d, id]); + const doelPayload = () => ({ + groep: f.doel_type === "groep" ? f.doel : null, + subgroep: f.doel_type === "subgroep" ? f.doel : null, + leerling: f.doel_type === "leerling" ? f.doel : null, + }); + async function opslaan() { + try { await updateResource("roosterblokken", blok.id, { activiteit: f.activiteit, locatie: f.locatie || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); await onKlaar(); } + catch (e) { setError(e.message); } + } + async function kopieer() { + try { + for (const ts of kopieDoelen) { + await createResource("roosterblokken", { schooljaar: blok.schooljaar, tijdslot: ts, activiteit: f.activiteit, locatie: f.locatie || null, ...doelPayload(), begeleiders: f.begeleiders, opmerkingen: f.opmerkingen }); + } + await onKlaar(); + } catch (e) { setError(e.message); } + } + async function verwijderen() { + if (!confirm("Dit blok (alle weken) verwijderen?")) return; + try { await deleteResource("roosterblokken", blok.id); await onKlaar(); } catch (e) { setError(e.message); } + } + return ( +
+
e.stopPropagation()}> +

Blok bewerken

+ +
+ + +
+
+ + +
+
+
Begeleiders:
+
+ {medewerkers.map((m) => )} +
+
+
setF({ ...f, opmerkingen: e.target.value })} />
+
+ + +
+
+
Kopiëren naar andere tijden/dagen:
+
+ {tijdsloten.map((t) => )} +
+
+ +
+
+
+ ); +} + /* ---------------------------------- Rooster ------------------------------- */ // Eén component met twee modi: // - mode="view" -> Rooster (alleen-lezen weergave, om te bekijken/printen) @@ -1033,10 +1252,16 @@ export function RoosterScherm({ mode }) { const [kalender, setKalender] = useState([]); const [afwezig, setAfwezig] = useState([]); const [uitzonderingen, setUitzonderingen] = useState([]); + const [leerpleinen, setLeerpleinen] = useState([]); const [maandag, setMaandag] = useState(() => maandagVan(new Date())); const [toonForm, setToonForm] = useState(false); const [tab, setTab] = useState("week"); const [error, setError] = useState(null); + // Scope-filter (ook voor gericht afdrukken): per klas/leerplein/leerling/personeel. + const [scopeType, setScopeType] = useState("alles"); + const [scopeId, setScopeId] = useState(""); + // Blok bewerken/kopiëren (alleen in de Roostermaker). + const [bewerkBlok, setBewerkBlok] = useState(null); const leeg = { tijdslot: "", activiteit: "", locatie: "", doel_type: "groep", doel: "", begeleiders: [], opmerkingen: "" }; const [form, setForm] = useState(leeg); @@ -1056,10 +1281,21 @@ export function RoosterScherm({ mode }) { listResource("subgroepen").then(setSubgroepen).catch(() => {}); listResource("personen", "?rol=leerling").then(setLeerlingen).catch(() => {}); listResource("personen").then((ps) => setMedewerkers(ps.filter((p) => p.rol !== "leerling"))).catch(() => {}); + listResource("leerpleinen").then(setLeerpleinen).catch(() => {}); // Afdrukken alleen tonen als de print-module aanstaat. getModules().then((ms) => setPrintAan(ms.some((m) => m.key === "printing" && m.enabled))).catch(() => {}); }, []); + // Scope-context + gefilterde blokken (voor weergave/afdrukken). + const scopeCtx = { leerlingen, subgroepen, leerpleinen }; + const scope = { type: scopeType, id: scopeId ? Number(scopeId) : "" }; + const zichtbareBlokken = blokken.filter((b) => blokInScope(b, scope, scopeCtx)); + const scopeOpties = scopeType === "klas" ? groepen + : scopeType === "leerplein" ? leerpleinen + : scopeType === "leerling" ? leerlingen + : scopeType === "personeel" ? medewerkers + : []; + const weekDatums = [0, 1, 2, 3, 4].map((i) => plusDagen(maandag, i)); const vanISO = fmtISO(maandag); const totISO = fmtISO(plusDagen(maandag, 6)); @@ -1176,6 +1412,27 @@ export function RoosterScherm({ mode }) { )} + {schooljaar && (edit ? tab === "week" : true) && ( +
+ + {scopeType !== "alles" && ( + + )} + {scopeType !== "alles" && scopeId && {zichtbareBlokken.length} blok(ken)} +
+ )} + {edit && (
@@ -1241,7 +1498,7 @@ export function RoosterScherm({ mode }) {
{slots.length === 0 &&
} {slots.map((t) => { - const slotBlokken = blokken.filter((b) => b.tijdslot === t.id); + const slotBlokken = zichtbareBlokken.filter((b) => b.tijdslot === t.id); return (
setBewerkBlok(b) : undefined} + title={edit ? "Klik om te bewerken" : undefined} >
- {edit && startDrag(e, b)} title="Sleep naar ander tijdslot">⠿} + {edit && startDrag(e, b)} onClick={(e) => e.stopPropagation()} title="Sleep naar ander tijdslot">⠿} {b.activiteit_naam} - {edit && } + {edit && }
🎯 {b.doel_naam} ({b.doel_type})
{b.locatie_naam &&
📍 {b.locatie_naam}
} @@ -1271,10 +1530,10 @@ export function RoosterScherm({ mode }) { ))}
)} {kal &&
Vervalt — {kal.type_label}
} - {!kal && uit && uit.vervalt &&
Vervalt {uit.opmerking ? `(${uit.opmerking})` : ""}{edit && <> · }
} + {!kal && uit && uit.vervalt &&
Vervalt {uit.opmerking ? `(${uit.opmerking})` : ""}{edit && <> · }
} {!kal && uit && !uit.vervalt && uit.vervanger_naam &&
↪ Vervanger: {uit.vervanger_naam}
} {edit && !kal && !uit && ( - + )} ); @@ -1306,6 +1565,15 @@ export function RoosterScherm({ mode }) { )} + {edit && bewerkBlok && ( + setBewerkBlok(null)} + onKlaar={async () => { setBewerkBlok(null); await laadBlokken(); }} + /> + )} + {edit && ghost &&
{ghost.label}
} ); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 523d2b4..4ec51a6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -147,6 +147,16 @@ input[type="color"] { padding: 2px; width: 44px; min-width: 44px; height: 38px; .drag-ghost { position: fixed; z-index: 1000; pointer-events: none; background: var(--brand); color: #fff; padding: 6px 12px; border-radius: 8px; font-size: 13px; font-weight: 600; box-shadow: 0 6px 18px rgba(0,0,0,.25); } +/* --- Blok-bewerker (modal) --- */ +.modal-overlay { position: fixed; inset: 0; background: rgba(15,23,42,.45); display: flex; align-items: flex-start; justify-content: center; padding: 40px 16px; z-index: 900; overflow: auto; } +.modal { background: var(--panel); border-radius: 12px; padding: 18px 20px; width: min(560px, 100%); box-shadow: 0 20px 50px rgba(0,0,0,.3); } +.modal-kop { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } +.modal-kop h2 { margin: 0; font-size: 18px; } +.modal-hr { border: 0; border-top: 1px solid var(--line); margin: 16px 0 12px; } +.rooster-blok.klikbaar { cursor: pointer; } +.rooster-blok.klikbaar:hover { box-shadow: 0 0 0 2px var(--brand-soft); } +.scope-balk { margin-top: -6px; } + /* --- Afdrukken --- */ .print-knop { margin-left: auto; background: #fff; } @media print {