Compare commits

...

23 Commits

Author SHA1 Message Date
26ad6cd650 fix: update age range in filters for consistency across templates
All checks were successful
Build & Push / Build & Push image (push) Successful in 39s
2026-03-06 12:11:20 +01:00
04fe593d0c feat: implement modal functionality for linking teachers and adding new classes with improved UI
All checks were successful
Build & Push / Build & Push image (push) Successful in 41s
2026-03-06 10:07:32 +01:00
bbd4e332f4 feat: add CRUD functionality for classes with API endpoints and UI integration
All checks were successful
Build & Push / Build & Push image (push) Successful in 41s
2026-03-06 09:12:32 +01:00
653cc6cd74 fix: update class teacher assignment and enhance UI for teacher selection
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-05 23:41:05 +01:00
4bfa3cd486 feat: add functionality for linking teachers to classes with a new UI tab and API endpoint
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-05 23:30:13 +01:00
2782c3bea6 feat: enhance class access logic for directors in my_classes endpoint
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-05 23:13:50 +01:00
d4f09bb368 fix: update status indicator symbols for better clarity
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-05 23:05:35 +01:00
1acaf26a38 Migrate assessments to class-based model
All checks were successful
Build & Push / Build & Push image (push) Successful in 42s
- Dropped the existing assessments table to remove user_id association.
- Created a new assessments table linked to classes (class_id) allowing multiple teachers to share assessments.
- Added necessary indexes for performance optimization.
- Downgrade functionality to revert back to user-based assessments if needed.
2026-03-05 22:36:36 +01:00
d55b700502 feat: enhance Google SSO management by adding save and clear buttons
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-05 12:46:34 +01:00
6e5c898d97 revert c59afa6595
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
revert fix: change file permissions for entrypoint.sh and migrate.sh
2026-03-05 11:24:28 +00:00
c59afa6595 fix: change file permissions for entrypoint.sh and migrate.sh
All checks were successful
Build & Push / Build & Push image (push) Successful in 42s
2026-03-05 11:58:28 +01:00
Sam
50d029c67e feat: add leerkracht-view route and update templates for director mode
All checks were successful
Build & Push / Build & Push image (push) Successful in 39s
2026-03-04 14:31:29 +01:00
Sam
187232a95f feat: update user access control to allow directors to list school users
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-04 14:14:28 +01:00
Sam
5afe297161 feat: add class management page for directors and enhance access control
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-04 11:45:45 +01:00
Sam
c571967701 feat: display redirect URI for Google SSO in user interface
All checks were successful
Build & Push / Build & Push image (push) Successful in 41s
2026-03-04 10:06:02 +01:00
Sam
ee8fcb231b feat: enhance user management table with search functionality and improved layout
All checks were successful
Build & Push / Build & Push image (push) Successful in 40s
2026-03-03 23:10:31 +01:00
Sam
b470cd017e feat: add Google Workspace SSO configuration per school
All checks were successful
Build & Push / Build & Push image (push) Successful in 39s
- Implemented Google SSO management in the school settings, allowing schools to configure their own OAuth2 credentials.
- Added fields for Client ID and Client Secret in the edit school modal and school detail page.
- Introduced functionality to save and clear Google SSO settings via API.
- Updated UI to display current SSO status and instructions for setting up Google OAuth2.
- Created a new database migration to add `google_client_id` and `google_client_secret` columns to the schools table.
2026-03-03 22:40:14 +01:00
Sam
55cd055645 Refactor school year button and enhance JavaScript bindings
All checks were successful
Build & Push / Build & Push image (push) Successful in 44s
- Updated the button for adding a new school year to have an ID for easier access.
- Changed the way IS_SUPERADMIN is defined to use JSON for better compatibility.
- Added event bindings for canceling and saving edits for schools in the JavaScript code.
- Introduced a new document for Google SSO instructions.
2026-03-03 20:33:49 +01:00
Sam
28c05edb0b Update access control for Scholengroep ICT management and adjust visibility of management buttons
All checks were successful
Build & Push / Build & Push image (push) Successful in 38s
2026-03-03 10:23:33 +01:00
Sam
85778855ca Update CSP to allow connections from cdnjs.cloudflare.com and enhance stat card styles with icons and improved layout
All checks were successful
Build & Push / Build & Push image (push) Successful in 38s
2026-03-03 10:15:08 +01:00
Sam
51c0755d67 Refactor styles for stat cards and update PDF export functionality
All checks were successful
Build & Push / Build & Push image (push) Successful in 39s
2026-03-03 10:06:21 +01:00
Sam
5f2e1fdb1b Add 'opmerking' column to assessments and implement related functionality
All checks were successful
Build & Push / Build & Push image (push) Successful in 38s
- Updated the database migration to include an 'opmerking' column in the assessments table.
- Modified the Assessment model to include the new 'opmerking' field.
- Enhanced the API to handle saving and retrieving remarks associated with assessments.
- Updated the frontend to display and edit remarks in the assessments table.
2026-03-03 09:17:50 +01:00
Sam
aa47399b62 add migration to include 'opmerking' column in assessments table 2026-03-03 09:15:49 +01:00
17 changed files with 2787 additions and 1711 deletions

View File

@@ -27,6 +27,14 @@ BASE_URL=https://leerdoelen.jouwdomain.be
MICROSOFT_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx MICROSOFT_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
MICROSOFT_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx MICROSOFT_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ── Google Workspace SSO ───────────────────────────────────────
# Aanmaken via: https://console.cloud.google.com
# → APIs & Services → Credentials → OAuth 2.0 Client ID
# Redirect URI: https://jouwdomain.be/auth/google/callback
# Zie handleiding: docs/Handleiding_Google_SSO.md
GOOGLE_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Naam van de scholengroep — verschijnt op de loginpagina # Naam van de scholengroep — verschijnt op de loginpagina
ORG_NAME=GO! Scholengroep 2 ORG_NAME=GO! Scholengroep 2

View File

@@ -43,6 +43,8 @@ def create_app():
# OAuth2 # OAuth2
app.config['MICROSOFT_CLIENT_ID'] = os.environ.get('MICROSOFT_CLIENT_ID') app.config['MICROSOFT_CLIENT_ID'] = os.environ.get('MICROSOFT_CLIENT_ID')
app.config['MICROSOFT_CLIENT_SECRET'] = os.environ.get('MICROSOFT_CLIENT_SECRET') app.config['MICROSOFT_CLIENT_SECRET'] = os.environ.get('MICROSOFT_CLIENT_SECRET')
app.config['GOOGLE_CLIENT_ID'] = os.environ.get('GOOGLE_CLIENT_ID')
app.config['GOOGLE_CLIENT_SECRET'] = os.environ.get('GOOGLE_CLIENT_SECRET')
app.config['MICROSOFT_TENANT_ID'] = os.environ.get('MICROSOFT_TENANT_ID', 'common') app.config['MICROSOFT_TENANT_ID'] = os.environ.get('MICROSOFT_TENANT_ID', 'common')
# Session cookie beveiliging # Session cookie beveiliging
@@ -95,7 +97,7 @@ def create_app():
'style-src': ["'self'", "'unsafe-inline'"], # inline styles in templates (aanvaardbaar) 'style-src': ["'self'", "'unsafe-inline'"], # inline styles in templates (aanvaardbaar)
'img-src': ["'self'", 'data:'], 'img-src': ["'self'", 'data:'],
'font-src': ["'self'"], 'font-src': ["'self'"],
'connect-src': ["'self'"], 'connect-src': ["'self'", 'cdnjs.cloudflare.com'],
'form-action': ["'self'"], # voorkomt form hijacking 'form-action': ["'self'"], # voorkomt form hijacking
'base-uri': ["'self'"], # voorkomt base tag injection 'base-uri': ["'self'"], # voorkomt base tag injection
'frame-ancestors': ["'none'"], # clickjacking preventie 'frame-ancestors': ["'none'"], # clickjacking preventie

View File

@@ -0,0 +1,23 @@
"""assessment: voeg opmerking kolom toe
Revision ID: 0002
Revises: 0001
Create Date: 2026-03-02
"""
from alembic import op
import sqlalchemy as sa
revision = '0002'
down_revision = '0001'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('assessments',
sa.Column('opmerking', sa.String(500), nullable=True)
)
def downgrade():
op.drop_column('assessments', 'opmerking')

View File

@@ -0,0 +1,29 @@
"""schools: voeg Google Workspace SSO credentials toe per school
Revision ID: 0003
Revises: 0002
Create Date: 2026-03-03
Elke school heeft zijn eigen Google Workspace omgeving en dus
zijn eigen OAuth2 client_id en client_secret. Deze worden per school
opgeslagen en nooit blootgesteld via de API (enkel of ze ingesteld zijn).
"""
from alembic import op
import sqlalchemy as sa
revision = '0003'
down_revision = '0002'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('schools',
sa.Column('google_client_id', sa.String(255), nullable=True))
op.add_column('schools',
sa.Column('google_client_secret', sa.String(255), nullable=True))
def downgrade():
op.drop_column('schools', 'google_client_secret')
op.drop_column('schools', 'google_client_id')

View File

