From 7c96d89927370412e62a1c8d03f96220b53a6aa4 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 21 May 2026 23:27:34 +0200 Subject: [PATCH] Stundenplan Phase 3d: all 15 constraint editors via shared shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend was already complete in Phase 2; this finishes the UI. - regeln/_shell.tsx introduces useConstraintCrud (handles list/create/ delete state + reload), ConstraintShell (header, prereq banner, form toggle, error display, empty/loading/table render), and useShellStyles for the recurring theme tokens. Each editor now only carries its schema-specific bits. - Existing 4 editors (TeacherUnavailableDay/Window, SubjectMax Consecutive/PreferredPeriod) refactored onto the shell — every Playwright selector preserved. - 11 new editors covering the remaining constraint tables: TeacherMaxHours{Day,Week}, TeacherExcluded{Subject,Room}, Subject{MinDayGap,ContiguousWhenRepeated,DoubleLesson}, Class{MaxHoursDay,NoGaps}, Room{RequiresType,Unavailable}. - RegelnHub now references all 15 editors directly — no more 'soon' placeholders. The two duplicate 'Max. Stunden / Tag' entries (teacher + class) are intentional and disambiguated by group. Tests: - e2e/stundenplan.spec.ts: mock routes added for all 11 new constraint endpoints. RegelnHub suite gains a single test that switches through 13 uniquely-labelled editors, plus a dedicated test for the two duplicate 'Max. Stunden / Tag' labels. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_components/regeln/ClassEditors.tsx | 140 +++++++++++ .../_components/regeln/RegelnHub.tsx | 88 ++++--- .../_components/regeln/RoomEditors.tsx | 154 +++++++++++++ .../regeln/SubjectMaxConsecutiveEditor.tsx | 193 +++++----------- .../regeln/SubjectPreferredPeriodEditor.tsx | 208 +++++------------ .../regeln/SubjectSimpleEditors.tsx | 200 ++++++++++++++++ .../regeln/TeacherExclusionEditors.tsx | 164 +++++++++++++ .../regeln/TeacherMaxHoursEditors.tsx | 147 ++++++++++++ .../regeln/TeacherUnavailableDayEditor.tsx | 178 ++++---------- .../regeln/TeacherUnavailableWindowEditor.tsx | 178 ++++---------- .../stundenplan/_components/regeln/_shell.tsx | 218 ++++++++++++++++++ studio-v2/e2e/stundenplan.spec.ts | 46 +++- 12 files changed, 1330 insertions(+), 584 deletions(-) create mode 100644 studio-v2/app/stundenplan/_components/regeln/ClassEditors.tsx create mode 100644 studio-v2/app/stundenplan/_components/regeln/RoomEditors.tsx create mode 100644 studio-v2/app/stundenplan/_components/regeln/SubjectSimpleEditors.tsx create mode 100644 studio-v2/app/stundenplan/_components/regeln/TeacherExclusionEditors.tsx create mode 100644 studio-v2/app/stundenplan/_components/regeln/TeacherMaxHoursEditors.tsx create mode 100644 studio-v2/app/stundenplan/_components/regeln/_shell.tsx diff --git a/studio-v2/app/stundenplan/_components/regeln/ClassEditors.tsx b/studio-v2/app/stundenplan/_components/regeln/ClassEditors.tsx new file mode 100644 index 0000000..d62b41c --- /dev/null +++ b/studio-v2/app/stundenplan/_components/regeln/ClassEditors.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useState, useEffect } from 'react' +import { classMaxHoursDayApi, classNoGapsApi, classesApi } from '@/lib/stundenplan/api' +import type { + ClassMaxHoursDay, ClassNoGaps, TimetableClass, +} from '@/app/stundenplan/types' +import { useConstraintCrud, ConstraintShell, useShellStyles } from './_shell' + +function useClasses() { + const [classes, setClasses] = useState([]) + useEffect(() => { classesApi.list().then(setClasses).catch(() => setClasses([])) }, []) + return classes +} + +function cLabel(classes: TimetableClass[], id: string): string { + const c = classes.find(x => x.id === id) + return c ? c.name : id.slice(0, 8) + '…' +} + +// ---------- Max Hours / Day ---------- + +type DayForm = Omit +const initialDay: DayForm = { class_id: '', max_hours: 6, is_hard: true, weight: 100, active: true, note: '' } + +export function ClassMaxHoursDayEditor() { + const styles = useShellStyles() + const classes = useClasses() + const crud = useConstraintCrud(classMaxHoursDayApi, initialDay) + + return ( + +
+ + +
+
+ + crud.setForm({ ...crud.form, max_hours: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ + crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+ + } + renderRow={(item) => { + const c = item as ClassMaxHoursDay + return ( + + {cLabel(classes, c.class_id)} + {c.max_hours} + {c.is_hard ? '✓' : '—'} + {c.weight} + + + ) + }} + /> + ) +} + +// ---------- No Gaps ---------- + +type GapForm = Omit +const initialGap: GapForm = { class_id: '', is_hard: false, weight: 80, active: true, note: '' } + +export function ClassNoGapsEditor() { + const styles = useShellStyles() + const classes = useClasses() + const crud = useConstraintCrud(classNoGapsApi, initialGap) + + return ( + +
+ + +
+
+ + crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+ + } + renderRow={(item) => { + const c = item as ClassNoGaps + return ( + + {cLabel(classes, c.class_id)} + {c.is_hard ? '✓' : '—'} + {c.weight} + + + ) + }} + /> + ) +} diff --git a/studio-v2/app/stundenplan/_components/regeln/RegelnHub.tsx b/studio-v2/app/stundenplan/_components/regeln/RegelnHub.tsx index 2056f62..d71ba88 100644 --- a/studio-v2/app/stundenplan/_components/regeln/RegelnHub.tsx +++ b/studio-v2/app/stundenplan/_components/regeln/RegelnHub.tsx @@ -4,61 +4,91 @@ import { useState } from 'react' import { useTheme } from '@/lib/ThemeContext' import { TeacherUnavailableDayEditor } from './TeacherUnavailableDayEditor' import { TeacherUnavailableWindowEditor } from './TeacherUnavailableWindowEditor' +import { TeacherMaxHoursDayEditor, TeacherMaxHoursWeekEditor } from './TeacherMaxHoursEditors' +import { TeacherExcludedSubjectEditor, TeacherExcludedRoomEditor } from './TeacherExclusionEditors' import { SubjectMaxConsecutiveEditor } from './SubjectMaxConsecutiveEditor' import { SubjectPreferredPeriodEditor } from './SubjectPreferredPeriodEditor' +import { + SubjectMinDayGapEditor, SubjectContiguousWhenRepeatedEditor, SubjectDoubleLessonEditor, +} from './SubjectSimpleEditors' +import { ClassMaxHoursDayEditor, ClassNoGapsEditor } from './ClassEditors' +import { RoomRequiresTypeEditor, RoomUnavailableEditor } from './RoomEditors' type RuleType = - | 'teacher-unavailable-day' - | 'teacher-unavailable-window' - | 'subject-max-consecutive' - | 'subject-preferred-period' + | 'teacher-unavailable-day' | 'teacher-unavailable-window' + | 'teacher-max-hours-day' | 'teacher-max-hours-week' + | 'teacher-excluded-subject' | 'teacher-excluded-room' + | 'subject-min-day-gap' | 'subject-max-consecutive' + | 'subject-contiguous-when-repeated' | 'subject-preferred-period' + | 'subject-double-lesson' + | 'class-max-hours-day' | 'class-no-gaps' + | 'room-requires-type' | 'room-unavailable' interface RuleGroup { group: string - rules: { id: RuleType | string; label: string; implemented: boolean }[] + rules: { id: RuleType; label: string }[] } 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 }, + { id: 'teacher-unavailable-day', label: 'Tag nicht verfuegbar' }, + { id: 'teacher-unavailable-window', label: 'Zeitfenster nicht verfuegbar' }, + { id: 'teacher-max-hours-day', label: 'Max. Stunden / Tag' }, + { id: 'teacher-max-hours-week', label: 'Max. Stunden / Woche' }, + { id: 'teacher-excluded-subject', label: 'Fach ausgeschlossen' }, + { id: 'teacher-excluded-room', label: 'Raum ausgeschlossen' }, ], }, { 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 }, + { id: 'subject-max-consecutive', label: 'Max. Stunden am Stueck' }, + { id: 'subject-preferred-period', label: 'Bevorzugter Stunden-Bereich' }, + { id: 'subject-min-day-gap', label: 'Min. Tagesabstand' }, + { id: 'subject-contiguous-when-repeated', label: 'Bei Mehrfach: zusammenhaengend' }, + { id: 'subject-double-lesson', label: 'Doppelstunde bevorzugt' }, ], }, { group: 'Klasse', rules: [ - { id: 'class-max-hours-day', label: 'Max. Stunden / Tag', implemented: false }, - { id: 'class-no-gaps', label: 'Keine Freistunden', implemented: false }, + { id: 'class-max-hours-day', label: 'Max. Stunden / Tag' }, + { id: 'class-no-gaps', label: 'Keine Freistunden' }, ], }, { group: 'Raum', rules: [ - { id: 'room-requires-type', label: 'Fach benoetigt Raumtyp', implemented: false }, - { id: 'room-unavailable', label: 'Raum nicht verfuegbar', implemented: false }, + { id: 'room-requires-type', label: 'Fach benoetigt Raumtyp' }, + { id: 'room-unavailable', label: 'Raum nicht verfuegbar' }, ], }, ] +const EDITORS: Record = { + 'teacher-unavailable-day': TeacherUnavailableDayEditor, + 'teacher-unavailable-window': TeacherUnavailableWindowEditor, + 'teacher-max-hours-day': TeacherMaxHoursDayEditor, + 'teacher-max-hours-week': TeacherMaxHoursWeekEditor, + 'teacher-excluded-subject': TeacherExcludedSubjectEditor, + 'teacher-excluded-room': TeacherExcludedRoomEditor, + 'subject-min-day-gap': SubjectMinDayGapEditor, + 'subject-max-consecutive': SubjectMaxConsecutiveEditor, + 'subject-contiguous-when-repeated': SubjectContiguousWhenRepeatedEditor, + 'subject-preferred-period': SubjectPreferredPeriodEditor, + 'subject-double-lesson': SubjectDoubleLessonEditor, + 'class-max-hours-day': ClassMaxHoursDayEditor, + 'class-no-gaps': ClassNoGapsEditor, + 'room-requires-type': RoomRequiresTypeEditor, + 'room-unavailable': RoomUnavailableEditor, +} + export function RegelnHub() { const { isDark } = useTheme() const [active, setActive] = useState('teacher-unavailable-day') + const Editor = EDITORS[active] return (
@@ -69,24 +99,17 @@ export function RegelnHub() {
{g.rules.map(r => { const isActive = active === r.id - const isDone = r.implemented return ( ) })} @@ -96,10 +119,7 @@ export function RegelnHub() {
- {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/RoomEditors.tsx b/studio-v2/app/stundenplan/_components/regeln/RoomEditors.tsx new file mode 100644 index 0000000..f2161c0 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/regeln/RoomEditors.tsx @@ -0,0 +1,154 @@ +'use client' + +import { useState, useEffect } from 'react' +import { roomRequiresTypeApi, roomUnavailableApi, subjectsApi, roomsApi } from '@/lib/stundenplan/api' +import type { + RoomRequiresType, RoomUnavailable, + TimetableSubject, TimetableRoom, +} from '@/app/stundenplan/types' +import { useConstraintCrud, ConstraintShell, useShellStyles, DAYS, dayLabel } from './_shell' + +// ---------- Room Requires Type (Subject → required room_type) ---------- + +type ReqForm = Omit +const initialReq: ReqForm = { subject_id: '', room_type: '', is_hard: true, weight: 100, active: true, note: '' } + +export function RoomRequiresTypeEditor() { + const styles = useShellStyles() + const [subjects, setSubjects] = useState([]) + const crud = useConstraintCrud(roomRequiresTypeApi, initialReq) + + useEffect(() => { subjectsApi.list().then(setSubjects).catch(() => setSubjects([])) }, []) + + const sLabel = (id: string): string => { + const s = subjects.find(x => x.id === id); return s ? s.name : id.slice(0, 8) + '…' + } + + return ( + +
+ + +
+
+ + crud.setForm({ ...crud.form, room_type: e.target.value })} placeholder="z.B. Sporthalle" className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ + crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+
+ } + renderRow={(item) => { + const c = item as RoomRequiresType + return ( + + {sLabel(c.subject_id)} + {c.room_type} + {c.is_hard ? '✓' : '—'} + {c.weight} + + + ) + }} + /> + ) +} + +// ---------- Room Unavailable ---------- + +type UnavForm = Omit +const initialUnav: UnavForm = { room_id: '', day_of_week: 1, period_index: 1, is_hard: true, weight: 100, active: true, note: '' } + +export function RoomUnavailableEditor() { + const styles = useShellStyles() + const [rooms, setRooms] = useState([]) + const crud = useConstraintCrud(roomUnavailableApi, initialUnav) + + useEffect(() => { roomsApi.list().then(setRooms).catch(() => setRooms([])) }, []) + + const rLabel = (id: string): string => { + const r = rooms.find(x => x.id === id); return r ? r.name : id.slice(0, 8) + '…' + } + + return ( + +
+ + +
+
+ + +
+
+ + crud.setForm({ ...crud.form, period_index: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ + crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+ + } + renderRow={(item) => { + const c = item as RoomUnavailable + return ( + + {rLabel(c.room_id)} + {dayLabel(c.day_of_week)} + {c.period_index}. + {c.is_hard ? '✓' : '—'} + {c.weight} + + + ) + }} + /> + ) +} diff --git a/studio-v2/app/stundenplan/_components/regeln/SubjectMaxConsecutiveEditor.tsx b/studio-v2/app/stundenplan/_components/regeln/SubjectMaxConsecutiveEditor.tsx index a05aac7..a9d0fcc 100644 --- a/studio-v2/app/stundenplan/_components/regeln/SubjectMaxConsecutiveEditor.tsx +++ b/studio-v2/app/stundenplan/_components/regeln/SubjectMaxConsecutiveEditor.tsx @@ -1,160 +1,81 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' -import { useTheme } from '@/lib/ThemeContext' +import { useState, useEffect } from 'react' import { subjectMaxConsecutiveApi, subjectsApi } from '@/lib/stundenplan/api' import type { SubjectMaxConsecutive, TimetableSubject } from '@/app/stundenplan/types' +import { useConstraintCrud, ConstraintShell, useShellStyles } from './_shell' type FormState = Omit const initialForm: FormState = { - subject_id: '', - max_consecutive: 2, - is_hard: true, - weight: 100, - active: true, - note: '', + subject_id: '', max_consecutive: 2, is_hard: true, weight: 100, active: true, note: '', } export function SubjectMaxConsecutiveEditor() { - const { isDark } = useTheme() - const [items, setItems] = useState([]) + const styles = useShellStyles() + const crud = useConstraintCrud(subjectMaxConsecutiveApi, initialForm) 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') } - } + useEffect(() => { subjectsApi.list().then(setSubjects).catch(() => setSubjects([])) }, []) 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}`} /> -
-
- -
+ +
+ + +
+
+ + crud.setForm({ ...crud.form, max_consecutive: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" /> + +
+
+ + crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.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} - -
- )} -
+ } + renderRow={(item) => { + const c = item as SubjectMaxConsecutive + return ( + + {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 index 3ba89e5..9ac6f57 100644 --- a/studio-v2/app/stundenplan/_components/regeln/SubjectPreferredPeriodEditor.tsx +++ b/studio-v2/app/stundenplan/_components/regeln/SubjectPreferredPeriodEditor.tsx @@ -1,169 +1,87 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' -import { useTheme } from '@/lib/ThemeContext' +import { useState, useEffect } from 'react' import { subjectPreferredPeriodApi, subjectsApi } from '@/lib/stundenplan/api' import type { SubjectPreferredPeriod, TimetableSubject } from '@/app/stundenplan/types' +import { useConstraintCrud, ConstraintShell, useShellStyles } from './_shell' type FormState = Omit const initialForm: FormState = { - subject_id: '', - period_from: 1, - period_to: 4, - is_hard: false, - weight: 40, - active: true, - note: '', + 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 styles = useShellStyles() + const crud = useConstraintCrud(subjectPreferredPeriodApi, initialForm, { + onBeforeSubmit: (f) => f.period_to < f.period_from ? '"Bis"-Stunde darf nicht kleiner sein als "Von"-Stunde.' : null, + }) 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') } - } + useEffect(() => { subjectsApi.list().then(setSubjects).catch(() => setSubjects([])) }, []) 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" /> - -
-
- -
+ +
+ + +
+
+ + crud.setForm({ ...crud.form, period_from: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ + crud.setForm({ ...crud.form, period_to: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ + crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ crud.setForm({ ...crud.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} - -
- )} -
+ } + renderRow={(item) => { + const c = item as SubjectPreferredPeriod + return ( + + {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/SubjectSimpleEditors.tsx b/studio-v2/app/stundenplan/_components/regeln/SubjectSimpleEditors.tsx new file mode 100644 index 0000000..7557991 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/regeln/SubjectSimpleEditors.tsx @@ -0,0 +1,200 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + subjectMinDayGapApi, subjectContiguousWhenRepeatedApi, subjectDoubleLessonApi, subjectsApi, +} from '@/lib/stundenplan/api' +import type { + SubjectMinDayGap, SubjectContiguousWhenRepeated, SubjectDoubleLesson, TimetableSubject, +} from '@/app/stundenplan/types' +import { useConstraintCrud, ConstraintShell, useShellStyles } from './_shell' + +function useSubjects() { + const [subjects, setSubjects] = useState([]) + useEffect(() => { subjectsApi.list().then(setSubjects).catch(() => setSubjects([])) }, []) + return subjects +} + +function sLabel(subjects: TimetableSubject[], id: string): string { + const s = subjects.find(x => x.id === id) + return s ? `${s.name} (${s.short_code})` : id.slice(0, 8) + '…' +} + +// ---------- Min Day Gap ---------- + +type GapForm = Omit +const initialGap: GapForm = { subject_id: '', min_gap_days: 1, is_hard: false, weight: 70, active: true, note: '' } + +export function SubjectMinDayGapEditor() { + const styles = useShellStyles() + const subjects = useSubjects() + const crud = useConstraintCrud(subjectMinDayGapApi, initialGap) + + return ( + +
+ + +
+
+ + crud.setForm({ ...crud.form, min_gap_days: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ + crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+
+ } + renderRow={(item) => { + const c = item as SubjectMinDayGap + return ( + + {sLabel(subjects, c.subject_id)} + {c.min_gap_days} + {c.is_hard ? '✓' : '—'} + {c.weight} + + + ) + }} + /> + ) +} + +// ---------- Contiguous When Repeated ---------- + +type ContForm = Omit +const initialCont: ContForm = { subject_id: '', is_hard: true, weight: 100, active: true, note: '' } + +export function SubjectContiguousWhenRepeatedEditor() { + const styles = useShellStyles() + const subjects = useSubjects() + const crud = useConstraintCrud(subjectContiguousWhenRepeatedApi, initialCont) + + return ( + +
+ + +
+
+ + crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+
+ } + renderRow={(item) => { + const c = item as SubjectContiguousWhenRepeated + return ( + + {sLabel(subjects, c.subject_id)} + {c.is_hard ? '✓' : '—'} + {c.weight} + + + ) + }} + /> + ) +} + +// ---------- Double Lesson ---------- + +type DoubleForm = Omit +const initialDouble: DoubleForm = { subject_id: '', is_hard: false, weight: 60, active: true, note: '' } + +export function SubjectDoubleLessonEditor() { + const styles = useShellStyles() + const subjects = useSubjects() + const crud = useConstraintCrud(subjectDoubleLessonApi, initialDouble) + + return ( + +
+ + +
+
+ + crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+ + } + renderRow={(item) => { + const c = item as SubjectDoubleLesson + return ( + + {sLabel(subjects, c.subject_id)} + {c.is_hard ? '✓' : '—'} + {c.weight} + + + ) + }} + /> + ) +} diff --git a/studio-v2/app/stundenplan/_components/regeln/TeacherExclusionEditors.tsx b/studio-v2/app/stundenplan/_components/regeln/TeacherExclusionEditors.tsx new file mode 100644 index 0000000..d3eb551 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/regeln/TeacherExclusionEditors.tsx @@ -0,0 +1,164 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + teacherExcludedSubjectApi, teacherExcludedRoomApi, + teachersApi, subjectsApi, roomsApi, +} from '@/lib/stundenplan/api' +import type { + TeacherExcludedSubject, TeacherExcludedRoom, + TimetableTeacher, TimetableSubject, TimetableRoom, +} from '@/app/stundenplan/types' +import { useConstraintCrud, ConstraintShell, useShellStyles } from './_shell' + +// ---------- Excluded Subject ---------- + +type SubForm = Omit +const initialSub: SubForm = { teacher_id: '', subject_id: '', is_hard: true, weight: 100, active: true, note: '' } + +export function TeacherExcludedSubjectEditor() { + const styles = useShellStyles() + const [teachers, setTeachers] = useState([]) + const [subjects, setSubjects] = useState([]) + const crud = useConstraintCrud(teacherExcludedSubjectApi, initialSub) + + useEffect(() => { + teachersApi.list().then(setTeachers).catch(() => setTeachers([])) + subjectsApi.list().then(setSubjects).catch(() => setSubjects([])) + }, []) + + 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 sLabel = (id: string): string => { + const s = subjects.find(x => x.id === id); return s ? s.name : id.slice(0, 8) + '…' + } + const missing = teachers.length === 0 || subjects.length === 0 + + return ( + +
+ + +
+
+ + +
+
+ crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+ + } + renderRow={(item) => { + const c = item as TeacherExcludedSubject + return ( + + {tLabel(c.teacher_id)} + {sLabel(c.subject_id)} + {c.is_hard ? '✓' : '—'} + {c.weight} + + + ) + }} + /> + ) +} + +// ---------- Excluded Room ---------- + +type RoomForm = Omit +const initialRoom: RoomForm = { teacher_id: '', room_id: '', is_hard: true, weight: 100, active: true, note: '' } + +export function TeacherExcludedRoomEditor() { + const styles = useShellStyles() + const [teachers, setTeachers] = useState([]) + const [rooms, setRooms] = useState([]) + const crud = useConstraintCrud(teacherExcludedRoomApi, initialRoom) + + useEffect(() => { + teachersApi.list().then(setTeachers).catch(() => setTeachers([])) + roomsApi.list().then(setRooms).catch(() => setRooms([])) + }, []) + + 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 rLabel = (id: string): string => { + const r = rooms.find(x => x.id === id); return r ? r.name : id.slice(0, 8) + '…' + } + const missing = teachers.length === 0 || rooms.length === 0 + + return ( + +
+ + +
+
+ + +
+
+ crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+ + } + renderRow={(item) => { + const c = item as TeacherExcludedRoom + return ( + + {tLabel(c.teacher_id)} + {rLabel(c.room_id)} + {c.is_hard ? '✓' : '—'} + {c.weight} + + + ) + }} + /> + ) +} diff --git a/studio-v2/app/stundenplan/_components/regeln/TeacherMaxHoursEditors.tsx b/studio-v2/app/stundenplan/_components/regeln/TeacherMaxHoursEditors.tsx new file mode 100644 index 0000000..8e5bf00 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/regeln/TeacherMaxHoursEditors.tsx @@ -0,0 +1,147 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + teacherMaxHoursDayApi, teacherMaxHoursWeekApi, teachersApi, +} from '@/lib/stundenplan/api' +import type { + TeacherMaxHoursDay, TeacherMaxHoursWeek, TimetableTeacher, +} from '@/app/stundenplan/types' +import { useConstraintCrud, ConstraintShell, useShellStyles } from './_shell' + +function useTeachers() { + const [teachers, setTeachers] = useState([]) + useEffect(() => { teachersApi.list().then(setTeachers).catch(() => setTeachers([])) }, []) + return teachers +} + +function tLabel(teachers: TimetableTeacher[], id: string): string { + const t = teachers.find(x => x.id === id) + return t ? `${t.last_name}, ${t.first_name}` : id.slice(0, 8) + '…' +} + +// ---------- Max Hours / Day ---------- + +type DayForm = Omit +const initialDay: DayForm = { teacher_id: '', max_hours: 6, is_hard: false, weight: 50, active: true, note: '' } + +export function TeacherMaxHoursDayEditor() { + const styles = useShellStyles() + const teachers = useTeachers() + const crud = useConstraintCrud(teacherMaxHoursDayApi, initialDay) + + return ( + +
+ + +
+
+ + crud.setForm({ ...crud.form, max_hours: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ + crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+ + } + renderRow={(item) => { + const c = item as TeacherMaxHoursDay + return ( + + {tLabel(teachers, c.teacher_id)} + {c.max_hours} + {c.is_hard ? '✓' : '—'} + {c.weight} + + + ) + }} + /> + ) +} + +// ---------- Max Hours / Week ---------- + +type WeekForm = Omit +const initialWeek: WeekForm = { teacher_id: '', max_hours: 28, is_hard: true, weight: 100, active: true, note: '' } + +export function TeacherMaxHoursWeekEditor() { + const styles = useShellStyles() + const teachers = useTeachers() + const crud = useConstraintCrud(teacherMaxHoursWeekApi, initialWeek) + + return ( + +
+ + +
+
+ + crud.setForm({ ...crud.form, max_hours: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ + crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} /> +
+
+ crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" /> + +
+
+ +
+ + } + renderRow={(item) => { + const c = item as TeacherMaxHoursWeek + return ( + + {tLabel(teachers, c.teacher_id)} + {c.max_hours} + {c.is_hard ? '✓' : '—'} + {c.weight} + + + ) + }} + /> + ) +} diff --git a/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableDayEditor.tsx b/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableDayEditor.tsx index 44c59a2..fe831b0 100644 --- a/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableDayEditor.tsx +++ b/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableDayEditor.tsx @@ -1,19 +1,9 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' -import { useTheme } from '@/lib/ThemeContext' +import { useState, useEffect } from 'react' 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' }, -] +import { useConstraintCrud, ConstraintShell, useShellStyles, DAYS, dayLabel } from './_shell' type FormState = Omit @@ -27,164 +17,84 @@ const initialForm: FormState = { } export function TeacherUnavailableDayEditor() { - const { isDark } = useTheme() - const [items, setItems] = useState([]) + const styles = useShellStyles() + const crud = useConstraintCrud(teacherUnavailableDayApi, initialForm) 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') - } - } + useEffect(() => { teachersApi.list().then(setTeachers).catch(() => setTeachers([])) }, []) 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 && ( -
+
- crud.setForm({ ...crud.form, teacher_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}> {teachers.map(t => )}
- crud.setForm({ ...crud.form, day_of_week: parseInt(e.target.value) })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}> {DAYS.map(d => )}
- setForm({ ...form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> + crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
- setForm({ ...form, is_hard: e.target.checked })} className="w-5 h-5" /> + crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
- setForm({ ...form, active: e.target.checked })} className="w-5 h-5" /> + crud.setForm({ ...crud.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}`} /> + crud.setForm({ ...crud.form, note: e.target.value })} placeholder="z.B. Zweitjob in Praxis" className={`w-full px-3 py-2 rounded-lg border ${styles.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 || '—'} - -
-
- )} -
+ + } + renderRow={(item) => { + const c = item as TeacherUnavailableDay + return ( + + {teacherLabel(c.teacher_id)} + {dayLabel(c.day_of_week)} + {c.is_hard ? '✓' : '—'} + {c.weight} + {c.active ? '✓' : '—'} + {c.note || '—'} + + + + + ) + }} + /> ) } diff --git a/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableWindowEditor.tsx b/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableWindowEditor.tsx index 31a38f2..c77dd98 100644 --- a/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableWindowEditor.tsx +++ b/studio-v2/app/stundenplan/_components/regeln/TeacherUnavailableWindowEditor.tsx @@ -1,183 +1,99 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' -import { useTheme } from '@/lib/ThemeContext' +import { useState, useEffect } from 'react' 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' }, -] +import { useConstraintCrud, ConstraintShell, useShellStyles, DAYS, dayLabel } from './_shell' 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: '', + 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 styles = useShellStyles() + const crud = useConstraintCrud(teacherUnavailableWindowApi, initialForm) 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(() => { teachersApi.list().then(setTeachers).catch(() => setTeachers([])) }, []) - 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 teacherLabel = (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 && ( -
+
- crud.setForm({ ...crud.form, teacher_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}> {teachers.map(t => )}
- crud.setForm({ ...crud.form, day_of_week: parseInt(e.target.value) })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}> {DAYS.map(d => )}
- setForm({ ...form, start_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> + crud.setForm({ ...crud.form, start_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
- setForm({ ...form, end_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> + crud.setForm({ ...crud.form, end_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
- setForm({ ...form, is_hard: e.target.checked })} className="w-5 h-5" /> + crud.setForm({ ...crud.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}`} /> + crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
-
- setForm({ ...form, note: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> + crud.setForm({ ...crud.form, note: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.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 || '—'} - -
-
- )} -
+ + } + renderRow={(item) => { + const c = item as TeacherUnavailableWindow + return ( + + {teacherLabel(c.teacher_id)} + {dayLabel(c.day_of_week)} + {c.start_time}–{c.end_time} + {c.is_hard ? '✓' : '—'} + {c.note || '—'} + + + + + ) + }} + /> ) } diff --git a/studio-v2/app/stundenplan/_components/regeln/_shell.tsx b/studio-v2/app/stundenplan/_components/regeln/_shell.tsx new file mode 100644 index 0000000..8e10085 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/regeln/_shell.tsx @@ -0,0 +1,218 @@ +'use client' + +import { useState, useEffect, useCallback, ReactNode } from 'react' +import { useTheme } from '@/lib/ThemeContext' + +/** + * Shared scaffolding for all 15 constraint editors. + * + * Each editor follows the same shape: list-load on mount, optional form to + * create a new entry, a table to render existing entries, delete with a + * confirm. Extracted here so individual editors only carry their schema- + * specific bits (form fields, table columns). + */ + +export interface ConstraintApi { + list(): Promise + create(form: TForm): Promise + remove(id: string): Promise +} + +export interface CrudState { + items: TItem[] + loading: boolean + error: string | null + showForm: boolean + submitting: boolean + form: TForm + setForm: (f: TForm | ((prev: TForm) => TForm)) => void + toggleForm: () => void + reload: () => Promise + submit: (e?: React.FormEvent) => Promise + remove: (id: string, confirmMsg?: string) => Promise + setError: (msg: string | null) => void +} + +export function useConstraintCrud( + api: ConstraintApi, + initialForm: TForm, + opts: { onBeforeSubmit?: (form: TForm) => string | null } = {}, +): CrudState { + 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 reload = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await api.list() + setItems(data || []) + } catch (e) { + setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen') + } finally { + setLoading(false) + } + }, [api]) + + useEffect(() => { reload() }, [reload]) + + const submit = useCallback(async (e?: React.FormEvent) => { + if (e) e.preventDefault() + if (opts.onBeforeSubmit) { + const err = opts.onBeforeSubmit(form) + if (err) { + setError(err) + return + } + } + setSubmitting(true) + setError(null) + try { + await api.create(form) + setForm(initialForm) + setShowForm(false) + await reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen') + } finally { + setSubmitting(false) + } + }, [api, form, initialForm, opts, reload]) + + const remove = useCallback(async (id: string, confirmMsg = 'Regel wirklich loeschen?') => { + if (!confirm(confirmMsg)) return + try { + await api.remove(id) + await reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen') + } + }, [api, reload]) + + return { + items, loading, error, showForm, submitting, form, setForm, + toggleForm: () => setShowForm(s => !s), + reload, submit, remove, setError, + } +} + +interface ShellProps { + testId: string + title: string + description: string + newLabel: string + newDisabled?: boolean + prereqWarning?: string | null + emptyText: string + loadingText?: string + tableHeaders: string[] + showFormButtonLabel?: string + cancelLabel?: string + state: { + loading: boolean + error: string | null + showForm: boolean + submitting: boolean + items: { id: string }[] + toggleForm: () => void + submit: (e?: React.FormEvent) => void | Promise + } + formBody: ReactNode + renderRow: (item: unknown, idx: number) => ReactNode +} + +export function ConstraintShell({ + testId, title, description, newLabel, newDisabled = false, + prereqWarning, emptyText, loadingText = 'Laedt…', tableHeaders, + cancelLabel = 'Abbrechen', + state, formBody, renderRow, +}: ShellProps) { + const { isDark } = useTheme() + const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900' + + return ( +
+
+
+

{title}

+

{description}

+
+ +
+ + {prereqWarning && !state.loading && ( +
+ {prereqWarning} +
+ )} + + {state.error &&
{state.error}
} + + {state.showForm && ( +
+ {formBody} +
+ )} + + {state.loading ? ( +
{loadingText}
+ ) : state.items.length === 0 ? ( +
{emptyText}
+ ) : ( +
+ + + + {tableHeaders.map((h, i) => )} + + + + + {state.items.map((item, idx) => renderRow(item, idx))} + +
{h}
+
+ )} +
+ ) +} + +// ---------- Shared style helpers ---------- + +export function useShellStyles() { + const { isDark } = useTheme() + return { + isDark, + cardClass: isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900', + inputClass: isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900', + rowClass: isDark ? 'border-t border-white/10' : 'border-t border-slate-200', + deleteBtn: 'text-red-400 hover:text-red-300 text-sm', + submitBtn: 'w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50', + } +} + +// ---------- Day-of-week constants reused across editors ---------- + +export const DAYS = [ + { v: 1, label: 'Montag', short: 'Mo' }, + { v: 2, label: 'Dienstag', short: 'Di' }, + { v: 3, label: 'Mittwoch', short: 'Mi' }, + { v: 4, label: 'Donnerstag', short: 'Do' }, + { v: 5, label: 'Freitag', short: 'Fr' }, + { v: 6, label: 'Samstag', short: 'Sa' }, + { v: 7, label: 'Sonntag', short: 'So' }, +] + +export function dayLabel(v: number): string { + return DAYS.find(d => d.v === v)?.label || String(v) +} diff --git a/studio-v2/e2e/stundenplan.spec.ts b/studio-v2/e2e/stundenplan.spec.ts index b6cba6e..b360ffd 100644 --- a/studio-v2/e2e/stundenplan.spec.ts +++ b/studio-v2/e2e/stundenplan.spec.ts @@ -78,8 +78,19 @@ async function mockSchoolApi(page: Page, opts: MockOpts = {}) { for (const path of [ 'constraints/teacher/unavailable-day', 'constraints/teacher/unavailable-window', + 'constraints/teacher/max-hours-day', + 'constraints/teacher/max-hours-week', + 'constraints/teacher/excluded-subject', + 'constraints/teacher/excluded-room', 'constraints/subject/max-consecutive', 'constraints/subject/preferred-period', + 'constraints/subject/min-day-gap', + 'constraints/subject/contiguous-when-repeated', + 'constraints/subject/double-lesson', + 'constraints/class/max-hours-day', + 'constraints/class/no-gaps', + 'constraints/room/requires-type', + 'constraints/room/unavailable', ]) { await staticList(path, []) } @@ -314,10 +325,37 @@ test.describe('Stundenplan — Regeln Hub', () => { 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() + test('all unique-labelled constraint editors mount when selected', async ({ page }) => { + // Skip 'Max. Stunden / Tag' (teacher + class both use it) — covered separately below. + const cases: { label: string; testId: string }[] = [ + { label: 'Tag nicht verfuegbar', testId: 'teacher-unavailable-day-editor' }, + { label: 'Zeitfenster nicht verfuegbar', testId: 'teacher-unavailable-window-editor' }, + { label: 'Max. Stunden / Woche', testId: 'teacher-max-hours-week-editor' }, + { label: 'Fach ausgeschlossen', testId: 'teacher-excluded-subject-editor' }, + { label: 'Raum ausgeschlossen', testId: 'teacher-excluded-room-editor' }, + { label: 'Max. Stunden am Stueck', testId: 'subject-max-consecutive-editor' }, + { label: 'Bevorzugter Stunden-Bereich', testId: 'subject-preferred-period-editor' }, + { label: 'Min. Tagesabstand', testId: 'subject-min-day-gap-editor' }, + { label: 'Bei Mehrfach: zusammenhaengend', testId: 'subject-contiguous-when-repeated-editor' }, + { label: 'Doppelstunde bevorzugt', testId: 'subject-double-lesson-editor' }, + { label: 'Keine Freistunden', testId: 'class-no-gaps-editor' }, + { label: 'Fach benoetigt Raumtyp', testId: 'room-requires-type-editor' }, + { label: 'Raum nicht verfuegbar', testId: 'room-unavailable-editor' }, + ] + for (const c of cases) { + await page.getByRole('button', { name: c.label, exact: true }).click() + await expect(page.getByTestId(c.testId)).toBeVisible() + } + }) + + test('both Max. Stunden / Tag entries mount the right editor', async ({ page }) => { + // The label appears twice (Lehrer + Klasse). .first() targets the + // teacher entry (earlier in the DOM), .nth(1) the class entry. + const buttons = page.getByRole('button', { name: 'Max. Stunden / Tag', exact: true }) + await buttons.first().click() + await expect(page.getByTestId('teacher-max-hours-day-editor')).toBeVisible() + await buttons.nth(1).click() + await expect(page.getByTestId('class-max-hours-day-editor')).toBeVisible() }) })