diff --git a/school-service/internal/services/timetable_constraints_more_test.go b/school-service/internal/services/timetable_constraints_more_test.go new file mode 100644 index 0000000..fb1ab78 --- /dev/null +++ b/school-service/internal/services/timetable_constraints_more_test.go @@ -0,0 +1,188 @@ +package services + +import ( + "testing" + + "github.com/breakpilot/school-service/internal/models" +) + +// Additional validator tests covering the 9 constraint DTOs not exercised in +// timetable_constraints_test.go. Each entry probes both the happy path and +// the boundary that the binding tags are supposed to reject. + +const ( + uidTeacher = "00000000-0000-0000-0000-0000000000a1" + uidSubject = "00000000-0000-0000-0000-0000000000a2" + uidClass = "00000000-0000-0000-0000-0000000000a3" + uidRoom = "00000000-0000-0000-0000-0000000000a4" +) + +func TestCreateTeacherMaxHoursDayRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.CreateTeacherMaxHoursDayRequest + wantErr bool + }{ + {"valid", models.CreateTeacherMaxHoursDayRequest{TeacherID: uidTeacher, MaxHours: 6, IsHard: false, Weight: 50}, false}, + {"missing teacher", models.CreateTeacherMaxHoursDayRequest{MaxHours: 6}, true}, + {"hours below 1", models.CreateTeacherMaxHoursDayRequest{TeacherID: uidTeacher, MaxHours: 0}, true}, + {"hours above 12", models.CreateTeacherMaxHoursDayRequest{TeacherID: uidTeacher, MaxHours: 13}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (validate.Struct(tt.req) != nil) != tt.wantErr { + t.Errorf("unexpected validation outcome") + } + }) + } +} + +func TestCreateTeacherMaxHoursWeekRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.CreateTeacherMaxHoursWeekRequest + wantErr bool + }{ + {"valid", models.CreateTeacherMaxHoursWeekRequest{TeacherID: uidTeacher, MaxHours: 28, IsHard: true, Weight: 100}, false}, + {"hours below 1", models.CreateTeacherMaxHoursWeekRequest{TeacherID: uidTeacher, MaxHours: 0}, true}, + {"hours above 40", models.CreateTeacherMaxHoursWeekRequest{TeacherID: uidTeacher, MaxHours: 41}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (validate.Struct(tt.req) != nil) != tt.wantErr { + t.Errorf("unexpected validation outcome") + } + }) + } +} + +func TestCreateTeacherExcludedSubjectRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.CreateTeacherExcludedSubjectRequest + wantErr bool + }{ + {"valid", models.CreateTeacherExcludedSubjectRequest{TeacherID: uidTeacher, SubjectID: uidSubject, IsHard: true, Weight: 100}, false}, + {"missing subject", models.CreateTeacherExcludedSubjectRequest{TeacherID: uidTeacher}, true}, + {"non-uuid subject", models.CreateTeacherExcludedSubjectRequest{TeacherID: uidTeacher, SubjectID: "nope"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (validate.Struct(tt.req) != nil) != tt.wantErr { + t.Errorf("unexpected validation outcome") + } + }) + } +} + +func TestCreateTeacherExcludedRoomRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.CreateTeacherExcludedRoomRequest + wantErr bool + }{ + {"valid", models.CreateTeacherExcludedRoomRequest{TeacherID: uidTeacher, RoomID: uidRoom, IsHard: true, Weight: 100}, false}, + {"missing room", models.CreateTeacherExcludedRoomRequest{TeacherID: uidTeacher}, true}, + {"non-uuid room", models.CreateTeacherExcludedRoomRequest{TeacherID: uidTeacher, RoomID: "nope"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (validate.Struct(tt.req) != nil) != tt.wantErr { + t.Errorf("unexpected validation outcome") + } + }) + } +} + +func TestCreateSubjectMinDayGapRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.CreateSubjectMinDayGapRequest + wantErr bool + }{ + {"valid", models.CreateSubjectMinDayGapRequest{SubjectID: uidSubject, MinGapDays: 1, IsHard: false, Weight: 70}, false}, + {"below 1", models.CreateSubjectMinDayGapRequest{SubjectID: uidSubject, MinGapDays: 0}, true}, + {"above 4", models.CreateSubjectMinDayGapRequest{SubjectID: uidSubject, MinGapDays: 5}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (validate.Struct(tt.req) != nil) != tt.wantErr { + t.Errorf("unexpected validation outcome") + } + }) + } +} + +func TestCreateSubjectContiguousWhenRepeatedRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.CreateSubjectContiguousWhenRepeatedRequest + wantErr bool + }{ + {"valid", models.CreateSubjectContiguousWhenRepeatedRequest{SubjectID: uidSubject, IsHard: true, Weight: 100}, false}, + {"missing subject", models.CreateSubjectContiguousWhenRepeatedRequest{}, true}, + {"weight above 100", models.CreateSubjectContiguousWhenRepeatedRequest{SubjectID: uidSubject, Weight: 200}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (validate.Struct(tt.req) != nil) != tt.wantErr { + t.Errorf("unexpected validation outcome") + } + }) + } +} + +func TestCreateSubjectDoubleLessonRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.CreateSubjectDoubleLessonRequest + wantErr bool + }{ + {"valid", models.CreateSubjectDoubleLessonRequest{SubjectID: uidSubject, IsHard: false, Weight: 60}, false}, + {"missing subject", models.CreateSubjectDoubleLessonRequest{}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (validate.Struct(tt.req) != nil) != tt.wantErr { + t.Errorf("unexpected validation outcome") + } + }) + } +} + +func TestCreateClassNoGapsRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.CreateClassNoGapsRequest + wantErr bool + }{ + {"valid", models.CreateClassNoGapsRequest{ClassID: uidClass, IsHard: false, Weight: 80}, false}, + {"missing class", models.CreateClassNoGapsRequest{}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (validate.Struct(tt.req) != nil) != tt.wantErr { + t.Errorf("unexpected validation outcome") + } + }) + } +} + +func TestCreateRoomRequiresTypeRequest_Validation(t *testing.T) { + tests := []struct { + name string + req models.CreateRoomRequiresTypeRequest + wantErr bool + }{ + {"valid", models.CreateRoomRequiresTypeRequest{SubjectID: uidSubject, RoomType: "Sporthalle", IsHard: true, Weight: 100}, false}, + {"missing room type", models.CreateRoomRequiresTypeRequest{SubjectID: uidSubject}, true}, + {"non-uuid subject", models.CreateRoomRequiresTypeRequest{SubjectID: "x", RoomType: "y"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (validate.Struct(tt.req) != nil) != tt.wantErr { + t.Errorf("unexpected validation outcome") + } + }) + } +} diff --git a/studio-v2/app/stundenplan/_components/FaecherManager.tsx b/studio-v2/app/stundenplan/_components/FaecherManager.tsx new file mode 100644 index 0000000..7c26adc --- /dev/null +++ b/studio-v2/app/stundenplan/_components/FaecherManager.tsx @@ -0,0 +1,142 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useTheme } from '@/lib/ThemeContext' +import { subjectsApi } from '@/lib/stundenplan/api' +import type { TimetableSubject, CreateTimetableSubject } from '@/app/stundenplan/types' + +const initialForm: CreateTimetableSubject = { + name: '', + short_code: '', + color: '#6366f1', + is_main_subject: false, + required_room_type: '', +} + +export function FaecherManager() { + const { isDark } = useTheme() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showForm, setShowForm] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [form, setForm] = useState(initialForm) + + const load = useCallback(async () => { + setLoading(true); setError(null) + try { + const data = await subjectsApi.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 subjectsApi.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('Fach wirklich loeschen? Verbundene Stundentafel-Eintraege werden mitgeloescht.')) return + try { + await subjectsApi.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 placeholder-white/40' : 'bg-white border-slate-300 text-slate-900 placeholder-slate-400' + + return ( +
+
+

