Stundenplan Phase 3d: all 15 constraint editors via shared shell
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 30s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m38s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 20s

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) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-21 23:27:34 +02:00
parent c2c09e1cd9
commit 7c96d89927
12 changed files with 1330 additions and 584 deletions
@@ -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<TimetableClass[]>([])
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<ClassMaxHoursDay, 'id' | 'created_by_user_id' | 'created_at'>
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<ClassMaxHoursDay, DayForm>(classMaxHoursDayApi, initialDay)
return (
<ConstraintShell
testId="class-max-hours-day-editor"
title="Klasse: Max. Stunden / Tag"
description="Beispiel: „5a hoechstens 6 Stunden pro Tag" (jugendgerecht)."
newLabel="+ Neue Regel"
newDisabled={classes.length === 0}
prereqWarning={classes.length === 0 ? 'Zuerst Klassen anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Klasse', 'Max. Std/Tag', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Klasse</label>
<select required value={crud.form.class_id} onChange={e => crud.setForm({ ...crud.form, class_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Max. Stunden (1-12)</label>
<input type="number" min={1} max={12} required value={crud.form.max_hours} onChange={e => crud.setForm({ ...crud.form, max_hours: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_cmhd" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_cmhd" className="text-sm">Harte Regel</label>
</div>
<div className="md:col-span-4 flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>{crud.submitting ? 'Speichert...' : 'Anlegen'}</button>
</div>
</div>
}
renderRow={(item) => {
const c = item as ClassMaxHoursDay
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{cLabel(classes, c.class_id)}</td>
<td className="px-4 py-3">{c.max_hours}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right"><button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button></td>
</tr>
)
}}
/>
)
}
// ---------- No Gaps ----------
type GapForm = Omit<ClassNoGaps, 'id' | 'created_by_user_id' | 'created_at'>
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<ClassNoGaps, GapForm>(classNoGapsApi, initialGap)
return (
<ConstraintShell
testId="class-no-gaps-editor"
title="Klasse: Keine Freistunden"
description="Soft-Regel: Klasse soll keine Loecher zwischen Lessons haben."
newLabel="+ Neue Regel"
newDisabled={classes.length === 0}
prereqWarning={classes.length === 0 ? 'Zuerst Klassen anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Klasse', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Klasse</label>
<select required value={crud.form.class_id} onChange={e => crud.setForm({ ...crud.form, class_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{classes.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_cng" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_cng" className="text-sm">Harte Regel</label>
</div>
<div className="flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>{crud.submitting ? 'Speichert...' : 'Anlegen'}</button>
</div>
</div>
}
renderRow={(item) => {
const c = item as ClassNoGaps
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{cLabel(classes, c.class_id)}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right"><button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button></td>
</tr>
)
}}
/>
)
}
@@ -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<RuleType, React.ComponentType> = {
'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<RuleType>('teacher-unavailable-day')
const Editor = EDITORS[active]
return (
<div className="grid grid-cols-1 md:grid-cols-[260px_1fr] gap-6" data-testid="regeln-hub">
@@ -69,24 +99,17 @@ export function RegelnHub() {
<div className="space-y-1">
{g.rules.map(r => {
const isActive = active === r.id
const isDone = r.implemented
return (
<button
key={r.id}
disabled={!isDone}
onClick={() => isDone && setActive(r.id as RuleType)}
onClick={() => setActive(r.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
isActive
? isDark ? 'bg-indigo-500/20 text-white' : 'bg-indigo-100 text-indigo-900'
: isDone
? isDark ? 'text-white/80 hover:bg-white/10' : 'text-slate-700 hover:bg-slate-100'
: isDark ? 'text-white/30 cursor-not-allowed' : 'text-slate-400 cursor-not-allowed'
: isDark ? 'text-white/80 hover:bg-white/10' : 'text-slate-700 hover:bg-slate-100'
}`}
>
<span className="flex items-center justify-between gap-2">
<span className="truncate">{r.label}</span>
{!isDone && <span className="text-xs opacity-60">soon</span>}
</span>
<span className="truncate">{r.label}</span>
</button>
)
})}
@@ -96,10 +119,7 @@ export function RegelnHub() {
</aside>
<div>
{active === 'teacher-unavailable-day' && <TeacherUnavailableDayEditor />}
{active === 'teacher-unavailable-window' && <TeacherUnavailableWindowEditor />}
{active === 'subject-max-consecutive' && <SubjectMaxConsecutiveEditor />}
{active === 'subject-preferred-period' && <SubjectPreferredPeriodEditor />}
<Editor />
</div>
</div>
)
@@ -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<RoomRequiresType, 'id' | 'created_by_user_id' | 'created_at'>
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<TimetableSubject[]>([])
const crud = useConstraintCrud<RoomRequiresType, ReqForm>(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 (
<ConstraintShell
testId="room-requires-type-editor"
title="Fach: benoetigter Raumtyp"
description="Beispiel: „Sport braucht immer Sporthalle"."
newLabel="+ Neue Regel"
newDisabled={subjects.length === 0}
prereqWarning={subjects.length === 0 ? 'Zuerst Faecher anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Fach', 'Raumtyp', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Fach</label>
<select required value={crud.form.subject_id} onChange={e => crud.setForm({ ...crud.form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Raumtyp</label>
<input required value={crud.form.room_type} onChange={e => 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}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_rrt" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_rrt" className="text-sm">Harte Regel</label>
</div>
<div className="md:col-span-4 flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>{crud.submitting ? 'Speichert...' : 'Anlegen'}</button>
</div>
</div>
}
renderRow={(item) => {
const c = item as RoomRequiresType
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{sLabel(c.subject_id)}</td>
<td className="px-4 py-3">{c.room_type}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right"><button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button></td>
</tr>
)
}}
/>
)
}
// ---------- Room Unavailable ----------
type UnavForm = Omit<RoomUnavailable, 'id' | 'created_by_user_id' | 'created_at'>
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<TimetableRoom[]>([])
const crud = useConstraintCrud<RoomUnavailable, UnavForm>(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 (
<ConstraintShell
testId="room-unavailable-editor"
title="Raum: nicht verfuegbar"
description="Beispiel: Sporthalle Di 5. Stunde Wartung"."
newLabel="+ Neue Regel"
newDisabled={rooms.length === 0}
prereqWarning={rooms.length === 0 ? 'Zuerst Raeume anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Raum', 'Tag', 'Stunde', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Raum</label>
<select required value={crud.form.room_id} onChange={e => crud.setForm({ ...crud.form, room_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value=""> bitte waehlen </option>
{rooms.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Wochentag</label>
<select value={crud.form.day_of_week} onChange={e => 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 => <option key={d.v} value={d.v}>{d.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Stunde (1-12)</label>
<input type="number" min={1} max={12} required value={crud.form.period_index} onChange={e => crud.setForm({ ...crud.form, period_index: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_ru" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_ru" className="text-sm">Harte Regel</label>
</div>
<div className="md:col-span-3 flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>{crud.submitting ? 'Speichert...' : 'Anlegen'}</button>
</div>
</div>
}
renderRow={(item) => {
const c = item as RoomUnavailable
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{rLabel(c.room_id)}</td>
<td className="px-4 py-3">{dayLabel(c.day_of_week)}</td>
<td className="px-4 py-3">{c.period_index}.</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right"><button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button></td>
</tr>
)
}}
/>
)
}
@@ -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<SubjectMaxConsecutive, 'id' | 'created_by_user_id' | 'created_at'>
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<SubjectMaxConsecutive[]>([])
const styles = useShellStyles()
const crud = useConstraintCrud<SubjectMaxConsecutive, FormState>(subjectMaxConsecutiveApi, initialForm)
const [subjects, setSubjects] = useState<TimetableSubject[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState<FormState>(initialForm)
const load = useCallback(async () => {
setLoading(true); setError(null)
try {
const [rules, s] = await Promise.all([subjectMaxConsecutiveApi.list(), subjectsApi.list()])
setItems(rules || [])
setSubjects(s || [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
} finally { setLoading(false) }
}, [])
useEffect(() => { load() }, [load])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true); setError(null)
try {
await subjectMaxConsecutiveApi.create(form)
setForm(initialForm); setShowForm(false); await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally { setSubmitting(false) }
}
const handleDelete = async (id: string) => {
if (!confirm('Regel wirklich loeschen?')) return
try { await subjectMaxConsecutiveApi.remove(id); await load() }
catch (err) { setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen') }
}
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 (
<div className="space-y-4" data-testid="subject-max-consecutive-editor">
<div className="flex items-center justify-between">
<div>
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Fach: Max. Stunden am Stueck
</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Beispiel: Mathe nicht mehr als 2 Stunden am Stueck" (keine Dreifach-Stunde).
</p>
</div>
<button
onClick={() => setShowForm(s => !s)}
disabled={subjects.length === 0}
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{showForm ? 'Abbrechen' : '+ Neue Regel'}
</button>
</div>
{subjects.length === 0 && !loading && (
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
Zuerst Faecher anlegen.
</div>
)}
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
{showForm && (
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Fach</label>
<select required value={form.subject_id} onChange={e => setForm({ ...form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
<option value="">— bitte waehlen —</option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Max. Stunden (1-5)</label>
<input type="number" min={1} max={5} required value={form.max_consecutive} onChange={e => setForm({ ...form, max_consecutive: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_mc" checked={form.is_hard} onChange={e => setForm({ ...form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_mc" className="text-sm">Harte Regel</label>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={form.weight} onChange={e => setForm({ ...form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div className="md:col-span-2 flex items-end">
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Speichert...' : 'Anlegen'}
</button>
</div>
<ConstraintShell
testId="subject-max-consecutive-editor"
title="Fach: Max. Stunden am Stueck"
description="Beispiel: „Mathe nicht mehr als 2 Stunden am Stueck" (keine Dreifach-Stunde)."
newLabel="+ Neue Regel"
newDisabled={subjects.length === 0}
prereqWarning={subjects.length === 0 ? 'Zuerst Faecher anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Fach', 'Max. Stunden', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Fach</label>
<select required value={crud.form.subject_id} onChange={e => crud.setForm({ ...crud.form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Max. Stunden (1-5)</label>
<input type="number" min={1} max={5} required value={crud.form.max_consecutive} onChange={e => crud.setForm({ ...crud.form, max_consecutive: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_mc" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_mc" className="text-sm">Harte Regel</label>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="md:col-span-2 flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>
{crud.submitting ? 'Speichert...' : 'Anlegen'}
</button>
</div>
</form>
)}
{loading ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
) : items.length === 0 ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Keine Regeln vorhanden.</div>
) : (
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
<table className="w-full">
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
<tr>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Fach</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Max. Stunden</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Hart</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Weight</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{items.map(c => (
<tr key={c.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="px-4 py-3 font-medium">{sLabel(c.subject_id)}</td>
<td className="px-4 py-3">{c.max_consecutive}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => handleDelete(c.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
}
renderRow={(item) => {
const c = item as SubjectMaxConsecutive
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{sLabel(c.subject_id)}</td>
<td className="px-4 py-3">{c.max_consecutive}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button>
</td>
</tr>
)
}}
/>
)
}
@@ -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<SubjectPreferredPeriod, 'id' | 'created_by_user_id' | 'created_at'>
const initialForm: FormState = {
subject_id: '',
period_from: 1,
period_to: 4,
is_hard: false,
weight: 40,
active: true,
note: '',
subject_id: '', period_from: 1, period_to: 4, is_hard: false, weight: 40, active: true, note: '',
}
export function SubjectPreferredPeriodEditor() {
const { isDark } = useTheme()
const [items, setItems] = useState<SubjectPreferredPeriod[]>([])
const styles = useShellStyles()
const crud = useConstraintCrud<SubjectPreferredPeriod, FormState>(subjectPreferredPeriodApi, initialForm, {
onBeforeSubmit: (f) => f.period_to < f.period_from ? '"Bis"-Stunde darf nicht kleiner sein als "Von"-Stunde.' : null,
})
const [subjects, setSubjects] = useState<TimetableSubject[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState<FormState>(initialForm)
const load = useCallback(async () => {
setLoading(true); setError(null)
try {
const [rules, s] = await Promise.all([subjectPreferredPeriodApi.list(), subjectsApi.list()])
setItems(rules || [])
setSubjects(s || [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
} finally { setLoading(false) }
}, [])
useEffect(() => { load() }, [load])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (form.period_to < form.period_from) {
setError('"Bis"-Stunde darf nicht kleiner sein als "Von"-Stunde.')
return
}
setSubmitting(true); setError(null)
try {
await subjectPreferredPeriodApi.create(form)
setForm(initialForm); setShowForm(false); await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally { setSubmitting(false) }
}
const handleDelete = async (id: string) => {
if (!confirm('Regel wirklich loeschen?')) return
try { await subjectPreferredPeriodApi.remove(id); await load() }
catch (err) { setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen') }
}
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 (
<div className="space-y-4" data-testid="subject-preferred-period-editor">
<div className="flex items-center justify-between">
<div>
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Fach: Bevorzugter Stunden-Bereich
</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Beispiel: Hauptfaecher lieber in den ersten 4 Stunden" (Soft-Regel).
</p>
</div>
<button
onClick={() => setShowForm(s => !s)}
disabled={subjects.length === 0}
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{showForm ? 'Abbrechen' : '+ Neue Regel'}
</button>
</div>
{subjects.length === 0 && !loading && (
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
Zuerst Faecher anlegen.
</div>
)}
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
{showForm && (
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Fach</label>
<select required value={form.subject_id} onChange={e => setForm({ ...form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
<option value="">— bitte waehlen —</option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Von Stunde</label>
<input type="number" min={1} max={12} required value={form.period_from} onChange={e => setForm({ ...form, period_from: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Bis Stunde</label>
<input type="number" min={1} max={12} required value={form.period_to} onChange={e => setForm({ ...form, period_to: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={form.weight} onChange={e => setForm({ ...form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_pp" checked={form.is_hard} onChange={e => setForm({ ...form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_pp" className="text-sm">Harte Regel (selten sinnvoll hier)</label>
</div>
<div className="md:col-span-3 flex items-end">
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Speichert...' : 'Anlegen'}
</button>
</div>
<ConstraintShell
testId="subject-preferred-period-editor"
title="Fach: Bevorzugter Stunden-Bereich"
description="Beispiel: „Hauptfaecher lieber in den ersten 4 Stunden" (Soft-Regel)."
newLabel="+ Neue Regel"
newDisabled={subjects.length === 0}
prereqWarning={subjects.length === 0 ? 'Zuerst Faecher anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Fach', 'Bereich', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Fach</label>
<select required value={crud.form.subject_id} onChange={e => crud.setForm({ ...crud.form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Von Stunde</label>
<input type="number" min={1} max={12} required value={crud.form.period_from} onChange={e => crud.setForm({ ...crud.form, period_from: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Bis Stunde</label>
<input type="number" min={1} max={12} required value={crud.form.period_to} onChange={e => crud.setForm({ ...crud.form, period_to: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_pp" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_pp" className="text-sm">Harte Regel (selten sinnvoll hier)</label>
</div>
<div className="md:col-span-3 flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>
{crud.submitting ? 'Speichert...' : 'Anlegen'}
</button>
</div>
</form>
)}
{loading ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
) : items.length === 0 ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Keine Regeln vorhanden.</div>
) : (
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
<table className="w-full">
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
<tr>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Fach</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Bereich</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Hart</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Weight</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{items.map(c => (
<tr key={c.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="px-4 py-3 font-medium">{sLabel(c.subject_id)}</td>
<td className="px-4 py-3">Stunde {c.period_from}{c.period_to}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => handleDelete(c.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
}
renderRow={(item) => {
const c = item as SubjectPreferredPeriod
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{sLabel(c.subject_id)}</td>
<td className="px-4 py-3">Stunde {c.period_from}{c.period_to}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button>
</td>
</tr>
)
}}
/>
)
}
@@ -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<TimetableSubject[]>([])
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<SubjectMinDayGap, 'id' | 'created_by_user_id' | 'created_at'>
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<SubjectMinDayGap, GapForm>(subjectMinDayGapApi, initialGap)
return (
<ConstraintShell
testId="subject-min-day-gap-editor"
title="Fach: Min. Tagesabstand"
description="Beispiel: „Mathe nicht an 2 Tagen direkt hintereinander"."
newLabel="+ Neue Regel"
newDisabled={subjects.length === 0}
prereqWarning={subjects.length === 0 ? 'Zuerst Faecher anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Fach', 'Mindestabstand (Tage)', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Fach</label>
<select required value={crud.form.subject_id} onChange={e => crud.setForm({ ...crud.form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Min. Tage (1-4)</label>
<input type="number" min={1} max={4} required value={crud.form.min_gap_days} onChange={e => crud.setForm({ ...crud.form, min_gap_days: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_mdg" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_mdg" className="text-sm">Harte Regel</label>
</div>
<div className="md:col-span-4 flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>{crud.submitting ? 'Speichert...' : 'Anlegen'}</button>
</div>
</div>
}
renderRow={(item) => {
const c = item as SubjectMinDayGap
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{sLabel(subjects, c.subject_id)}</td>
<td className="px-4 py-3">{c.min_gap_days}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right"><button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button></td>
</tr>
)
}}
/>
)
}
// ---------- Contiguous When Repeated ----------
type ContForm = Omit<SubjectContiguousWhenRepeated, 'id' | 'created_by_user_id' | 'created_at'>
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<SubjectContiguousWhenRepeated, ContForm>(subjectContiguousWhenRepeatedApi, initialCont)
return (
<ConstraintShell
testId="subject-contiguous-when-repeated-editor"
title="Fach: Wenn mehrfach pro Tag, dann zusammenhaengend"
description="Beispiel: Mathe Stunde 1+2 ja, aber 1+3 mit Sachkunde dazwischen verboten."
newLabel="+ Neue Regel"
newDisabled={subjects.length === 0}
prereqWarning={subjects.length === 0 ? 'Zuerst Faecher anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Fach', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Fach</label>
<select required value={crud.form.subject_id} onChange={e => crud.setForm({ ...crud.form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_cwr" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_cwr" className="text-sm">Harte Regel</label>
</div>
<div className="flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>{crud.submitting ? 'Speichert...' : 'Anlegen'}</button>
</div>
</div>
}
renderRow={(item) => {
const c = item as SubjectContiguousWhenRepeated
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{sLabel(subjects, c.subject_id)}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right"><button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button></td>
</tr>
)
}}
/>
)
}
// ---------- Double Lesson ----------
type DoubleForm = Omit<SubjectDoubleLesson, 'id' | 'created_by_user_id' | 'created_at'>
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<SubjectDoubleLesson, DoubleForm>(subjectDoubleLessonApi, initialDouble)
return (
<ConstraintShell
testId="subject-double-lesson-editor"
title="Fach: Doppelstunde bevorzugt"
description="Soft-Regel: Sport oder Chemie laufen oft besser als 90-Min-Block statt zwei Einzelstunden."
newLabel="+ Neue Regel"
newDisabled={subjects.length === 0}
prereqWarning={subjects.length === 0 ? 'Zuerst Faecher anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Fach', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Fach</label>
<select required value={crud.form.subject_id} onChange={e => crud.setForm({ ...crud.form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_dl" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_dl" className="text-sm">Harte Regel</label>
</div>
<div className="flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>{crud.submitting ? 'Speichert...' : 'Anlegen'}</button>
</div>
</div>
}
renderRow={(item) => {
const c = item as SubjectDoubleLesson
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{sLabel(subjects, c.subject_id)}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right"><button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button></td>
</tr>
)
}}
/>
)
}
@@ -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<TeacherExcludedSubject, 'id' | 'created_by_user_id' | 'created_at'>
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<TimetableTeacher[]>([])
const [subjects, setSubjects] = useState<TimetableSubject[]>([])
const crud = useConstraintCrud<TeacherExcludedSubject, SubForm>(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 (
<ConstraintShell
testId="teacher-excluded-subject-editor"
title="Lehrer: Fach ausgeschlossen"
description="Beispiel: „Anna darf kein Sport unterrichten" (oft Pflicht, z.B. Qualifikation/Behinderung)."
newLabel="+ Neue Regel"
newDisabled={missing}
prereqWarning={missing ? 'Zuerst Lehrer und Faecher anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Lehrer', 'Fach', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Lehrer</label>
<select required value={crud.form.teacher_id} onChange={e => crud.setForm({ ...crud.form, teacher_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{teachers.map(t => <option key={t.id} value={t.id}>{t.last_name}, {t.first_name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Fach</label>
<select required value={crud.form.subject_id} onChange={e => crud.setForm({ ...crud.form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_tes" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_tes" className="text-sm">Harte Regel</label>
</div>
<div className="flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>{crud.submitting ? 'Speichert...' : 'Anlegen'}</button>
</div>
</div>
}
renderRow={(item) => {
const c = item as TeacherExcludedSubject
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{tLabel(c.teacher_id)}</td>
<td className="px-4 py-3">{sLabel(c.subject_id)}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right"><button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button></td>
</tr>
)
}}
/>
)
}
// ---------- Excluded Room ----------
type RoomForm = Omit<TeacherExcludedRoom, 'id' | 'created_by_user_id' | 'created_at'>
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<TimetableTeacher[]>([])
const [rooms, setRooms] = useState<TimetableRoom[]>([])
const crud = useConstraintCrud<TeacherExcludedRoom, RoomForm>(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 (
<ConstraintShell
testId="teacher-excluded-room-editor"
title="Lehrer: Raum ausgeschlossen"
description="Beispiel: Anna im Rollstuhl, Raum F kein Fahrstuhl"."
newLabel="+ Neue Regel"
newDisabled={missing}
prereqWarning={missing ? 'Zuerst Lehrer und Raeume anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Lehrer', 'Raum', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Lehrer</label>
<select required value={crud.form.teacher_id} onChange={e => crud.setForm({ ...crud.form, teacher_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value=""> bitte waehlen </option>
{teachers.map(t => <option key={t.id} value={t.id}>{t.last_name}, {t.first_name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Raum</label>
<select required value={crud.form.room_id} onChange={e => crud.setForm({ ...crud.form, room_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value=""> bitte waehlen </option>
{rooms.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_ter" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_ter" className="text-sm">Harte Regel</label>
</div>
<div className="flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>{crud.submitting ? 'Speichert...' : 'Anlegen'}</button>
</div>
</div>
}
renderRow={(item) => {
const c = item as TeacherExcludedRoom
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{tLabel(c.teacher_id)}</td>
<td className="px-4 py-3">{rLabel(c.room_id)}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right"><button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button></td>
</tr>
)
}}
/>
)
}
@@ -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<TimetableTeacher[]>([])
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<TeacherMaxHoursDay, 'id' | 'created_by_user_id' | 'created_at'>
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<TeacherMaxHoursDay, DayForm>(teacherMaxHoursDayApi, initialDay)
return (
<ConstraintShell
testId="teacher-max-hours-day-editor"
title="Lehrer: Max. Stunden / Tag"
description="Beispiel: „Anna unterrichtet hoechstens 6 Stunden pro Tag" (oft Soft-Regel)."
newLabel="+ Neue Regel"
newDisabled={teachers.length === 0}
prereqWarning={teachers.length === 0 ? 'Zuerst Lehrer anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Lehrer', 'Max. Std/Tag', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Lehrer</label>
<select required value={crud.form.teacher_id} onChange={e => crud.setForm({ ...crud.form, teacher_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{teachers.map(t => <option key={t.id} value={t.id}>{t.last_name}, {t.first_name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Max. Stunden (1-12)</label>
<input type="number" min={1} max={12} required value={crud.form.max_hours} onChange={e => crud.setForm({ ...crud.form, max_hours: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_mhd" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_mhd" className="text-sm">Harte Regel</label>
</div>
<div className="md:col-span-4 flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>{crud.submitting ? 'Speichert...' : 'Anlegen'}</button>
</div>
</div>
}
renderRow={(item) => {
const c = item as TeacherMaxHoursDay
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{tLabel(teachers, c.teacher_id)}</td>
<td className="px-4 py-3">{c.max_hours}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right"><button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button></td>
</tr>
)
}}
/>
)
}
// ---------- Max Hours / Week ----------
type WeekForm = Omit<TeacherMaxHoursWeek, 'id' | 'created_by_user_id' | 'created_at'>
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<TeacherMaxHoursWeek, WeekForm>(teacherMaxHoursWeekApi, initialWeek)
return (
<ConstraintShell
testId="teacher-max-hours-week-editor"
title="Lehrer: Max. Stunden / Woche"
description="Beispiel: Teilzeit 50% max. 14 h/Woche (harte Regel aus Vertrag)."
newLabel="+ Neue Regel"
newDisabled={teachers.length === 0}
prereqWarning={teachers.length === 0 ? 'Zuerst Lehrer anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Lehrer', 'Max. Std/Woche', 'Hart', 'Weight']}
state={crud}
formBody={
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Lehrer</label>
<select required value={crud.form.teacher_id} onChange={e => crud.setForm({ ...crud.form, teacher_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{teachers.map(t => <option key={t.id} value={t.id}>{t.last_name}, {t.first_name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Max. Stunden (1-40)</label>
<input type="number" min={1} max={40} required value={crud.form.max_hours} onChange={e => crud.setForm({ ...crud.form, max_hours: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_mhw" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_mhw" className="text-sm">Harte Regel</label>
</div>
<div className="md:col-span-4 flex items-end">
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>{crud.submitting ? 'Speichert...' : 'Anlegen'}</button>
</div>
</div>
}
renderRow={(item) => {
const c = item as TeacherMaxHoursWeek
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{tLabel(teachers, c.teacher_id)}</td>
<td className="px-4 py-3">{c.max_hours}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right"><button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button></td>
</tr>
)
}}
/>
)
}
@@ -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<TeacherUnavailableDay, 'id' | 'created_by_user_id' | 'created_at'>
@@ -27,164 +17,84 @@ const initialForm: FormState = {
}
export function TeacherUnavailableDayEditor() {
const { isDark } = useTheme()
const [items, setItems] = useState<TeacherUnavailableDay[]>([])
const styles = useShellStyles()
const crud = useConstraintCrud<TeacherUnavailableDay, FormState>(teacherUnavailableDayApi, initialForm)
const [teachers, setTeachers] = useState<TimetableTeacher[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState<FormState>(initialForm)
const load = useCallback(async () => {
setLoading(true); setError(null)
try {
const [rules, t] = await Promise.all([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 (
<div className="space-y-4" data-testid="teacher-unavailable-day-editor">
<div className="flex items-center justify-between">
<div>
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Lehrer: Tag nicht verfuegbar
</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Beispiel: Lehrer X kann Montags nie".
</p>
</div>
<button
onClick={() => setShowForm(s => !s)}
disabled={teachers.length === 0}
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{showForm ? 'Abbrechen' : '+ Neue Regel'}
</button>
</div>
{teachers.length === 0 && !loading && (
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
Zuerst Lehrer anlegen, dann koennen Regeln vergeben werden.
</div>
)}
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
{showForm && (
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<ConstraintShell
testId="teacher-unavailable-day-editor"
title="Lehrer: Tag nicht verfuegbar"
description="Beispiel: „Lehrer X kann Montags nie"."
newLabel="+ Neue Regel"
newDisabled={teachers.length === 0}
prereqWarning={teachers.length === 0 ? 'Zuerst Lehrer anlegen, dann koennen Regeln vergeben werden.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Lehrer', 'Tag', 'Hart', 'Weight', 'Aktiv', 'Notiz']}
state={crud}
formBody={
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Lehrer</label>
<select required value={form.teacher_id} onChange={e => setForm({ ...form, teacher_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
<select required value={crud.form.teacher_id} onChange={e => crud.setForm({ ...crud.form, teacher_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{teachers.map(t => <option key={t.id} value={t.id}>{t.last_name}, {t.first_name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Wochentag</label>
<select value={form.day_of_week} onChange={e => setForm({ ...form, day_of_week: parseInt(e.target.value) })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
<select value={crud.form.day_of_week} onChange={e => 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 => <option key={d.v} value={d.v}>{d.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Gewichtung (0-100)</label>
<input type="number" min={0} max={100} value={form.weight} onChange={e => setForm({ ...form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard" checked={form.is_hard} onChange={e => setForm({ ...form, is_hard: e.target.checked })} className="w-5 h-5" />
<input type="checkbox" id="is_hard" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard" className="text-sm">Harte Regel (muss eingehalten werden)</label>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="active" checked={form.active} onChange={e => setForm({ ...form, active: e.target.checked })} className="w-5 h-5" />
<input type="checkbox" id="active" checked={crud.form.active} onChange={e => crud.setForm({ ...crud.form, active: e.target.checked })} className="w-5 h-5" />
<label htmlFor="active" className="text-sm">Aktiv</label>
</div>
<div className="flex items-end">
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Speichert...' : 'Anlegen'}
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>
{crud.submitting ? 'Speichert...' : 'Anlegen'}
</button>
</div>
</div>
<div className="mt-3">
<label className="block text-sm mb-1 opacity-70">Begruendung (optional)</label>
<input value={form.note || ''} onChange={e => setForm({ ...form, note: e.target.value })} placeholder="z.B. Zweitjob in Praxis" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
<input value={crud.form.note || ''} onChange={e => 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}`} />
</div>
</form>
)}
{loading ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
) : items.length === 0 ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Keine Regeln vorhanden.</div>
) : (
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
<table className="w-full">
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
<tr>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Lehrer</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Tag</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Hart</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Weight</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Aktiv</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Notiz</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{items.map(c => (
<tr key={c.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="px-4 py-3 font-medium">{teacherLabel(c.teacher_id)}</td>
<td className="px-4 py-3">{DAYS.find(d => d.v === c.day_of_week)?.label || c.day_of_week}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3">{c.active ? '✓' : '—'}</td>
<td className="px-4 py-3 text-sm opacity-70">{c.note || '—'}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => handleDelete(c.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
}
renderRow={(item) => {
const c = item as TeacherUnavailableDay
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{teacherLabel(c.teacher_id)}</td>
<td className="px-4 py-3">{dayLabel(c.day_of_week)}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3">{c.active ? '✓' : '—'}</td>
<td className="px-4 py-3 text-sm opacity-70">{c.note || '—'}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button>
</td>
</tr>
)
}}
/>
)
}
@@ -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<TeacherUnavailableWindow, 'id' | 'created_by_user_id' | 'created_at'>
const initialForm: FormState = {
teacher_id: '',
day_of_week: 2,
start_time: '13:00',
end_time: '17:00',
is_hard: true,
weight: 100,
active: true,
note: '',
teacher_id: '', day_of_week: 2, start_time: '13:00', end_time: '17:00',
is_hard: true, weight: 100, active: true, note: '',
}
export function TeacherUnavailableWindowEditor() {
const { isDark } = useTheme()
const [items, setItems] = useState<TeacherUnavailableWindow[]>([])
const styles = useShellStyles()
const crud = useConstraintCrud<TeacherUnavailableWindow, FormState>(teacherUnavailableWindowApi, initialForm)
const [teachers, setTeachers] = useState<TimetableTeacher[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState<FormState>(initialForm)
const load = useCallback(async () => {
setLoading(true); setError(null)
try {
const [rules, t] = await Promise.all([teacherUnavailableWindowApi.list(), teachersApi.list()])
setItems(rules || [])
setTeachers(t || [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
} finally { setLoading(false) }
}, [])
useEffect(() => { 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 (
<div className="space-y-4" data-testid="teacher-unavailable-window-editor">
<div className="flex items-center justify-between">
<div>
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Lehrer: Zeitfenster nicht verfuegbar
</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Beispiel: Lehrer Z Dienstags 13:0017:00 nicht".
</p>
</div>
<button
onClick={() => setShowForm(s => !s)}
disabled={teachers.length === 0}
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{showForm ? 'Abbrechen' : '+ Neue Regel'}
</button>
</div>
{teachers.length === 0 && !loading && (
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
Zuerst Lehrer anlegen.
</div>
)}
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
{showForm && (
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<ConstraintShell
testId="teacher-unavailable-window-editor"
title="Lehrer: Zeitfenster nicht verfuegbar"
description="Beispiel: „Lehrer Z Dienstags 13:0017:00 nicht"."
newLabel="+ Neue Regel"
newDisabled={teachers.length === 0}
prereqWarning={teachers.length === 0 ? 'Zuerst Lehrer anlegen.' : null}
emptyText="Keine Regeln vorhanden."
tableHeaders={['Lehrer', 'Tag', 'Zeitfenster', 'Hart', 'Notiz']}
state={crud}
formBody={
<>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Lehrer</label>
<select required value={form.teacher_id} onChange={e => setForm({ ...form, teacher_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
<select required value={crud.form.teacher_id} onChange={e => crud.setForm({ ...crud.form, teacher_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`}>
<option value="">— bitte waehlen —</option>
{teachers.map(t => <option key={t.id} value={t.id}>{t.last_name}, {t.first_name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Wochentag</label>
<select value={form.day_of_week} onChange={e => setForm({ ...form, day_of_week: parseInt(e.target.value) })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
<select value={crud.form.day_of_week} onChange={e => 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 => <option key={d.v} value={d.v}>{d.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Von</label>
<input type="time" required value={form.start_time} onChange={e => setForm({ ...form, start_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
<input type="time" required value={crud.form.start_time} onChange={e => crud.setForm({ ...crud.form, start_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Bis</label>
<input type="time" required value={form.end_time} onChange={e => setForm({ ...form, end_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
<input type="time" required value={crud.form.end_time} onChange={e => crud.setForm({ ...crud.form, end_time: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_w" checked={form.is_hard} onChange={e => setForm({ ...form, is_hard: e.target.checked })} className="w-5 h-5" />
<input type="checkbox" id="is_hard_w" checked={crud.form.is_hard} onChange={e => crud.setForm({ ...crud.form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_w" className="text-sm">Harte Regel</label>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={form.weight} onChange={e => setForm({ ...form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
<input type="number" min={0} max={100} value={crud.form.weight} onChange={e => crud.setForm({ ...crud.form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
<div className="md:col-span-2 flex items-end">
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Speichert...' : 'Anlegen'}
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>
{crud.submitting ? 'Speichert...' : 'Anlegen'}
</button>
</div>
</div>
<div className="mt-3">
<label className="block text-sm mb-1 opacity-70">Begruendung (optional)</label>
<input value={form.note || ''} onChange={e => setForm({ ...form, note: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
<input value={crud.form.note || ''} onChange={e => crud.setForm({ ...crud.form, note: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${styles.inputClass}`} />
</div>
</form>
)}
{loading ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
) : items.length === 0 ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Keine Regeln vorhanden.</div>
) : (
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
<table className="w-full">
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
<tr>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Lehrer</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Tag</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Zeitfenster</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Hart</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Notiz</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{items.map(c => (
<tr key={c.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="px-4 py-3 font-medium">{tLabel(c.teacher_id)}</td>
<td className="px-4 py-3">{DAYS.find(d => d.v === c.day_of_week)?.label || c.day_of_week}</td>
<td className="px-4 py-3">{c.start_time}{c.end_time}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3 text-sm opacity-70">{c.note || '—'}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => handleDelete(c.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
}
renderRow={(item) => {
const c = item as TeacherUnavailableWindow
return (
<tr key={c.id} className={styles.rowClass}>
<td className="px-4 py-3 font-medium">{teacherLabel(c.teacher_id)}</td>
<td className="px-4 py-3">{dayLabel(c.day_of_week)}</td>
<td className="px-4 py-3">{c.start_time}{c.end_time}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3 text-sm opacity-70">{c.note || '—'}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button>
</td>
</tr>
)
}}
/>
)
}
@@ -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<TItem, TForm> {
list(): Promise<TItem[]>
create(form: TForm): Promise<TItem>
remove(id: string): Promise<void>
}
export interface CrudState<TItem, TForm> {
items: TItem[]
loading: boolean
error: string | null
showForm: boolean
submitting: boolean
form: TForm
setForm: (f: TForm | ((prev: TForm) => TForm)) => void
toggleForm: () => void
reload: () => Promise<void>
submit: (e?: React.FormEvent) => Promise<void>
remove: (id: string, confirmMsg?: string) => Promise<void>
setError: (msg: string | null) => void
}
export function useConstraintCrud<TItem extends { id: string }, TForm>(
api: ConstraintApi<TItem, TForm>,
initialForm: TForm,
opts: { onBeforeSubmit?: (form: TForm) => string | null } = {},
): CrudState<TItem, TForm> {
const [items, setItems] = useState<TItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState<TForm>(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<void>
}
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 (
<div className="space-y-4" data-testid={testId}>
<div className="flex items-center justify-between">
<div>
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{title}</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{description}</p>
</div>
<button
onClick={state.toggleForm}
disabled={newDisabled}
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{state.showForm ? cancelLabel : newLabel}
</button>
</div>
{prereqWarning && !state.loading && (
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
{prereqWarning}
</div>
)}
{state.error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{state.error}</div>}
{state.showForm && (
<form onSubmit={state.submit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
{formBody}
</form>
)}
{state.loading ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>{loadingText}</div>
) : state.items.length === 0 ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>{emptyText}</div>
) : (
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
<table className="w-full">
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
<tr>
{tableHeaders.map((h, i) => <th key={i} className="text-left px-4 py-3 text-sm font-medium opacity-70">{h}</th>)}
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{state.items.map((item, idx) => renderRow(item, idx))}
</tbody>
</table>
</div>
)}
</div>
)
}
// ---------- 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)
}
+42 -4
View File
@@ -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()
})
})