Stundenplan Phase 3c: complete Stammdaten + RegelnHub with 4 editors
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 31s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 3m31s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 22s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 31s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 3m31s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 22s
Frontend additions in studio-v2:
- PeriodsManager renders the weekly grid as a Mo–So table with one
row per period_index. New entries auto-increment period_index so
the user can hit Anlegen repeatedly for a full day's slots.
- CurriculumManager joins classes + subjects; new entries refuse to
open when either prerequisite list is empty (banner instead).
- AssignmentsManager joins teacher × class × subject with the same
prerequisite-banner pattern.
- regeln/RegelnHub: vertical sidebar grouping all 15 constraint
types by parent entity (Lehrer/Fach/Klasse/Raum). Implemented
editors are clickable, the other 11 are visibly disabled with
a 'soon' tag.
- Three new editors:
TeacherUnavailableWindowEditor (time-window pattern),
SubjectMaxConsecutiveEditor (number-input pattern),
SubjectPreferredPeriodEditor (number range pattern).
- page.tsx wires every tab to its manager; the not-implemented
placeholder is gone (no more empty tabs).
Test coverage:
- e2e/stundenplan.spec.ts rewritten: 23 tests across 7 suites,
covering all 8 tabs, the new managers' prerequisite banners,
sub-tab switching in the RegelnHub, and the disabled state of
not-yet-implemented constraint rules. Each test mocks the
backend via page.route() so the suite stays hermetic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { assignmentsApi, classesApi, subjectsApi, teachersApi } from '@/lib/stundenplan/api'
|
||||||
|
import type {
|
||||||
|
TimetableAssignment, CreateTimetableAssignment,
|
||||||
|
TimetableClass, TimetableSubject, TimetableTeacher,
|
||||||
|
} from '@/app/stundenplan/types'
|
||||||
|
|
||||||
|
const initialForm: CreateTimetableAssignment = {
|
||||||
|
teacher_id: '',
|
||||||
|
class_id: '',
|
||||||
|
subject_id: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssignmentsManager() {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const [items, setItems] = useState<TimetableAssignment[]>([])
|
||||||
|
const [classes, setClasses] = useState<TimetableClass[]>([])
|
||||||
|
const [subjects, setSubjects] = useState<TimetableSubject[]>([])
|
||||||
|
const [teachers, setTeachers] = useState<TimetableTeacher[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [form, setForm] = useState<CreateTimetableAssignment>(initialForm)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true); setError(null)
|
||||||
|
try {
|
||||||
|
const [asg, cls, sub, t] = await Promise.all([
|
||||||
|
assignmentsApi.list(),
|
||||||
|
classesApi.list(),
|
||||||
|
subjectsApi.list(),
|
||||||
|
teachersApi.list(),
|
||||||
|
])
|
||||||
|
setItems(asg || [])
|
||||||
|
setClasses(cls || [])
|
||||||
|
setSubjects(sub || [])
|
||||||
|
setTeachers(t || [])
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
||||||
|
} finally { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true); setError(null)
|
||||||
|
try {
|
||||||
|
await assignmentsApi.create(form)
|
||||||
|
setForm(initialForm)
|
||||||
|
setShowForm(false)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
|
||||||
|
} finally { setSubmitting(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Lehrauftrag wirklich loeschen?')) return
|
||||||
|
try {
|
||||||
|
await assignmentsApi.remove(id)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
|
||||||
|
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||||
|
|
||||||
|
const prereqMissing = classes.length === 0 || subjects.length === 0 || teachers.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="assignments-manager">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
|
Lehrauftraege ({items.length})
|
||||||
|
</h2>
|
||||||
|
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||||
|
Welcher Lehrer unterrichtet welches Fach in welcher Klasse.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(s => !s)}
|
||||||
|
disabled={prereqMissing}
|
||||||
|
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : '+ Neuer Lehrauftrag'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{prereqMissing && !loading && (
|
||||||
|
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
|
||||||
|
Zuerst Klassen, Faecher und Lehrer anlegen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Lehrer</label>
|
||||||
|
<select required value={form.teacher_id} onChange={e => setForm({ ...form, teacher_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||||
|
<option value="">— bitte waehlen —</option>
|
||||||
|
{teachers.map(t => <option key={t.id} value={t.id}>{t.last_name}, {t.first_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Klasse</label>
|
||||||
|
<select required value={form.class_id} onChange={e => setForm({ ...form, class_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||||
|
<option value="">— bitte waehlen —</option>
|
||||||
|
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Fach</label>
|
||||||
|
<select required value={form.subject_id} onChange={e => setForm({ ...form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||||
|
<option value="">— bitte waehlen —</option>
|
||||||
|
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
|
||||||
|
{submitting ? 'Speichert...' : 'Anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Noch keine Lehrauftraege.</div>
|
||||||
|
) : (
|
||||||
|
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Lehrer</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Klasse</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Fach</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(a => (
|
||||||
|
<tr key={a.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
|
||||||
|
<td className="px-4 py-3 font-medium">{a.teacher_name || a.teacher_id.slice(0, 8) + '…'}</td>
|
||||||
|
<td className="px-4 py-3">{a.class_name || a.class_id.slice(0, 8) + '…'}</td>
|
||||||
|
<td className="px-4 py-3">{a.subject_name || a.subject_id.slice(0, 8) + '…'}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button onClick={() => handleDelete(a.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { curriculumApi, classesApi, subjectsApi } from '@/lib/stundenplan/api'
|
||||||
|
import type {
|
||||||
|
TimetableCurriculum, CreateTimetableCurriculum,
|
||||||
|
TimetableClass, TimetableSubject,
|
||||||
|
} from '@/app/stundenplan/types'
|
||||||
|
|
||||||
|
const initialForm: CreateTimetableCurriculum = {
|
||||||
|
class_id: '',
|
||||||
|
subject_id: '',
|
||||||
|
weekly_hours: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CurriculumManager() {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const [items, setItems] = useState<TimetableCurriculum[]>([])
|
||||||
|
const [classes, setClasses] = useState<TimetableClass[]>([])
|
||||||
|
const [subjects, setSubjects] = useState<TimetableSubject[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [form, setForm] = useState<CreateTimetableCurriculum>(initialForm)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true); setError(null)
|
||||||
|
try {
|
||||||
|
const [curr, cls, sub] = await Promise.all([
|
||||||
|
curriculumApi.list(),
|
||||||
|
classesApi.list(),
|
||||||
|
subjectsApi.list(),
|
||||||
|
])
|
||||||
|
setItems(curr || [])
|
||||||
|
setClasses(cls || [])
|
||||||
|
setSubjects(sub || [])
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
||||||
|
} finally { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true); setError(null)
|
||||||
|
try {
|
||||||
|
await curriculumApi.create(form)
|
||||||
|
setForm(initialForm)
|
||||||
|
setShowForm(false)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
|
||||||
|
} finally { setSubmitting(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Stundentafel-Eintrag wirklich loeschen?')) return
|
||||||
|
try {
|
||||||
|
await curriculumApi.remove(id)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const className = (id: string): string => {
|
||||||
|
const c = classes.find(x => x.id === id)
|
||||||
|
return c ? c.name : id.slice(0, 8) + '…'
|
||||||
|
}
|
||||||
|
const subjectName = (id: string): string => {
|
||||||
|
const s = subjects.find(x => x.id === id)
|
||||||
|
return s ? `${s.name} (${s.short_code})` : id.slice(0, 8) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
|
||||||
|
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||||
|
|
||||||
|
const prereqMissing = classes.length === 0 || subjects.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="curriculum-manager">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
|
Stundentafel ({items.length})
|
||||||
|
</h2>
|
||||||
|
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||||
|
Pro Klasse: wie viele Wochenstunden fuer jedes Fach.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(s => !s)}
|
||||||
|
disabled={prereqMissing}
|
||||||
|
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : '+ Neuer Eintrag'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{prereqMissing && !loading && (
|
||||||
|
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
|
||||||
|
Zuerst Klassen und Faecher anlegen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Klasse</label>
|
||||||
|
<select required value={form.class_id} onChange={e => setForm({ ...form, class_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||||
|
<option value="">— bitte waehlen —</option>
|
||||||
|
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Fach</label>
|
||||||
|
<select required value={form.subject_id} onChange={e => setForm({ ...form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||||
|
<option value="">— bitte waehlen —</option>
|
||||||
|
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Stunden/Woche (1-10)</label>
|
||||||
|
<input type="number" min={1} max={10} required value={form.weekly_hours} onChange={e => setForm({ ...form, weekly_hours: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
|
||||||
|
{submitting ? 'Speichert...' : 'Anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Noch keine Eintraege.</div>
|
||||||
|
) : (
|
||||||
|
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Klasse</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Fach</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Stunden/Woche</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(c => (
|
||||||
|
<tr key={c.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
|
||||||
|
<td className="px-4 py-3 font-medium">{c.class_name || className(c.class_id)}</td>
|
||||||
|
<td className="px-4 py-3">{c.subject_name || subjectName(c.subject_id)}</td>
|
||||||
|
<td className="px-4 py-3">{c.weekly_hours}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button onClick={() => handleDelete(c.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { periodsApi } from '@/lib/stundenplan/api'
|
||||||
|
import type { TimetablePeriod, CreateTimetablePeriod } from '@/app/stundenplan/types'
|
||||||
|
|
||||||
|
const DAYS = [
|
||||||
|
{ v: 1, label: 'Mo' },
|
||||||
|
{ v: 2, label: 'Di' },
|
||||||
|
{ v: 3, label: 'Mi' },
|
||||||
|
{ v: 4, label: 'Do' },
|
||||||
|
{ v: 5, label: 'Fr' },
|
||||||
|
{ v: 6, label: 'Sa' },
|
||||||
|
{ v: 7, label: 'So' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const initialForm: CreateTimetablePeriod = {
|
||||||
|
day_of_week: 1,
|
||||||
|
period_index: 1,
|
||||||
|
start_time: '08:00',
|
||||||
|
end_time: '08:45',
|
||||||
|
is_break: false,
|
||||||
|
label: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeriodsManager() {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const [items, setItems] = useState<TimetablePeriod[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [form, setForm] = useState<CreateTimetablePeriod>(initialForm)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true); setError(null)
|
||||||
|
try {
|
||||||
|
const data = await periodsApi.list()
|
||||||
|
setItems(data || [])
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
||||||
|
} finally { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true); setError(null)
|
||||||
|
try {
|
||||||
|
await periodsApi.create(form)
|
||||||
|
setForm({ ...initialForm, day_of_week: form.day_of_week, period_index: form.period_index + 1 })
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
|
||||||
|
} finally { setSubmitting(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Zeitraster-Eintrag wirklich loeschen?')) return
|
||||||
|
try {
|
||||||
|
await periodsApi.remove(id)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
|
||||||
|
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||||
|
|
||||||
|
// Group periods by period_index for an at-a-glance week grid.
|
||||||
|
const periodIndices = Array.from(new Set(items.map(i => i.period_index))).sort((a, b) => a - b)
|
||||||
|
const periodByDay = (day: number, idx: number) => items.find(p => p.day_of_week === day && p.period_index === idx)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="periods-manager">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
|
Zeitraster ({items.length})
|
||||||
|
</h2>
|
||||||
|
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||||
|
Pro Wochentag die Stunden-Slots (z.B. 1. Stunde 08:00–08:45).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(s => !s)}
|
||||||
|
className={`px-4 py-2 rounded-xl font-medium transition-colors ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : '+ Neuer Slot'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Wochentag</label>
|
||||||
|
<select value={form.day_of_week} onChange={e => setForm({ ...form, day_of_week: parseInt(e.target.value) })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||||
|
{DAYS.map(d => <option key={d.v} value={d.v}>{d.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Stunde (1-12)</label>
|
||||||
|
<input type="number" min={1} max={12} required value={form.period_index} onChange={e => setForm({ ...form, period_index: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="is_break" checked={!!form.is_break} onChange={e => setForm({ ...form, is_break: e.target.checked })} className="w-5 h-5" />
|
||||||
|
<label htmlFor="is_break" className="text-sm">Pause</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Startzeit</label>
|
||||||
|
<input type="time" required value={form.start_time} onChange={e => setForm({ ...form, start_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Endzeit</label>
|
||||||
|
<input type="time" required value={form.end_time} onChange={e => setForm({ ...form, end_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
|
||||||
|
{submitting ? 'Speichert...' : 'Anlegen (+1)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Noch kein Zeitraster definiert.</div>
|
||||||
|
) : (
|
||||||
|
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Stunde</th>
|
||||||
|
{DAYS.map(d => <th key={d.v} className="text-left px-4 py-3 text-sm font-medium opacity-70">{d.label}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{periodIndices.map(idx => (
|
||||||
|
<tr key={idx} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
|
||||||
|
<td className="px-4 py-3 font-medium">{idx}.</td>
|
||||||
|
{DAYS.map(d => {
|
||||||
|
const p = periodByDay(d.v, idx)
|
||||||
|
if (!p) return <td key={d.v} className="px-4 py-3 opacity-30">—</td>
|
||||||
|
return (
|
||||||
|
<td key={d.v} className="px-4 py-3">
|
||||||
|
<div className={`text-sm ${p.is_break ? 'italic opacity-60' : ''}`}>
|
||||||
|
{p.start_time}–{p.end_time}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleDelete(p.id)} className="text-xs text-red-400 hover:text-red-300 mt-1">×</button>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { TeacherUnavailableDayEditor } from './TeacherUnavailableDayEditor'
|
||||||
|
import { TeacherUnavailableWindowEditor } from './TeacherUnavailableWindowEditor'
|
||||||
|
import { SubjectMaxConsecutiveEditor } from './SubjectMaxConsecutiveEditor'
|
||||||
|
import { SubjectPreferredPeriodEditor } from './SubjectPreferredPeriodEditor'
|
||||||
|
|
||||||
|
type RuleType =
|
||||||
|
| 'teacher-unavailable-day'
|
||||||
|
| 'teacher-unavailable-window'
|
||||||
|
| 'subject-max-consecutive'
|
||||||
|
| 'subject-preferred-period'
|
||||||
|
|
||||||
|
interface RuleGroup {
|
||||||
|
group: string
|
||||||
|
rules: { id: RuleType | string; label: string; implemented: boolean }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const RULE_GROUPS: RuleGroup[] = [
|
||||||
|
{
|
||||||
|
group: 'Lehrer',
|
||||||
|
rules: [
|
||||||
|
{ id: 'teacher-unavailable-day', label: 'Tag nicht verfuegbar', implemented: true },
|
||||||
|
{ id: 'teacher-unavailable-window', label: 'Zeitfenster nicht verfuegbar', implemented: true },
|
||||||
|
{ id: 'teacher-max-hours-day', label: 'Max. Stunden / Tag', implemented: false },
|
||||||
|
{ id: 'teacher-max-hours-week', label: 'Max. Stunden / Woche', implemented: false },
|
||||||
|
{ id: 'teacher-excluded-subject', label: 'Fach ausgeschlossen', implemented: false },
|
||||||
|
{ id: 'teacher-excluded-room', label: 'Raum ausgeschlossen', implemented: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Fach',
|
||||||
|
rules: [
|
||||||
|
{ id: 'subject-max-consecutive', label: 'Max. Stunden am Stueck', implemented: true },
|
||||||
|
{ id: 'subject-preferred-period', label: 'Bevorzugter Stunden-Bereich', implemented: true },
|
||||||
|
{ id: 'subject-min-day-gap', label: 'Min. Tagesabstand', implemented: false },
|
||||||
|
{ id: 'subject-contiguous-when-repeated', label: 'Bei Mehrfach: zusammenhaengend', implemented: false },
|
||||||
|
{ id: 'subject-double-lesson', label: 'Doppelstunde bevorzugt', implemented: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Klasse',
|
||||||
|
rules: [
|
||||||
|
{ id: 'class-max-hours-day', label: 'Max. Stunden / Tag', implemented: false },
|
||||||
|
{ id: 'class-no-gaps', label: 'Keine Freistunden', implemented: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Raum',
|
||||||
|
rules: [
|
||||||
|
{ id: 'room-requires-type', label: 'Fach benoetigt Raumtyp', implemented: false },
|
||||||
|
{ id: 'room-unavailable', label: 'Raum nicht verfuegbar', implemented: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function RegelnHub() {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const [active, setActive] = useState<RuleType>('teacher-unavailable-day')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-[260px_1fr] gap-6" data-testid="regeln-hub">
|
||||||
|
<aside className={`rounded-2xl border backdrop-blur-xl p-3 ${isDark ? 'bg-white/5 border-white/10' : 'bg-white/80 border-black/10'}`}>
|
||||||
|
{RULE_GROUPS.map(g => (
|
||||||
|
<div key={g.group} className="mb-4 last:mb-0">
|
||||||
|
<h4 className={`text-xs uppercase tracking-wide mb-2 px-2 ${isDark ? 'text-white/40' : 'text-slate-500'}`}>{g.group}</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{g.rules.map(r => {
|
||||||
|
const isActive = active === r.id
|
||||||
|
const isDone = r.implemented
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
disabled={!isDone}
|
||||||
|
onClick={() => isDone && setActive(r.id as RuleType)}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? isDark ? 'bg-indigo-500/20 text-white' : 'bg-indigo-100 text-indigo-900'
|
||||||
|
: isDone
|
||||||
|
? isDark ? 'text-white/80 hover:bg-white/10' : 'text-slate-700 hover:bg-slate-100'
|
||||||
|
: isDark ? 'text-white/30 cursor-not-allowed' : 'text-slate-400 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate">{r.label}</span>
|
||||||
|
{!isDone && <span className="text-xs opacity-60">soon</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{active === 'teacher-unavailable-day' && <TeacherUnavailableDayEditor />}
|
||||||
|
{active === 'teacher-unavailable-window' && <TeacherUnavailableWindowEditor />}
|
||||||
|
{active === 'subject-max-consecutive' && <SubjectMaxConsecutiveEditor />}
|
||||||
|
{active === 'subject-preferred-period' && <SubjectPreferredPeriodEditor />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { subjectMaxConsecutiveApi, subjectsApi } from '@/lib/stundenplan/api'
|
||||||
|
import type { SubjectMaxConsecutive, TimetableSubject } from '@/app/stundenplan/types'
|
||||||
|
|
||||||
|
type FormState = Omit<SubjectMaxConsecutive, 'id' | 'created_by_user_id' | 'created_at'>
|
||||||
|
|
||||||
|
const initialForm: FormState = {
|
||||||
|
subject_id: '',
|
||||||
|
max_consecutive: 2,
|
||||||
|
is_hard: true,
|
||||||
|
weight: 100,
|
||||||
|
active: true,
|
||||||
|
note: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubjectMaxConsecutiveEditor() {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const [items, setItems] = useState<SubjectMaxConsecutive[]>([])
|
||||||
|
const [subjects, setSubjects] = useState<TimetableSubject[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [form, setForm] = useState<FormState>(initialForm)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true); setError(null)
|
||||||
|
try {
|
||||||
|
const [rules, s] = await Promise.all([subjectMaxConsecutiveApi.list(), subjectsApi.list()])
|
||||||
|
setItems(rules || [])
|
||||||
|
setSubjects(s || [])
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
||||||
|
} finally { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true); setError(null)
|
||||||
|
try {
|
||||||
|
await subjectMaxConsecutiveApi.create(form)
|
||||||
|
setForm(initialForm); setShowForm(false); await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
|
||||||
|
} finally { setSubmitting(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Regel wirklich loeschen?')) return
|
||||||
|
try { await subjectMaxConsecutiveApi.remove(id); await load() }
|
||||||
|
catch (err) { setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sLabel = (id: string): string => {
|
||||||
|
const s = subjects.find(x => x.id === id)
|
||||||
|
return s ? `${s.name} (${s.short_code})` : id.slice(0, 8) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
|
||||||
|
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="subject-max-consecutive-editor">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
|
Fach: Max. Stunden am Stueck
|
||||||
|
</h3>
|
||||||
|
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||||
|
Beispiel: „Mathe nicht mehr als 2 Stunden am Stueck" (keine Dreifach-Stunde).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(s => !s)}
|
||||||
|
disabled={subjects.length === 0}
|
||||||
|
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : '+ Neue Regel'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subjects.length === 0 && !loading && (
|
||||||
|
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
|
||||||
|
Zuerst Faecher anlegen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Fach</label>
|
||||||
|
<select required value={form.subject_id} onChange={e => setForm({ ...form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||||
|
<option value="">— bitte waehlen —</option>
|
||||||
|
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Max. Stunden (1-5)</label>
|
||||||
|
<input type="number" min={1} max={5} required value={form.max_consecutive} onChange={e => setForm({ ...form, max_consecutive: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="is_hard_mc" checked={form.is_hard} onChange={e => setForm({ ...form, is_hard: e.target.checked })} className="w-5 h-5" />
|
||||||
|
<label htmlFor="is_hard_mc" className="text-sm">Harte Regel</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
|
||||||
|
<input type="number" min={0} max={100} value={form.weight} onChange={e => setForm({ ...form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 flex items-end">
|
||||||
|
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
|
||||||
|
{submitting ? 'Speichert...' : 'Anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Keine Regeln vorhanden.</div>
|
||||||
|
) : (
|
||||||
|
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Fach</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Max. Stunden</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Hart</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Weight</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(c => (
|
||||||
|
<tr key={c.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
|
||||||
|
<td className="px-4 py-3 font-medium">{sLabel(c.subject_id)}</td>
|
||||||
|
<td className="px-4 py-3">{c.max_consecutive}</td>
|
||||||
|
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
|
||||||
|
<td className="px-4 py-3">{c.weight}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button onClick={() => handleDelete(c.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { subjectPreferredPeriodApi, subjectsApi } from '@/lib/stundenplan/api'
|
||||||
|
import type { SubjectPreferredPeriod, TimetableSubject } from '@/app/stundenplan/types'
|
||||||
|
|
||||||
|
type FormState = Omit<SubjectPreferredPeriod, 'id' | 'created_by_user_id' | 'created_at'>
|
||||||
|
|
||||||
|
const initialForm: FormState = {
|
||||||
|
subject_id: '',
|
||||||
|
period_from: 1,
|
||||||
|
period_to: 4,
|
||||||
|
is_hard: false,
|
||||||
|
weight: 40,
|
||||||
|
active: true,
|
||||||
|
note: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubjectPreferredPeriodEditor() {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const [items, setItems] = useState<SubjectPreferredPeriod[]>([])
|
||||||
|
const [subjects, setSubjects] = useState<TimetableSubject[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [form, setForm] = useState<FormState>(initialForm)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true); setError(null)
|
||||||
|
try {
|
||||||
|
const [rules, s] = await Promise.all([subjectPreferredPeriodApi.list(), subjectsApi.list()])
|
||||||
|
setItems(rules || [])
|
||||||
|
setSubjects(s || [])
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
||||||
|
} finally { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (form.period_to < form.period_from) {
|
||||||
|
setError('"Bis"-Stunde darf nicht kleiner sein als "Von"-Stunde.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true); setError(null)
|
||||||
|
try {
|
||||||
|
await subjectPreferredPeriodApi.create(form)
|
||||||
|
setForm(initialForm); setShowForm(false); await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
|
||||||
|
} finally { setSubmitting(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Regel wirklich loeschen?')) return
|
||||||
|
try { await subjectPreferredPeriodApi.remove(id); await load() }
|
||||||
|
catch (err) { setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sLabel = (id: string): string => {
|
||||||
|
const s = subjects.find(x => x.id === id)
|
||||||
|
return s ? `${s.name} (${s.short_code})` : id.slice(0, 8) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
|
||||||
|
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="subject-preferred-period-editor">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
|
Fach: Bevorzugter Stunden-Bereich
|
||||||
|
</h3>
|
||||||
|
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||||
|
Beispiel: „Hauptfaecher lieber in den ersten 4 Stunden" (Soft-Regel).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(s => !s)}
|
||||||
|
disabled={subjects.length === 0}
|
||||||
|
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : '+ Neue Regel'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subjects.length === 0 && !loading && (
|
||||||
|
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
|
||||||
|
Zuerst Faecher anlegen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Fach</label>
|
||||||
|
<select required value={form.subject_id} onChange={e => setForm({ ...form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||||
|
<option value="">— bitte waehlen —</option>
|
||||||
|
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Von Stunde</label>
|
||||||
|
<input type="number" min={1} max={12} required value={form.period_from} onChange={e => setForm({ ...form, period_from: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Bis Stunde</label>
|
||||||
|
<input type="number" min={1} max={12} required value={form.period_to} onChange={e => setForm({ ...form, period_to: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
|
||||||
|
<input type="number" min={0} max={100} value={form.weight} onChange={e => setForm({ ...form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="is_hard_pp" checked={form.is_hard} onChange={e => setForm({ ...form, is_hard: e.target.checked })} className="w-5 h-5" />
|
||||||
|
<label htmlFor="is_hard_pp" className="text-sm">Harte Regel (selten sinnvoll hier)</label>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-3 flex items-end">
|
||||||
|
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
|
||||||
|
{submitting ? 'Speichert...' : 'Anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Keine Regeln vorhanden.</div>
|
||||||
|
) : (
|
||||||
|
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Fach</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Bereich</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Hart</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Weight</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(c => (
|
||||||
|
<tr key={c.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
|
||||||
|
<td className="px-4 py-3 font-medium">{sLabel(c.subject_id)}</td>
|
||||||
|
<td className="px-4 py-3">Stunde {c.period_from}–{c.period_to}</td>
|
||||||
|
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
|
||||||
|
<td className="px-4 py-3">{c.weight}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button onClick={() => handleDelete(c.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { teacherUnavailableWindowApi, teachersApi } from '@/lib/stundenplan/api'
|
||||||
|
import type { TeacherUnavailableWindow, TimetableTeacher } from '@/app/stundenplan/types'
|
||||||
|
|
||||||
|
const DAYS = [
|
||||||
|
{ v: 1, label: 'Montag' }, { v: 2, label: 'Dienstag' }, { v: 3, label: 'Mittwoch' },
|
||||||
|
{ v: 4, label: 'Donnerstag' }, { v: 5, label: 'Freitag' }, { v: 6, label: 'Samstag' }, { v: 7, label: 'Sonntag' },
|
||||||
|
]
|
||||||
|
|
||||||
|
type FormState = Omit<TeacherUnavailableWindow, 'id' | 'created_by_user_id' | 'created_at'>
|
||||||
|
|
||||||
|
const initialForm: FormState = {
|
||||||
|
teacher_id: '',
|
||||||
|
day_of_week: 2,
|
||||||
|
start_time: '13:00',
|
||||||
|
end_time: '17:00',
|
||||||
|
is_hard: true,
|
||||||
|
weight: 100,
|
||||||
|
active: true,
|
||||||
|
note: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeacherUnavailableWindowEditor() {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const [items, setItems] = useState<TeacherUnavailableWindow[]>([])
|
||||||
|
const [teachers, setTeachers] = useState<TimetableTeacher[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [form, setForm] = useState<FormState>(initialForm)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true); setError(null)
|
||||||
|
try {
|
||||||
|
const [rules, t] = await Promise.all([teacherUnavailableWindowApi.list(), teachersApi.list()])
|
||||||
|
setItems(rules || [])
|
||||||
|
setTeachers(t || [])
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
||||||
|
} finally { setLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true); setError(null)
|
||||||
|
try {
|
||||||
|
await teacherUnavailableWindowApi.create(form)
|
||||||
|
setForm(initialForm); setShowForm(false); await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
|
||||||
|
} finally { setSubmitting(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Regel wirklich loeschen?')) return
|
||||||
|
try { await teacherUnavailableWindowApi.remove(id); await load() }
|
||||||
|
catch (err) { setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tLabel = (id: string): string => {
|
||||||
|
const t = teachers.find(x => x.id === id)
|
||||||
|
return t ? `${t.last_name}, ${t.first_name}` : id.slice(0, 8) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
|
||||||
|
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="teacher-unavailable-window-editor">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
|
Lehrer: Zeitfenster nicht verfuegbar
|
||||||
|
</h3>
|
||||||
|
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||||
|
Beispiel: „Lehrer Z Dienstags 13:00–17:00 nicht".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(s => !s)}
|
||||||
|
disabled={teachers.length === 0}
|
||||||
|
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : '+ Neue Regel'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{teachers.length === 0 && !loading && (
|
||||||
|
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
|
||||||
|
Zuerst Lehrer anlegen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Lehrer</label>
|
||||||
|
<select required value={form.teacher_id} onChange={e => setForm({ ...form, teacher_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||||
|
<option value="">— bitte waehlen —</option>
|
||||||
|
{teachers.map(t => <option key={t.id} value={t.id}>{t.last_name}, {t.first_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Wochentag</label>
|
||||||
|
<select value={form.day_of_week} onChange={e => setForm({ ...form, day_of_week: parseInt(e.target.value) })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
|
||||||
|
{DAYS.map(d => <option key={d.v} value={d.v}>{d.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Von</label>
|
||||||
|
<input type="time" required value={form.start_time} onChange={e => setForm({ ...form, start_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Bis</label>
|
||||||
|
<input type="time" required value={form.end_time} onChange={e => setForm({ ...form, end_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="is_hard_w" checked={form.is_hard} onChange={e => setForm({ ...form, is_hard: e.target.checked })} className="w-5 h-5" />
|
||||||
|
<label htmlFor="is_hard_w" className="text-sm">Harte Regel</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
|
||||||
|
<input type="number" min={0} max={100} value={form.weight} onChange={e => setForm({ ...form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 flex items-end">
|
||||||
|
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
|
||||||
|
{submitting ? 'Speichert...' : 'Anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="block text-sm mb-1 opacity-70">Begruendung (optional)</label>
|
||||||
|
<input value={form.note || ''} onChange={e => setForm({ ...form, note: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Keine Regeln vorhanden.</div>
|
||||||
|
) : (
|
||||||
|
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Lehrer</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Tag</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Zeitfenster</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Hart</th>
|
||||||
|
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Notiz</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(c => (
|
||||||
|
<tr key={c.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
|
||||||
|
<td className="px-4 py-3 font-medium">{tLabel(c.teacher_id)}</td>
|
||||||
|
<td className="px-4 py-3">{DAYS.find(d => d.v === c.day_of_week)?.label || c.day_of_week}</td>
|
||||||
|
<td className="px-4 py-3">{c.start_time}–{c.end_time}</td>
|
||||||
|
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-sm opacity-70">{c.note || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button onClick={() => handleDelete(c.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,10 @@ import { KlassenManager } from './_components/KlassenManager'
|
|||||||
import { LehrerManager } from './_components/LehrerManager'
|
import { LehrerManager } from './_components/LehrerManager'
|
||||||
import { FaecherManager } from './_components/FaecherManager'
|
import { FaecherManager } from './_components/FaecherManager'
|
||||||
import { RaeumeManager } from './_components/RaeumeManager'
|
import { RaeumeManager } from './_components/RaeumeManager'
|
||||||
import { TeacherUnavailableDayEditor } from './_components/regeln/TeacherUnavailableDayEditor'
|
import { PeriodsManager } from './_components/PeriodsManager'
|
||||||
|
import { CurriculumManager } from './_components/CurriculumManager'
|
||||||
|
import { AssignmentsManager } from './_components/AssignmentsManager'
|
||||||
|
import { RegelnHub } from './_components/regeln/RegelnHub'
|
||||||
import { setStundenplanToken, getStundenplanToken } from '@/lib/stundenplan/api'
|
import { setStundenplanToken, getStundenplanToken } from '@/lib/stundenplan/api'
|
||||||
|
|
||||||
type Tab = 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln'
|
type Tab = 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln'
|
||||||
@@ -109,20 +112,10 @@ export default function StundenplanPage() {
|
|||||||
{tab === 'lehrer' && <LehrerManager />}
|
{tab === 'lehrer' && <LehrerManager />}
|
||||||
{tab === 'faecher' && <FaecherManager />}
|
{tab === 'faecher' && <FaecherManager />}
|
||||||
{tab === 'raeume' && <RaeumeManager />}
|
{tab === 'raeume' && <RaeumeManager />}
|
||||||
{tab === 'regeln' && <TeacherUnavailableDayEditor />}
|
{tab === 'periods' && <PeriodsManager />}
|
||||||
{(tab === 'periods' || tab === 'curriculum' || tab === 'assignments') && (
|
{tab === 'curriculum' && <CurriculumManager />}
|
||||||
<div
|
{tab === 'assignments' && <AssignmentsManager />}
|
||||||
className={`rounded-2xl border backdrop-blur-xl p-8 text-center ${
|
{tab === 'regeln' && <RegelnHub />}
|
||||||
isDark ? 'bg-white/5 border-white/10 text-white/60' : 'bg-white/80 border-black/10 text-slate-500'
|
|
||||||
}`}
|
|
||||||
data-testid="not-implemented"
|
|
||||||
>
|
|
||||||
<p className="text-lg">Noch nicht implementiert: {TABS.find(t => t.id === tab)?.label}</p>
|
|
||||||
<p className="text-sm mt-2">
|
|
||||||
Folgt dem gleichen Muster wie Klassen / Lehrer / Faecher / Raeume.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { test, expect, Page } from '@playwright/test'
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const MOCK_TEACHER_ID = '11111111-1111-1111-1111-111111111111'
|
const MOCK_TEACHER_ID = '11111111-1111-1111-1111-111111111111'
|
||||||
|
const MOCK_SUBJECT_ID = '22222222-2222-2222-2222-222222222222'
|
||||||
|
const MOCK_CLASS_ID = '33333333-3333-3333-3333-333333333333'
|
||||||
|
|
||||||
interface MockClass {
|
interface MockClass {
|
||||||
id: string
|
id: string
|
||||||
@@ -20,9 +22,24 @@ interface MockClass {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mockSchoolApi(page: Page, opts: { classes?: MockClass[]; teachers?: unknown[] } = {}) {
|
interface MockOpts {
|
||||||
|
classes?: MockClass[]
|
||||||
|
teachers?: unknown[]
|
||||||
|
subjects?: unknown[]
|
||||||
|
rooms?: unknown[]
|
||||||
|
periods?: unknown[]
|
||||||
|
curriculum?: unknown[]
|
||||||
|
assignments?: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockSchoolApi(page: Page, opts: MockOpts = {}) {
|
||||||
const classes = opts.classes ?? []
|
const classes = opts.classes ?? []
|
||||||
const teachers = opts.teachers ?? []
|
const teachers = opts.teachers ?? []
|
||||||
|
const subjects = opts.subjects ?? []
|
||||||
|
const rooms = opts.rooms ?? []
|
||||||
|
const periods = opts.periods ?? []
|
||||||
|
const curriculum = opts.curriculum ?? []
|
||||||
|
const assignments = opts.assignments ?? []
|
||||||
|
|
||||||
await page.route('**/api/school/timetable/classes', async (route) => {
|
await page.route('**/api/school/timetable/classes', async (route) => {
|
||||||
if (route.request().method() === 'GET') {
|
if (route.request().method() === 'GET') {
|
||||||
@@ -45,24 +62,27 @@ async function mockSchoolApi(page: Page, opts: { classes?: MockClass[]; teachers
|
|||||||
return route.fulfill({ status: 405 })
|
return route.fulfill({ status: 405 })
|
||||||
})
|
})
|
||||||
|
|
||||||
await page.route('**/api/school/timetable/teachers', async (route) => {
|
// Helper to mount a read-only endpoint with a static list.
|
||||||
if (route.request().method() === 'GET') {
|
const staticList = (path: string, data: unknown) =>
|
||||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(teachers) })
|
page.route(`**/api/school/timetable/${path}`, async (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }))
|
||||||
|
|
||||||
|
await staticList('teachers', teachers)
|
||||||
|
await staticList('subjects', subjects)
|
||||||
|
await staticList('rooms', rooms)
|
||||||
|
await staticList('periods', periods)
|
||||||
|
await staticList('curriculum', curriculum)
|
||||||
|
await staticList('assignments', assignments)
|
||||||
|
|
||||||
|
// Constraint endpoints — all empty by default.
|
||||||
|
for (const path of [
|
||||||
|
'constraints/teacher/unavailable-day',
|
||||||
|
'constraints/teacher/unavailable-window',
|
||||||
|
'constraints/subject/max-consecutive',
|
||||||
|
'constraints/subject/preferred-period',
|
||||||
|
]) {
|
||||||
|
await staticList(path, [])
|
||||||
}
|
}
|
||||||
return route.fulfill({ status: 405 })
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.route('**/api/school/timetable/subjects', async (route) => {
|
|
||||||
return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.route('**/api/school/timetable/rooms', async (route) => {
|
|
||||||
return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.route('**/api/school/timetable/constraints/teacher/unavailable-day', async (route) => {
|
|
||||||
return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Stundenplan — Page Shell', () => {
|
test.describe('Stundenplan — Page Shell', () => {
|
||||||
@@ -78,7 +98,7 @@ test.describe('Stundenplan — Page Shell', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('shows all 8 tabs', async ({ page }) => {
|
test('shows all 8 tabs', async ({ page }) => {
|
||||||
// Sidebar entries collide with tab labels for 'Lehrer' — scope to <nav> and use exact match.
|
// Sidebar entries collide with tab labels for 'Lehrer' — scope to <main nav>.
|
||||||
const tabs = page.locator('main nav')
|
const tabs = page.locator('main nav')
|
||||||
for (const label of ['Klassen', 'Lehrer', 'Faecher', 'Raeume', 'Zeitraster', 'Stundentafel', 'Lehrauftraege', 'Regeln (Constraints)']) {
|
for (const label of ['Klassen', 'Lehrer', 'Faecher', 'Raeume', 'Zeitraster', 'Stundentafel', 'Lehrauftraege', 'Regeln (Constraints)']) {
|
||||||
await expect(tabs.getByRole('button', { name: label, exact: true })).toBeVisible()
|
await expect(tabs.getByRole('button', { name: label, exact: true })).toBeVisible()
|
||||||
@@ -106,32 +126,21 @@ test.describe('Stundenplan — Tab navigation', () => {
|
|||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('switching to Lehrer shows the Lehrer manager', async ({ page }) => {
|
test('all 8 tabs render their manager', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: 'Lehrer', exact: true }).click()
|
const tabs = page.locator('main nav')
|
||||||
await expect(page.getByTestId('lehrer-manager')).toBeVisible()
|
const cases: { label: string; testId: string }[] = [
|
||||||
await expect(page.getByRole('heading', { name: /^Lehrer/ })).toBeVisible()
|
{ label: 'Lehrer', testId: 'lehrer-manager' },
|
||||||
})
|
{ label: 'Faecher', testId: 'faecher-manager' },
|
||||||
|
{ label: 'Raeume', testId: 'raeume-manager' },
|
||||||
test('switching to Faecher shows the Faecher manager', async ({ page }) => {
|
{ label: 'Zeitraster', testId: 'periods-manager' },
|
||||||
await page.getByRole('button', { name: 'Faecher', exact: true }).click()
|
{ label: 'Stundentafel', testId: 'curriculum-manager' },
|
||||||
await expect(page.getByTestId('faecher-manager')).toBeVisible()
|
{ label: 'Lehrauftraege', testId: 'assignments-manager' },
|
||||||
})
|
{ label: 'Regeln (Constraints)', testId: 'regeln-hub' },
|
||||||
|
]
|
||||||
test('switching to Raeume shows the Raeume manager', async ({ page }) => {
|
for (const c of cases) {
|
||||||
await page.getByRole('button', { name: 'Raeume', exact: true }).click()
|
await tabs.getByRole('button', { name: c.label, exact: true }).click()
|
||||||
await expect(page.getByTestId('raeume-manager')).toBeVisible()
|
await expect(page.getByTestId(c.testId)).toBeVisible()
|
||||||
})
|
}
|
||||||
|
|
||||||
test('switching to Regeln shows the constraint editor', async ({ page }) => {
|
|
||||||
await page.getByRole('button', { name: 'Regeln (Constraints)' }).click()
|
|
||||||
await expect(page.getByTestId('teacher-unavailable-day-editor')).toBeVisible()
|
|
||||||
await expect(page.getByText('Lehrer: Tag nicht verfuegbar')).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('unimplemented tabs show placeholder', async ({ page }) => {
|
|
||||||
await page.getByRole('button', { name: 'Zeitraster' }).click()
|
|
||||||
await expect(page.getByTestId('not-implemented')).toBeVisible()
|
|
||||||
await expect(page.getByText('Noch nicht implementiert')).toBeVisible()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -184,31 +193,131 @@ test.describe('Stundenplan — Klassen CRUD', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('Stundenplan — Constraint editor empty teacher state', () => {
|
test.describe('Stundenplan — Periods grid', () => {
|
||||||
test('shows warning when no teachers exist', async ({ page }) => {
|
test('empty state shows when no periods', async ({ page }) => {
|
||||||
await mockSchoolApi(page, { teachers: [] })
|
await mockSchoolApi(page, { periods: [] })
|
||||||
await page.goto('/stundenplan')
|
await page.goto('/stundenplan')
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
await page.getByRole('button', { name: 'Regeln (Constraints)' }).click()
|
await page.locator('main nav').getByRole('button', { name: 'Zeitraster', exact: true }).click()
|
||||||
await expect(page.getByText('Zuerst Lehrer anlegen')).toBeVisible()
|
await expect(page.getByText('Noch kein Zeitraster definiert.')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('enables form button when teachers exist', async ({ page }) => {
|
test('renders weekday grid with period entries', async ({ page }) => {
|
||||||
await mockSchoolApi(page, {
|
await mockSchoolApi(page, {
|
||||||
teachers: [
|
periods: [
|
||||||
{ id: MOCK_TEACHER_ID, first_name: 'Anna', last_name: 'Schmidt', short_code: 'SCH', employment_percentage: 100, max_hours_week: 28, created_by_user_id: 'u', created_at: '2026-05-21T10:00:00Z' },
|
{ id: 'p1', day_of_week: 1, period_index: 1, start_time: '08:00', end_time: '08:45', is_break: false, created_by_user_id: 'u', created_at: '' },
|
||||||
|
{ id: 'p2', day_of_week: 2, period_index: 1, start_time: '08:00', end_time: '08:45', is_break: false, created_by_user_id: 'u', created_at: '' },
|
||||||
|
{ id: 'p3', day_of_week: 1, period_index: 2, start_time: '08:50', end_time: '09:35', is_break: false, created_by_user_id: 'u', created_at: '' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
await page.goto('/stundenplan')
|
await page.goto('/stundenplan')
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
await page.getByRole('button', { name: 'Regeln (Constraints)' }).click()
|
await page.locator('main nav').getByRole('button', { name: 'Zeitraster', exact: true }).click()
|
||||||
const btn = page.getByRole('button', { name: '+ Neue Regel' })
|
await expect(page.getByText('Zeitraster (3)')).toBeVisible()
|
||||||
|
// Header row contains weekday abbreviations Mo–So.
|
||||||
|
for (const day of ['Mo', 'Di', 'Mi']) {
|
||||||
|
await expect(page.getByRole('columnheader', { name: day, exact: true })).toBeVisible()
|
||||||
|
}
|
||||||
|
// Two distinct time labels should appear in the grid.
|
||||||
|
await expect(page.getByText('08:00–08:45').first()).toBeVisible()
|
||||||
|
await expect(page.getByText('08:50–09:35')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Stundenplan — Curriculum prerequisites', () => {
|
||||||
|
test('shows warning when classes or subjects missing', async ({ page }) => {
|
||||||
|
await mockSchoolApi(page, { classes: [], subjects: [] })
|
||||||
|
await page.goto('/stundenplan')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.locator('main nav').getByRole('button', { name: 'Stundentafel', exact: true }).click()
|
||||||
|
await expect(page.getByText('Zuerst Klassen und Faecher anlegen.')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('enables form when both classes and subjects exist', async ({ page }) => {
|
||||||
|
await mockSchoolApi(page, {
|
||||||
|
classes: [{ id: MOCK_CLASS_ID, name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }],
|
||||||
|
subjects: [{ id: MOCK_SUBJECT_ID, name: 'Mathematik', short_code: 'M', is_main_subject: true, created_by_user_id: 'u', created_at: '' }],
|
||||||
|
})
|
||||||
|
await page.goto('/stundenplan')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.locator('main nav').getByRole('button', { name: 'Stundentafel', exact: true }).click()
|
||||||
|
const btn = page.getByRole('button', { name: '+ Neuer Eintrag' })
|
||||||
await expect(btn).toBeEnabled()
|
await expect(btn).toBeEnabled()
|
||||||
await btn.click()
|
await btn.click()
|
||||||
const select = page.getByRole('combobox').first()
|
await expect(page.getByRole('combobox').first().locator('option', { hasText: '5a' })).toHaveCount(1)
|
||||||
await expect(select).toBeVisible()
|
await expect(page.locator('select').nth(1).locator('option', { hasText: 'Mathematik' })).toHaveCount(1)
|
||||||
// <option> elements live inside <select>; assert on the option's text, not visibility.
|
})
|
||||||
await expect(select.locator('option', { hasText: 'Schmidt, Anna' })).toHaveCount(1)
|
})
|
||||||
|
|
||||||
|
test.describe('Stundenplan — Assignments prerequisites', () => {
|
||||||
|
test('warns when teachers/classes/subjects missing', async ({ page }) => {
|
||||||
|
await mockSchoolApi(page)
|
||||||
|
await page.goto('/stundenplan')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.locator('main nav').getByRole('button', { name: 'Lehrauftraege', exact: true }).click()
|
||||||
|
await expect(page.getByText('Zuerst Klassen, Faecher und Lehrer anlegen.')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders existing assignments with joined names', async ({ page }) => {
|
||||||
|
await mockSchoolApi(page, {
|
||||||
|
teachers: [{ id: MOCK_TEACHER_ID, first_name: 'Anna', last_name: 'Schmidt', short_code: 'SCH', employment_percentage: 100, max_hours_week: 28, created_by_user_id: 'u', created_at: '' }],
|
||||||
|
classes: [{ id: MOCK_CLASS_ID, name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }],
|
||||||
|
subjects: [{ id: MOCK_SUBJECT_ID, name: 'Mathe', short_code: 'M', is_main_subject: true, created_by_user_id: 'u', created_at: '' }],
|
||||||
|
assignments: [{
|
||||||
|
id: 'a1', teacher_id: MOCK_TEACHER_ID, class_id: MOCK_CLASS_ID, subject_id: MOCK_SUBJECT_ID,
|
||||||
|
teacher_name: 'Schmidt, Anna', class_name: '5a', subject_name: 'Mathe', created_at: '',
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
await page.goto('/stundenplan')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.locator('main nav').getByRole('button', { name: 'Lehrauftraege', exact: true }).click()
|
||||||
|
await expect(page.getByText('Lehrauftraege (1)')).toBeVisible()
|
||||||
|
await expect(page.getByRole('cell', { name: 'Schmidt, Anna' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('cell', { name: 'Mathe' })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Stundenplan — Regeln Hub', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await mockSchoolApi(page, {
|
||||||
|
teachers: [{ id: MOCK_TEACHER_ID, first_name: 'Anna', last_name: 'Schmidt', short_code: 'SCH', employment_percentage: 100, max_hours_week: 28, created_by_user_id: 'u', created_at: '' }],
|
||||||
|
subjects: [{ id: MOCK_SUBJECT_ID, name: 'Mathe', short_code: 'M', is_main_subject: true, created_by_user_id: 'u', created_at: '' }],
|
||||||
|
})
|
||||||
|
await page.goto('/stundenplan')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.locator('main nav').getByRole('button', { name: 'Regeln (Constraints)' }).click()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows all 4 rule groups in the sidebar', async ({ page }) => {
|
||||||
|
for (const group of ['Lehrer', 'Fach', 'Klasse', 'Raum']) {
|
||||||
|
await expect(page.getByTestId('regeln-hub').getByText(group, { exact: true })).toBeVisible()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('TeacherUnavailableDay editor is active by default', async ({ page }) => {
|
||||||
|
await expect(page.getByTestId('teacher-unavailable-day-editor')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching to UnavailableWindow editor swaps the right pane', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: 'Zeitfenster nicht verfuegbar' }).click()
|
||||||
|
await expect(page.getByTestId('teacher-unavailable-window-editor')).toBeVisible()
|
||||||
|
await expect(page.getByText('„Lehrer Z Dienstags 13:00–17:00 nicht".')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching to SubjectMaxConsecutive editor works', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: 'Max. Stunden am Stueck' }).click()
|
||||||
|
await expect(page.getByTestId('subject-max-consecutive-editor')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching to SubjectPreferredPeriod editor works', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: 'Bevorzugter Stunden-Bereich' }).click()
|
||||||
|
await expect(page.getByTestId('subject-preferred-period-editor')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unimplemented rules are disabled in the sidebar', async ({ page }) => {
|
||||||
|
const soonBtn = page.getByRole('button', { name: /Min\. Tagesabstand/ })
|
||||||
|
await expect(soonBtn).toBeDisabled()
|
||||||
|
await expect(page.getByText('soon').first()).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user