Faecher ({items.length})

+ +
+ + {error &&
{error}
} + + {showForm && ( +
+
+
+ + setForm({ ...form, name: e.target.value })} placeholder="z.B. Mathematik" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, short_code: e.target.value.toUpperCase() })} placeholder="z.B. M" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, color: e.target.value })} className="w-full h-10 rounded-lg border cursor-pointer" /> +
+
+ + setForm({ ...form, required_room_type: e.target.value })} placeholder="z.B. Sporthalle" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ setForm({ ...form, is_main_subject: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+
+
+ )} + + {loading ? ( +
Laedt…
+ ) : items.length === 0 ? ( +
Noch keine Faecher angelegt.
+ ) : ( +
+ + + + + + + + + + + + + {items.map(s => ( + + + + + + + + + ))} + +
FarbeNameKuerzelHauptfachRaumtyp
{s.name}{s.short_code}{s.is_main_subject ? 'Ja' : '—'}{s.required_room_type || '—'} + +
+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/_components/KlassenManager.tsx b/studio-v2/app/stundenplan/_components/KlassenManager.tsx index 06a5755..91944ad 100644 --- a/studio-v2/app/stundenplan/_components/KlassenManager.tsx +++ b/studio-v2/app/stundenplan/_components/KlassenManager.tsx @@ -68,7 +68,7 @@ export function KlassenManager() { : 'bg-white border-slate-300 text-slate-900 placeholder-slate-400' return ( -
+

Klassen ({classes.length}) diff --git a/studio-v2/app/stundenplan/_components/LehrerManager.tsx b/studio-v2/app/stundenplan/_components/LehrerManager.tsx new file mode 100644 index 0000000..9dbe06f --- /dev/null +++ b/studio-v2/app/stundenplan/_components/LehrerManager.tsx @@ -0,0 +1,154 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useTheme } from '@/lib/ThemeContext' +import { teachersApi } from '@/lib/stundenplan/api' +import type { TimetableTeacher, CreateTimetableTeacher } from '@/app/stundenplan/types' + +const initialForm: CreateTimetableTeacher = { + first_name: '', + last_name: '', + short_code: '', + employment_percentage: 100, + max_hours_week: 28, + notes: '', +} + +export function LehrerManager() { + const { isDark } = useTheme() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showForm, setShowForm] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [form, setForm] = useState(initialForm) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await teachersApi.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 teachersApi.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('Lehrer wirklich loeschen? Verbundene Constraints werden mitgeloescht.')) return + try { + await teachersApi.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 placeholder-white/40' : 'bg-white border-slate-300 text-slate-900 placeholder-slate-400' + + return ( +
+
+

+ Lehrer ({items.length}) +

+ +
+ + {error && ( +
{error}
+ )} + + {showForm && ( +
+
+
+ + setForm({ ...form, first_name: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, last_name: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, short_code: e.target.value.toUpperCase() })} placeholder="z.B. MUE" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, employment_percentage: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, max_hours_week: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ +
+
+
+ )} + + {loading ? ( +
Laedt…
+ ) : items.length === 0 ? ( +
Noch keine Lehrer angelegt.
+ ) : ( +
+ + + + + + + + + + + + {items.map(t => ( + + + + + + + + ))} + +
NameKuerzelStellenanteilMax. h/Woche
{t.last_name}, {t.first_name}{t.short_code}{t.employment_percentage}%{t.max_hours_week} + +
+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/_components/RaeumeManager.tsx b/studio-v2/app/stundenplan/_components/RaeumeManager.tsx new file mode 100644 index 0000000..38bf712 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/RaeumeManager.tsx @@ -0,0 +1,143 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useTheme } from '@/lib/ThemeContext' +import { roomsApi } from '@/lib/stundenplan/api' +import type { TimetableRoom, CreateTimetableRoom } from '@/app/stundenplan/types' + +const initialForm: CreateTimetableRoom = { + name: '', + room_type: '', + capacity: 30, + floor_level: 0, + has_elevator: true, + notes: '', +} + +export function RaeumeManager() { + const { isDark } = useTheme() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showForm, setShowForm] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [form, setForm] = useState(initialForm) + + const load = useCallback(async () => { + setLoading(true); setError(null) + try { + const data = await roomsApi.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 roomsApi.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('Raum wirklich loeschen?')) return + try { + await roomsApi.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 placeholder-white/40' : 'bg-white border-slate-300 text-slate-900 placeholder-slate-400' + + return ( +
+
+

Raeume ({items.length})

+ +
+ + {error &&
{error}
} + + {showForm && ( +
+
+
+ + setForm({ ...form, name: e.target.value })} placeholder="z.B. A101" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, room_type: e.target.value })} placeholder="z.B. Sporthalle, Chemie" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, capacity: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, floor_level: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ setForm({ ...form, has_elevator: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+
+
+ )} + + {loading ? ( +
Laedt…
+ ) : items.length === 0 ? ( +
Noch keine Raeume angelegt.
+ ) : ( +
+ + + + + + + + + + + + + {items.map(r => ( + + + + + + + + + ))} + +
NameTypKapazitaetStockwerkAufzug
{r.name}{r.room_type || '—'}{r.capacity}{r.floor_level}{r.has_elevator ? 'Ja' : 'Nein'} + +
+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableDayEditor.tsx b/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableDayEditor.tsx new file mode 100644 index 0000000..44c59a2 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableDayEditor.tsx @@ -0,0 +1,190 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useTheme } from '@/lib/ThemeContext' +import { teacherUnavailableDayApi, teachersApi } from '@/lib/stundenplan/api' +import type { TeacherUnavailableDay, 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 + +const initialForm: FormState = { + teacher_id: '', + day_of_week: 1, + is_hard: true, + weight: 100, + active: true, + note: '', +} + +export function TeacherUnavailableDayEditor() { + const { isDark } = useTheme() + const [items, setItems] = useState([]) + const [teachers, setTeachers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showForm, setShowForm] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [form, setForm] = useState(initialForm) + + const load = useCallback(async () => { + setLoading(true); setError(null) + try { + const [rules, t] = await Promise.all([teacherUnavailableDayApi.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 teacherUnavailableDayApi.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 teacherUnavailableDayApi.remove(id) + await load() + } catch (err) { + setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen') + } + } + + const teacherLabel = (id: string): string => { + const t = teachers.find(x => x.id === id) + return t ? `${t.last_name}, ${t.first_name} (${t.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 ( +
+
+
+

+ Lehrer: Tag nicht verfuegbar +

+

+ Beispiel: „Lehrer X kann Montags nie". +

+
+ +
+ + {teachers.length === 0 && !loading && ( +
+ Zuerst Lehrer anlegen, dann koennen Regeln vergeben werden. +
+ )} + + {error &&
{error}
} + + {showForm && ( +
+
+
+ + +
+
+ + +
+
+ + setForm({ ...form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ setForm({ ...form, is_hard: e.target.checked })} className="w-5 h-5" /> + +
+
+ setForm({ ...form, active: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+
+
+ + setForm({ ...form, note: e.target.value })} placeholder="z.B. Zweitjob in Praxis" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ )} + + {loading ? ( +
Laedt…
+ ) : items.length === 0 ? ( +
Keine Regeln vorhanden.
+ ) : ( +
+ + + + + + + + + + + + + + {items.map(c => ( + + + + + + + + + + ))} + +
LehrerTagHartWeightAktivNotiz
{teacherLabel(c.teacher_id)}{DAYS.find(d => d.v === c.day_of_week)?.label || c.day_of_week}{c.is_hard ? '✓' : '—'}{c.weight}{c.active ? '✓' : '—'}{c.note || '—'} + +
+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/page.tsx b/studio-v2/app/stundenplan/page.tsx index 09e9c68..e6a4622 100644 --- a/studio-v2/app/stundenplan/page.tsx +++ b/studio-v2/app/stundenplan/page.tsx @@ -6,6 +6,10 @@ import { Sidebar } from '@/components/Sidebar' import { ThemeToggle } from '@/components/ThemeToggle' import { LanguageDropdown } from '@/components/LanguageDropdown' import { KlassenManager } from './_components/KlassenManager' +import { LehrerManager } from './_components/LehrerManager' +import { FaecherManager } from './_components/FaecherManager' +import { RaeumeManager } from './_components/RaeumeManager' +import { TeacherUnavailableDayEditor } from './_components/regeln/TeacherUnavailableDayEditor' import { setStundenplanToken, getStundenplanToken } from '@/lib/stundenplan/api' type Tab = 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln' @@ -102,15 +106,20 @@ export default function StundenplanPage() {
{tab === 'klassen' && } - {tab !== 'klassen' && ( + {tab === 'lehrer' && } + {tab === 'faecher' && } + {tab === 'raeume' && } + {tab === 'regeln' && } + {(tab === 'periods' || tab === 'curriculum' || tab === 'assignments') && (

Noch nicht implementiert: {TABS.find(t => t.id === tab)?.label}

- Das Klassen-Modul ist der Prototyp. Die anderen Stammdaten und alle 15 Constraints folgen dem gleichen Muster. + Folgt dem gleichen Muster wie Klassen / Lehrer / Faecher / Raeume.

)} diff --git a/studio-v2/e2e/stundenplan.spec.ts b/studio-v2/e2e/stundenplan.spec.ts new file mode 100644 index 0000000..ec7e146 --- /dev/null +++ b/studio-v2/e2e/stundenplan.spec.ts @@ -0,0 +1,219 @@ +import { test, expect, Page } from '@playwright/test' + +/** + * E2E tests for /stundenplan + * + * Backend calls go through /api/school/* (Next.js proxy → school-service). + * For most tests we intercept those routes so the suite is hermetic and does + * not depend on a populated database or a valid JWT. + */ + +const MOCK_TEACHER_ID = '11111111-1111-1111-1111-111111111111' + +interface MockClass { + id: string + name: string + grade_level: number + student_count: number + notes?: string + created_by_user_id: string + created_at: string +} + +async function mockSchoolApi(page: Page, opts: { classes?: MockClass[]; teachers?: unknown[] } = {}) { + const classes = opts.classes ?? [] + const teachers = opts.teachers ?? [] + + await page.route('**/api/school/timetable/classes', async (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(classes) }) + } + if (route.request().method() === 'POST') { + const body = JSON.parse(route.request().postData() || '{}') + const created: MockClass = { + id: 'new-class-id', + name: body.name, + grade_level: body.grade_level, + student_count: body.student_count ?? 0, + notes: body.notes, + created_by_user_id: 'test-user', + created_at: new Date().toISOString(), + } + classes.push(created) + return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(created) }) + } + return route.fulfill({ status: 405 }) + }) + + await page.route('**/api/school/timetable/teachers', async (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(teachers) }) + } + 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.beforeEach(async ({ page }) => { + await mockSchoolApi(page) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + }) + + test('page loads with title and subtitle', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Stundenplan' })).toBeVisible() + await expect(page.getByText('Stammdaten und Regeln fuer den Solver')).toBeVisible() + }) + + test('shows all 8 tabs', async ({ page }) => { + for (const label of ['Klassen', 'Lehrer', 'Faecher', 'Raeume', 'Zeitraster', 'Stundentafel', 'Lehrauftraege', 'Regeln (Constraints)']) { + await expect(page.getByRole('button', { name: label })).toBeVisible() + } + }) + + test('Klassen tab is active by default', async ({ page }) => { + await expect(page.getByTestId('klassen-manager')).toBeVisible() + }) + + test('JWT dev field exists and persists into localStorage', async ({ page }) => { + await page.getByText('Dev: JWT-Token setzen').click() + page.on('dialog', d => d.accept()) + await page.getByPlaceholder('Bearer-Token').fill('test-jwt-abc') + await page.getByRole('button', { name: 'Speichern' }).click() + const stored = await page.evaluate(() => localStorage.getItem('bp_stundenplan_jwt')) + expect(stored).toBe('test-jwt-abc') + }) +}) + +test.describe('Stundenplan — Tab navigation', () => { + test.beforeEach(async ({ page }) => { + await mockSchoolApi(page) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + }) + + test('switching to Lehrer shows the Lehrer manager', async ({ page }) => { + await page.getByRole('button', { name: 'Lehrer', exact: true }).click() + await expect(page.getByTestId('lehrer-manager')).toBeVisible() + await expect(page.getByRole('heading', { name: /^Lehrer/ })).toBeVisible() + }) + + test('switching to Faecher shows the Faecher manager', async ({ page }) => { + await page.getByRole('button', { name: 'Faecher', exact: true }).click() + await expect(page.getByTestId('faecher-manager')).toBeVisible() + }) + + test('switching to Raeume shows the Raeume manager', async ({ page }) => { + await page.getByRole('button', { name: 'Raeume', exact: true }).click() + await expect(page.getByTestId('raeume-manager')).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() + }) +}) + +test.describe('Stundenplan — Klassen CRUD', () => { + test('empty state shows when no classes', async ({ page }) => { + await mockSchoolApi(page, { classes: [] }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await expect(page.getByText('Noch keine Klassen angelegt.')).toBeVisible() + }) + + test('renders classes returned by the backend', async ({ page }) => { + await mockSchoolApi(page, { + classes: [ + { id: 'c1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '2026-05-21T10:00:00Z' }, + { id: 'c2', name: '5b', grade_level: 5, student_count: 23, created_by_user_id: 'u', created_at: '2026-05-21T10:00:00Z' }, + ], + }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await expect(page.getByText('Klassen (2)')).toBeVisible() + await expect(page.getByRole('cell', { name: '5a' })).toBeVisible() + await expect(page.getByRole('cell', { name: '5b' })).toBeVisible() + }) + + test('+ Neue Klasse toggles the form', async ({ page }) => { + await mockSchoolApi(page) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + + await expect(page.getByPlaceholder('z.B. 5a')).toHaveCount(0) + await page.getByRole('button', { name: '+ Neue Klasse' }).click() + await expect(page.getByPlaceholder('z.B. 5a')).toBeVisible() + await page.getByRole('button', { name: 'Abbrechen' }).click() + await expect(page.getByPlaceholder('z.B. 5a')).toHaveCount(0) + }) + + test('form submission appends a new class to the list', async ({ page }) => { + await mockSchoolApi(page, { classes: [] }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + + await page.getByRole('button', { name: '+ Neue Klasse' }).click() + await page.getByPlaceholder('z.B. 5a').fill('7c') + await page.locator('input[type=number]').first().fill('7') + await page.getByRole('button', { name: 'Anlegen' }).click() + + await expect(page.getByText('Klassen (1)')).toBeVisible() + await expect(page.getByRole('cell', { name: '7c' })).toBeVisible() + }) +}) + +test.describe('Stundenplan — Constraint editor empty teacher state', () => { + test('shows warning when no teachers exist', async ({ page }) => { + await mockSchoolApi(page, { teachers: [] }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await page.getByRole('button', { name: 'Regeln (Constraints)' }).click() + await expect(page.getByText('Zuerst Lehrer anlegen')).toBeVisible() + }) + + test('enables form button when teachers exist', 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: '2026-05-21T10:00:00Z' }, + ], + }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await page.getByRole('button', { name: 'Regeln (Constraints)' }).click() + const btn = page.getByRole('button', { name: '+ Neue Regel' }) + await expect(btn).toBeEnabled() + await btn.click() + await expect(page.getByRole('combobox').first()).toBeVisible() + await expect(page.getByText('Schmidt, Anna')).toBeVisible() + }) +}) + +test.describe('Stundenplan — Sidebar entry', () => { + test('sidebar contains Stundenplan link', async ({ page }) => { + await mockSchoolApi(page) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + const sidebar = page.locator('aside').first() + await expect(sidebar.getByText(/Stundenplan|Timetable/).first()).toBeVisible() + }) +})