diff --git a/studio-v2/app/stundenplan/_components/AssignmentsManager.tsx b/studio-v2/app/stundenplan/_components/AssignmentsManager.tsx new file mode 100644 index 0000000..c22906d --- /dev/null +++ b/studio-v2/app/stundenplan/_components/AssignmentsManager.tsx @@ -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([]) + const [classes, setClasses] = useState([]) + const [subjects, setSubjects] = 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 [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 ( +
+
+
+

+ Lehrauftraege ({items.length}) +

+

+ Welcher Lehrer unterrichtet welches Fach in welcher Klasse. +

+
+ +
+ + {prereqMissing && !loading && ( +
+ Zuerst Klassen, Faecher und Lehrer anlegen. +
+ )} + + {error &&
{error}
} + + {showForm && ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ )} + + {loading ? ( +
Laedt…
+ ) : items.length === 0 ? ( +
Noch keine Lehrauftraege.
+ ) : ( +
+ + + + + + + + + + + {items.map(a => ( + + + + + + + ))} + +
LehrerKlasseFach
{a.teacher_name || a.teacher_id.slice(0, 8) + '…'}{a.class_name || a.class_id.slice(0, 8) + '…'}{a.subject_name || a.subject_id.slice(0, 8) + '…'} + +
+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/_components/CurriculumManager.tsx b/studio-v2/app/stundenplan/_components/CurriculumManager.tsx new file mode 100644 index 0000000..0e179cd --- /dev/null +++ b/studio-v2/app/stundenplan/_components/CurriculumManager.tsx @@ -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([]) + const [classes, setClasses] = useState([]) + const [subjects, setSubjects] = 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 [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 ( +
+
+
+

+ Stundentafel ({items.length}) +

+

+ Pro Klasse: wie viele Wochenstunden fuer jedes Fach. +

+
+ +
+ + {prereqMissing && !loading && ( +
+ Zuerst Klassen und Faecher anlegen. +
+ )} + + {error &&
{error}
} + + {showForm && ( +
+
+
+ + +
+
+ + +
+
+ + setForm({ ...form, weekly_hours: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ +
+
+
+ )} + + {loading ? ( +
Laedt…
+ ) : items.length === 0 ? ( +
Noch keine Eintraege.
+ ) : ( +
+ + + + + + + + + + + {items.map(c => ( + + + + + + + ))} + +
KlasseFachStunden/Woche
{c.class_name || className(c.class_id)}{c.subject_name || subjectName(c.subject_id)}{c.weekly_hours} + +
+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/_components/PeriodsManager.tsx b/studio-v2/app/stundenplan/_components/PeriodsManager.tsx new file mode 100644 index 0000000..15a5a2c --- /dev/null +++ b/studio-v2/app/stundenplan/_components/PeriodsManager.tsx @@ -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([]) + 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 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 ( +
+
+
+

+ Zeitraster ({items.length}) +

+

+ Pro Wochentag die Stunden-Slots (z.B. 1. Stunde 08:00–08:45). +

+
+ +
+ + {error &&
{error}
} + + {showForm && ( +
+
+
+ + +
+
+ + setForm({ ...form, period_index: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ setForm({ ...form, is_break: e.target.checked })} className="w-5 h-5" /> + +
+
+ + setForm({ ...form, start_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, end_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ +
+
+
+ )} + + {loading ? ( +
Laedt…
+ ) : items.length === 0 ? ( +
Noch kein Zeitraster definiert.
+ ) : ( +
+ + + + + {DAYS.map(d => )} + + + + {periodIndices.map(idx => ( + + + {DAYS.map(d => { + const p = periodByDay(d.v, idx) + if (!p) return + return ( + + ) + })} + + ))} + +
Stunde{d.label}
{idx}. +
+ {p.start_time}–{p.end_time} +
+ +
+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/_components/regeln/RegelnHub.tsx b/studio-v2/app/stundenplan/_components/regeln/RegelnHub.tsx new file mode 100644 index 0000000..2056f62 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/regeln/RegelnHub.tsx @@ -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('teacher-unavailable-day') + + return ( +
+ + +
+ {active === 'teacher-unavailable-day' && } + {active === 'teacher-unavailable-window' && } + {active === 'subject-max-consecutive' && } + {active === 'subject-preferred-period' && } +
+
+ ) +} diff --git a/studio-v2/app/stundenplan/_components/regeln/SubjectMaxConsecutiveEditor.tsx b/studio-v2/app/stundenplan/_components/regeln/SubjectMaxConsecutiveEditor.tsx new file mode 100644 index 0000000..a05aac7 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/regeln/SubjectMaxConsecutiveEditor.tsx @@ -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 + +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([]) + const [subjects, setSubjects] = 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, 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 ( +
+
+
+

+ Fach: Max. Stunden am Stueck +

+

+ Beispiel: „Mathe nicht mehr als 2 Stunden am Stueck" (keine Dreifach-Stunde). +

+
+ +
+ + {subjects.length === 0 && !loading && ( +
+ Zuerst Faecher anlegen. +
+ )} + + {error &&
{error}
} + + {showForm && ( +
+
+
+ + +
+
+ + setForm({ ...form, max_consecutive: parseInt(e.target.value) || 1 })} 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, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ +
+
+
+ )} + + {loading ? ( +
Laedt…
+ ) : items.length === 0 ? ( +
Keine Regeln vorhanden.
+ ) : ( +
+ + + + + + + + + + + + {items.map(c => ( + + + + + + + + ))} + +
FachMax. StundenHartWeight
{sLabel(c.subject_id)}{c.max_consecutive}{c.is_hard ? '✓' : '—'}{c.weight} + +
+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/_components/regeln/SubjectPreferredPeriodEditor.tsx b/studio-v2/app/stundenplan/_components/regeln/SubjectPreferredPeriodEditor.tsx new file mode 100644 index 0000000..3ba89e5 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/regeln/SubjectPreferredPeriodEditor.tsx @@ -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 + +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([]) + const [subjects, setSubjects] = 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, 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 ( +
+
+
+

+ Fach: Bevorzugter Stunden-Bereich +

+

+ Beispiel: „Hauptfaecher lieber in den ersten 4 Stunden" (Soft-Regel). +

+
+ +
+ + {subjects.length === 0 && !loading && ( +
+ Zuerst Faecher anlegen. +
+ )} + + {error &&
{error}
} + + {showForm && ( +
+
+
+ + +
+
+ + setForm({ ...form, period_from: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, period_to: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + 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" /> + +
+
+ +
+
+
+ )} + + {loading ? ( +
Laedt…
+ ) : items.length === 0 ? ( +
Keine Regeln vorhanden.
+ ) : ( +
+ + + + + + + + + + + + {items.map(c => ( + + + + + + + + ))} + +
FachBereichHartWeight
{sLabel(c.subject_id)}Stunde {c.period_from}–{c.period_to}{c.is_hard ? '✓' : '—'}{c.weight} + +
+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableWindowEditor.tsx b/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableWindowEditor.tsx new file mode 100644 index 0000000..31a38f2 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableWindowEditor.tsx @@ -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 + +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([]) + 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([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 ( +
+
+
+

+ Lehrer: Zeitfenster nicht verfuegbar +

+

+ Beispiel: „Lehrer Z Dienstags 13:00–17:00 nicht". +

+
+ +
+ + {teachers.length === 0 && !loading && ( +
+ Zuerst Lehrer anlegen. +
+ )} + + {error &&
{error}
} + + {showForm && ( +
+
+
+ + +
+
+ + +
+
+ + setForm({ ...form, start_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ + setForm({ ...form, end_time: e.target.value })} 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, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ +
+
+
+ + setForm({ ...form, note: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+
+ )} + + {loading ? ( +
Laedt…
+ ) : items.length === 0 ? ( +
Keine Regeln vorhanden.
+ ) : ( +
+ + + + + + + + + + + + + {items.map(c => ( + + + + + + + + + ))} + +
LehrerTagZeitfensterHartNotiz
{tLabel(c.teacher_id)}{DAYS.find(d => d.v === c.day_of_week)?.label || c.day_of_week}{c.start_time}–{c.end_time}{c.is_hard ? '✓' : '—'}{c.note || '—'} + +
+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/page.tsx b/studio-v2/app/stundenplan/page.tsx index e6a4622..44a0c4c 100644 --- a/studio-v2/app/stundenplan/page.tsx +++ b/studio-v2/app/stundenplan/page.tsx @@ -9,7 +9,10 @@ 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 { 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' type Tab = 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln' @@ -109,20 +112,10 @@ export default function StundenplanPage() { {tab === 'lehrer' && } {tab === 'faecher' && } {tab === 'raeume' && } - {tab === 'regeln' && } - {(tab === 'periods' || tab === 'curriculum' || tab === 'assignments') && ( -
-

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

-

- Folgt dem gleichen Muster wie Klassen / Lehrer / Faecher / Raeume. -

-
- )} + {tab === 'periods' && } + {tab === 'curriculum' && } + {tab === 'assignments' && } + {tab === 'regeln' && } diff --git a/studio-v2/e2e/stundenplan.spec.ts b/studio-v2/e2e/stundenplan.spec.ts index 65da016..b6cba6e 100644 --- a/studio-v2/e2e/stundenplan.spec.ts +++ b/studio-v2/e2e/stundenplan.spec.ts @@ -9,6 +9,8 @@ import { test, expect, Page } from '@playwright/test' */ 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 { id: string @@ -20,9 +22,24 @@ interface MockClass { 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 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) => { if (route.request().method() === 'GET') { @@ -45,24 +62,27 @@ async function mockSchoolApi(page: Page, opts: { classes?: MockClass[]; teachers 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 }) - }) + // Helper to mount a read-only endpoint with a static list. + const staticList = (path: string, data: unknown) => + page.route(`**/api/school/timetable/${path}`, async (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) })) - await page.route('**/api/school/timetable/subjects', async (route) => { - return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) - }) + await staticList('teachers', teachers) + await staticList('subjects', subjects) + await staticList('rooms', rooms) + await staticList('periods', periods) + await staticList('curriculum', curriculum) + await staticList('assignments', assignments) - 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: '[]' }) - }) + // 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, []) + } } test.describe('Stundenplan — Page Shell', () => { @@ -78,7 +98,7 @@ test.describe('Stundenplan — Page Shell', () => { }) test('shows all 8 tabs', async ({ page }) => { - // Sidebar entries collide with tab labels for 'Lehrer' — scope to