@@ -0,0 +1,60 @@
"""assessments: herstructureer naar klasgebonden model
Revision ID: 0004
Revises: 0003
Create Date: 2026-03-05
Wijziging: assessments zijn niet langer gekoppeld aan een individuele
leerkracht (user_id) maar aan een klas (class_id). Meerdere leerkrachten
van dezelfde klas delen één set beoordelingen.
OPGELET: dit dropt de bestaande assessments tabel — testdata gaat verloren.
"""
from alembic import op
import sqlalchemy as sa
revision = '0004'
down_revision = '0003'
branch_labels = None
depends_on = None
def upgrade():
# Drop oude tabel volledig (testomgeving — geen productiedata)
op.execute("DROP TABLE IF EXISTS assessments CASCADE")
# Nieuwe tabel: klasgebonden, geen user_id
op.execute("""
CREATE TABLE assessments (
id SERIAL PRIMARY KEY,
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
school_year_id INTEGER NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
vak_id VARCHAR(50) NOT NULL,
goal_id VARCHAR(50) NOT NULL,
status VARCHAR(10) NOT NULL,
opmerking VARCHAR(500),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(class_id, school_year_id, vak_id, goal_id)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_assessments_class_year ON assessments(class_id, school_year_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_assessments_vak ON assessments(vak_id)")
def downgrade():
op.execute("DROP TABLE IF EXISTS assessments CASCADE")
# Zet terug naar user-gebaseerde tabel (zonder data)
op.execute("""
CREATE TABLE assessments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
school_id INTEGER NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
school_year_id INTEGER NOT NULL REFERENCES school_years(id) ON DELETE CASCADE,
vak_id VARCHAR(50) NOT NULL,
goal_id VARCHAR(50) NOT NULL,
status VARCHAR(10) NOT NULL,
opmerking VARCHAR(500),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, school_year_id, vak_id, goal_id)
)
""")

View File

@@ -12,6 +12,9 @@ class School(db.Model):
slug = db.Column(db.String(100), nullable=False, unique=True) slug = db.Column(db.String(100), nullable=False, unique=True)
email_domains = db.Column(db.ARRAY(db.Text), nullable=False, default=list) email_domains = db.Column(db.ARRAY(db.Text), nullable=False, default=list)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Google Workspace SSO — per school eigen OAuth2 credentials
google_client_id = db.Column(db.String(255), nullable=True)
google_client_secret = db.Column(db.String(255), nullable=True)
users = db.relationship('User', back_populates='school', lazy='dynamic') users = db.relationship('User', back_populates='school', lazy='dynamic')
school_years = db.relationship('SchoolYear', back_populates='school', lazy='dynamic') school_years = db.relationship('SchoolYear', back_populates='school', lazy='dynamic')
@@ -23,6 +26,9 @@ class School(db.Model):
'name': self.name, 'name': self.name,
'slug': self.slug, 'slug': self.slug,
'email_domains': self.email_domains or [], 'email_domains': self.email_domains or [],
'google_client_id': self.google_client_id or '',
# Secret nooit teruggeven — enkel of het ingesteld is
'google_sso_configured': bool(self.google_client_id and self.google_client_secret),
} }
@@ -145,28 +151,31 @@ class Assessment(db.Model):
__tablename__ = 'assessments' __tablename__ = 'assessments'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) # Klasgebonden — geen koppeling aan individuele leerkracht
school_id = db.Column(db.Integer, db.ForeignKey('schools.id', ondelete='CASCADE'), nullable=False) class_id = db.Column(db.Integer, db.ForeignKey('classes.id', ondelete='CASCADE'), nullable=False)
school_year_id = db.Column(db.Integer, db.ForeignKey('school_years.id', ondelete='CASCADE'), nullable=False) school_year_id = db.Column(db.Integer, db.ForeignKey('school_years.id', ondelete='CASCADE'), nullable=False)
vak_id = db.Column(db.String(50), nullable=False) vak_id = db.Column(db.String(50), nullable=False)
goal_id = db.Column(db.String(50), nullable=False) goal_id = db.Column(db.String(50), nullable=False)
status = db.Column(db.String(10), nullable=False) status = db.Column(db.String(10), nullable=False)
opmerking = db.Column(db.String(500), nullable=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = db.relationship('User') klas = db.relationship('Class')
school = db.relationship('School')
school_year = db.relationship('SchoolYear', back_populates='assessments') school_year = db.relationship('SchoolYear', back_populates='assessments')
__table_args__ = ( __table_args__ = (
db.UniqueConstraint('user_id', 'school_year_id', 'vak_id', 'goal_id'), db.UniqueConstraint('class_id', 'school_year_id', 'vak_id', 'goal_id',
name='uq_assessment_class_year_vak_goal'),
) )
def to_dict(self): def to_dict(self):
return { return {
'id': self.id, 'id': self.id,
'class_id': self.class_id,
'vak_id': self.vak_id, 'vak_id': self.vak_id,
'goal_id': self.goal_id, 'goal_id': self.goal_id,
'status': self.status, 'status': self.status,
'opmerking': self.opmerking,
'updated_at': self.updated_at.isoformat() if self.updated_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None,
} }

View File

@@ -48,6 +48,16 @@ def school_ict_required(f):
return decorated return decorated
def director_or_ict_required(f):
"""Decorator: school_ict én director mogen door (binnen eigen school)."""
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_director: # is_director omvat ook school_ict en hoger
return jsonify({'error': 'Geen toegang'}), 403
return f(*args, **kwargs)
return decorated
# ── Scholen (scholengroep_ict) ──────────────────────────────────────────────── # ── Scholen (scholengroep_ict) ────────────────────────────────────────────────
@admin_bp.route('/schools', methods=['GET']) @admin_bp.route('/schools', methods=['GET'])
@@ -102,6 +112,55 @@ def update_school(school_id):
return jsonify({'school': school.to_dict()}) return jsonify({'school': school.to_dict()})
@admin_bp.route('/schools/<int:school_id>/google-sso', methods=['PUT'])
@login_required
@school_ict_required
def update_school_google_sso(school_id):
"""
Sla Google Workspace OAuth2 credentials op voor een school.
Toegankelijk voor scholengroep_ict (alle scholen) én school_ict
(enkel hun eigen school).
Body:
google_client_id: string (verplicht om in te stellen)
google_client_secret: string (verplicht om in te stellen)
clear: boolean (optioneel — verwijdert de credentials)
"""
# School ICT mag enkel zijn eigen school aanpassen
if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
return jsonify({'error': 'Geen toegang tot deze school'}), 403
school = School.query.get_or_404(school_id)
data = request.get_json() or {}
if data.get('clear'):
school.google_client_id = None
school.google_client_secret = None
audit_log('school.google_sso_removed', 'school',
target_type='school', target_id=str(school_id),
detail={'name': school.name}, school_id=school_id)
db.session.commit()
return jsonify({'school': school.to_dict(), 'message': 'Google SSO verwijderd'})
client_id = (data.get('google_client_id') or '').strip()
client_secret = (data.get('google_client_secret') or '').strip()
if not client_id or not client_secret:
return jsonify({'error': 'Zowel Client ID als Client Secret zijn verplicht'}), 400
# Basis validatie: Google client IDs eindigen op .apps.googleusercontent.com
if not client_id.endswith('.apps.googleusercontent.com'):
return jsonify({'error': 'Ongeldig Client ID — moet eindigen op .apps.googleusercontent.com'}), 400
school.google_client_id = client_id
school.google_client_secret = client_secret
audit_log('school.google_sso_configured', 'school',
target_type='school', target_id=str(school_id),
detail={'name': school.name}, school_id=school_id)
db.session.commit()
return jsonify({'school': school.to_dict(), 'message': 'Google SSO ingesteld'})
@admin_bp.route('/schools/<int:school_id>', methods=['DELETE']) @admin_bp.route('/schools/<int:school_id>', methods=['DELETE'])
@login_required @login_required
@scholengroep_ict_required @scholengroep_ict_required
@@ -179,7 +238,7 @@ def activate_year(year_id):
@admin_bp.route('/schools/<int:school_id>/users', methods=['GET']) @admin_bp.route('/schools/<int:school_id>/users', methods=['GET'])
@login_required @login_required
@school_ict_required @director_or_ict_required
def list_school_users(school_id): def list_school_users(school_id):
if not current_user.is_scholengroep_ict and current_user.school_id != school_id: if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
return jsonify({'error': 'Geen toegang tot deze school'}), 403 return jsonify({'error': 'Geen toegang tot deze school'}), 403
@@ -265,11 +324,11 @@ def remove_user_from_school(school_id, user_id):
return jsonify({'deleted': True}) return jsonify({'deleted': True})
# ── Scholengroep ICT beheer (superadmin) ────────────────────────────────────── # ── Scholengroep ICT beheer ───────────────────────────────────────────────────
@admin_bp.route('/scholengroep-ict', methods=['GET']) @admin_bp.route('/scholengroep-ict', methods=['GET'])
@login_required @login_required
@superadmin_required @scholengroep_ict_required # scholengroep_ict mag de lijst lezen; superadmin ook
def list_scholengroep_ict(): def list_scholengroep_ict():
users = User.query.filter_by(role='scholengroep_ict', is_active=True)\ users = User.query.filter_by(role='scholengroep_ict', is_active=True)\
.order_by(User.last_name).all() .order_by(User.last_name).all()
@@ -528,7 +587,7 @@ def global_stats():
@admin_bp.route('/schools/<int:school_id>/classes', methods=['GET']) @admin_bp.route('/schools/<int:school_id>/classes', methods=['GET'])
@login_required @login_required
@school_ict_required @director_or_ict_required
def list_classes(school_id): def list_classes(school_id):
if not current_user.is_scholengroep_ict and current_user.school_id != school_id: if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
return jsonify({'error': 'Geen toegang'}), 403 return jsonify({'error': 'Geen toegang'}), 403
@@ -538,7 +597,7 @@ def list_classes(school_id):
@admin_bp.route('/schools/<int:school_id>/classes', methods=['POST']) @admin_bp.route('/schools/<int:school_id>/classes', methods=['POST'])
@login_required @login_required
@school_ict_required @director_or_ict_required
def create_class(school_id): def create_class(school_id):
if not current_user.is_scholengroep_ict and current_user.school_id != school_id: if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
return jsonify({'error': 'Geen toegang'}), 403 return jsonify({'error': 'Geen toegang'}), 403
@@ -560,7 +619,7 @@ def create_class(school_id):
@admin_bp.route('/schools/<int:school_id>/classes/<int:class_id>', methods=['DELETE']) @admin_bp.route('/schools/<int:school_id>/classes/<int:class_id>', methods=['DELETE'])
@login_required @login_required
@school_ict_required @director_or_ict_required
def delete_class(school_id, class_id): def delete_class(school_id, class_id):
if not current_user.is_scholengroep_ict and current_user.school_id != school_id: if not current_user.is_scholengroep_ict and current_user.school_id != school_id:
return jsonify({'error': 'Geen toegang'}), 403 return jsonify({'error': 'Geen toegang'}), 403
@@ -574,7 +633,7 @@ def delete_class(school_id, class_id):
@admin_bp.route('/schools/<int:school_id>/classes/<int:class_id>/teachers', methods=['PUT']) @admin_bp.route('/schools/<int:school_id>/classes/<int:class_id>/teachers', methods=['PUT'])
@login_required @login_required
@school_ict_required @director_or_ict_required
def set_class_teachers(school_id, class_id): def set_class_teachers(school_id, class_id):
"""Vervang alle leerkrachten van een klas in één keer.""" """Vervang alle leerkrachten van een klas in één keer."""
if not current_user.is_scholengroep_ict and current_user.school_id != school_id: if not current_user.is_scholengroep_ict and current_user.school_id != school_id:

View File

@@ -19,18 +19,36 @@ def director_required(f):
return decorated return decorated
def get_active_year(school_id=None): def get_active_year():
"""Geeft het globaal actief schooljaar terug (school_id wordt genegeerd).""" """Geeft het globaal actief schooljaar terug."""
return SchoolYear.query.filter_by(school_id=None, is_active=True).first() return SchoolYear.query.filter_by(school_id=None, is_active=True).first()
def check_class_access(class_id):
"""
Geeft de klas terug als de huidige gebruiker er toegang toe heeft.
- Leerkrachten: enkel hun eigen klassen (via teacher_classes).
- Directeur en hoger: alle klassen van hun school.
- Geeft False terug als de klas niet bestaat.
- Geeft None terug als de gebruiker geen toegang heeft.
"""
klas = Class.query.filter_by(id=class_id).first()
if not klas:
return False
if klas.school_id != current_user.school_id:
return None
if current_user.is_teacher:
if not any(c.id == class_id for c in current_user.classes):
return None
return klas
# ── Doelen (statische JSON bestanden) ───────────────────────────────────────── # ── Doelen (statische JSON bestanden) ─────────────────────────────────────────
@api_bp.route('/doelen/index') @api_bp.route('/doelen/index')
@login_required @login_required
def doelen_index(): def doelen_index():
data = load_index() data = load_index()
# Altijd een geldig object teruggeven — lege vakkenlijst is geen fout
return jsonify(data) return jsonify(data)
@@ -50,16 +68,30 @@ def doelen_vak(vak_id):
@api_bp.route('/assessments', methods=['GET']) @api_bp.route('/assessments', methods=['GET'])
@login_required @login_required
def get_assessments(): def get_assessments():
if not current_user.school_id: """Haal beoordelingen op voor een klas (en optioneel een vak)."""
class_id_str = request.args.get('class_id')
if not class_id_str:
return jsonify({'assessments': []}) return jsonify({'assessments': []})
school_year = get_active_year(current_user.school_id)
try:
class_id = int(class_id_str)
except ValueError:
return jsonify({'error': 'Ongeldig class_id'}), 400
klas = check_class_access(class_id)
if klas is False:
return jsonify({'error': 'Klas niet gevonden'}), 404
if klas is None:
return jsonify({'error': 'Geen toegang tot deze klas'}), 403
school_year = get_active_year()
if not school_year: if not school_year:
return jsonify({'assessments': []}) return jsonify({'assessments': []})
year_id = request.args.get('year_id', school_year.id) year_id = request.args.get('year_id', school_year.id)
vak_id = request.args.get('vak_id') vak_id = request.args.get('vak_id')
query = Assessment.query.filter_by(user_id=current_user.id, school_year_id=year_id) query = Assessment.query.filter_by(class_id=class_id, school_year_id=year_id)
if vak_id: if vak_id:
query = query.filter_by(vak_id=vak_id) query = query.filter_by(vak_id=vak_id)
@@ -68,29 +100,39 @@ def get_assessments():
@api_bp.route('/assessments', methods=['POST']) @api_bp.route('/assessments', methods=['POST'])
@login_required @login_required
@limiter.limit('120 per minute') # max 2 per seconde per gebruiker @limiter.limit('120 per minute')
def save_assessment(): def save_assessment():
data = request.get_json() or {} data = request.get_json() or {}
class_id = data.get('class_id')
vak_id = (data.get('vak_id') or '').strip() vak_id = (data.get('vak_id') or '').strip()
goal_id = (data.get('goal_id') or '').strip() goal_id = (data.get('goal_id') or '').strip()
status = (data.get('status') or '').strip() status = (data.get('status') or '').strip()
opmerking = (data.get('opmerking') or '').strip()[:500]
if not vak_id or not goal_id: if not class_id or not vak_id or not goal_id:
return jsonify({'error': 'vak_id en goal_id zijn verplicht'}), 400 return jsonify({'error': 'class_id, vak_id en goal_id zijn verplicht'}), 400
if status not in ('groen', 'oranje', 'roze', ''): if status not in ('groen', 'oranje', 'roze', ''):
return jsonify({'error': 'Ongeldige status — gebruik groen, oranje, roze of leeg'}), 400 return jsonify({'error': 'Ongeldige status'}), 400
# Sanitiseer input — voorkomt oversized data in DB
if len(vak_id) > 100 or len(goal_id) > 50: if len(vak_id) > 100 or len(goal_id) > 50:
return jsonify({'error': 'Ongeldige invoer'}), 400 return jsonify({'error': 'Ongeldige invoer'}), 400
if not current_user.school_id:
return jsonify({'error': 'Account is nog niet gekoppeld aan een school'}), 400
school_year = get_active_year(current_user.school_id) try:
class_id = int(class_id)
except (ValueError, TypeError):
return jsonify({'error': 'Ongeldig class_id'}), 400
klas = check_class_access(class_id)
if klas is False:
return jsonify({'error': 'Klas niet gevonden'}), 404
if klas is None:
return jsonify({'error': 'Geen toegang tot deze klas'}), 403
school_year = get_active_year()
if not school_year: if not school_year:
return jsonify({'error': 'Geen actief schooljaar gevonden'}), 400 return jsonify({'error': 'Geen actief schooljaar gevonden'}), 400
assessment = Assessment.query.filter_by( assessment = Assessment.query.filter_by(
user_id=current_user.id, class_id=class_id,
school_year_id=school_year.id, school_year_id=school_year.id,
vak_id=vak_id, vak_id=vak_id,
goal_id=goal_id, goal_id=goal_id,
@@ -104,52 +146,117 @@ def save_assessment():
if assessment: if assessment:
assessment.status = status assessment.status = status
assessment.opmerking = opmerking or None
assessment.updated_at = datetime.utcnow() assessment.updated_at = datetime.utcnow()
else: else:
assessment = Assessment( assessment = Assessment(
user_id=current_user.id, class_id=class_id,
school_id=current_user.school_id,
school_year_id=school_year.id, school_year_id=school_year.id,
vak_id=vak_id, vak_id=vak_id,
goal_id=goal_id, goal_id=goal_id,
status=status, status=status,
opmerking=opmerking or None,
) )
db.session.add(assessment) db.session.add(assessment)
db.session.commit() db.session.commit()
# Auditlog enkel bij statuswijziging (niet bij elke klik)
audit_log('assessment.save', 'assessment', audit_log('assessment.save', 'assessment',
target_type='goal', target_id=f'{vak_id}:{goal_id}', target_type='class', target_id=str(class_id),
detail={'status': status}) detail={'status': status, 'vak': vak_id, 'goal': goal_id})
return jsonify({'assessment': assessment.to_dict()}) return jsonify({'assessment': assessment.to_dict()})
@api_bp.route('/assessments/opmerking', methods=['POST'])
@login_required
@limiter.limit('120 per minute')
def save_opmerking():
data = request.get_json() or {}
class_id = data.get('class_id')
vak_id = (data.get('vak_id') or '').strip()
goal_id = (data.get('goal_id') or '').strip()
opmerking = (data.get('opmerking') or '').strip()[:500]
if not class_id or not vak_id or not goal_id:
return jsonify({'error': 'class_id, vak_id en goal_id zijn verplicht'}), 400
if len(vak_id) > 100 or len(goal_id) > 50:
return jsonify({'error': 'Ongeldige invoer'}), 400
try:
class_id = int(class_id)
except (ValueError, TypeError):
return jsonify({'error': 'Ongeldig class_id'}), 400
klas = check_class_access(class_id)
if klas is False:
return jsonify({'error': 'Klas niet gevonden'}), 404
if klas is None:
return jsonify({'error': 'Geen toegang tot deze klas'}), 403
school_year = get_active_year()
if not school_year:
return jsonify({'error': 'Geen actief schooljaar'}), 400
assessment = Assessment.query.filter_by(
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
).first()
if assessment:
assessment.opmerking = opmerking or None
assessment.updated_at = datetime.utcnow()
else:
assessment = Assessment(
class_id=class_id,
school_year_id=school_year.id,
vak_id=vak_id,
goal_id=goal_id,
status='',
opmerking=opmerking or None,
)
db.session.add(assessment)
db.session.commit()
return jsonify({'ok': True})
@api_bp.route('/assessments/bulk-import', methods=['POST']) @api_bp.route('/assessments/bulk-import', methods=['POST'])
@login_required @login_required
@limiter.limit('5 per minute') @limiter.limit('5 per minute')
def bulk_import_assessments(): def bulk_import_assessments():
""" """
Importeer beoordelingen vanuit de legacy standalone JSON export. Importeer beoordelingen vanuit legacy standalone JSON export.
Body: { "vakken": { "vak_id": { "goal_id": "status", ... }, ... } } Body: { "class_id": 1, "vakken": { "vak_id": { "goal_id": "status" } } }
of v4 formaat: { "vakken": { "vak_id": { "statussen": { "goal_id": "status" } } } }
""" """
if not current_user.school_id:
return jsonify({'error': 'Account niet gekoppeld aan een school'}), 400
school_year = get_active_year(current_user.school_id)
if not school_year:
return jsonify({'error': 'Geen actief schooljaar'}), 400
data = request.get_json() or {} data = request.get_json() or {}
class_id = data.get('class_id')
vakken = data.get('vakken', {}) vakken = data.get('vakken', {})
if not class_id:
return jsonify({'error': 'class_id is verplicht'}), 400
if not vakken: if not vakken:
return jsonify({'error': 'Geen vakken gevonden in payload'}), 400 return jsonify({'error': 'Geen vakken gevonden in payload'}), 400
try:
class_id = int(class_id)
except (ValueError, TypeError):
return jsonify({'error': 'Ongeldig class_id'}), 400
klas = check_class_access(class_id)
if klas is False:
return jsonify({'error': 'Klas niet gevonden'}), 404
if klas is None:
return jsonify({'error': 'Geen toegang tot deze klas'}), 403
school_year = get_active_year()
if not school_year:
return jsonify({'error': 'Geen actief schooljaar'}), 400
totaal = 0 totaal = 0
fouten = 0 fouten = 0
for vak_id, vak_data in vakken.items(): for vak_id, vak_data in vakken.items():
# Sanitiseer vak_id
if not isinstance(vak_id, str) or len(vak_id) > 100: if not isinstance(vak_id, str) or len(vak_id) > 100:
fouten += 1 fouten += 1
continue continue
@@ -172,7 +279,7 @@ def bulk_import_assessments():
try: try:
assessment = Assessment.query.filter_by( assessment = Assessment.query.filter_by(
user_id=current_user.id, class_id=class_id,
school_year_id=school_year.id, school_year_id=school_year.id,
vak_id=vak_id, vak_id=vak_id,
goal_id=goal_id, goal_id=goal_id,
@@ -183,8 +290,7 @@ def bulk_import_assessments():
assessment.updated_at = datetime.utcnow() assessment.updated_at = datetime.utcnow()
else: else:
db.session.add(Assessment( db.session.add(Assessment(
user_id=current_user.id, class_id=class_id,
school_id=current_user.school_id,
school_year_id=school_year.id, school_year_id=school_year.id,
vak_id=vak_id, vak_id=vak_id,
goal_id=goal_id, goal_id=goal_id,
@@ -197,6 +303,7 @@ def bulk_import_assessments():
db.session.commit() db.session.commit()
audit_log('assessment.bulk_import', 'assessment', audit_log('assessment.bulk_import', 'assessment',
target_type='class', target_id=str(class_id),
detail={'totaal': totaal, 'fouten': fouten}) detail={'totaal': totaal, 'fouten': fouten})
return jsonify({'totaal': totaal, 'fouten': fouten}) return jsonify({'totaal': totaal, 'fouten': fouten})
@@ -209,47 +316,44 @@ def bulk_import_assessments():
def school_overview(): def school_overview():
if not current_user.school_id: if not current_user.school_id:
return jsonify({'error': 'Geen school gekoppeld'}), 400 return jsonify({'error': 'Geen school gekoppeld'}), 400
school_year = get_active_year(current_user.school_id)
school_year = get_active_year()
if not school_year: if not school_year:
return jsonify({'error': 'Geen actief schooljaar'}), 400 return jsonify({'error': 'Geen actief schooljaar'}), 400
# year_id param: directeur/admin kan wisselen, leerkracht zit vast aan actief jaar
year_id_param = request.args.get('year_id') year_id_param = request.args.get('year_id')
if year_id_param and current_user.is_director: if year_id_param:
year_id = int(year_id_param) selected_year = SchoolYear.query.get(int(year_id_param)) or school_year
selected_year = SchoolYear.query.filter_by(
id=year_id, school_id=current_user.school_id
).first() or school_year
else: else:
selected_year = school_year selected_year = school_year
year_id = school_year.id
vak_id = request.args.get('vak_id') vak_id = request.args.get('vak_id')
teachers = User.query.filter_by( # Alle klassen van deze school
school_id=current_user.school_id, role='teacher', is_active=True klassen = Class.query.filter_by(school_id=current_user.school_id)\
).all() .order_by(Class.name).all()
class_ids = [k.id for k in klassen]
query = Assessment.query.filter_by( query = Assessment.query.filter(
school_id=current_user.school_id, school_year_id=year_id Assessment.class_id.in_(class_ids),
Assessment.school_year_id == selected_year.id,
) )
if vak_id: if vak_id:
query = query.filter_by(vak_id=vak_id) query = query.filter_by(vak_id=vak_id)
by_teacher = {t.id: {} for t in teachers} # Groepeer per klas → vak → goal
by_class = {k.id: {} for k in klassen}
for a in query.all(): for a in query.all():
by_teacher.setdefault(a.user_id, {}) by_class[a.class_id].setdefault(a.vak_id, {})[a.goal_id] = a.status
by_teacher[a.user_id].setdefault(a.vak_id, {})
by_teacher[a.user_id][a.vak_id][a.goal_id] = a.status
return jsonify({ return jsonify({
'school_year': selected_year.to_dict(), 'school_year': selected_year.to_dict(),
'teachers': [t.to_dict() for t in teachers], 'classes': [k.to_dict() for k in klassen],
'assessments_by_teacher': by_teacher, 'assessments_by_class': by_class,
}) })
# ── Gebruikersbeheer (school_ict / directeur) ────────────────────────────────── # ── Gebruikersbeheer (director / school_ict) ──────────────────────────────────
@api_bp.route('/users', methods=['GET']) @api_bp.route('/users', methods=['GET'])
@login_required @login_required
@@ -295,54 +399,57 @@ def delete_user(user_id):
return jsonify({'deleted': True}) return jsonify({'deleted': True})
# ── Schooljaren ────────────────────────────────────────────────────────────────
# ── Schooljaren (directeur/admin leesbaar) ────────────────────────────────────
@api_bp.route('/school/years') @api_bp.route('/school/years')
@login_required @login_required
@director_required @director_required
def get_school_years(): def get_school_years():
"""Geeft alle globale schooljaren terug (voor jaarselectie in directeur dashboard).""" years = SchoolYear.query.filter_by(school_id=None)\
years = SchoolYear.query.filter_by(school_id=None) .order_by(SchoolYear.label.desc()).all() .order_by(SchoolYear.label.desc()).all()
return jsonify({'years': [y.to_dict() for y in years]}) return jsonify({'years': [y.to_dict() for y in years]})
# ── Huidig ingelogde gebruiker ──────────────────────────────────────────────── # ── Huidig ingelogde gebruiker ────────────────────────────────────────────────
@api_bp.route('/me') @api_bp.route('/me')
@login_required @login_required
def me(): def me():
school_year = get_active_year(current_user.school_id) if current_user.school_id else None school_year = get_active_year() if current_user.school_id else None
return jsonify({ return jsonify({
'user': current_user.to_dict(), 'user': current_user.to_dict(),
'school_year': school_year.to_dict() if school_year else None, 'school_year': school_year.to_dict() if school_year else None,
}) })
# ── Klassen voor leerkracht (zelf instellen) ────────────────────────────────── # ── Klassen voor leerkracht ────────────────────────────────────────────────────
@api_bp.route('/my/classes', methods=['GET']) @api_bp.route('/my/classes', methods=['GET'])
@login_required @login_required
def my_classes(): def my_classes():
"""Geeft alle beschikbare klassen en eigen klassen terug.""" """Geeft alle klassen van de school en de eigen klassen van de leerkracht.
Directeurs en hoger zien automatisch alle klassen als my_classes."""
if not current_user.school_id: if not current_user.school_id:
return jsonify({'all_classes': [], 'my_classes': []}) return jsonify({'all_classes': [], 'my_classes': []})
all_cls = Class.query.filter_by(school_id=current_user.school_id) .order_by(Class.name).all() all_cls = Class.query.filter_by(school_id=current_user.school_id)\
.order_by(Class.name).all()
# Directeurs en hoger hebben toegang tot alle klassen zonder expliciete koppeling
my_cls = all_cls if current_user.is_director else current_user.classes
return jsonify({ return jsonify({
'all_classes': [{'id': c.id, 'name': c.name} for c in all_cls], 'all_classes': [{'id': c.id, 'name': c.name} for c in all_cls],
'my_classes': [{'id': c.id, 'name': c.name} for c in current_user.classes], 'my_classes': [{'id': c.id, 'name': c.name} for c in my_cls],
}) })
@api_bp.route('/my/classes', methods=['PUT']) @api_bp.route('/my/classes', methods=['PUT'])
@login_required @login_required
def set_my_classes(): def set_my_classes():
"""Leerkracht stelt eigen klassen in.""" """Leerkracht stelt zijn eigen klassen in."""
data = request.get_json() or {} data = request.get_json() or {}
class_ids = data.get('class_ids', []) class_ids = data.get('class_ids', [])
classes = Class.query.filter( classes = Class.query.filter(
Class.id.in_(class_ids), Class.id.in_(class_ids),
Class.school_id == current_user.school_id Class.school_id == current_user.school_id,
).all() ).all()
current_user.classes = classes current_user.classes = classes
audit_log('class.user_assignment', 'class', target_type='user', audit_log('class.user_assignment', 'class', target_type='user',
@@ -353,7 +460,75 @@ def set_my_classes():
# ── Auditlog ──────────────────────────────────────────────────────────────────
# ── Klassen CRUD (directeur) ───────────────────────────────────────────────────
@api_bp.route('/classes', methods=['GET'])
@login_required
@director_required
def list_classes():
"""Alle klassen van de school."""
classes = Class.query.filter_by(school_id=current_user.school_id) .order_by(Class.name).all()
return jsonify({'classes': [
{'id': c.id, 'name': c.name,
'teachers': [{'id': t.id, 'full_name': t.full_name} for t in c.teachers]}
for c in classes
]})
@api_bp.route('/classes', methods=['POST'])
@login_required
@director_required
def create_class():
"""Nieuwe klas aanmaken."""
data = request.get_json() or {}
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Naam is verplicht'}), 400
if Class.query.filter_by(school_id=current_user.school_id, name=name).first():
return jsonify({'error': 'Een klas met deze naam bestaat al'}), 409
klas = Class(name=name, school_id=current_user.school_id)
db.session.add(klas)
audit_log('class.create', 'class', detail={'name': name})
db.session.commit()
return jsonify({'class': {'id': klas.id, 'name': klas.name, 'teachers': []}}), 201
@api_bp.route('/classes/<int:class_id>', methods=['DELETE'])
@login_required
@director_required
def delete_class(class_id):
"""Klas verwijderen (enkel eigen school)."""
klas = Class.query.filter_by(id=class_id, school_id=current_user.school_id).first_or_404()
name = klas.name
db.session.delete(klas)
audit_log('class.delete', 'class', target_id=str(class_id), detail={'name': name})
db.session.commit()
return jsonify({'deleted': class_id})
# ── Klas-leerkracht koppeling (directeur) ──────────────────────────────────────
@api_bp.route('/classes/<int:class_id>/teachers', methods=['PUT'])
@login_required
@director_required
def set_class_teachers(class_id):
"""Directeur koppelt leerkrachten aan een klas."""
klas = Class.query.filter_by(id=class_id, school_id=current_user.school_id).first_or_404()
data = request.get_json() or {}
user_ids = data.get('teacher_ids', [])
teachers = User.query.filter(
User.id.in_(user_ids),
User.school_id == current_user.school_id,
User.is_active == True,
).all()
klas.teachers = teachers
audit_log('class.user_assignment', 'class', target_id=str(class_id),
detail={'class_name': klas.name, 'teacher_ids': user_ids,
'teacher_names': [t.full_name for t in teachers]})
db.session.commit()
return jsonify({'teachers': [{'id': t.id, 'full_name': t.full_name} for t in teachers]})
# ── Auditlog ───────────────────────────────────────────────────────────────────
@api_bp.route('/audit-log') @api_bp.route('/audit-log')
@login_required @login_required
@@ -362,16 +537,13 @@ def get_audit_log():
return jsonify({'error': 'Geen toegang'}), 403 return jsonify({'error': 'Geen toegang'}), 403
page = max(1, int(request.args.get('page', 1))) page = max(1, int(request.args.get('page', 1)))
per_page = min(100, max(1, int(request.args.get('per_page', 50)))) # max 100 per pagina per_page = min(100, max(1, int(request.args.get('per_page', 50))))
category = request.args.get('category') category = request.args.get('category')
search = request.args.get('search', '').strip() search = request.args.get('search', '').strip()
query = AuditLog.query query = AuditLog.query
# School ICT ziet enkel eigen school
if not current_user.is_scholengroep_ict: if not current_user.is_scholengroep_ict:
query = query.filter(AuditLog.school_id == current_user.school_id) query = query.filter(AuditLog.school_id == current_user.school_id)
if category: if category:
query = query.filter(AuditLog.category == category) query = query.filter(AuditLog.category == category)
if search: if search:
@@ -383,7 +555,8 @@ def get_audit_log():
) )
total = query.count() total = query.count()
entries = query.order_by(AuditLog.timestamp.desc()) .offset((page - 1) * per_page).limit(per_page).all() entries = query.order_by(AuditLog.timestamp.desc())\
.offset((page - 1) * per_page).limit(per_page).all()
return jsonify({ return jsonify({
'total': total, 'total': total,
@@ -392,3 +565,36 @@ def get_audit_log():
'entries': [e.to_dict() for e in entries], 'entries': [e.to_dict() for e in entries],
}) })
# ── SSO-lookup ─────────────────────────────────────────────────────────────────
@api_bp.route('/sso-lookup')
def sso_lookup():
from flask import current_app
email = request.args.get('email', '').lower().strip()
if not email or '@' not in email:
return jsonify({'error': 'Ongeldig e-mailadres'}), 400
domain = email.split('@')[-1]
schools = School.query.all()
school = next(
(s for s in schools if s.email_domains and domain in [d.lower() for d in s.email_domains]),
None
)
microsoft_available = bool(
current_app.config.get('MICROSOFT_CLIENT_ID') and
current_app.config.get('MICROSOFT_CLIENT_SECRET')
)
if not school:
return jsonify({'found': False, 'microsoft': microsoft_available, 'google': False})
return jsonify({
'found': True,
'school_id': school.id,
'school_name': school.name,
'microsoft': microsoft_available,
'google': bool(school.google_client_id and school.google_client_secret),
})

View File

@@ -113,7 +113,10 @@ def login():
return redirect(url_for('pages.dashboard')) return redirect(url_for('pages.dashboard'))
entra_configured = bool(_entra_client_id() and _entra_client_secret()) entra_configured = bool(_entra_client_id() and _entra_client_secret())
org_name = current_app.config.get('ORG_NAME', 'GO! Scholengroep') org_name = current_app.config.get('ORG_NAME', 'GO! Scholengroep')
return render_template('login.html', entra_configured=entra_configured, org_name=org_name) # Google SSO is per school — we tonen altijd de Google-sectie
# zodat gebruikers hun e-mail kunnen invullen voor de lookup
return render_template('login.html', entra_configured=entra_configured,
google_configured=True, org_name=org_name)
@auth_bp.route('/logout') @auth_bp.route('/logout')
@@ -239,6 +242,202 @@ def microsoft_callback():
return redirect(_safe_next_url(request.args.get('next'))) return redirect(_safe_next_url(request.args.get('next')))
# ── Google OAuth2 (multi-tenant: per school eigen credentials) ────────────────
#
# Waarom per school? Microsoft heeft één "common" endpoint dat voor alle
# tenants werkt — één globale client_id volstaat. Google heeft dit NIET:
# elke Google Workspace organisatie is een aparte OAuth2-app. We slaan
# google_client_id + google_client_secret daarom per school op in de DB.
# De beheerder (scholengroep ICT of school ICT) vult die in via de web UI.
#
# Login flow:
# 1. Gebruiker typt e-mailadres op de loginpagina
# 2. JS roept /api/sso-lookup?email=... aan
# 3. Backend zoekt school via e-maildomein → geeft school_id + google_sso terug
# 4. JS stuurt door naar /auth/google?school_id=<id>
# 5. We laden credentials uit DB, starten OAuth, bewaren school_id in sessie
# 6. Callback leest school_id uit sessie → gebruikt zelfde credentials
# om de autorisatiecode in te wisselen
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
GOOGLE_SCOPES = "openid email profile"
def _google_callback_url():
base = current_app.config.get('BASE_URL', 'http://localhost').rstrip('/')
return f"{base}/auth/google/callback"
def _get_school_google_creds(school_id: int):
"""Haal Google credentials op voor een specifieke school. Geeft (id, secret) of (None, None)."""
school = School.query.get(school_id)
if school and school.google_client_id and school.google_client_secret:
return school.google_client_id, school.google_client_secret
return None, None
def _get_or_create_google_user(email, first_name, last_name, google_sub):
"""Zoek gebruiker op Google sub, dan e-mail, maak aan als nieuw."""
user = User.query.filter_by(oauth_provider='google', oauth_id=google_sub).first()
if user:
user.first_name = first_name or user.first_name
user.last_name = last_name or user.last_name
user.email = email
db.session.commit()
return user, False
user = User.query.filter_by(email=email).first()
if user:
user.oauth_provider = 'google'
user.oauth_id = google_sub
user.first_name = first_name or user.first_name
user.last_name = last_name or user.last_name
db.session.commit()
return user, False
school = _find_school_for_email(email)
user = User(
email=email, first_name=first_name, last_name=last_name,
role='teacher', school_id=school.id if school else None,
oauth_provider='google', oauth_id=google_sub,
is_active=True,
)
db.session.add(user)
db.session.commit()
return user, True
@auth_bp.route('/google')
@limiter.limit('20 per minute')
def google_login():
school_id = request.args.get('school_id', type=int)
if not school_id:
flash('Geen school gevonden voor dit e-mailadres. Contacteer uw ICT-beheerder.', 'error')
return redirect(url_for('auth.login'))
client_id, _ = _get_school_google_creds(school_id)
if not client_id:
flash('Google login is niet geconfigureerd voor deze school. '
'Contacteer uw ICT-beheerder.', 'error')
return redirect(url_for('auth.login'))
state = secrets.token_urlsafe(32)
session['google_oauth_state'] = state
session['google_oauth_school'] = school_id # bewaren voor de callback
params = {
'client_id': client_id,
'response_type': 'code',
'redirect_uri': _google_callback_url(),
'scope': GOOGLE_SCOPES,
'state': state,
'access_type': 'online',
'prompt': 'select_account',
}
return redirect(f"{GOOGLE_AUTH_URL}?{urlencode(params)}")
@auth_bp.route('/google/callback')
@limiter.limit('20 per minute')
def google_callback():
error = request.args.get('error')
if error:
logger.warning(f"Google OAuth fout: {error}")
flash('Inloggen via Google mislukt. Probeer opnieuw.', 'error')
return redirect(url_for('auth.login'))
state = request.args.get('state', '')
expected_state = session.pop('google_oauth_state', None)
school_id = session.pop('google_oauth_school', None)
if not expected_state or state != expected_state:
logger.warning("Google OAuth2 state mismatch")
flash('Ongeldige sessie. Probeer opnieuw in te loggen.', 'error')
return redirect(url_for('auth.login'))
if not school_id:
flash('Sessie verlopen. Probeer opnieuw in te loggen.', 'error')
return redirect(url_for('auth.login'))
client_id, client_secret = _get_school_google_creds(school_id)
if not client_id:
flash('Google login is niet (meer) geconfigureerd voor deze school.', 'error')
return redirect(url_for('auth.login'))
code = request.args.get('code')
if not code:
flash('Geen autorisatiecode ontvangen van Google.', 'error')
return redirect(url_for('auth.login'))
try:
token_resp = requests.post(GOOGLE_TOKEN_URL, data={
'client_id': client_id,
'client_secret': client_secret,
'code': code,
'redirect_uri': _google_callback_url(),
'grant_type': 'authorization_code',
}, timeout=15)
token_resp.raise_for_status()
tokens = token_resp.json()
except requests.RequestException as e:
logger.error(f"Google token uitwisseling mislukt: {e}")
flash('Kon niet communiceren met Google. Probeer opnieuw.', 'error')
return redirect(url_for('auth.login'))
access_token = tokens.get('access_token')
if not access_token:
flash('Geen access token ontvangen van Google.', 'error')
return redirect(url_for('auth.login'))
try:
userinfo_resp = requests.get(
GOOGLE_USERINFO_URL,
headers={'Authorization': f'Bearer {access_token}'},
timeout=10
)
userinfo_resp.raise_for_status()
profile = userinfo_resp.json()
except requests.RequestException as e:
logger.error(f"Google userinfo mislukt: {e}")
flash('Kon gebruikersgegevens niet ophalen bij Google.', 'error')
return redirect(url_for('auth.login'))
email = profile.get('email', '').lower().strip()
first_name = profile.get('given_name', '')
last_name = profile.get('family_name', '')
google_sub = profile.get('sub', '')
if not email or not google_sub:
flash('Onvoldoende profielgegevens ontvangen van Google.', 'error')
return redirect(url_for('auth.login'))
if not profile.get('email_verified', False):
flash('Uw Google e-mailadres is nog niet geverifieerd.', 'error')
return redirect(url_for('auth.login'))
user, is_new = _get_or_create_google_user(email, first_name, last_name, google_sub)
if not user.is_active:
flash('Uw account is gedeactiveerd. Contacteer uw ICT-beheerder.', 'error')
return redirect(url_for('auth.login'))
if not user.school_id and not user.is_scholengroep_ict and not user.is_superadmin:
flash(
'Uw account is aangemaakt maar nog niet gekoppeld aan een school. '
'Contacteer uw ICT-beheerder.', 'warning'
)
login_user(user, remember=True)
user.last_login = datetime.utcnow()
audit_log('login.success', 'auth', detail={'provider': 'google', 'new_user': is_new})
db.session.commit()
logger.info(f"Google login: {email} (nieuw: {is_new}, school_id: {user.school_id})")
return redirect(_safe_next_url(request.args.get('next')))
@auth_bp.route('/setup', methods=['GET', 'POST']) @auth_bp.route('/setup', methods=['GET', 'POST'])
@limiter.limit('5 per minute') @limiter.limit('5 per minute')
def setup(): def setup():

View File

@@ -43,7 +43,6 @@ def dashboard():
return render_template('directeur.html', org_name=org) return render_template('directeur.html', org_name=org)
return render_template('leerkracht.html', org_name=org) return render_template('leerkracht.html', org_name=org)
@pages_bp.route('/doelen-beheer') @pages_bp.route('/doelen-beheer')
@login_required @login_required
@_beheer_required @_beheer_required
@@ -58,3 +57,23 @@ def doelen_beheer():
@login_required @login_required
def admin_page(): def admin_page():
return redirect(url_for('pages.dashboard')) return redirect(url_for('pages.dashboard'))
@pages_bp.route('/klassen')
@login_required
def klassen_beheer():
"""Klassenbeheer voor directeurs (en school_ict)."""
if not current_user.is_director:
from flask import abort
abort(403)
return render_template('directeur_klassen.html', org_name=_org_name())
@pages_bp.route('/leerkracht-view')
@login_required
def leerkracht_view():
"""Directeur bekijkt de tracker als leerkracht."""
if not current_user.is_director:
from flask import abort
abort(403)
return render_template('leerkracht.html', org_name=_org_name(), director_mode=True)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,363 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Klassenbeheer — {{ org_name }}</title>
<style>
:root {
--primary: #4f46e5; --primary-dark: #4338ca;
--success: #10b981; --danger: #ef4444; --warning: #f59e0b;
--gray-50: #f9fafb; --gray-100: #f3f4f6; --gray-200: #e5e7eb;
--gray-300: #d1d5db; --gray-400: #9ca3af; --gray-500: #6b7280;
--gray-600: #4b5563; --gray-700: #374151; --gray-800: #1f2937;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--gray-100); color: var(--gray-800); line-height: 1.5; }
.container { max-width: 900px; margin: 0 auto; padding: 1.25rem; }
/* Header */
.header { background: white; border-radius: 12px; padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,.1);
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .75rem; }
.header-left { display: flex; align-items: center; gap: .75rem; }
.back-link { display: inline-flex; align-items: center; gap: .35rem; padding: .4rem .75rem;
border: 1px solid var(--gray-300); border-radius: 6px; font-size: .85rem;
color: var(--gray-600); text-decoration: none; transition: all .15s; }
.back-link:hover { background: var(--gray-100); border-color: var(--gray-400); }
.header h1 { font-size: 1.25rem; color: var(--gray-900);
display: flex; align-items: center; gap: .5rem; }
.school-badge { font-size: .75rem; background: var(--primary); color: white;
padding: .2rem .55rem; border-radius: 9999px; font-weight: 500; }
/* Section */
.section { background: white; border-radius: 12px; padding: 1.5rem;
margin-bottom: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.section-header { display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.1rem; flex-wrap: wrap; gap: .5rem; }
.section-header h2 { font-size: 1rem; color: var(--gray-700);
display: flex; align-items: center; gap: .4rem; }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: .35rem; padding: .45rem .9rem;
border: none; border-radius: 6px; font-size: .85rem; font-weight: 500;
cursor: pointer; transition: all .15s; }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-secondary { background: var(--gray-200); color: var(--gray-700); }
.btn-secondary:hover { background: var(--gray-300); }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { background: #dc2626; }
.btn-sm { padding: .3rem .65rem; font-size: .8rem; }
/* Tabel */
table { width: 100%; border-collapse: collapse; font-size: .875rem; }
thead th { padding: .6rem .85rem; text-align: left; font-size: .75rem; font-weight: 700;
text-transform: uppercase; letter-spacing: .04em; color: var(--gray-500);
border-bottom: 2px solid var(--gray-200); background: var(--gray-50); }
tbody td { padding: .7rem .85rem; border-bottom: 1px solid var(--gray-100); vertical-align: middle; }
tbody tr:hover td { background: var(--gray-50); }
tbody tr:last-child td { border-bottom: none; }
.teacher-names { font-size: .82rem; color: var(--gray-500); }
.no-teacher { font-style: italic; color: var(--gray-400); font-size: .82rem; }
td:last-child { white-space: nowrap; text-align: right; }
/* Empty state */
.empty { text-align: center; padding: 2.5rem; color: var(--gray-500); font-style: italic; }
/* Assign modal (dynamisch ingeladen) */
.assign-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5);
z-index: 1000; display: flex; align-items: center; justify-content: center; }
.assign-modal { background: white; border-radius: 12px; padding: 1.5rem;
max-width: 420px; width: 90%; max-height: 80vh; overflow-y: auto; }
.assign-modal h3 { font-size: 1rem; margin-bottom: 1rem; }
.checkbox-list { max-height: 240px; overflow-y: auto; display: flex;
flex-direction: column; gap: .4rem; margin-bottom: 1.1rem; }
.checkbox-list label { display: flex; align-items: center; gap: .5rem;
cursor: pointer; font-size: .875rem; padding: .25rem 0; }
.checkbox-list label:hover { color: var(--primary); }
.modal-buttons { display: flex; gap: .5rem; justify-content: flex-end; }
.form-error { color: var(--danger); font-size: .82rem; margin-top: .4rem; display: none; }
/* Notification */
.notification { position: fixed; bottom: 1rem; right: 1rem; padding: .85rem 1.25rem;
border-radius: 8px; color: white; font-weight: 500;
transform: translateY(100px); opacity: 0; transition: all .3s; z-index: 2000; }
.notification.show { transform: translateY(0); opacity: 1; }
.notification.success { background: var(--success); }
.notification.error { background: var(--danger); }
.notification.warning { background: var(--warning); }
/* Dark mode */
@media (prefers-color-scheme: dark) {
:root { --gray-50:#1a1a2e; --gray-100:#16213e; --gray-200:#0f3460;
--gray-300:#1a1a3e; --gray-500:#9ca3af; --gray-600:#d1d5db;
--gray-700:#e5e7eb; --gray-800:#f3f4f6; }
body { background: #0f172a; color: #e2e8f0; }
.header, .section, .assign-modal { background: #1e293b !important; }
.back-link { border-color: #334155; color: #94a3b8; }
.back-link:hover { background: #263548; }
thead th { background: #1e293b !important; border-color: #334155 !important; }
tbody td { border-color: #1e293b !important; }
tbody tr:hover td { background: #263548 !important; }
.btn-secondary { background: #334155 !important; color: #e2e8f0 !important; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-left">
<a href="/dashboard" class="back-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
Terug naar dashboard
</a>
<h1>
🏫 Klassenbeheer
<span class="school-badge" id="schoolBadge">Laden...</span>
</h1>
</div>
<a href="/auth/logout" class="btn btn-secondary btn-sm">Uitloggen</a>
</div>
<div class="section">
<div class="section-header">
<h2>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/>
</svg>
Klassen
</h2>
<button class="btn btn-primary btn-sm" id="btnAddKlas">+ Klas toevoegen</button>
</div>
<div id="klassenList">
<div class="empty">Laden...</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Leerkrachten van deze school
</h2>
</div>
<div id="teachersList">
<div class="empty">Laden...</div>
</div>
</div>
</div>
<div class="notification" id="notification"></div>
<script nonce="{{ csp_nonce() }}">
let mySchoolId = null;
let allTeachers = [];
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('btnAddKlas').addEventListener('click', openAddKlas);
const me = await fetch('/api/me').then(r => r.json());
mySchoolId = me.user?.school_id;
document.getElementById('schoolBadge').textContent = me.user?.school_name || 'Mijn school';
await Promise.all([loadKlassen(), loadTeachers()]);
});
// ── Klassen laden & renderen ──────────────────────────────────────────────────
async function loadKlassen() {
if (!mySchoolId) return;
const res = await fetch(`/admin/schools/${mySchoolId}/classes`);
const data = await res.json();
const klassen = data.classes || [];
const container = document.getElementById('klassenList');
if (!klassen.length) {
container.innerHTML = '<div class="empty">Nog geen klassen aangemaakt. Klik op "+ Klas toevoegen" om te starten.</div>';
return;
}
container.innerHTML = `
<table>
<thead>
<tr>
<th>Klas</th>
<th>Leerkrachten</th>
<th></th>
</tr>
</thead>
<tbody>
${klassen.map(c => `
<tr>
<td><strong>${c.name}</strong></td>
<td>
${c.teachers?.length
? `<span class="teacher-names">${c.teachers.map(t => t.full_name).join(', ')}</span>`
: '<span class="no-teacher">Geen leerkracht gekoppeld</span>'}
</td>
<td>
<button class="btn btn-secondary btn-sm"
data-action="assignTeachers"
data-id="${c.id}"
data-name="${c.name.replace(/'/g,'&#39;')}"
data-teachers="${JSON.stringify(c.teachers?.map(t=>t.id)||[]).replace(/"/g,'&quot;')}">
Leerkrachten koppelen
</button>
<button class="btn btn-danger btn-sm"
data-action="deleteKlas"
data-id="${c.id}"
data-name="${c.name.replace(/'/g,'&#39;')}">
×
</button>
</td>
</tr>`).join('')}
</tbody>
</table>`;
}
// ── Leerkrachtenlijst ─────────────────────────────────────────────────────────
async function loadTeachers() {
if (!mySchoolId) return;
const res = await fetch(`/admin/schools/${mySchoolId}/users`);
const data = await res.json();
allTeachers = (data.users || []).filter(u => u.role === 'teacher');
const container = document.getElementById('teachersList');
if (!allTeachers.length) {
container.innerHTML = '<div class="empty">Nog geen leerkrachten geregistreerd via SSO.</div>';
return;
}
container.innerHTML = `
<table>
<thead>
<tr>
<th>Naam</th>
<th>E-mail</th>
<th>Laatste login</th>
</tr>
</thead>
<tbody>
${allTeachers.map(u => `
<tr>
<td><strong>${u.full_name}</strong></td>
<td style="color:var(--gray-500);font-size:.85rem;">${u.email}</td>
<td style="color:var(--gray-500);font-size:.82rem;">
${u.last_login
? new Date(u.last_login).toLocaleString('nl-BE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})
: '<em>Nog niet ingelogd</em>'}
</td>
</tr>`).join('')}
</tbody>
</table>`;
}
// ── Klas toevoegen ────────────────────────────────────────────────────────────
async function openAddKlas() {
const name = prompt('Naam van de nieuwe klas (bv. 3A):');
if (!name?.trim()) return;
const res = await fetch(`/admin/schools/${mySchoolId}/classes`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name: name.trim() })
});
const data = await res.json();
if (!res.ok) { notify(data.error || 'Aanmaken mislukt', 'error'); return; }
notify(`Klas "${name.trim()}" aangemaakt`, 'success');
await loadKlassen();
}
// ── Klas verwijderen ──────────────────────────────────────────────────────────
async function deleteKlas(classId, name) {
if (!confirm(`Klas "${name}" verwijderen? Alle leerkrachtkoppelingen worden ook verwijderd.`)) return;
const res = await fetch(`/admin/schools/${mySchoolId}/classes/${classId}`, { method: 'DELETE' });
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
notify(`Klas "${name}" verwijderd`, 'success');
await loadKlassen();
}
// ── Leerkrachten koppelen ─────────────────────────────────────────────────────
let assignClassId = null;
async function openAssignTeachers(classId, className, currentTeacherIds) {
assignClassId = classId;
// Haal verse leerkrachtenlijst op als nog niet geladen
if (!allTeachers.length) await loadTeachers();
const html = `
<div class="assign-overlay" id="assignModal">
<div class="assign-modal">
<h3>Leerkrachten koppelen aan <strong>${className}</strong></h3>
<div class="checkbox-list">
${allTeachers.length
? allTeachers.map(t => `
<label>
<input type="checkbox" value="${t.id}"
${currentTeacherIds.includes(t.id) ? 'checked' : ''}>
<span>${t.full_name}</span>
<span style="font-size:.75rem;color:var(--gray-400);">(${t.email})</span>
</label>`).join('')
: '<em style="color:var(--gray-400);">Geen leerkrachten beschikbaar.<br>Leerkrachten verschijnen hier zodra ze voor het eerst inloggen via SSO.</em>'}
</div>
<div class="modal-buttons">
<button class="btn btn-secondary" id="btnCancelAssign">Annuleren</button>
<button class="btn btn-primary" id="btnSaveAssign">Opslaan</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
document.getElementById('btnCancelAssign').addEventListener('click', () => document.getElementById('assignModal')?.remove());
document.getElementById('btnSaveAssign').addEventListener('click', saveAssignTeachers);
// Klik buiten modal sluit ook
document.getElementById('assignModal').addEventListener('click', e => {
if (e.target.id === 'assignModal') document.getElementById('assignModal').remove();
});
}
async function saveAssignTeachers() {
const ids = [...document.querySelectorAll('#assignModal input:checked')].map(i => parseInt(i.value));
const res = await fetch(`/admin/schools/${mySchoolId}/classes/${assignClassId}/teachers`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ user_ids: ids })
});
document.getElementById('assignModal')?.remove();
if (!res.ok) { notify('Opslaan mislukt', 'error'); return; }
notify('Leerkrachten bijgewerkt', 'success');
await loadKlassen();
}
// ── Event delegation ──────────────────────────────────────────────────────────
document.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'deleteKlas') {
deleteKlas(btn.dataset.id, btn.dataset.name);
}
if (action === 'assignTeachers') {
const ids = JSON.parse(btn.dataset.teachers.replace(/&quot;/g, '"'));
openAssignTeachers(btn.dataset.id, btn.dataset.name, ids);
}
});
// ── Notificaties ──────────────────────────────────────────────────────────────
function notify(msg, type = 'success') {
const el = document.getElementById('notification');
el.textContent = msg;
el.className = `notification ${type} show`;
setTimeout(() => el.classList.remove('show'), 3500);
}
</script>
</body>
</html>

View File

@@ -5,7 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Leerdoelen Tracker</title> <title>Leerdoelen Tracker</title>
<style> <style>
/* Zelfde CSS als origineel - ingekort voor leesbaarheid */
:root { :root {
--primary: #4f46e5; --primary-dark: #4338ca; --primary: #4f46e5; --primary-dark: #4338ca;
--success: #10b981; --warning: #f59e0b; --danger: #ef4444; --success: #10b981; --warning: #f59e0b; --danger: #ef4444;
@@ -23,20 +22,38 @@
.header { background: white; border-radius: 12px; padding: 1.25rem 1.5rem; margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; } .header { background: white; border-radius: 12px; padding: 1.25rem 1.5rem; margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; }
.header h1 { font-size: 1.4rem; color: var(--gray-900); display: flex; align-items: center; gap: 0.5rem; } .header h1 { font-size: 1.4rem; color: var(--gray-900); display: flex; align-items: center; gap: 0.5rem; }
.user-info { display: flex; align-items: center; gap: 1rem; font-size: 0.9rem; color: var(--gray-600); } .user-info { display: flex; align-items: center; gap: 1rem; font-size: 0.9rem; color: var(--gray-600); }
.user-info strong { color: var(--gray-800); }
.btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 0.85rem; border: none; border-radius: 6px; font-size: 0.85rem; font-weight: 500; cursor: pointer; transition: all 0.2s; text-decoration: none; } .btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 0.85rem; border: none; border-radius: 6px; font-size: 0.85rem; font-weight: 500; cursor: pointer; transition: all 0.2s; text-decoration: none; }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-secondary { background: var(--gray-100); color: var(--gray-700); } .btn-secondary { background: var(--gray-100); color: var(--gray-700); }
.btn-secondary:hover { background: var(--gray-200); } .btn-secondary:hover { background: var(--gray-200); }
.btn-danger { background: var(--danger); color: white; } .btn-import { background: var(--warning); color: white; }
.vak-selector { background: white; border-radius: 12px; padding: 1rem 1.5rem; margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; transition: background 0.2s; } .btn-import:hover { background: #d97706; }
.vak-selector label { font-weight: 600; color: var(--gray-700); font-size: 0.9rem; }
.vak-selector select { padding: 0.5rem 0.75rem; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 0.95rem; min-width: 250px; cursor: pointer; background: white; color: var(--gray-800); } /* Klas + vak selector balk */
.vak-selector select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(79,70,229,0.1); } .selector-bar { background: white; border-radius: 12px; padding: 1rem 1.5rem; margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap; }
.selector-group { display: flex; flex-direction: column; gap: 0.25rem; }
.selector-group label { font-size: 0.75rem; font-weight: 600; color: var(--gray-500); text-transform: uppercase; letter-spacing: 0.05em; }
.selector-group select { padding: 0.5rem 0.75rem; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 0.95rem; min-width: 200px; cursor: pointer; background: white; color: var(--gray-800); }
.selector-group select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(79,70,229,0.1); }
/* Geen klas waarschuwing */
.no-klas-banner { background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 1rem 1.5rem; margin-bottom: 1rem; font-size: 0.9rem; color: #92400e; }
.stats-bar { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 0.75rem; margin-bottom: 1rem; } .stats-bar { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
.stat-card { background: white; border-radius: 8px; padding: 0.75rem 1rem; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .stat-card { background: white; border-radius: 8px; padding: 0.75rem 1rem; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.stat-card.highlight { background: linear-gradient(135deg, var(--primary), var(--primary-dark)); color: white; } .stat-card.highlight { background: linear-gradient(135deg, var(--primary), var(--primary-dark)); color: white; }
.stat-value { font-size: 1.5rem; font-weight: 700; } .stat-value { font-size: 1.5rem; font-weight: 700; }
.stat-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.8; } .stat-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.8; }
.legend-container { background: white; border-radius: 12px; padding: 1rem 1.5rem; margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.legend-title { font-weight: 600; color: var(--gray-700); margin-bottom: .75rem; font-size: .9rem; }
.legend-grid { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: center; }
.legend-section { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; }
.legend-item { display: flex; align-items: center; gap: .4rem; font-size: .85rem; }
.legend-color { width: 18px; height: 18px; border-radius: 4px; flex-shrink: 0; }
.legend-divider { width: 1px; height: 24px; background: var(--gray-300); }
.filters-container { background: white; border-radius: 12px; padding: 1rem 1.5rem; margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .filters-container { background: white; border-radius: 12px; padding: 1rem 1.5rem; margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.filters-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.75rem; } .filters-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.75rem; }
.filter-group { display: flex; flex-direction: column; gap: 0.25rem; } .filter-group { display: flex; flex-direction: column; gap: 0.25rem; }
@@ -47,6 +64,7 @@
.leeftijd-checkbox { display: flex; align-items: center; gap: 0.25rem; padding: 0.3rem 0.5rem; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 0.8rem; cursor: pointer; transition: all 0.15s; user-select: none; } .leeftijd-checkbox { display: flex; align-items: center; gap: 0.25rem; padding: 0.3rem 0.5rem; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 0.8rem; cursor: pointer; transition: all 0.15s; user-select: none; }
.leeftijd-checkbox:has(input:checked) { background: var(--primary); border-color: var(--primary); color: white; } .leeftijd-checkbox:has(input:checked) { background: var(--primary); border-color: var(--primary); color: white; }
.leeftijd-checkbox input { display: none; } .leeftijd-checkbox input { display: none; }
.table-container { background: white; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; } .table-container { background: white; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }
.table-scroll { overflow-x: auto; } .table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
@@ -54,9 +72,10 @@
th { padding: 0.75rem 1rem; text-align: left; font-weight: 600; color: var(--gray-600); border-bottom: 2px solid var(--gray-200); white-space: nowrap; } th { padding: 0.75rem 1rem; text-align: left; font-weight: 600; color: var(--gray-600); border-bottom: 2px solid var(--gray-200); white-space: nowrap; }
td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--gray-100); vertical-align: top; } td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--gray-100); vertical-align: top; }
tr:hover { background: var(--gray-50); } tr:hover { background: var(--gray-50); }
tr.status-groen { background: var(--status-groen-bg); } tr.status-groen { background: var(--status-groen-bg); } tr.status-groen:hover { background: #a7f3d0; }
tr.status-oranje { background: var(--status-oranje-bg); } tr.status-oranje { background: var(--status-oranje-bg); } tr.status-oranje:hover { background: #fde68a; }
tr.status-roze { background: var(--status-roze-bg); } tr.status-roze { background: var(--status-roze-bg); } tr.status-roze:hover { background: #fbcfe8; }
.status-selector { width: 32px; height: 32px; border-radius: 6px; border: 2px solid var(--gray-300); background: white; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; font-size: 1rem; font-weight: bold; transition: all 0.15s; } .status-selector { width: 32px; height: 32px; border-radius: 6px; border: 2px solid var(--gray-300); background: white; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; font-size: 1rem; font-weight: bold; transition: all 0.15s; }
.status-selector:hover { transform: scale(1.1); } .status-selector:hover { transform: scale(1.1); }
.status-selector.status-none { color: var(--gray-400); } .status-selector.status-none { color: var(--gray-400); }
@@ -67,20 +86,19 @@
.status-selector.status-oranje::after { content: '~'; } .status-selector.status-oranje::after { content: '~'; }
.status-selector.status-roze { background: var(--status-roze); border-color: var(--status-roze); color: white; } .status-selector.status-roze { background: var(--status-roze); border-color: var(--status-roze); color: white; }
.status-selector.status-roze::after { content: '!'; } .status-selector.status-roze::after { content: '!'; }
.ebg-badge { display: inline-block; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; } .ebg-badge { display: inline-block; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
.ebg-engageren { background: var(--kleur-engageren); color: white; } .ebg-engageren { background: var(--kleur-engageren); color: white; }
.ebg-begrijpen { background: var(--kleur-begrijpen); color: var(--gray-800); } .ebg-begrijpen { background: var(--kleur-begrijpen); color: var(--gray-800); }
.ebg-gebruiken { background: var(--kleur-gebruiken); color: white; } .ebg-gebruiken { background: var(--kleur-gebruiken); color: white; }
.legend-container { background: white; border-radius: 12px; padding: 1rem 1.5rem; margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
.legend-title { font-weight: 600; color: var(--gray-700); margin-bottom: .75rem; font-size: .9rem; }
.legend-grid { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: center; }
.legend-section { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; }
.legend-item { display: flex; align-items: center; gap: .4rem; font-size: .85rem; }
.legend-color { width: 18px; height: 18px; border-radius: 4px; flex-shrink: 0; }
.legend-divider { width: 1px; height: 24px; background: var(--gray-300); }
.leeftijden { display: flex; flex-wrap: wrap; gap: 0.25rem; } .leeftijden { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.leeftijd-badge { font-size: 0.7rem; padding: 0.15rem 0.35rem; background: var(--gray-200); border-radius: 3px; color: var(--gray-600); } .leeftijd-badge { font-size: 0.7rem; padding: 0.15rem 0.35rem; background: var(--gray-200); border-radius: 3px; color: var(--gray-600); }
.beschrijving-cell { max-width: 400px; } .beschrijving-cell { max-width: 400px; }
.opm-col { width: 200px; min-width: 150px; }
.opm-cell { padding: 0.4rem 0.5rem; vertical-align: middle; }
.opm-input { width: 100%; padding: 0.3rem 0.4rem; border: 1px solid var(--gray-300); border-radius: 4px; font-size: 0.8rem; background: transparent; color: var(--gray-700); transition: border-color 0.15s; }
.opm-input:focus { outline: none; border-color: var(--primary); background: white; box-shadow: 0 0 0 2px rgba(79,70,229,0.1); }
.opm-input::placeholder { color: var(--gray-400); }
.mia-container { background: var(--gray-50); border-radius: 6px; padding: 0.5rem; font-size: 0.8rem; margin-top: 0.5rem; } .mia-container { background: var(--gray-50); border-radius: 6px; padding: 0.5rem; font-size: 0.8rem; margin-top: 0.5rem; }
.mia-items { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.25rem; } .mia-items { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.25rem; }
.mia-item { background: white; padding: 0.2rem 0.4rem; border-radius: 3px; border: 1px solid var(--gray-200); } .mia-item { background: white; padding: 0.2rem 0.4rem; border-radius: 3px; border: 1px solid var(--gray-200); }
@@ -92,169 +110,54 @@
.notification.show { transform: translateY(0); opacity: 1; } .notification.show { transform: translateY(0); opacity: 1; }
.notification.success { background: var(--success); } .notification.success { background: var(--success); }
.notification.error { background: var(--danger); } .notification.error { background: var(--danger); }
.notification.warning { background: var(--warning); }
.saving-indicator { font-size: 0.8rem; color: var(--gray-400); margin-left: auto; } .saving-indicator { font-size: 0.8rem; color: var(--gray-400); margin-left: auto; }
/* Klassen modal */
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 1000; align-items: center; justify-content: center; }
.modal-overlay.active { display: flex; }
.modal-inner { background: white; border-radius: 12px; padding: 1.5rem; max-width: 400px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,.3); }
.modal-inner h2 { font-size: 1.1rem; margin-bottom: .5rem; }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root { --gray-50: #1a1a2e; --gray-100: #16213e; --gray-200: #0f3460; --gray-300: #1a1a3e; --gray-400: #6b7280; --gray-500: #9ca3af; --gray-600: #d1d5db; --gray-700: #e5e7eb; --gray-800: #f3f4f6; --gray-900: #f9fafb; }
--gray-50: #1a1a2e;
--gray-100: #16213e;
--gray-200: #0f3460;
--gray-300: #1a1a3e;
--gray-400: #6b7280;
--gray-500: #9ca3af;
--gray-600: #d1d5db;
--gray-700: #e5e7eb;
--gray-800: #f3f4f6;
--gray-900: #f9fafb;
}
body { background: #0f172a; color: #e2e8f0; } body { background: #0f172a; color: #e2e8f0; }
.header, .selector-bar, .stat-card, .legend-container, .filters-container, .table-container { background: #1e293b !important; border-color: #334155 !important; }
/* Kaarten en secties */
.card, .section, .stat-card, .school-card,
.table-container, .filters-container, .legend-container,
.stats-bar .stat-card, .stats-overview, .vak-stats,
.import-section, .detail-section, .filters-bar,
.header:not([class*="gradient"]) {
background: #1e293b !important;
border-color: #334155 !important;
}
/* Header kaart in leerkracht.html */
.header { background: #1e293b !important; }
/* Tabellen */
thead, th { background: #1e293b !important; color: #94a3b8 !important; border-color: #334155 !important; } thead, th { background: #1e293b !important; color: #94a3b8 !important; border-color: #334155 !important; }
td { border-color: #1e293b !important; color: #e2e8f0; } td { border-color: #1e293b !important; color: #e2e8f0; }
tr:hover td, tr:hover { background: #263548 !important; } tr:hover td, tr:hover { background: #263548 !important; }
tr.status-groen { background: #064e3b !important; } tr.status-groen { background: #064e3b !important; } tr.status-groen:hover { background: #065f46 !important; }
tr.status-groen:hover { background: #065f46 !important; } tr.status-oranje { background: #451a03 !important; } tr.status-oranje:hover { background: #78350f !important; }
tr.status-oranje { background: #451a03 !important; } tr.status-roze { background: #500724 !important; } tr.status-roze:hover { background: #701a35 !important; }
tr.status-oranje:hover { background: #78350f !important; } input, select, textarea { background: #0f172a !important; color: #e2e8f0 !important; border-color: #334155 !important; }
tr.status-roze { background: #500724 !important; }
tr.status-roze:hover { background: #701a35 !important; }
/* Inputs en selects */
input, select, textarea {
background: #0f172a !important;
color: #e2e8f0 !important;
border-color: #334155 !important;
}
input::placeholder { color: #64748b !important; } input::placeholder { color: #64748b !important; }
input:focus, select:focus, textarea:focus { input:focus, select:focus { border-color: #6366f1 !important; box-shadow: 0 0 0 3px rgba(99,102,241,0.2) !important; }
border-color: #6366f1 !important;
box-shadow: 0 0 0 3px rgba(99,102,241,0.2) !important;
}
/* Role select inline */
.role-select {
background: #1e293b !important;
color: #e2e8f0 !important;
border-color: #334155 !important;
}
/* Modals */
.modal { background: #1e293b !important; color: #e2e8f0; }
.modal h2 { color: #f1f5f9; }
/* Knoppen */
.btn-secondary { background: #334155 !important; color: #e2e8f0 !important; } .btn-secondary { background: #334155 !important; color: #e2e8f0 !important; }
.btn-secondary:hover { background: #475569 !important; } .btn-secondary:hover { background: #475569 !important; }
/* Status selector knoppen (leerkracht tabel) */
.status-selector.status-none { background: #1e293b !important; border-color: #475569 !important; color: #64748b !important; } .status-selector.status-none { background: #1e293b !important; border-color: #475569 !important; color: #64748b !important; }
/* Stat cards */
.stat-card { background: #1e293b !important; }
/* School card header */
.school-card-header { background: #162032 !important; border-color: #334155 !important; }
.school-card { border-color: #334155 !important; }
/* Drop zone */
.drop-zone { background: #162032 !important; border-color: #334155 !important; }
.drop-zone:hover, .drop-zone.over { background: #1a2744 !important; border-color: #6366f1 !important; }
/* Domain chips */
.domain-chip { background: #1e3a5f !important; border-color: #2563eb !important; color: #93c5fd !important; }
/* Badges */
.leeftijd-badge { background: #334155 !important; color: #94a3b8 !important; } .leeftijd-badge { background: #334155 !important; color: #94a3b8 !important; }
.ebg-begrijpen { color: #1f2937 !important; }
/* MIA container */
.mia-container { background: #162032 !important; }
.mia-item { background: #1e293b !important; border-color: #334155 !important; color: #e2e8f0 !important; }
.mia-item.niet-aanklikbaar { background: #162032 !important; color: #64748b !important; }
/* Not configured box */
.not-configured { background: #162032 !important; border-color: #334155 !important; color: #94a3b8 !important; }
.not-configured code { background: #0f172a !important; color: #a5b4fc !important; }
/* Profile section */
.profile-section { background: #162032 !important; }
/* Leeftijd checkboxes */
.leeftijd-checkbox { border-color: #334155 !important; color: #e2e8f0; } .leeftijd-checkbox { border-color: #334155 !important; color: #e2e8f0; }
.leeftijd-checkbox:has(input:checked) { background: var(--primary) !important; } .leeftijd-checkbox:has(input:checked) { background: var(--primary) !important; }
/* Vak indicator */
/* .vak-indicator — stijl via inline op het element */
/* Legend container */
.legend-container { background: #1e293b !important; border-color: #334155 !important; }
.legend-title { color: #94a3b8 !important; } .legend-title { color: #94a3b8 !important; }
.legend-divider { background: #334155 !important; } .legend-divider { background: #334155 !important; }
.mia-container { background: #162032 !important; }
/* Vak selector balk */ .mia-item { background: #1e293b !important; border-color: #334155 !important; color: #e2e8f0 !important; }
.vak-selector { background: #1e293b !important; } .opm-input { background: transparent !important; color: #e2e8f0 !important; border-color: #334155 !important; }
.vak-selector label { color: #94a3b8 !important; } .opm-input:focus { background: #0f172a !important; border-color: #6366f1 !important; }
.vak-selector select { background: #0f172a !important; color: #e2e8f0 !important; border-color: #334155 !important; } .opm-input::placeholder { color: #475569 !important; }
.modal-inner { background: #1e293b !important; color: #e2e8f0; }
/* Progress bars achtergrond */ .no-klas-banner { background: #451a03 !important; border-color: #78350f !important; color: #fcd34d !important; }
.progress-bar { background: #334155 !important; }
/* Vak card */
.vak-card { background: #162032 !important; }
/* Upload results */
.upload-ok { background: #064e3b !important; border-color: #065f46 !important; }
.upload-err { background: #450a0a !important; border-color: #7f1d1d !important; }
/* Alert boxes */
.alert-error { background: #450a0a !important; border-color: #7f1d1d !important; color: #fca5a5 !important; }
.alert-success { background: #064e3b !important; border-color: #065f46 !important; color: #6ee7b7 !important; }
.alert-warning { background: #451a03 !important; border-color: #78350f !important; color: #fcd34d !important; }
.alert-info { background: #1e3a5f !important; border-color: #1d4ed8 !important; color: #93c5fd !important; }
/* Error text */
.form-error, #sa-error, #addUser-error { color: #f87171 !important; }
.form-hint { color: #64748b !important; }
/* Superadmin toggle */
.superadmin-toggle { border-color: #334155 !important; }
.superadmin-toggle button { color: #475569 !important; }
.superadmin-toggle button:hover { color: #94a3b8 !important; }
/* Superadmin form inputs */
.superadmin-form label { color: #94a3b8 !important; }
/* Footer */
.footer { color: #64748b !important; }
.status-legend { background: #162032 !important; border-color: #334155 !important; color: #94a3b8 !important; }
/* Scrollbar (webkit) */
::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #0f172a; } ::-webkit-scrollbar-track { background: #0f172a; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; } ::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #475569; } ::-webkit-scrollbar-thumb:hover { background: #475569; }
} }
.btn-import { background: var(--warning); color: white; }
.btn-import:hover { background: #d97706; }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<!-- Header -->
<div class="header"> <div class="header">
<h1>📚 Leerdoelen Tracker</h1> <h1>📚 Leerdoelen Tracker</h1>
<div class="user-info"> <div class="user-info">
@@ -262,78 +165,76 @@
<span class="saving-indicator" id="savingIndicator"></span> <span class="saving-indicator" id="savingIndicator"></span>
</div> </div>
<div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;">
<div id="klasSelector" style="display:none;align-items:center;gap:.4rem;"> <button id="btnOpenKlas" class="btn btn-secondary" style="display:none;">
<label style="font-size:.75rem;color:var(--gray-400);white-space:nowrap;">Mijn klassen:</label> ✎ Mijn klassen wijzigen
<div id="klasChips" style="display:flex;gap:.3rem;flex-wrap:wrap;"></div>
<button id="btnOpenKlas" style="background:none;border:1px dashed var(--gray-500);border-radius:6px;padding:.4rem .8rem;font-size:.8rem;color:var(--gray-600);cursor:pointer;">
✎ Wijzigen
</button> </button>
</div> <button id="btnImportJson" class="btn btn-import"
<button id="btnImportJson" class="btn btn-import" title="Importeer beoordelingen uit de vorige standalone versie van de app (JSON bestand)"> title="Importeer beoordelingen uit de vorige standalone versie van de app">
📥 Vorige beoordelingen importeren 📥 Vorige beoordelingen importeren
</button> </button>
<input type="file" id="importJsonFile" accept=".json" style="display:none"> <input type="file" id="importJsonFile" accept=".json" style="display:none">
{% if director_mode %}
<a href="/dashboard" class="btn btn-secondary" style="font-weight:600;">
← Terug naar directeurdashboard
</a>
{% else %}
<a href="/auth/logout" class="btn btn-secondary">Uitloggen</a> <a href="/auth/logout" class="btn btn-secondary">Uitloggen</a>
{% endif %}
</div> </div>
</div> </div>
<div class="vak-selector"> <!-- Klas + Vak selector -->
<label>Vak:</label> <div class="selector-bar">
<select id="vakSelector"> <div class="selector-group">
<option value="">-- Kies een vak --</option> <label>Actieve klas</label>
<select id="klasSelector">
<option value="">-- Kies een klas --</option>
</select> </select>
<span id="vakProgress" style="color: var(--gray-500); font-size: 0.85rem;"></span> </div>
<div class="selector-group">
<label>Vak</label>
<select id="vakSelector" disabled>
<option value="">-- Kies eerst een klas --</option>
</select>
</div>
<span id="vakProgress" style="color:var(--gray-500);font-size:0.85rem;margin-top:1.25rem;"></span>
</div> </div>
<!-- Waarschuwing: geen klassen toegewezen -->
<div class="no-klas-banner" id="noKlasBanner" style="display:none;">
⚠️ Je hebt nog geen klassen toegewezen gekregen.
Vraag je ICT-beheerder of directeur om je aan een klas te koppelen.
</div>
<!-- Statistieken -->
<div class="stats-bar"> <div class="stats-bar">
<div class="stat-card"><div class="stat-value" id="statTotal">-</div><div class="stat-label">Totaal</div></div> <div class="stat-card"><div class="stat-value" id="statTotal">-</div><div class="stat-label">Totaal</div></div>
<div class="stat-card" style="border-left: 3px solid var(--status-groen);"><div class="stat-value" id="statGroen">-</div><div class="stat-label">Groen</div></div> <div class="stat-card" style="border-left:3px solid var(--status-groen);"><div class="stat-value" id="statGroen">-</div><div class="stat-label">Groen</div></div>
<div class="stat-card" style="border-left: 3px solid var(--status-oranje);"><div class="stat-value" id="statOranje">-</div><div class="stat-label">Oranje</div></div> <div class="stat-card" style="border-left:3px solid var(--status-oranje);"><div class="stat-value" id="statOranje">-</div><div class="stat-label">Oranje</div></div>
<div class="stat-card" style="border-left: 3px solid var(--status-roze);"><div class="stat-value" id="statRoze">-</div><div class="stat-label">Roze</div></div> <div class="stat-card" style="border-left:3px solid var(--status-roze);"><div class="stat-value" id="statRoze">-</div><div class="stat-label">Roze</div></div>
<div class="stat-card highlight"><div class="stat-value" id="statBeoordeeld">-</div><div class="stat-label">Beoordeeld</div></div> <div class="stat-card highlight"><div class="stat-value" id="statBeoordeeld">-</div><div class="stat-label">Beoordeeld</div></div>
</div> </div>
<!-- Legenda --> <!-- Legenda -->
<div class="legend-container"> <div class="legend-container">
<div class="legend-title">Legenda</div> <div class="legend-title">Legenda</div>
<div class="legend-grid"> <div class="legend-grid">
<div class="legend-section"> <div class="legend-section">
<div class="legend-item"> <div class="legend-item"><div class="status-selector status-groen" style="width:22px;height:22px;font-size:.85rem;pointer-events:none;flex-shrink:0;"></div><span>Doen we al</span></div>
<div class="status-selector status-groen" style="width:22px;height:22px;font-size:.85rem;pointer-events:none;flex-shrink:0;"></div> <div class="legend-item"><div class="status-selector status-oranje" style="width:22px;height:22px;font-size:.85rem;pointer-events:none;flex-shrink:0;"></div><span>Doen we ongeveer</span></div>
<span>Doen we al</span> <div class="legend-item"><div class="status-selector status-roze" style="width:22px;height:22px;font-size:.85rem;pointer-events:none;flex-shrink:0;"></div><span>Nieuw (doen we nog niet)</span></div>
</div> <div class="legend-item"><div class="status-selector status-none" style="width:22px;height:22px;font-size:.85rem;pointer-events:none;flex-shrink:0;"></div><span>Nog geen status</span></div>
<div class="legend-item">
<div class="status-selector status-oranje" style="width:22px;height:22px;font-size:.85rem;pointer-events:none;flex-shrink:0;"></div>
<span>Doen we ongeveer</span>
</div>
<div class="legend-item">
<div class="status-selector status-roze" style="width:22px;height:22px;font-size:.85rem;pointer-events:none;flex-shrink:0;"></div>
<span>Nieuw (doen we nog niet)</span>
</div>
<div class="legend-item">
<div class="status-selector status-none" style="width:22px;height:22px;font-size:.85rem;pointer-events:none;flex-shrink:0;"></div>
<span>Nog geen status</span>
</div>
</div> </div>
<div class="legend-divider"></div> <div class="legend-divider"></div>
<div class="legend-section"> <div class="legend-section">
<div class="legend-item"> <div class="legend-item"><div class="legend-color" style="background:var(--kleur-engageren);"></div><span><strong>Engageren</strong></span></div>
<div class="legend-color" style="background:var(--kleur-engageren);"></div> <div class="legend-item"><div class="legend-color" style="background:var(--kleur-begrijpen);"></div><span><strong>Begrijpen</strong></span></div>
<span><strong>Engageren</strong></span> <div class="legend-item"><div class="legend-color" style="background:var(--kleur-gebruiken);"></div><span><strong>Gebruiken</strong></span></div>
</div>
<div class="legend-item">
<div class="legend-color" style="background:var(--kleur-begrijpen);"></div>
<span><strong>Begrijpen</strong></span>
</div>
<div class="legend-item">
<div class="legend-color" style="background:var(--kleur-gebruiken);"></div>
<span><strong>Gebruiken</strong></span>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Filters -->
<div class="filters-container"> <div class="filters-container">
<div class="filters-grid"> <div class="filters-grid">
<div class="filter-group"> <div class="filter-group">
@@ -361,14 +262,12 @@
</div> </div>
<div class="filter-group"> <div class="filter-group">
<label>Sectie</label> <label>Sectie</label>
<select id="sectieFilter"> <select id="sectieFilter"><option value="all">Alle secties</option></select>
<option value="all">Alle secties</option>
</select>
</div> </div>
<div class="filter-group" style="grid-column: span 2;"> <div class="filter-group" style="grid-column:span 2;">
<label>Leeftijd</label> <label>Leeftijd</label>
<div class="leeftijd-checkboxes"> <div class="leeftijd-checkboxes">
{% for age in ['2,5-4','4-5','5-6','6-7','7-8','8-9','9-10','10-11','11-12'] %} {% for age in ['3-4','4-5','5-6','6-7','7-8','8-9','9-10','10-11','11-12'] %}
<label class="leeftijd-checkbox"><input type="checkbox" value="{{ age }}"><span>{{ age }}</span></label> <label class="leeftijd-checkbox"><input type="checkbox" value="{{ age }}"><span>{{ age }}</span></label>
{% endfor %} {% endfor %}
</div> </div>
@@ -376,6 +275,7 @@
</div> </div>
</div> </div>
<!-- Tabel -->
<div class="table-container"> <div class="table-container">
<div class="table-scroll"> <div class="table-scroll">
<table> <table>
@@ -387,10 +287,11 @@
<th>Leeftijden</th> <th>Leeftijden</th>
<th>Sectie</th> <th>Sectie</th>
<th>Beschrijving</th> <th>Beschrijving</th>
<th class="opm-col">Opm.</th>
</tr> </tr>
</thead> </thead>
<tbody id="tableBody"> <tbody id="tableBody">
<tr><td colspan="6" class="empty-state">Selecteer een vak om te beginnen</td></tr> <tr><td colspan="7" class="empty-state">Selecteer een klas en een vak om te beginnen</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -398,9 +299,9 @@
</div> </div>
<!-- Klassen modal --> <!-- Klassen modal -->
<div id="klasModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;align-items:center;justify-content:center;"> <div class="modal-overlay" id="klasModal">
<div style="background:var(--gray-800, white);border-radius:12px;padding:1.5rem;max-width:400px;width:90%;box-shadow:0 20px 60px rgba(0,0,0,.3);"> <div class="modal-inner">
<h2 style="font-size:1.1rem;margin-bottom:.5rem;">📚 Mijn klassen instellen</h2> <h2>📚 Mijn klassen instellen</h2>
<p style="font-size:.85rem;color:var(--gray-400);margin-bottom:1rem;"> <p style="font-size:.85rem;color:var(--gray-400);margin-bottom:1rem;">
Selecteer de klassen waarvoor jij beoordelingen invult. Selecteer de klassen waarvoor jij beoordelingen invult.
</p> </p>
@@ -415,94 +316,119 @@
<div class="notification" id="notification"></div> <div class="notification" id="notification"></div>
<script nonce="{{ csp_nonce() }}"> <script nonce="{{ csp_nonce() }}">
// ── State ──────────────────────────────────────────────────────────────────── // ── State ────────────────────────────────────────────────────────────────────
let currentUser = null; let currentUser = null;
let activeKlasId = null;
let currentVakId = null; let currentVakId = null;
let vakData = {}; // cache van geladen vak JSON let vakData = {};
let assessments = {}; // { goal_id: status } voor huidig vak let assessments = {};
let opmerkingen = {};
let doelzinnen = []; let doelzinnen = [];
let filteredData = []; let filteredData = [];
let allKlassen = [];
let myKlassen = [];
let saveTimeout = null; let saveTimeout = null;
let opmTimer = null;
// ── Init ───────────────────────────────────────────────────────────────────── // ── Init ─────────────────────────────────────────────────────────────────────
function bind(id, ev, fn) { function bind(id, ev, fn) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.addEventListener(ev, fn); if (el) el.addEventListener(ev, fn);
else console.warn('Element niet gevonden:', id);
} }
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
bind('btnImportJson', 'click', function() { document.getElementById('importJsonFile').click(); }); bind('klasSelector', 'change', switchKlas);
bind('importJsonFile', 'change', function() { importLegacyJson(this.files[0]); this.value=''; }); bind('vakSelector', 'change', switchVak);
bind('btnOpenKlas', 'click', openKlasModal); bind('btnOpenKlas', 'click', openKlasModal);
bind('btnSluitKlas', 'click', closeKlasModal); bind('btnSluitKlas', 'click', closeKlasModal);
bind('btnSlaKlas', 'click', saveKlassen); bind('btnSlaKlas', 'click', saveKlassen);
bind('vakSelector', 'change', switchVak); bind('btnImportJson', 'click', () => document.getElementById('importJsonFile').click());
bind('importJsonFile', 'change', function() { importLegacyJson(this.files[0]); this.value=''; });
bind('searchInput', 'input', applyFilters); bind('searchInput', 'input', applyFilters);
bind('statusFilter', 'change', applyFilters); bind('statusFilter', 'change', applyFilters);
bind('ebgFilter', 'change', applyFilters); bind('ebgFilter', 'change', applyFilters);
bind('sectieFilter', 'change', applyFilters); bind('sectieFilter', 'change', applyFilters);
document.querySelectorAll('.leeftijd-checkboxes input').forEach(cb => cb.addEventListener('change', applyFilters)); document.querySelectorAll('.leeftijd-checkboxes input').forEach(cb => cb.addEventListener('change', applyFilters));
try { await loadUser(); } catch(e) { console.error('loadUser fout:', e); } try { await loadUser(); } catch(e) { console.error('loadUser fout:', e); }
try { await loadVakken(); } catch(e) { console.error('loadVakken fout:', e); } await loadVakken();
}); });
async function loadUser() { async function loadUser() {
try {
const res = await fetch('/api/me'); const res = await fetch('/api/me');
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json(); const data = await res.json();
currentUser = data.user; currentUser = data.user;
if (!currentUser) throw new Error('Geen gebruikersdata');
document.getElementById('userInfo').textContent = document.getElementById('userInfo').textContent =
`${currentUser.full_name}${currentUser.school?.name || ''}`; currentUser.full_name + ' — ' + (currentUser.school?.name || '');
await loadKlassen(); await loadKlassen();
} catch(e) {
console.error('loadUser fout:', e);
document.getElementById('userInfo').textContent = 'Fout bij laden gebruiker';
}
} }
// ── Klassen ─────────────────────────────────────────────────────────────────── // ── Klassen ───────────────────────────────────────────────────────────────────
let allKlassen = [];
let myKlassen = [];
async function loadKlassen() { async function loadKlassen() {
const res = await fetch('/api/my/classes'); const res = await fetch('/api/my/classes');
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
allKlassen = data.all_classes || []; allKlassen = data.all_classes || [];
myKlassen = data.my_classes || []; myKlassen = data.my_classes || [];
renderKlasChips();
const sel = document.getElementById('klasSelector');
sel.innerHTML = '<option value="">-- Kies een klas --</option>';
if (!myKlassen.length) {
document.getElementById('noKlasBanner').style.display = 'block';
} else {
document.getElementById('noKlasBanner').style.display = 'none';
myKlassen.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
sel.appendChild(opt);
});
if (myKlassen.length === 1) {
sel.value = myKlassen[0].id;
await switchKlas();
}
}
if (allKlassen.length > 0) { if (allKlassen.length > 0) {
document.getElementById('klasSelector').style.display = 'flex'; document.getElementById('btnOpenKlas').style.display = 'inline-flex';
} }
} }
function renderKlasChips() { async function switchKlas() {
const container = document.getElementById('klasChips'); const klasId = parseInt(document.getElementById('klasSelector').value);
if (!myKlassen.length) { activeKlasId = klasId || null;
container.innerHTML = '<span style="font-size:.75rem;color:var(--gray-500);font-style:italic;">Geen klas</span>'; currentVakId = null;
} else { assessments = {};
container.innerHTML = myKlassen.map(c => opmerkingen = {};
`<span style="background:var(--primary);color:white;padding:.15rem .5rem;border-radius:4px;font-size:.75rem;">${c.name}</span>` doelzinnen = [];
).join('');
const vakSel = document.getElementById('vakSelector');
if (!activeKlasId) {
vakSel.disabled = true;
vakSel.innerHTML = '<option value="">-- Kies eerst een klas --</option>';
renderEmptyState();
updateStats();
return;
} }
vakSel.disabled = false;
vakSel.value = '';
renderEmptyState();
updateStats();
} }
function openKlasModal() { function openKlasModal() {
const container = document.getElementById('klasCheckboxes'); const container = document.getElementById('klasCheckboxes');
container.innerHTML = allKlassen.map(c => ` container.innerHTML = allKlassen.map(c =>
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;padding:.4rem;border-radius:4px;"> '<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;padding:.4rem;border-radius:4px;">' +
<input type="checkbox" value="${c.id}" '<input type="checkbox" value="' + c.id + '" ' + (myKlassen.some(m => m.id === c.id) ? 'checked' : '') + '>' +
${myKlassen.some(m => m.id === c.id) ? 'checked' : ''}> '<span>' + c.name + '</span></label>'
<span>${c.name}</span> ).join('');
</label>`).join(''); document.getElementById('klasModal').classList.add('active');
document.getElementById('klasModal').style.display = 'flex';
} }
function closeKlasModal() { function closeKlasModal() {
document.getElementById('klasModal').style.display = 'none'; document.getElementById('klasModal').classList.remove('active');
} }
async function saveKlassen() { async function saveKlassen() {
@@ -514,74 +440,57 @@ async function saveKlassen() {
if (!res.ok) { showNotification('Opslaan mislukt', 'error'); return; } if (!res.ok) { showNotification('Opslaan mislukt', 'error'); return; }
const data = await res.json(); const data = await res.json();
myKlassen = data.my_classes; myKlassen = data.my_classes;
renderKlasChips();
closeKlasModal(); closeKlasModal();
await loadKlassen();
showNotification('Klassen opgeslagen', 'success'); showNotification('Klassen opgeslagen', 'success');
} }
// ── Vakken ────────────────────────────────────────────────────────────────────
async function loadVakken() { async function loadVakken() {
const sel = document.getElementById('vakSelector'); const sel = document.getElementById('vakSelector');
try { try {
const res = await fetch('/api/doelen/index'); const res = await fetch('/api/doelen/index');
if (!res.ok) { if (!res.ok) { showNotification('Fout bij laden vakken', 'error'); return; }
showNotification('Fout bij laden vakken (HTTP ' + res.status + ')', 'error');
return;
}
const data = await res.json(); const data = await res.json();
if (!data.vakken?.length) { if (!data.vakken?.length) {
// Geen doelen geüpload — toon duidelijke boodschap in selector sel.innerHTML = '<option value="">Geen vakken beschikbaar</option>';
const opt = document.createElement('option');
opt.value = '';
opt.textContent = '⚠️ Geen vakken beschikbaar — beheerder moet doelen uploaden';
opt.disabled = true;
sel.appendChild(opt);
document.getElementById('tableBody').innerHTML =
`<tr><td colspan="6" style="padding:2rem;text-align:center;color:var(--gray-500);">
<strong>Geen leerdoelen beschikbaar.</strong><br><br>
Vraag je beheerder om de doelensets te uploaden via het beheerderspaneel
(<em>Beheer → Leerdoelen bestanden</em>).<br><br>
<span style="font-size:.85rem;">Heb je al beoordelingen uit de vorige versie?
Gebruik de <strong>📥 Vorige beoordelingen importeren</strong> knop hierboven
om ze te migreren zodra de vakken beschikbaar zijn.</span>
</td></tr>`;
return; return;
} }
const sorted = [...data.vakken].sort((a,b) => (a.naam||a.id).localeCompare(b.naam||b.id,'nl'));
const sorted = [...data.vakken].sort((a,b) => (a.naam||a.id).localeCompare(b.naam||b.id, 'nl')); sel.innerHTML = '<option value="">-- Kies een vak --</option>';
sorted.forEach(v => { sorted.forEach(v => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = v.id; opt.value = v.id;
opt.textContent = `${v.naam || vakNaam(v.id)} (${v.aantalDoelzinnen} doelen)`; opt.textContent = (v.naam || v.id) + ' (' + v.aantalDoelzinnen + ' doelen)';
sel.appendChild(opt); sel.appendChild(opt);
}); });
} catch(e) { } catch(e) {
console.error('loadVakken fout:', e); showNotification('Netwerkfout bij laden vakken', 'error');
showNotification('Netwerk­fout bij laden vakken', 'error');
} }
} }
// ── Vak wisselen ───────────────────────────────────────────────────────────── // ── Vak wisselen ─────────────────────────────────────────────────────────────
async function switchVak() { async function switchVak() {
if (!activeKlasId) { showNotification('Selecteer eerst een klas', 'warning'); return; }
const vakId = document.getElementById('vakSelector').value; const vakId = document.getElementById('vakSelector').value;
if (!vakId) { if (!vakId) { currentVakId = null; doelzinnen = []; renderEmptyState(); updateStats(); return; }
currentVakId = null; doelzinnen = []; renderEmptyState(); updateStats(); return;
}
currentVakId = vakId; currentVakId = vakId;
showLoading(); showLoading();
// Laad vak data (cache)
if (!vakData[vakId]) { if (!vakData[vakId]) {
const res = await fetch(`/api/doelen/${vakId}`); const res = await fetch('/api/doelen/' + vakId);
if (!res.ok) { showNotification(`Kon ${vakId} niet laden`, 'error'); return; } if (!res.ok) { showNotification('Kon ' + vakId + ' niet laden', 'error'); return; }
vakData[vakId] = await res.json(); vakData[vakId] = await res.json();
} }
// Laad beoordelingen voor dit vak const res2 = await fetch('/api/assessments?class_id=' + activeKlasId + '&vak_id=' + vakId);
const res2 = await fetch(`/api/assessments?vak_id=${vakId}`);
const data2 = await res2.json(); const data2 = await res2.json();
assessments = {}; assessments = {};
data2.assessments.forEach(a => { assessments[a.goal_id] = a.status; }); opmerkingen = {};
(data2.assessments || []).forEach(a => {
if (a.status) assessments[a.goal_id] = a.status;
if (a.opmerking) opmerkingen[a.goal_id] = a.opmerking;
});
processVakData(vakId); processVakData(vakId);
populateSectieFilter(); populateSectieFilter();
@@ -598,9 +507,8 @@ function processVakData(vakId) {
}); });
const miaPerDoel = {}; const miaPerDoel = {};
data.rijen.forEach(r => { data.rijen.forEach(r => {
if (r.parentDoelzinId && r.type?.startsWith('MIA')) { if (r.parentDoelzinId && r.type && r.type.startsWith('MIA'))
(miaPerDoel[r.parentDoelzinId] = miaPerDoel[r.parentDoelzinId] || []).push(r); (miaPerDoel[r.parentDoelzinId] = miaPerDoel[r.parentDoelzinId] || []).push(r);
}
}); });
data.rijen.forEach(r => { data.rijen.forEach(r => {
if (r.type === 'doelzin' && r.goNr) { if (r.type === 'doelzin' && r.goNr) {
@@ -625,7 +533,7 @@ function applyFilters() {
const leeftijd = [...document.querySelectorAll('.leeftijd-checkboxes input:checked')].map(c => c.value); const leeftijd = [...document.querySelectorAll('.leeftijd-checkboxes input:checked')].map(c => c.value);
filteredData = doelzinnen.filter(d => { filteredData = doelzinnen.filter(d => {
if (search && !`${d.goNr} ${d.inhoud}`.toLowerCase().includes(search)) return false; if (search && !(d.goNr + ' ' + d.inhoud).toLowerCase().includes(search)) return false;
const ds = assessments[d.id] || ''; const ds = assessments[d.id] || '';
if (status === 'none' && ds) return false; if (status === 'none' && ds) return false;
if (status !== 'all' && status !== 'none' && ds !== status) return false; if (status !== 'all' && status !== 'none' && ds !== status) return false;
@@ -634,7 +542,6 @@ function applyFilters() {
if (leeftijd.length > 0 && !leeftijd.some(l => d.leeftijden.includes(l))) return false; if (leeftijd.length > 0 && !leeftijd.some(l => d.leeftijden.includes(l))) return false;
return true; return true;
}); });
renderTable(); renderTable();
updateStats(); updateStats();
} }
@@ -656,68 +563,68 @@ function populateSectieFilter() {
} }
// ── Render ──────────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────────
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
function renderTable() { function renderTable() {
const tbody = document.getElementById('tableBody'); const tbody = document.getElementById('tableBody');
if (!filteredData.length) { if (!filteredData.length) {
tbody.innerHTML = `<tr><td colspan="6" class="empty-state">${currentVakId ? 'Geen doelen gevonden' : 'Selecteer een vak'}</td></tr>`; tbody.innerHTML = '<tr><td colspan="7" class="empty-state">' + (currentVakId ? 'Geen doelen gevonden' : 'Selecteer een klas en een vak') + '</td></tr>';
return; return;
} }
tbody.innerHTML = filteredData.map(d => { tbody.innerHTML = filteredData.map(d => {
const s = assessments[d.id] || ''; const s = assessments[d.id] || '';
const ebg = (d.kennisverwerking||'').toLowerCase(); const ebg = (d.kennisverwerking||'').toLowerCase();
return ` return '<tr class="' + (s ? 'status-'+s : '') + '">' +
<tr class="${s ? 'status-'+s : ''}"> '<td><button class="status-selector status-' + (s||'none') + '" data-action="cycleStatus" data-id="' + d.id + '"></button></td>' +
<td><button class="status-selector status-${s||'none'}" data-action="cycleStatus" data-id="${d.id}"></button></td> '<td><strong>' + d.goNr + '</strong></td>' +
<td><strong>${d.goNr}</strong></td> '<td>' + (ebg ? '<span class="ebg-badge ebg-' + ebg + '">' + ebg.charAt(0).toUpperCase()+ebg.slice(1) + '</span>' : '-') + '</td>' +
<td>${ebg ? `<span class="ebg-badge ebg-${ebg}">${ebg.charAt(0).toUpperCase()+ebg.slice(1)}</span>` : '-'}</td> '<td><div class="leeftijden">' + d.leeftijden.map(l => '<span class="leeftijd-badge">'+l+'</span>').join('') + '</div></td>' +
<td><div class="leeftijden">${d.leeftijden.map(l=>`<span class="leeftijd-badge">${l}</span>`).join('')}</div></td> '<td>' + (d.sectie||'-') + '</td>' +
<td>${d.sectie||'-'}</td> '<td class="beschrijving-cell">' + escapeHtml(d.inhoud) + renderMIA(d.mia) + '</td>' +
<td class="beschrijving-cell"> '<td class="opm-cell"><input type="text" class="opm-input" maxlength="150" value="' + escapeHtml(opmerkingen[d.id] || '') + '" data-action="saveOpmerking" data-id="' + d.id + '" placeholder="..."></td>' +
${d.inhoud} '</tr>';
${renderMIA(d.mia)}
</td>
</tr>`;
}).join(''); }).join('');
} }
function renderMIA(items) { function renderMIA(items) {
if (!items?.length) return ''; if (!items || !items.length) return '';
const aankl = items.filter(m => m.type === 'MIA - aanklikbaar'); const aankl = items.filter(m => m.type === 'MIA - aanklikbaar');
const niet = items.filter(m => m.type === 'MIA - niet aanklikbaar'); const niet = items.filter(m => m.type === 'MIA - niet aanklikbaar');
const titels = items.filter(m => m.type === 'MIA - titel'); const titels = items.filter(m => m.type === 'MIA - titel');
if (!aankl.length && !niet.length) return ''; if (!aankl.length && !niet.length) return '';
return `<div class="mia-container"> return '<div class="mia-container">' +
${titels.length ? `<strong>${titels.map(t=>t.inhoud).join(' ')}</strong>` : ''} (titels.length ? '<strong>' + titels.map(t=>t.inhoud).join(' ') + '</strong>' : '') +
<div class="mia-items"> '<div class="mia-items">' +
${aankl.map(m=>`<span class="mia-item">${m.inhoud}</span>`).join('')} aankl.map(m => '<span class="mia-item">' + escapeHtml(m.inhoud) + '</span>').join('') +
${niet.map(m=>`<span class="mia-item" style="opacity:.6;font-style:italic">${m.inhoud}</span>`).join('')} niet.map(m => '<span class="mia-item" style="opacity:.6;font-style:italic">' + escapeHtml(m.inhoud) + '</span>').join('') +
</div></div>`; '</div></div>';
} }
function showLoading() { function showLoading() {
document.getElementById('tableBody').innerHTML = document.getElementById('tableBody').innerHTML =
`<tr><td colspan="6" class="loading"><div class="spinner"></div>Laden...</td></tr>`; '<tr><td colspan="7" class="loading"><div class="spinner"></div>Laden...</td></tr>';
} }
function renderEmptyState() { function renderEmptyState() {
document.getElementById('tableBody').innerHTML = document.getElementById('tableBody').innerHTML =
`<tr><td colspan="6" class="empty-state">Selecteer een vak om te beginnen</td></tr>`; '<tr><td colspan="7" class="empty-state">Selecteer een klas en een vak om te beginnen</td></tr>';
document.getElementById('vakProgress').textContent = '';
} }
// ── Status ──────────────────────────────────────────────────────────────────── // ── Status ────────────────────────────────────────────────────────────────────
function cycleStatus(goalId) { function cycleStatus(goalId) {
if (!activeKlasId || !currentVakId) return;
const cycle = ['', 'groen', 'oranje', 'roze']; const cycle = ['', 'groen', 'oranje', 'roze'];
const cur = assessments[goalId] || ''; const cur = assessments[goalId] || '';
const next = cycle[(cycle.indexOf(cur) + 1) % cycle.length]; const next = cycle[(cycle.indexOf(cur) + 1) % cycle.length];
if (next === '') delete assessments[goalId]; if (next === '') delete assessments[goalId];
else assessments[goalId] = next; else assessments[goalId] = next;
// Optimistisch de UI updaten
renderTable(); renderTable();
updateStats(); updateStats();
// Debounced save naar API
clearTimeout(saveTimeout); clearTimeout(saveTimeout);
setSavingIndicator('Opslaan...'); setSavingIndicator('Opslaan...');
saveTimeout = setTimeout(() => saveToApi(goalId, next), 500); saveTimeout = setTimeout(() => saveToApi(goalId, next), 500);
@@ -727,8 +634,8 @@ async function saveToApi(goalId, status) {
try { try {
const res = await fetch('/api/assessments', { const res = await fetch('/api/assessments', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ vak_id: currentVakId, goal_id: goalId, status }) body: JSON.stringify({ class_id: activeKlasId, vak_id: currentVakId, goal_id: goalId, status })
}); });
if (!res.ok) throw new Error('Opslaan mislukt'); if (!res.ok) throw new Error('Opslaan mislukt');
setSavingIndicator('✓ Opgeslagen'); setSavingIndicator('✓ Opgeslagen');
@@ -743,6 +650,23 @@ function setSavingIndicator(text) {
document.getElementById('savingIndicator').textContent = text; document.getElementById('savingIndicator').textContent = text;
} }
// ── Opmerkingen ───────────────────────────────────────────────────────────────
function saveOpmerking(goalId, tekst) {
if (!activeKlasId || !currentVakId) return;
if (tekst.trim()) opmerkingen[goalId] = tekst.trim();
else delete opmerkingen[goalId];
clearTimeout(opmTimer);
opmTimer = setTimeout(async () => {
try {
await fetch('/api/assessments/opmerking', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ class_id: activeKlasId, vak_id: currentVakId, goal_id: goalId, opmerking: tekst.trim() })
});
} catch(e) { console.error('Opmerking opslaan mislukt:', e); }
}, 600);
}
// ── Stats ───────────────────────────────────────────────────────────────────── // ── Stats ─────────────────────────────────────────────────────────────────────
function updateStats() { function updateStats() {
const total = doelzinnen.length; const total = doelzinnen.length;
@@ -751,80 +675,58 @@ function updateStats() {
const oranje = vals.filter(s=>s==='oranje').length; const oranje = vals.filter(s=>s==='oranje').length;
const roze = vals.filter(s=>s==='roze').length; const roze = vals.filter(s=>s==='roze').length;
const pct = total > 0 ? Math.round((groen+oranje+roze)/total*100) : 0; const pct = total > 0 ? Math.round((groen+oranje+roze)/total*100) : 0;
document.getElementById('statTotal').textContent = total || '-'; document.getElementById('statTotal').textContent = total || '-';
document.getElementById('statGroen').textContent = groen || '-'; document.getElementById('statGroen').textContent = groen || '-';
document.getElementById('statOranje').textContent = oranje || '-'; document.getElementById('statOranje').textContent = oranje || '-';
document.getElementById('statRoze').textContent = roze || '-'; document.getElementById('statRoze').textContent = roze || '-';
document.getElementById('statBeoordeeld').textContent = total > 0 ? `${pct}%` : '-'; document.getElementById('statBeoordeeld').textContent = total > 0 ? pct + '%' : '-';
document.getElementById('vakProgress').textContent = document.getElementById('vakProgress').textContent =
currentVakId ? `${groen+oranje+roze}/${total} beoordeeld` : ''; (activeKlasId && currentVakId) ? (groen+oranje+roze) + '/' + total + ' beoordeeld' : '';
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Legacy import ─────────────────────────────────────────────────────────────
function vakNaam(id) {
// Naam komt uit de API (index.json via /api/doelen/index, veld 'naam').
// Deze functie is enkel een fallback als de naam niet beschikbaar is.
return id.replace(/^doelenset-bao-/, '').replace(/-/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase());
}
function showNotification(msg, type='success') {
const el = document.getElementById('notification');
el.textContent = msg;
el.className = `notification ${type} show`;
setTimeout(() => el.classList.remove('show'), 3000);
}
// ── Event delegation voor dynamisch gegenereerde elementen ────────────────────
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'cycleStatus') { cycleStatus(btn.dataset.id); }
});
// ── Legacy JSON import (uit vorige standalone versie) ────────────────────────
async function importLegacyJson(file) { async function importLegacyJson(file) {
if (!file) return; if (!file) return;
if (!activeKlasId) { showNotification('Selecteer eerst een klas voor je importeert', 'warning'); return; }
let data; let data;
try { try {
const text = await file.text(); data = JSON.parse(await file.text());
data = JSON.parse(text); if (!data.vakken) { showNotification('Ongeldig bestand', 'error'); return; }
// Valideer dat het een herkenbaar formaat is } catch(e) { showNotification('Ongeldig JSON bestand', 'error'); return; }
if (!data.vakken) {
showNotification('Ongeldig bestand — geen vakken gevonden. Verwacht een export uit de Leerdoelen Tracker.', 'error');
return;
}
} catch(e) {
showNotification('Ongeldig JSON bestand', 'error'); return;
}
if (!data.vakken) { showNotification('Geen vakken gevonden in dit bestand', 'error'); return; }
showNotification('Bezig met importeren...', 'info');
showNotification('Bezig met importeren...', 'warning');
try { try {
const res = await fetch('/api/assessments/bulk-import', { const res = await fetch('/api/assessments/bulk-import', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ vakken: data.vakken }) body: JSON.stringify({ class_id: activeKlasId, vakken: data.vakken })
}); });
const result = await res.json(); const result = await res.json();
if (!res.ok) { showNotification(result.error || 'Import mislukt', 'error'); return; } if (!res.ok) { showNotification(result.error || 'Import mislukt', 'error'); return; }
showNotification('Import klaar: ' + result.totaal + ' beoordelingen' + (result.fouten > 0 ? ', ' + result.fouten + ' fouten' : ''),
showNotification( result.fouten > 0 ? 'warning' : 'success');
`Import klaar: ${result.totaal} beoordelingen geïmporteerd` + if (currentVakId) await switchVak();
(result.fouten > 0 ? `, ${result.fouten} fouten` : ''), } catch(e) { showNotification('Netwerkfout tijdens import', 'error'); }
result.fouten > 0 ? 'warning' : 'success'
);
if (currentVakId) await loadAssessments(currentVakId);
updateStats();
} catch(e) {
showNotification('Netwerkfout tijdens import', 'error');
}
} }
// ── Event delegation ──────────────────────────────────────────────────────────
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-action="cycleStatus"]');
if (btn) cycleStatus(btn.dataset.id);
});
document.addEventListener('change', function(e) {
const inp = e.target.closest('[data-action="saveOpmerking"]');
if (inp) saveOpmerking(inp.dataset.id, inp.value);
});
// ── Notificaties ──────────────────────────────────────────────────────────────
function showNotification(msg, type) {
type = type || 'success';
const el = document.getElementById('notification');
el.textContent = msg;
el.className = 'notification ' + type + ' show';
setTimeout(() => el.classList.remove('show'), 3000);
}
</script> </script>
</body> </body>
</html> </html>

View File

@@ -9,250 +9,128 @@
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh; min-height: 100vh; display: flex;
display: flex; align-items: center; justify-content: center; padding: 1rem;
align-items: center;
justify-content: center;
} }
.card { .card {
background: white; background: white; border-radius: 16px; padding: 2.5rem;
border-radius: 16px; width: 100%; max-width: 400px; box-shadow: 0 20px 60px rgba(0,0,0,0.2);
padding: 2.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
} }
.logo { text-align: center; margin-bottom: 2rem; } .logo { text-align: center; margin-bottom: 2rem; }
.logo .icon { font-size: 3rem; margin-bottom: 0.5rem; } .logo .icon { font-size: 3rem; margin-bottom: 0.5rem; }
.logo h1 { font-size: 1.4rem; color: var(--gray-900, #1f2937); font-weight: 700; } .logo h1 { font-size: 1.4rem; color: #1f2937; font-weight: 700; }
.logo p { color: var(--gray-500, #6b7280); font-size: 0.85rem; margin-top: 0.25rem; } .logo p { color: #6b7280; font-size: 0.85rem; margin-top: 0.25rem; }
.btn-microsoft { .btn-microsoft {
width: 100%; width: 100%; padding: 0.85rem; background: #0078d4; color: white;
padding: 0.85rem; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600;
background: #0078d4; cursor: pointer; display: flex; align-items: center; justify-content: center;
color: white; gap: 0.75rem; text-decoration: none; transition: background 0.2s; margin-bottom: 1rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
text-decoration: none;
transition: background 0.2s;
margin-bottom: 1rem;
} }
.btn-microsoft:hover { background: #006cbe; } .btn-microsoft:hover { background: #006cbe; }
.sso-divider {
.alert { display: flex; align-items: center; gap: 0.75rem;
padding: 0.85rem 1rem; margin: 0.5rem 0 1.25rem; color: #9ca3af; font-size: 0.8rem;
border-radius: 8px;
margin-bottom: 1.25rem;
font-size: 0.875rem;
line-height: 1.5;
} }
.sso-divider::before, .sso-divider::after { content: ''; flex: 1; height: 1px; background: #e5e7eb; }
.google-section-title {
font-size: 0.82rem; font-weight: 600; color: #374151;
margin-bottom: 0.6rem; display: flex; align-items: center; gap: 0.4rem;
}
.email-input-row { display: flex; gap: 0.5rem; }
.email-input-row input {
flex: 1; padding: 0.65rem 0.75rem;
border: 1px solid #d1d5db; border-radius: 8px; font-size: 0.95rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
.email-input-row input:focus {
outline: none; border-color: #4285f4;
box-shadow: 0 0 0 3px rgba(66,133,244,0.15);
}
.btn-lookup {
padding: 0.65rem 1rem; background: #f3f4f6; color: #374151;
border: 1px solid #d1d5db; border-radius: 8px;
font-size: 0.9rem; font-weight: 500; cursor: pointer;
transition: background 0.15s; white-space: nowrap;
}
.btn-lookup:hover { background: #e5e7eb; }
.btn-lookup:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-google {
display: flex; align-items: center; justify-content: center; gap: 0.75rem;
width: 100%; padding: 0.8rem 1rem; background: white; color: #1f2937;
border: 1px solid #d1d5db; border-radius: 8px; font-size: 0.95rem;
font-weight: 500; text-decoration: none; cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
}
.btn-google:hover { background: #f9fafb; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
.school-found-label { font-size: 0.78rem; color: #6b7280; text-align: center; margin-top: 0.5rem; }
.lookup-msg {
margin-top: 0.5rem; padding: 0.6rem 0.75rem; border-radius: 6px;
font-size: 0.82rem; display: none;
}
.lookup-msg.error { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; }
.lookup-msg.warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; }
.alert { padding: 0.85rem 1rem; border-radius: 8px; margin-bottom: 1.25rem; font-size: 0.875rem; }
.alert-error { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; } .alert-error { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; }
.alert-success { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; }
.alert-warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; } .alert-warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; }
.alert-success { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; }
.not-configured {
text-align: center; padding: 1.5rem; background: #f9fafb;
border-radius: 8px; color: #6b7280; font-size: 0.9rem;
border: 1px dashed #d1d5db;
}
.not-configured code {
background: #e5e7eb; padding: 0.15rem 0.4rem;
border-radius: 4px; font-size: 0.8rem;
}
/* Superadmin fallback */
.superadmin-toggle { .superadmin-toggle {
text-align: center; text-align: center; margin-top: 1.5rem;
margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #f3f4f6;
padding-top: 1rem;
border-top: 1px solid #f3f4f6;
} }
.superadmin-toggle button { .superadmin-toggle button {
background: none; border: none; background: none; border: none; color: #9ca3af;
color: #9ca3af; font-size: 0.75rem; font-size: 0.75rem; cursor: pointer;
cursor: pointer; text-decoration: underline; text-decoration: underline; text-underline-offset: 2px;
text-underline-offset: 2px;
} }
.superadmin-toggle button:hover { color: #6b7280; } .superadmin-toggle button:hover { color: #6b7280; }
.superadmin-form { display: none; margin-top: 1rem; } .superadmin-form { display: none; margin-top: 1rem; }
.superadmin-form.visible { display: block; } .superadmin-form.visible { display: block; }
.superadmin-form .form-group { margin-bottom: 0.75rem; } .superadmin-form .form-group { margin-bottom: 0.75rem; }
.superadmin-form label { .superadmin-form label { display: block; font-size: 0.8rem; font-weight: 600; color: #374151; margin-bottom: 0.3rem; }
display: block; font-size: 0.8rem;
font-weight: 600; color: #374151; margin-bottom: 0.3rem;
}
.superadmin-form input { .superadmin-form input {
width: 100%; padding: 0.6rem 0.75rem; width: 100%; padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem;
} }
.superadmin-form input:focus { .superadmin-form input:focus { outline: none; border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,0.1); }
outline: none; border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79,70,229,0.1);
}
.btn-superadmin { .btn-superadmin {
width: 100%; padding: 0.6rem; width: 100%; padding: 0.6rem; background: #6b7280; color: white;
background: #6b7280; color: white; border: none; border-radius: 6px; font-size: 0.85rem; font-weight: 600; cursor: pointer;
border: none; border-radius: 6px;
font-size: 0.85rem; font-weight: 600; cursor: pointer;
} }
.btn-superadmin:hover { background: #4b5563; } .btn-superadmin:hover { background: #4b5563; }
#sa-error { color: #dc2626; font-size: 0.8rem; margin-top: 0.5rem; display: none; } #sa-error { color: #dc2626; font-size: 0.8rem; margin-top: 0.5rem; display: none; }
.spinner-inline {
@media (prefers-color-scheme: dark) { display: inline-block; width: 14px; height: 14px;
:root { border: 2px solid #d1d5db; border-top-color: #6b7280;
--gray-50: #1a1a2e; border-radius: 50%; animation: spin 0.7s linear infinite;
--gray-100: #16213e;
--gray-200: #0f3460;
--gray-300: #1a1a3e;
--gray-400: #6b7280;
--gray-500: #9ca3af;
--gray-600: #d1d5db;
--gray-700: #e5e7eb;
--gray-800: #f3f4f6;
--gray-900: #f9fafb;
} }
@keyframes spin { to { transform: rotate(360deg); } }
body { background: #0f172a; color: #e2e8f0; } @media (prefers-color-scheme: dark) {
body { background: linear-gradient(135deg, #1e1b4b, #312e81); }
/* Kaarten en secties */ .card { background: #1e293b; color: #e2e8f0; }
.card, .section, .stat-card, .school-card, .logo h1 { color: #f1f5f9; }
.table-container, .filters-container, .legend-container, .logo p { color: #94a3b8; }
.stats-bar .stat-card, .stats-overview, .vak-stats, .sso-divider { color: #475569; }
.import-section, .detail-section, .filters-bar, .sso-divider::before, .sso-divider::after { background: #334155; }
.header:not([class*="gradient"]) { .google-section-title { color: #cbd5e1; }
background: #1e293b !important; .email-input-row input { background: #0f172a; color: #e2e8f0; border-color: #334155; }
border-color: #334155 !important; .email-input-row input::placeholder { color: #475569; }
.email-input-row input:focus { border-color: #4285f4; }
.btn-lookup { background: #334155; color: #e2e8f0; border-color: #475569; }
.btn-lookup:hover { background: #475569; }
.btn-google { background: #0f172a; color: #e2e8f0; border-color: #334155; }
.btn-google:hover { background: #1e293b; }
.school-found-label { color: #94a3b8; }
.lookup-msg.error { background: #450a0a; border-color: #7f1d1d; color: #fca5a5; }
.lookup-msg.warning { background: #451a03; border-color: #78350f; color: #fcd34d; }
.superadmin-toggle { border-color: #334155; }
.superadmin-toggle button { color: #475569; }
.superadmin-toggle button:hover { color: #94a3b8; }
.superadmin-form label { color: #94a3b8; }
.superadmin-form input { background: #0f172a; color: #e2e8f0; border-color: #334155; }
.alert-error { background: #450a0a; border-color: #7f1d1d; color: #fca5a5; }
.alert-warning { background: #451a03; border-color: #78350f; color: #fcd34d; }
} }
</style>
/* Header kaart in leerkracht.html */
.header { background: #1e293b !important; }
/* Tabellen */
thead, th { background: #1e293b !important; color: #94a3b8 !important; border-color: #334155 !important; }
td { border-color: #1e293b !important; color: #e2e8f0; }
tr:hover td, tr:hover { background: #263548 !important; }
tr.status-groen { background: #064e3b !important; }
tr.status-groen:hover { background: #065f46 !important; }
tr.status-oranje { background: #451a03 !important; }
tr.status-oranje:hover { background: #78350f !important; }
tr.status-roze { background: #500724 !important; }
tr.status-roze:hover { background: #701a35 !important; }
/* Inputs en selects */
input, select, textarea {
background: #0f172a !important;
color: #e2e8f0 !important;
border-color: #334155 !important;
}
input::placeholder { color: #64748b !important; }
input:focus, select:focus, textarea:focus {
border-color: #6366f1 !important;
box-shadow: 0 0 0 3px rgba(99,102,241,0.2) !important;
}
/* Role select inline */
.role-select {
background: #1e293b !important;
color: #e2e8f0 !important;
border-color: #334155 !important;
}
/* Modals */
.modal { background: #1e293b !important; color: #e2e8f0; }
.modal h2 { color: #f1f5f9; }
/* Knoppen */
.btn-secondary { background: #334155 !important; color: #e2e8f0 !important; }
.btn-secondary:hover { background: #475569 !important; }
/* Status selector knoppen (leerkracht tabel) */
.status-selector.status-none { background: #1e293b !important; border-color: #475569 !important; color: #64748b !important; }
/* Stat cards */
.stat-card { background: #1e293b !important; }
/* School card header */
.school-card-header { background: #162032 !important; border-color: #334155 !important; }
.school-card { border-color: #334155 !important; }
/* Drop zone */
.drop-zone { background: #162032 !important; border-color: #334155 !important; }
.drop-zone:hover, .drop-zone.over { background: #1a2744 !important; border-color: #6366f1 !important; }
/* Domain chips */
.domain-chip { background: #1e3a5f !important; border-color: #2563eb !important; color: #93c5fd !important; }
/* Badges */
.leeftijd-badge { background: #334155 !important; color: #94a3b8 !important; }
.ebg-begrijpen { color: #1f2937 !important; }
/* MIA container */
.mia-container { background: #162032 !important; }
.mia-item { background: #1e293b !important; border-color: #334155 !important; color: #e2e8f0 !important; }
.mia-item.niet-aanklikbaar { background: #162032 !important; color: #64748b !important; }
/* Not configured box */
.not-configured { background: #162032 !important; border-color: #334155 !important; color: #94a3b8 !important; }
.not-configured code { background: #0f172a !important; color: #a5b4fc !important; }
/* Profile section */
.profile-section { background: #162032 !important; }
/* Leeftijd checkboxes */
.leeftijd-checkbox { border-color: #334155 !important; color: #e2e8f0; }
.leeftijd-checkbox:has(input:checked) { background: var(--primary) !important; }
/* Vak indicator */
.vak-indicator { /* gradient blijft, ziet er goed uit */ }
/* Progress bars achtergrond */
.progress-bar { background: #334155 !important; }
/* Vak card */
.vak-card { background: #162032 !important; }
/* Upload results */
.upload-ok { background: #064e3b !important; border-color: #065f46 !important; }
.upload-err { background: #450a0a !important; border-color: #7f1d1d !important; }
/* Alert boxes */
.alert-error { background: #450a0a !important; border-color: #7f1d1d !important; color: #fca5a5 !important; }
.alert-success { background: #064e3b !important; border-color: #065f46 !important; color: #6ee7b7 !important; }
.alert-warning { background: #451a03 !important; border-color: #78350f !important; color: #fcd34d !important; }
.alert-info { background: #1e3a5f !important; border-color: #1d4ed8 !important; color: #93c5fd !important; }
/* Error text */
.form-error, #sa-error, #addUser-error { color: #f87171 !important; }
.form-hint { color: #64748b !important; }
/* Superadmin toggle */
.superadmin-toggle { border-color: #334155 !important; }
.superadmin-toggle button { color: #475569 !important; }
.superadmin-toggle button:hover { color: #94a3b8 !important; }
/* Superadmin form inputs */
.superadmin-form label { color: #94a3b8 !important; }
/* Footer */
.footer { color: #64748b !important; }
.status-legend { background: #162032 !important; border-color: #334155 !important; color: #94a3b8 !important; }
/* Scrollbar (webkit) */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #0f172a; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #475569; }
}
</style>
</head> </head>
<body> <body>
<div class="card"> <div class="card">
@@ -268,6 +146,7 @@
{% endfor %} {% endfor %}
{% endwith %} {% endwith %}
{# Microsoft: één global endpoint — werkt direct voor alle scholen #}
{% if entra_configured %} {% if entra_configured %}
<a href="/auth/microsoft" class="btn-microsoft"> <a href="/auth/microsoft" class="btn-microsoft">
<svg width="20" height="20" viewBox="0 0 21 21"> <svg width="20" height="20" viewBox="0 0 21 21">
@@ -278,24 +157,36 @@
</svg> </svg>
Inloggen met Microsoft Inloggen met Microsoft
</a> </a>
<p style="text-align:center; color:#6b7280; font-size:0.8rem;"> <div class="sso-divider">of via Google Workspace</div>
Log in met uw school Microsoft account
</p>
{% else %}
<div class="not-configured">
<strong>Microsoft login niet geconfigureerd</strong><br><br>
Stel <code>MICROSOFT_CLIENT_ID</code> en <code>MICROSOFT_CLIENT_SECRET</code>
in de <code>.env</code> in om Entra login te activeren.
</div>
{% endif %} {% endif %}
<!-- Superadmin fallback — zichtbaar maar discreet --> {# Google: email-first — domein bepaalt welke school-credentials gebruikt worden #}
<div class="google-section">
<div class="google-section-title">
<svg width="15" height="15" viewBox="0 0 48 48">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/>
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.18 1.48-4.97 2.31-8.16 2.31-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
</svg>
Inloggen met Google Workspace
</div>
<div class="email-input-row">
<input type="email" id="googleEmail"
placeholder="uw.naam@school.be"
autocomplete="email" autocapitalize="none" spellcheck="false">
<button class="btn-lookup" id="btnLookup">Verder →</button>
</div>
<div class="lookup-msg error" id="lookupError"></div>
<div class="lookup-msg warning" id="lookupWarning"></div>
<div id="googleResult" style="display:none; margin-top:0.85rem;"></div>
</div>
<div class="superadmin-toggle"> <div class="superadmin-toggle">
<button id="btnToggleSuperadmin">Platformbeheerder</button> <button id="btnToggleSuperadmin">Platformbeheerder</button>
</div> </div>
<div class="superadmin-form" id="superadminForm"> <div class="superadmin-form" id="superadminForm">
<div style="font-size:0.8rem; color:#6b7280; margin-bottom:0.75rem; text-align:center;"> <div style="font-size:0.8rem;color:#6b7280;margin-bottom:.75rem;text-align:center;">
Platformbeheerder toegang Platformbeheerder toegang
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -312,49 +203,94 @@
</div> </div>
<script nonce="{{ csp_nonce() }}"> <script nonce="{{ csp_nonce() }}">
function bind(id, ev, fn) { function bind(id, ev, fn) { const el = document.getElementById(id); if (el) el.addEventListener(ev, fn); }
const el = document.getElementById(id);
if (el) el.addEventListener(ev, fn); const GOOGLE_SVG = `<svg width="20" height="20" viewBox="0 0 48 48">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/>
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.18 1.48-4.97 2.31-8.16 2.31-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
</svg>`;
let busy = false;
async function doGoogleLookup() {
if (busy) return;
const email = document.getElementById('googleEmail').value.trim();
const errEl = document.getElementById('lookupError');
const warnEl = document.getElementById('lookupWarning');
const resultEl = document.getElementById('googleResult');
const btn = document.getElementById('btnLookup');
errEl.style.display = 'none';
warnEl.style.display = 'none';
resultEl.style.display = 'none';
resultEl.innerHTML = '';
if (!email || !email.includes('@')) {
errEl.textContent = 'Vul een geldig e-mailadres in.';
errEl.style.display = 'block';
return;
}
busy = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner-inline"></span>';
try {
const res = await fetch('/api/sso-lookup?email=' + encodeURIComponent(email));
const data = await res.json();
if (!data.found) {
warnEl.innerHTML = 'Geen school gevonden voor <strong>' + email.split('@')[1] + '</strong>. '
+ 'Controleer uw e-mailadres of contacteer uw ICT-beheerder.';
warnEl.style.display = 'block';
} else if (!data.google) {
warnEl.innerHTML = '<strong>' + data.school_name + '</strong> heeft Google Workspace SSO '
+ 'nog niet ingesteld. Contacteer uw school ICT-beheerder.';
warnEl.style.display = 'block';
} else {
resultEl.innerHTML = '<a href="/auth/google?school_id=' + data.school_id + '" class="btn-google">'
+ GOOGLE_SVG + 'Doorgaan met Google &mdash; ' + data.school_name + '</a>'
+ '<p class="school-found-label">School herkend via ' + email.split('@')[1] + '</p>';
resultEl.style.display = 'block';
}
} catch {
errEl.textContent = 'Verbindingsfout. Probeer opnieuw.';
errEl.style.display = 'block';
} finally {
busy = false; btn.disabled = false; btn.textContent = 'Verder \u2192';
}
} }
let saVisible = false; let saVisible = false;
function toggleSuperadmin() {
function toggleSuperadmin() {
saVisible = !saVisible; saVisible = !saVisible;
document.getElementById('superadminForm').classList.toggle('visible', saVisible); document.getElementById('superadminForm').classList.toggle('visible', saVisible);
if (saVisible) document.getElementById('saPassword').focus(); if (saVisible) document.getElementById('saPassword').focus();
} }
async function superadminLogin() {
const errorEl = document.getElementById('sa-error');
errorEl.style.display = 'none';
async function superadminLogin() {
const errEl = document.getElementById('sa-error');
errEl.style.display = 'none';
const res = await fetch('/auth/superadmin-login', { const res = await fetch('/auth/superadmin-login', {
method: 'POST', method: 'POST', headers: {'Content-Type': 'application/json'},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
email: document.getElementById('saEmail').value, email: document.getElementById('saEmail').value,
password: document.getElementById('saPassword').value, password: document.getElementById('saPassword').value,
}) })
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) { window.location.href = data.redirect || '/dashboard'; }
window.location.href = data.redirect || '/dashboard'; else { errEl.textContent = data.error || 'Inloggen mislukt'; errEl.style.display = 'block'; }
} else { }
errorEl.textContent = data.error || 'Inloggen mislukt';
errorEl.style.display = 'block';
}
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && saVisible) superadminLogin();
});
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
bind('btnToggleSuperadmin', 'click', () => { toggleSuperadmin() }); bind('btnLookup', 'click', doGoogleLookup);
bind('btnSuperadminLogin', 'click', () => { superadminLogin() }); bind('btnToggleSuperadmin', 'click', toggleSuperadmin);
bind('btnSuperadminLogin', 'click', superadminLogin);
document.getElementById('googleEmail').addEventListener('keydown', e => { if (e.key === 'Enter') doGoogleLookup(); });
document.getElementById('saPassword').addEventListener('keydown', e => { if (e.key === 'Enter') superadminLogin(); });
}); });
</script> </script>
</body> </body>
</html> </html>

View File

@@ -49,12 +49,20 @@
.domain-chip{display:inline-block;padding:.15rem .45rem;background:#eff6ff;color:#1d4ed8;border-radius:4px;font-size:.72rem;margin:.1rem;border:1px solid #bfdbfe;} .domain-chip{display:inline-block;padding:.15rem .45rem;background:#eff6ff;color:#1d4ed8;border-radius:4px;font-size:.72rem;margin:.1rem;border:1px solid #bfdbfe;}
/* Scholen grid */ /* Gebruikers-per-school tabel */
.schools-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:1rem;} .schools-user-table{width:100%;border-collapse:collapse;}
.school-card{border:1px solid var(--gray-200);border-radius:10px;overflow:hidden;} .schools-user-table th{padding:.6rem .85rem;text-align:left;font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--gray-500);border-bottom:2px solid var(--gray-200);background:var(--gray-50);}
.school-card-header{padding:.9rem 1.1rem;background:var(--gray-50);border-bottom:1px solid var(--gray-200);display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;} .school-row td{padding:.7rem .85rem;border-bottom:1px solid var(--gray-100);vertical-align:middle;font-size:.875rem;}
.school-card-header h3{font-size:.95rem;} .school-row{cursor:pointer;transition:background .12s;}
.school-card-body{padding:.75rem 1.1rem;} .school-row:hover td{background:var(--gray-50);}
.school-row.expanded td{background:#f0f9ff;border-bottom:none;}
.expand-icon{display:inline-block;transition:transform .2s;font-style:normal;width:18px;text-align:center;}
.school-row.expanded .expand-icon{transform:rotate(90deg);}
.users-panel-row td{padding:0;border-bottom:1px solid var(--gray-200);}
.users-panel{padding:.75rem 1.1rem 1rem;background:#f8fafc;display:none;}
.users-panel.open{display:block;}
.users-search{width:100%;max-width:280px;padding:.4rem .65rem;border:1px solid var(--gray-300);border-radius:6px;font-size:.82rem;margin-bottom:.75rem;}
.users-search:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 2px rgba(79,70,229,.1);}
.user-row{display:flex;align-items:center;justify-content:space-between;padding:.35rem 0;border-bottom:1px solid var(--gray-100);font-size:.84rem;gap:.5rem;} .user-row{display:flex;align-items:center;justify-content:space-between;padding:.35rem 0;border-bottom:1px solid var(--gray-100);font-size:.84rem;gap:.5rem;}
.user-row:last-child{border-bottom:none;} .user-row:last-child{border-bottom:none;}
.user-info{flex:1;min-width:0;} .user-info{flex:1;min-width:0;}
@@ -64,6 +72,10 @@
.role-select{padding:.2rem .4rem;border:1px solid var(--gray-300);border-radius:4px;font-size:.75rem;cursor:pointer;background:white;color:var(--gray-700);} .role-select{padding:.2rem .4rem;border:1px solid var(--gray-300);border-radius:4px;font-size:.75rem;cursor:pointer;background:white;color:var(--gray-700);}
.role-select:focus{outline:none;border-color:var(--primary);} .role-select:focus{outline:none;border-color:var(--primary);}
.group-label{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--gray-400);margin:.6rem 0 .3rem;display:flex;justify-content:space-between;} .group-label{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--gray-400);margin:.6rem 0 .3rem;display:flex;justify-content:space-between;}
.school-search-bar{display:flex;align-items:center;gap:.5rem;margin-bottom:.85rem;}
.school-search-input{flex:1;max-width:320px;padding:.5rem .75rem;border:1px solid var(--gray-300);border-radius:6px;font-size:.875rem;}
.school-search-input:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(79,70,229,.1);}
.school-count-badge{font-size:.75rem;color:var(--gray-500);}
/* Modal */ /* Modal */
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;align-items:center;justify-content:center;} .modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;align-items:center;justify-content:center;}
@@ -91,8 +103,10 @@
th{background:#1e293b !important;color:#94a3b8 !important;border-color:#334155 !important;} th{background:#1e293b !important;color:#94a3b8 !important;border-color:#334155 !important;}
td{border-color:#1e293b !important;color:#e2e8f0;} td{border-color:#1e293b !important;color:#e2e8f0;}
tr:hover td{background:#263548 !important;} tr:hover td{background:#263548 !important;}
.school-card{border-color:#334155 !important;} .school-row.expanded td{background:#1a2744 !important;}
.school-card-header{background:#162032 !important;border-color:#334155 !important;} .users-panel{background:#162032 !important;}
.users-search{background:#0f172a !important;color:#e2e8f0 !important;border-color:#334155 !important;}
.school-search-input{background:#0f172a !important;color:#e2e8f0 !important;border-color:#334155 !important;}
.domain-chip{background:#1e3a5f !important;border-color:#2563eb !important;color:#93c5fd !important;} .domain-chip{background:#1e3a5f !important;border-color:#2563eb !important;color:#93c5fd !important;}
.role-select,.form-group input,.form-group select{background:#0f172a !important;color:#e2e8f0 !important;border-color:#334155 !important;} .role-select,.form-group input,.form-group select{background:#0f172a !important;color:#e2e8f0 !important;border-color:#334155 !important;}
.btn-secondary{background:#334155 !important;color:#e2e8f0 !important;} .btn-secondary{background:#334155 !important;color:#e2e8f0 !important;}
@@ -128,12 +142,13 @@
<div class="stat-card"><div class="stat-value" id="st-teachers">-</div><div class="stat-label">Leerkrachten</div></div> <div class="stat-card"><div class="stat-value" id="st-teachers">-</div><div class="stat-label">Leerkrachten</div></div>
</div> </div>
<!-- Scholengroep ICT accounts — alleen superadmin --> <!-- Scholengroep ICT accounts — iedereen ziet lijst, enkel superadmin kan beheren -->
{% if is_superadmin %}
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
<h2>👥 Scholengroep ICT medewerkers</h2> <h2>👥 Scholengroep ICT medewerkers</h2>
<button class="btn btn-primary btn-sm">+ Toevoegen</button> {% if is_superadmin %}
<button class="btn btn-primary btn-sm" id="btnAddSgIct">+ Toevoegen</button>
{% endif %}
</div> </div>
<p class="section-hint"> <p class="section-hint">
Scholengroep ICT medewerkers kunnen alle scholen en gebruikers beheren, Scholengroep ICT medewerkers kunnen alle scholen en gebruikers beheren,
@@ -144,14 +159,13 @@
<tbody id="sgIctTable"><tr class="empty-row"><td colspan="4">Laden...</td></tr></tbody> <tbody id="sgIctTable"><tr class="empty-row"><td colspan="4">Laden...</td></tr></tbody>
</table> </table>
</div> </div>
{% endif %}
<!-- Schooljaren — globaal --> <!-- Schooljaren — globaal -->
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
<h2>📅 Schooljaren</h2> <h2>📅 Schooljaren</h2>
<button class="btn btn-primary btn-sm">+ Nieuw schooljaar</button> <button id="btnAddJaar" class="btn btn-primary btn-sm">+ Nieuw schooljaar</button>
</div> </div>
<p class="section-hint"> <p class="section-hint">
Het actieve schooljaar geldt voor alle scholen tegelijk. Het actieve schooljaar geldt voor alle scholen tegelijk.
@@ -184,13 +198,32 @@ toevoegen</button>
</table> </table>
</div> </div>
<!-- Scholen & gebruikers detail --> <!-- Gebruikers per school — lazy tabel -->
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
<h2>👥 Gebruikers per school</h2> <h2>👥 Gebruikers per school</h2>
<button id="btnAddUser" class="btn btn-primary btn-sm">+ Gebruiker toevoegen</button> <button id="btnAddUser" class="btn btn-primary btn-sm">+ Gebruiker toevoegen</button>
</div> </div>
<div class="schools-grid" id="schoolsGrid">Laden...</div> <div class="school-search-bar">
<input type="text" id="schoolSearchInput" class="school-search-input"
placeholder="Zoek op schoolnaam of gebruikersnaam...">
<span class="school-count-badge" id="schoolCountBadge"></span>
</div>
<div style="overflow-x:auto;">
<table class="schools-user-table">
<thead>
<tr>
<th style="width:32px;"></th>
<th>School</th>
<th>Domeinen</th>
<th>SSO</th>
<th style="text-align:center;">Gebruikers</th>
<th style="text-align:right;">Acties</th>
</tr>
</thead>
<tbody id="schoolsUserTbody"></tbody>
</table>
</div>
</div> </div>
<!-- Auditlog --> <!-- Auditlog -->
@@ -256,11 +289,36 @@ toevoegen</button>
</div> </div>
<div class="modal-overlay" id="modal-editSchool"> <div class="modal-overlay" id="modal-editSchool">
<div class="modal"> <div class="modal" style="max-width:520px;">
<h2>School bewerken</h2> <h2>School bewerken</h2>
<input type="hidden" id="editSchoolId"> <input type="hidden" id="editSchoolId">
<div class="form-group"><label>Naam</label><input type="text" id="editSchoolName"></div> <div class="form-group"><label>Naam</label><input type="text" id="editSchoolName"></div>
<div class="form-group"><label>E-maildomeinen</label><input type="text" id="editSchoolDomains"><div class="form-hint">Komma-gescheiden.</div></div> <div class="form-group"><label>E-maildomeinen</label><input type="text" id="editSchoolDomains"><div class="form-hint">Komma-gescheiden.</div></div>
<hr style="border:none;border-top:1px solid var(--gray-200);margin:1rem 0;">
<div style="font-size:.82rem;font-weight:700;color:var(--gray-600);margin-bottom:.65rem;">
<svg width="13" height="13" viewBox="0 0 48 48" style="vertical-align:middle;margin-right:.3rem;">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/>
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.18 1.48-4.97 2.31-8.16 2.31-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
</svg>
Google Workspace SSO
</div>
<div id="editSsoStatus" style="margin-bottom:.75rem;font-size:.8rem;"></div>
<div class="form-group">
<label>Client ID</label>
<input type="text" id="editGoogleClientId" placeholder="...apps.googleusercontent.com" style="font-family:monospace;font-size:.82rem;">
<div class="form-hint">Leeg laten = huidige waarde behouden. Vul in om te wijzigen.</div>
</div>
<div class="form-group">
<label>Client Secret</label>
<input type="password" id="editGoogleClientSecret" placeholder="GOCSPX-..." style="font-family:monospace;font-size:.82rem;">
</div>
<div style="margin-bottom:.75rem;">
<label style="display:flex;align-items:center;gap:.4rem;font-size:.8rem;cursor:pointer;color:var(--danger);">
<input type="checkbox" id="editGoogleClear"> Google SSO verwijderen voor deze school
</label>
</div>
<div class="form-error" id="edit-school-error"></div> <div class="form-error" id="edit-school-error"></div>
<div class="modal-buttons"> <div class="modal-buttons">
<button class="btn btn-secondary" id="btnCancelEditSch">Annuleren</button> <button class="btn btn-secondary" id="btnCancelEditSch">Annuleren</button>
@@ -338,7 +396,7 @@ function bind(id, ev, fn) {
if (el) el.addEventListener(ev, fn); if (el) el.addEventListener(ev, fn);
} }
const IS_SUPERADMIN = {{ 'true' if is_superadmin else 'false' }}; const IS_SUPERADMIN = {{ is_superadmin | tojson }};
let schools = []; let schools = [];
const SCHOOL_ROLLEN = [ const SCHOOL_ROLLEN = [
@@ -357,14 +415,23 @@ document.addEventListener('DOMContentLoaded', async () => {
bind('auditSearch', 'input', loadAuditLog); bind('auditSearch', 'input', loadAuditLog);
document.getElementById('btnCancelSchool') && bind('btnCancelSchool', 'click', closeModal); document.getElementById('btnCancelSchool') && bind('btnCancelSchool', 'click', closeModal);
document.getElementById('btnSaveSchool') && bind('btnSaveSchool', 'click', addSchool); document.getElementById('btnSaveSchool') && bind('btnSaveSchool', 'click', addSchool);
document.getElementById('btnCancelEditSch') && bind('btnCancelEditSch', 'click', closeModal);
document.getElementById('btnSaveEditSch') && bind('btnSaveEditSch', 'click', saveSchool);
document.getElementById('btnCancelSgIct') && bind('btnCancelSgIct', 'click', closeModal); document.getElementById('btnCancelSgIct') && bind('btnCancelSgIct', 'click', closeModal);
document.getElementById('btnSaveSgIct') && bind('btnSaveSgIct', 'click', addSgIct); document.getElementById('btnSaveSgIct') && bind('btnSaveSgIct', 'click', addSgIct);
document.getElementById('btnCancelJaar') && bind('btnCancelJaar', 'click', closeModal); document.getElementById('btnCancelJaar') && bind('btnCancelJaar', 'click', closeModal);
document.getElementById('btnSaveJaar') && bind('btnSaveJaar', 'click', addJaar); document.getElementById('btnSaveJaar') && bind('btnSaveJaar', 'click', addJaar);
document.getElementById('btnCancelUser') && bind('btnCancelUser', 'click', closeModal); document.getElementById('btnCancelUser') && bind('btnCancelUser', 'click', closeModal);
document.getElementById('btnSaveUser') && bind('btnSaveUser', 'click', addUser); document.getElementById('btnSaveUser') && bind('btnSaveUser', 'click', addUser);
const tasks = [loadStats(), loadSchoolsTable(), loadSchoolsGrid()]; // Zoekbalk scholen/gebruikers
if (IS_SUPERADMIN) tasks.push(loadSgIct()); const schoolSearchEl = document.getElementById('schoolSearchInput');
if (schoolSearchEl) {
schoolSearchEl.addEventListener('input', () => {
schoolFilter = schoolSearchEl.value.trim();
renderSchoolsUserTable();
});
}
const tasks = [loadStats(), loadSchoolsTable(), loadSchoolsGrid(), loadSgIct()];
await Promise.all(tasks); await Promise.all(tasks);
await loadJaren(); await loadJaren();
await loadAuditLog(); await loadAuditLog();
@@ -396,7 +463,7 @@ async function loadSgIct() {
<td>${u.full_name}</td> <td>${u.full_name}</td>
<td style="color:var(--gray-500);font-size:.82rem;">${u.email}</td> <td style="color:var(--gray-500);font-size:.82rem;">${u.email}</td>
<td style="color:var(--gray-500);font-size:.8rem;">${u.last_login ? new Date(u.last_login).toLocaleDateString('nl-BE') : 'Nog niet ingelogd'}</td> <td style="color:var(--gray-500);font-size:.8rem;">${u.last_login ? new Date(u.last_login).toLocaleDateString('nl-BE') : 'Nog niet ingelogd'}</td>
<td><button class="btn btn-danger btn-sm" data-action="removeSgIct" data-id="${u.id}" data-name="${u.full_name.replace(/'/g,'&#39;')}">Verwijderen</button></td> <td>${IS_SUPERADMIN ? `<button class="btn btn-danger btn-sm" data-action="removeSgIct" data-id="${u.id}" data-name="${u.full_name.replace(/'/g,'&#39;')}">Verwijderen</button>` : ''}</td>
</tr>`).join(''); </tr>`).join('');
} }
@@ -434,7 +501,10 @@ async function loadSchoolsTable() {
tbody.innerHTML = schools.map(s => ` tbody.innerHTML = schools.map(s => `
<tr> <tr>
<td><strong>${s.name}</strong></td> <td><strong>${s.name}</strong></td>
<td>${(s.email_domains||[]).map(d=>`<span class="domain-chip">${d}</span>`).join('') || '<em style="color:var(--gray-400)">geen</em>'}</td> <td>${(s.email_domains||[]).map(d=>`<span class="domain-chip">${d}</span>`).join('') || '<em style="color:var(--gray-400)">geen</em>'}
${s.google_sso_configured
? '<span style="margin-left:.4rem;font-size:.7rem;background:#d1fae5;color:#065f46;padding:.15rem .4rem;border-radius:4px;font-weight:600;">G SSO ✓</span>'
: ''}</td>
<td style="color:var(--gray-500);">${s.user_count}</td> <td style="color:var(--gray-500);">${s.user_count}</td>
<td style="display:flex;gap:.35rem;"> <td style="display:flex;gap:.35rem;">
<button class="btn btn-secondary btn-sm" data-action="editSchool" data-id="${s.id}" data-name="${s.name.replace(/'/g,'&#39;')}" data-domains="${(s.email_domains||[]).join(', ')}">Bewerken</button> <button class="btn btn-secondary btn-sm" data-action="editSchool" data-id="${s.id}" data-name="${s.name.replace(/'/g,'&#39;')}" data-domains="${(s.email_domains||[]).join(', ')}">Bewerken</button>
@@ -457,6 +527,7 @@ async function addSchool() {
const data = await res.json(); const data = await res.json();
if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; } if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; }
closeModal(); notify('School aangemaakt', 'success'); closeModal(); notify('School aangemaakt', 'success');
delete loadedUsers[data.school?.id]; // forceer herlaad als al gecached
await Promise.all([loadSchoolsTable(), loadSchoolsGrid(), loadStats()]); await Promise.all([loadSchoolsTable(), loadSchoolsGrid(), loadStats()]);
} }
@@ -464,6 +535,18 @@ function editSchool(id, name, domainsStr) {
document.getElementById('editSchoolId').value = id; document.getElementById('editSchoolId').value = id;
document.getElementById('editSchoolName').value = name; document.getElementById('editSchoolName').value = name;
document.getElementById('editSchoolDomains').value = domainsStr; document.getElementById('editSchoolDomains').value = domainsStr;
// Reset Google velden
document.getElementById('editGoogleClientId').value = '';
document.getElementById('editGoogleClientSecret').value = '';
document.getElementById('editGoogleClear').checked = false;
// Toon huidige SSO status
const school = schools.find(s => s.id == id);
const ssoStatusEl = document.getElementById('editSsoStatus');
if (ssoStatusEl && school) {
ssoStatusEl.innerHTML = school.google_sso_configured
? `<span style="color:#065f46;background:#d1fae5;padding:.25rem .5rem;border-radius:4px;">✅ Google SSO actief — Client ID: ${school.google_client_id}</span>`
: `<span style="color:#92400e;background:#fef3c7;padding:.25rem .5rem;border-radius:4px;">⚠️ Google SSO nog niet ingesteld</span>`;
}
openModal('editSchool'); openModal('editSchool');
} }
@@ -471,13 +554,49 @@ async function saveSchool() {
const err = document.getElementById('edit-school-error'); const err = document.getElementById('edit-school-error');
err.style.display = 'none'; err.style.display = 'none';
const id = document.getElementById('editSchoolId').value; const id = document.getElementById('editSchoolId').value;
// 1. Sla naam en domeinen op (bestaand endpoint — scholengroep_ict)
const res = await fetch(`/admin/schools/${id}`, { const res = await fetch(`/admin/schools/${id}`, {
method: 'PUT', headers: {'Content-Type':'application/json'}, method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name: document.getElementById('editSchoolName').value, email_domains: document.getElementById('editSchoolDomains').value.split(',').map(d=>d.trim()).filter(Boolean) }) body: JSON.stringify({
name: document.getElementById('editSchoolName').value,
email_domains: document.getElementById('editSchoolDomains').value.split(',').map(d=>d.trim()).filter(Boolean)
})
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; } if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; }
closeModal(); notify('School opgeslagen', 'success');
// 2. Verwerk Google SSO velden (apart endpoint — ondersteunt ook school_ict)
const clearSso = document.getElementById('editGoogleClear').checked;
const clientId = document.getElementById('editGoogleClientId').value.trim();
const clientSec = document.getElementById('editGoogleClientSecret').value.trim();
if (clearSso) {
const ssoRes = await fetch(`/admin/schools/${id}/google-sso`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ clear: true })
});
if (!ssoRes.ok) {
const ssoData = await ssoRes.json();
err.textContent = 'SSO: ' + (ssoData.error || 'Verwijderen mislukt');
err.style.display = 'block'; return;
}
} else if (clientId || clientSec) {
const ssoRes = await fetch(`/admin/schools/${id}/google-sso`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ google_client_id: clientId, google_client_secret: clientSec })
});
if (!ssoRes.ok) {
const ssoData = await ssoRes.json();
err.textContent = 'SSO: ' + (ssoData.error || 'Opslaan mislukt');
err.style.display = 'block'; return;
}
}
closeModal();
notify('School opgeslagen', 'success');
// Gebruikerscache wissen zodat heropenen verse data toont
delete loadedUsers[parseInt(id)];
await Promise.all([loadSchoolsTable(), loadSchoolsGrid()]); await Promise.all([loadSchoolsTable(), loadSchoolsGrid()]);
} }
@@ -486,114 +605,239 @@ async function deleteSchool(id, name) {
const res = await fetch(`/admin/schools/${id}`, { method: 'DELETE' }); const res = await fetch(`/admin/schools/${id}`, { method: 'DELETE' });
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; } if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
notify('School verwijderd', 'success'); notify('School verwijderd', 'success');
expandedSchools.delete(parseInt(id));
delete loadedUsers[parseInt(id)];
await Promise.all([loadSchoolsTable(), loadSchoolsGrid(), loadStats()]); await Promise.all([loadSchoolsTable(), loadSchoolsGrid(), loadStats()]);
} }
// ── Gebruikers grid ─────────────────────────────────────────────────────────── // ── Gebruikers-per-school: lazy tabel ────────────────────────────────────────
// State
const expandedSchools = new Set(); // welke school-rijen zijn open
const loadedUsers = {}; // cache: school_id -> users array
const loadingSchools = new Set(); // bezig met laden
let schoolFilter = ''; // huidig zoekterm
// Laad de scholenlijst (zonder gebruikers) en render de tabel
async function loadSchoolsGrid() { async function loadSchoolsGrid() {
const res = await fetch('/admin/schools'); const res = await fetch('/admin/schools');
const data = await res.json(); const data = await res.json();
schools = data.schools || []; schools = data.schools || [];
const grid = document.getElementById('schoolsGrid'); renderSchoolsUserTable();
if (!schools.length) { updateSchoolCountBadge();
grid.innerHTML = '<p style="color:var(--gray-500);font-style:italic;padding:.5rem 0;">Nog geen scholen aangemaakt.</p>';
// Vul ook de school select in het "gebruiker toevoegen" modal
const sel = document.getElementById('addUserSchool');
if (sel) sel.innerHTML = schools.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
}
// Teken (of herteken) de volledige tabel op basis van huidige filter + expanded state
function renderSchoolsUserTable() {
const tbody = document.getElementById('schoolsUserTbody');
const term = schoolFilter.toLowerCase();
// Filter: schoolnaam OF (als school al geladen) gebruikersnaam
const filtered = schools.filter(s => {
if (s.name.toLowerCase().includes(term)) return true;
const users = loadedUsers[s.id];
if (users && term) return users.some(u => u.full_name.toLowerCase().includes(term));
return !term;
});
updateSchoolCountBadge(filtered.length);
if (!filtered.length) {
tbody.innerHTML = `<tr class="empty-row"><td colspan="6">Geen scholen gevonden voor "${schoolFilter}"</td></tr>`;
return; return;
} }
grid.innerHTML = '';
await Promise.all(schools.map(s => renderSchoolCard(s, grid))); const rows = filtered.map(s => buildSchoolRows(s, term)).join('');
tbody.innerHTML = rows;
} }
async function renderSchoolCard(school, container) { function buildSchoolRows(school, term) {
const res = await fetch(`/admin/schools/${school.id}/users`); const isOpen = expandedSchools.has(school.id);
const data = await res.json(); const isLoading = loadingSchools.has(school.id);
const users = data.users || []; const ssoIcon = school.google_sso_configured
? '<span style="font-size:.7rem;background:#d1fae5;color:#065f46;padding:.15rem .4rem;border-radius:4px;font-weight:600;">G ✓</span>'
: '<span style="font-size:.7rem;color:var(--gray-400);">—</span>';
const domainHtml = (school.email_domains||[]).map(d=>`<span class="domain-chip">${d}</span>`).join('')
|| '<em style="color:var(--gray-400);font-size:.75rem;">geen</em>';
// Hoofdrij
const mainRow = `
<tr class="school-row${isOpen ? ' expanded' : ''}" data-action="toggleSchool" data-id="${school.id}">
<td><i class="expand-icon">▶</i></td>
<td><strong>${school.name}</strong></td>
<td>${domainHtml}</td>
<td>${ssoIcon}</td>
<td style="text-align:center;color:var(--gray-500);">${school.user_count}</td>
<td style="text-align:right;white-space:nowrap;">
<button class="btn btn-secondary btn-sm" data-action="editSchool"
data-id="${school.id}"
data-name="${school.name.replace(/'/g,'&#39;')}"
data-domains="${(school.email_domains||[]).join(', ')}">Bewerken</button>
<button class="btn btn-danger btn-sm" data-action="deleteSchool"
data-id="${school.id}"
data-name="${school.name.replace(/'/g,'&#39;')}">Verwijderen</button>
</td>
</tr>`;
// Uitklapbare gebruikerspaneel rij
const panelRow = `
<tr class="users-panel-row" id="panel-row-${school.id}">
<td colspan="6">
<div class="users-panel${isOpen ? ' open' : ''}" id="users-panel-${school.id}">
${isLoading
? '<div style="padding:.5rem;color:var(--gray-500);font-size:.85rem;">⏳ Laden...</div>'
: renderUsersPanel(school.id, term)}
</div>
</td>
</tr>`;
return mainRow + panelRow;
}
function renderUsersPanel(schoolId, term) {
const users = loadedUsers[schoolId];
if (!users) return ''; // nog niet geladen
// Filter op gebruikersnaam als er een zoekterm is
const filtered = term
? users.filter(u => u.full_name.toLowerCase().includes(term) || u.email.toLowerCase().includes(term))
: users;
const byRole = { const byRole = {
school_ict: users.filter(u => u.role === 'school_ict'), school_ict: filtered.filter(u => u.role === 'school_ict'),
director: users.filter(u => u.role === 'director'), director: filtered.filter(u => u.role === 'director'),
teacher: users.filter(u => u.role === 'teacher'), teacher: filtered.filter(u => u.role === 'teacher'),
}; };
const card = document.createElement('div'); const total = filtered.length;
card.className = 'school-card';
card.id = `school-card-${school.id}`; if (!total) return `<div style="color:var(--gray-500);font-size:.85rem;padding:.25rem 0;">Geen gebruikers gevonden${term ? ` voor "${term}"` : ''}.</div>`;
card.innerHTML = `
<div class="school-card-header"> return [
<div> renderUserGroup(schoolId, 'School ICT', byRole.school_ict),
<h3>${school.name}</h3> renderUserGroup(schoolId, 'Directeurs', byRole.director),
<div style="margin-top:.35rem;">${(school.email_domains||[]).map(d=>`<span class="domain-chip">${d}</span>`).join('') || '<span style="color:var(--gray-400);font-size:.75rem;">geen domeinen</span>'}</div> renderUserGroup(schoolId, 'Leerkrachten', byRole.teacher),
</div> ].join('');
<span style="color:var(--gray-500);font-size:.8rem;white-space:nowrap;">${users.length} gebruikers</span>
</div>
<div class="school-card-body">
${renderUserGroup(school.id,'School ICT',byRole.school_ict)}
${renderUserGroup(school.id,'Directeurs',byRole.director)}
${renderUserGroup(school.id,'Leerkrachten',byRole.teacher,5)}
</div>`;
container.appendChild(card);
} }
function renderUserGroup(schoolId, label, users, maxShow=99) { function renderUserGroup(schoolId, label, users) {
if (!users.length) return ''; if (!users.length) return '';
const shown = users.slice(0, maxShow);
const hidden = users.length - shown.length;
return ` return `
<div class="group-label"><span>${label}</span><span style="font-weight:400;text-transform:none;letter-spacing:0;">${users.length}</span></div> <div class="group-label">
${shown.map(u => ` <span>${label}</span>
<span style="font-weight:400;text-transform:none;letter-spacing:0;">${users.length}</span>
</div>
${users.map(u => `
<div class="user-row"> <div class="user-row">
<div class="user-info"> <div class="user-info">
<div class="user-name">${u.full_name}</div> <div class="user-name">${u.full_name}</div>
<div class="user-email">${u.email}</div> <div class="user-email">${u.email}</div>
<div class="user-email" style="color:var(--gray-400);font-size:.7rem;"> <div class="user-email" style="color:var(--gray-400);font-size:.7rem;">
${u.last_login ${u.last_login
? '↩ ' + new Date(u.last_login).toLocaleString('nl-BE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) ? '↩ ' + new Date(u.last_login).toLocaleString('nl-BE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})
: 'Nog niet ingelogd'} : 'Nog niet ingelogd'}
</div> </div>
</div> </div>
<div class="user-actions"> <div class="user-actions">
<select class="role-select" data-action="changeRole" data-school-id="${schoolId}" data-user-id="${u.id}" data-name="${u.full_name.replace(/'/g,'&#39;')}"> <select class="role-select" data-action="changeRole"
data-school-id="${schoolId}" data-user-id="${u.id}"
data-name="${u.full_name.replace(/'/g,'&#39;')}">
${SCHOOL_ROLLEN.map(r=>`<option value="${r.value}" ${r.value===u.role?'selected':''}>${r.label}</option>`).join('')} ${SCHOOL_ROLLEN.map(r=>`<option value="${r.value}" ${r.value===u.role?'selected':''}>${r.label}</option>`).join('')}
</select> </select>
<button class="btn btn-danger btn-sm" data-action="removeUser" data-school-id="${schoolId}" data-user-id="${u.id}" data-name="${u.full_name.replace(/'/g,'&#39;')}">×</button> <button class="btn btn-danger btn-sm" data-action="removeUser"
data-school-id="${schoolId}" data-user-id="${u.id}"
data-name="${u.full_name.replace(/'/g,'&#39;')}">×</button>
</div> </div>
</div>`).join('')} </div>`).join('')}`;
${hidden > 0 ? `<div style="color:var(--gray-500);font-size:.8rem;padding:.3rem 0 0;">+ ${hidden} meer...</div>` : ''}`;
} }
async function changeRole(schoolId, userId, newRole, naam, selectEl) { function updateSchoolCountBadge(visibleCount) {
const el = document.getElementById('schoolCountBadge');
if (!el) return;
const total = schools.length;
if (visibleCount === undefined || visibleCount === total) {
el.textContent = `${total} school${total !== 1 ? 'en' : ''}`;
} else {
el.textContent = `${visibleCount} van ${total} scholen`;
}
}
// Toggle een school open/dicht; laad gebruikers als dat nog niet is gebeurd
async function toggleSchool(schoolId) {
if (expandedSchools.has(schoolId)) {
expandedSchools.delete(schoolId);
renderSchoolsUserTable();
return;
}
expandedSchools.add(schoolId);
if (!loadedUsers[schoolId]) {
// Toon laad-indicator via hertekenen (loading state)
loadingSchools.add(schoolId);
renderSchoolsUserTable();
const res = await fetch(`/admin/schools/${schoolId}/users`);
const data = await res.json();
loadedUsers[schoolId] = data.users || [];
loadingSchools.delete(schoolId);
}
renderSchoolsUserTable();
}
// Herlaad de gebruikers van één school (na wijziging) en herteken
async function refreshSchool(schoolId) {
const res = await fetch(`/admin/schools/${schoolId}/users`);
const data = await res.json();
loadedUsers[schoolId] = data.users || [];
// Bijwerken user_count in schools array
const school = schools.find(s => s.id === schoolId);
if (school) school.user_count = (data.users || []).length;
renderSchoolsUserTable();
}
async function changeRole(schoolId, userId, newRole, naam) {
const res = await fetch(`/admin/schools/${schoolId}/users/${userId}/role`, { const res = await fetch(`/admin/schools/${schoolId}/users/${userId}/role`, {
method: 'PUT', headers: {'Content-Type':'application/json'}, method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ role: newRole }) body: JSON.stringify({ role: newRole })
}); });
if (!res.ok) { notify((await res.json()).error || 'Wijzigen mislukt', 'error'); await refreshCard(schoolId); return; } if (!res.ok) { notify((await res.json()).error || 'Wijzigen mislukt', 'error'); await refreshSchool(schoolId); return; }
notify(`${naam} is nu ${SCHOOL_ROLLEN.find(r=>r.value===newRole)?.label}`, 'success'); notify(`${naam} is nu ${SCHOOL_ROLLEN.find(r=>r.value===newRole)?.label}`, 'success');
await refreshCard(schoolId); await refreshSchool(schoolId);
} }
async function addUser() { async function addUser() {
const err = document.getElementById('addUser-error'); const err = document.getElementById('addUser-error');
err.style.display = 'none'; err.style.display = 'none';
const schoolId = document.getElementById('addUserSchool').value; const schoolId = parseInt(document.getElementById('addUserSchool').value);
const res = await fetch(`/admin/schools/${schoolId}/users`, { const res = await fetch(`/admin/schools/${schoolId}/users`, {
method: 'POST', headers: {'Content-Type':'application/json'}, method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ email: document.getElementById('addUserEmail').value, first_name: document.getElementById('addUserFirst').value, last_name: document.getElementById('addUserLast').value, role: document.getElementById('addUserRole').value }) body: JSON.stringify({
email: document.getElementById('addUserEmail').value,
first_name: document.getElementById('addUserFirst').value,
last_name: document.getElementById('addUserLast').value,
role: document.getElementById('addUserRole').value,
})
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; } if (!res.ok) { err.textContent = data.error; err.style.display = 'block'; return; }
closeModal(); notify(data.linked ? 'Account heractiveerd en bijgewerkt' : 'Gebruiker toegevoegd', 'success'); closeModal();
await Promise.all([refreshCard(parseInt(schoolId)), loadStats()]); notify(data.linked ? 'Account heractiveerd en bijgewerkt' : 'Gebruiker toegevoegd', 'success');
// Zorg dat de school open staat zodat de nieuwe gebruiker zichtbaar is
expandedSchools.add(schoolId);
delete loadedUsers[schoolId]; // forceer herlaad
await Promise.all([refreshSchool(schoolId), loadStats()]);
} }
async function removeUser(schoolId, userId, naam) { async function removeUser(schoolId, userId, naam) {
if (!confirm(`${naam} verwijderen?`)) return; if (!confirm(`${naam} verwijderen?`)) return;
const res = await fetch(`/admin/schools/${schoolId}/users/${userId}`, { method: 'DELETE' }); const res = await fetch(`/admin/schools/${parseInt(schoolId)}/users/${userId}`, { method: 'DELETE' });
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; } if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
notify(`${naam} verwijderd`, 'success'); notify(`${naam} verwijderd`, 'success');
await Promise.all([refreshCard(schoolId), loadStats()]); delete loadedUsers[parseInt(schoolId)]; // forceer herlaad
} await Promise.all([refreshSchool(parseInt(schoolId)), loadStats()]);
async function refreshCard(schoolId) {
const card = document.getElementById(`school-card-${schoolId}`);
const school = schools.find(s => s.id === schoolId);
if (card && school) { card.remove(); await renderSchoolCard(school, document.getElementById('schoolsGrid')); }
} }
// ── Schooljaren ─────────────────────────────────────────────────────────────── // ── Schooljaren ───────────────────────────────────────────────────────────────
@@ -723,6 +967,12 @@ function notify(msg, type='success') {
// ── Event delegation voor dynamisch gegenereerde elementen ──────────────────── // ── Event delegation voor dynamisch gegenereerde elementen ────────────────────
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
// toggleSchool: klik op de rij zelf, maar NIET op knoppen erin
const schoolRow = e.target.closest('.school-row[data-action="toggleSchool"]');
if (schoolRow && !e.target.closest('button') && !e.target.closest('select')) {
toggleSchool(parseInt(schoolRow.dataset.id));
return;
}
const btn = e.target.closest('[data-action]'); const btn = e.target.closest('[data-action]');
if (!btn) return; if (!btn) return;
const action = btn.dataset.action; const action = btn.dataset.action;
@@ -735,7 +985,7 @@ document.addEventListener('click', function(e) {
}); });
document.addEventListener('change', function(e) { document.addEventListener('change', function(e) {
const sel = e.target.closest('[data-action="changeRole"]'); const sel = e.target.closest('[data-action="changeRole"]');
if (sel) { changeRole(sel.dataset.schoolId, sel.dataset.userId, sel.value, sel.dataset.name, sel); } if (sel) { changeRole(sel.dataset.schoolId, sel.dataset.userId, sel.value, sel.dataset.name); }
}); });
</script> </script>
</body> </body>

View File

@@ -248,6 +248,75 @@
<div id="klassenList">Laden...</div> <div id="klassenList">Laden...</div>
</div> </div>
<!-- Google Workspace SSO -->
<div class="section">
<div class="section-header">
<h2>🔑 Google Workspace SSO</h2>
</div>
<p style="color:var(--gray-500);font-size:.85rem;margin-bottom:1.25rem;line-height:1.6;">
Leerkrachten en directeurs kunnen inloggen met hun Google Workspace account van deze school.
Maak hiervoor een OAuth2-app aan in de
<a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener"
style="color:var(--primary);">Google Cloud Console</a>
en vul de gegevens hieronder in.
</p>
<!-- Status badge -->
<div id="ssoStatus" style="margin-bottom:1.25rem;"></div>
<div style="display:grid;gap:.85rem;max-width:520px;">
<div>
<label style="display:block;font-size:.82rem;font-weight:600;color:var(--gray-600);margin-bottom:.35rem;">
Client ID
</label>
<input type="text" id="ssoClientId"
placeholder="1234567890-abc123.apps.googleusercontent.com"
style="width:100%;padding:.6rem .75rem;border:1px solid var(--gray-300);border-radius:6px;font-size:.85rem;font-family:monospace;">
<div style="font-size:.75rem;color:var(--gray-500);margin-top:.3rem;">
Eindigt altijd op <code>.apps.googleusercontent.com</code>
</div>
</div>
<div>
<label style="display:block;font-size:.82rem;font-weight:600;color:var(--gray-600);margin-bottom:.35rem;">
Client Secret
</label>
<input type="password" id="ssoClientSecret"
placeholder="GOCSPX-..."
style="width:100%;padding:.6rem .75rem;border:1px solid var(--gray-300);border-radius:6px;font-size:.85rem;font-family:monospace;">
<div style="font-size:.75rem;color:var(--gray-500);margin-top:.3rem;">
Het secret is nooit zichtbaar na opslaan — vul het opnieuw in om te wijzigen.
</div>
</div>
</div>
<div style="margin-top:1rem;display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn btn-primary btn-sm" id="btnSaveSso">💾 Opslaan</button>
<button class="btn btn-secondary btn-sm" id="btnClearSso"
style="color:var(--danger);">🗑 SSO verwijderen</button>
</div>
<div id="ssoError" style="color:var(--danger);font-size:.82rem;margin-top:.5rem;display:none;"></div>
<!-- Instructies -->
<details style="margin-top:1.5rem;border:1px solid var(--gray-200);border-radius:8px;padding:.85rem 1rem;">
<summary style="cursor:pointer;font-weight:600;font-size:.85rem;color:var(--gray-700);">
📋 Hoe stel ik een Google OAuth2-app in?
</summary>
<ol style="margin-top:.85rem;padding-left:1.25rem;font-size:.83rem;color:var(--gray-600);line-height:2;">
<li>Ga naar <strong>console.cloud.google.com</strong> → maak een project aan voor uw school</li>
<li>Ga naar <strong>API's en services → Inlogscherm OAuth</strong> → kies "Intern" (enkel uw Workspace)</li>
<li>Ga naar <strong>Credentials → Create Credentials → OAuth client ID</strong></li>
<li>Type: <strong>Webapplicatie</strong></li>
<li>Voeg als Redirect URI toe:
<code id="redirectUriDisplay"
style="display:block;margin-top:.25rem;padding:.35rem .5rem;background:var(--gray-100);border-radius:4px;font-size:.8rem;word-break:break-all;user-select:all;">
Laden...
</code>
</li>
<li>Kopieer de <strong>Client ID</strong> en het <strong>Client Secret</strong> en plak ze hierboven</li>
</ol>
</details>
</div>
<!-- Auditlog --> <!-- Auditlog -->
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
@@ -340,11 +409,18 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('btnConfirmUser') && bind('btnConfirmUser', 'click', addUser); document.getElementById('btnConfirmUser') && bind('btnConfirmUser', 'click', addUser);
document.getElementById('auditCategory') && bind('auditCategory', 'change', loadAuditLog); document.getElementById('auditCategory') && bind('auditCategory', 'change', loadAuditLog);
document.getElementById('auditSearch') && bind('auditSearch', 'input', loadAuditLog); document.getElementById('auditSearch') && bind('auditSearch', 'input', loadAuditLog);
document.getElementById('btnSaveSso') && bind('btnSaveSso', 'click', saveSso);
document.getElementById('btnClearSso') && bind('btnClearSso', 'click', clearSso);
// Redirect URI is altijd bekend — vul meteen in zodat het niet "Laden..." blijft
const redirectEl = document.getElementById('redirectUriDisplay');
if (redirectEl) redirectEl.textContent = window.location.origin + '/auth/google/callback';
const me = await fetch('/api/me').then(r => r.json()); const me = await fetch('/api/me').then(r => r.json());
mySchoolId = me.user?.school_id; mySchoolId = me.user?.school_id;
document.getElementById('schoolName').textContent = me.user?.school_name || ''; document.getElementById('schoolName').textContent = me.user?.school_name || '';
await loadUsers(); await loadUsers();
await loadKlassen(); await loadKlassen();
loadSsoStatus(me.user?.school); // geef school direct mee — geen extra API call nodig
await loadAuditLog(); await loadAuditLog();
}); });
@@ -592,6 +668,73 @@ async function loadAuditLog(page = 1) {
</button>`).join(''); </button>`).join('');
} }
// ── Google SSO beheer ─────────────────────────────────────────────────────────
async function loadSsoStatus(school) {
// Als geen school meegegeven: haal op via /api/me (school_ict heeft geen toegang tot /admin/schools)
if (!school) {
const res = await fetch('/api/me');
if (!res.ok) return;
const data = await res.json();
school = data.user?.school;
}
const statusEl = document.getElementById('ssoStatus');
if (!statusEl || !school) return;
if (school.google_sso_configured) {
statusEl.innerHTML = `
<div style="display:inline-flex;align-items:center;gap:.5rem;
padding:.5rem .85rem;background:#d1fae5;color:#065f46;
border-radius:6px;font-size:.83rem;font-weight:600;">
✅ Google SSO is actief
<span style="font-weight:400;opacity:.8;">— Client ID: ${school.google_client_id}</span>
</div>`;
} else {
statusEl.innerHTML = `
<div style="display:inline-flex;align-items:center;gap:.5rem;
padding:.5rem .85rem;background:#fef3c7;color:#92400e;
border-radius:6px;font-size:.83rem;">
⚠️ Google SSO is nog niet ingesteld
</div>`;
}
}
async function saveSso() {
const errEl = document.getElementById('ssoError');
const clientId = document.getElementById('ssoClientId').value.trim();
const clientSecret = document.getElementById('ssoClientSecret').value.trim();
errEl.style.display = 'none';
if (!clientId || !clientSecret) {
errEl.textContent = 'Vul zowel het Client ID als het Client Secret in.';
errEl.style.display = 'block';
return;
}
const res = await fetch(`/admin/schools/${mySchoolId}/google-sso`, {
method: 'PUT', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ google_client_id: clientId, google_client_secret: clientSecret })
});
const data = await res.json();
if (!res.ok) { errEl.textContent = data.error; errEl.style.display = 'block'; return; }
document.getElementById('ssoClientId').value = '';
document.getElementById('ssoClientSecret').value = '';
notify('Google SSO ingesteld ✅', 'success');
await loadSsoStatus(); // herlaadt via /api/me
}
async function clearSso() {
if (!confirm('Google SSO verwijderen? Leerkrachten kunnen dan niet meer inloggen via Google.')) return;
const res = await fetch(`/admin/schools/${mySchoolId}/google-sso`, {
method: 'PUT', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ clear: true })
});
if (!res.ok) { notify('Verwijderen mislukt', 'error'); return; }
notify('Google SSO verwijderd', 'success');
await loadSsoStatus();
}
// ── Event delegation voor dynamisch gegenereerde elementen ──────────────────── // ── Event delegation voor dynamisch gegenereerde elementen ────────────────────
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-action]'); const btn = e.target.closest('[data-action]');

Binary file not shown.