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
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:
@@ -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 { useTheme } from '@/lib/ThemeContext'
|
||||||
import { TeacherUnavailableDayEditor } from './TeacherUnavailableDayEditor'
|
import { TeacherUnavailableDayEditor } from './TeacherUnavailableDayEditor'
|
||||||
import { TeacherUnavailableWindowEditor } from './TeacherUnavailableWindowEditor'
|
import { TeacherUnavailableWindowEditor } from './TeacherUnavailableWindowEditor'
|
||||||
|
import { TeacherMaxHoursDayEditor, TeacherMaxHoursWeekEditor } from './TeacherMaxHoursEditors'
|
||||||
|
import { TeacherExcludedSubjectEditor, TeacherExcludedRoomEditor } from './TeacherExclusionEditors'
|
||||||
import { SubjectMaxConsecutiveEditor } from './SubjectMaxConsecutiveEditor'
|
import { SubjectMaxConsecutiveEditor } from './SubjectMaxConsecutiveEditor'
|
||||||
import { SubjectPreferredPeriodEditor } from './SubjectPreferredPeriodEditor'
|
import { SubjectPreferredPeriodEditor } from './SubjectPreferredPeriodEditor'
|
||||||
|
import {
|
||||||
|
SubjectMinDayGapEditor, SubjectContiguousWhenRepeatedEditor, SubjectDoubleLessonEditor,
|
||||||
|
} from './SubjectSimpleEditors'
|
||||||
|
import { ClassMaxHoursDayEditor, ClassNoGapsEditor } from './ClassEditors'
|
||||||
|
import { RoomRequiresTypeEditor, RoomUnavailableEditor } from './RoomEditors'
|
||||||
|
|
||||||
type RuleType =
|
type RuleType =
|
||||||
| 'teacher-unavailable-day'
|
| 'teacher-unavailable-day' | 'teacher-unavailable-window'
|
||||||
| 'teacher-unavailable-window'
|
| 'teacher-max-hours-day' | 'teacher-max-hours-week'
|
||||||
| 'subject-max-consecutive'
|
| 'teacher-excluded-subject' | 'teacher-excluded-room'
|
||||||
| 'subject-preferred-period'
|
| '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 {
|
interface RuleGroup {
|
||||||
group: string
|
group: string
|
||||||
rules: { id: RuleType | string; label: string; implemented: boolean }[]
|
rules: { id: RuleType; label: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const RULE_GROUPS: RuleGroup[] = [
|
const RULE_GROUPS: RuleGroup[] = [
|
||||||
{
|
{
|
||||||
group: 'Lehrer',
|
group: 'Lehrer',
|
||||||
rules: [
|
rules: [
|
||||||
{ id: 'teacher-unavailable-day', label: 'Tag nicht verfuegbar', implemented: true },
|
{ id: 'teacher-unavailable-day', label: 'Tag nicht verfuegbar' },
|
||||||
{ id: 'teacher-unavailable-window', label: 'Zeitfenster nicht verfuegbar', implemented: true },
|
{ id: 'teacher-unavailable-window', label: 'Zeitfenster nicht verfuegbar' },
|
||||||
{ id: 'teacher-max-hours-day', label: 'Max. Stunden / Tag', implemented: false },
|
{ id: 'teacher-max-hours-day', label: 'Max. Stunden / Tag' },
|
||||||
{ id: 'teacher-max-hours-week', label: 'Max. Stunden / Woche', implemented: false },
|
{ id: 'teacher-max-hours-week', label: 'Max. Stunden / Woche' },
|
||||||
{ id: 'teacher-excluded-subject', label: 'Fach ausgeschlossen', implemented: false },
|
{ id: 'teacher-excluded-subject', label: 'Fach ausgeschlossen' },
|
||||||
{ id: 'teacher-excluded-room', label: 'Raum ausgeschlossen', implemented: false },
|
{ id: 'teacher-excluded-room', label: 'Raum ausgeschlossen' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Fach',
|
group: 'Fach',
|
||||||
rules: [
|
rules: [
|
||||||
{ id: 'subject-max-consecutive', label: 'Max. Stunden am Stueck', implemented: true },
|
{ id: 'subject-max-consecutive', label: 'Max. Stunden am Stueck' },
|
||||||
{ id: 'subject-preferred-period', label: 'Bevorzugter Stunden-Bereich', implemented: true },
|
{ id: 'subject-preferred-period', label: 'Bevorzugter Stunden-Bereich' },
|
||||||
{ id: 'subject-min-day-gap', label: 'Min. Tagesabstand', implemented: false },
|
{ id: 'subject-min-day-gap', label: 'Min. Tagesabstand' },
|
||||||
{ id: 'subject-contiguous-when-repeated', label: 'Bei Mehrfach: zusammenhaengend', implemented: false },
|
{ id: 'subject-contiguous-when-repeated', label: 'Bei Mehrfach: zusammenhaengend' },
|
||||||
{ id: 'subject-double-lesson', label: 'Doppelstunde bevorzugt', implemented: false },
|
{ id: 'subject-double-lesson', label: 'Doppelstunde bevorzugt' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Klasse',
|
group: 'Klasse',
|
||||||
rules: [
|
rules: [
|
||||||
{ id: 'class-max-hours-day', label: 'Max. Stunden / Tag', implemented: false },
|
{ id: 'class-max-hours-day', label: 'Max. Stunden / Tag' },
|
||||||
{ id: 'class-no-gaps', label: 'Keine Freistunden', implemented: false },
|
{ id: 'class-no-gaps', label: 'Keine Freistunden' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Raum',
|
group: 'Raum',
|
||||||
rules: [
|
rules: [
|
||||||
{ id: 'room-requires-type', label: 'Fach benoetigt Raumtyp', implemented: false },
|
{ id: 'room-requires-type', label: 'Fach benoetigt Raumtyp' },
|
||||||
{ id: 'room-unavailable', label: 'Raum nicht verfuegbar', implemented: false },
|
{ 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() {
|
export function RegelnHub() {
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const [active, setActive] = useState<RuleType>('teacher-unavailable-day')
|
const [active, setActive] = useState<RuleType>('teacher-unavailable-day')
|
||||||
|
const Editor = EDITORS[active]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-[260px_1fr] gap-6" data-testid="regeln-hub">
|
<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">
|
<div className="space-y-1">
|
||||||
{g.rules.map(r => {
|
{g.rules.map(r => {
|
||||||
const isActive = active === r.id
|
const isActive = active === r.id
|
||||||
const isDone = r.implemented
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={r.id}
|
key={r.id}
|
||||||
disabled={!isDone}
|
onClick={() => setActive(r.id)}
|
||||||
onClick={() => isDone && setActive(r.id as RuleType)}
|
|
||||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? isDark ? 'bg-indigo-500/20 text-white' : 'bg-indigo-100 text-indigo-900'
|
? 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/80 hover:bg-white/10' : 'text-slate-700 hover:bg-slate-100'
|
|
||||||
: isDark ? 'text-white/30 cursor-not-allowed' : 'text-slate-400 cursor-not-allowed'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center justify-between gap-2">
|
|
||||||
<span className="truncate">{r.label}</span>
|
<span className="truncate">{r.label}</span>
|
||||||
{!isDone && <span className="text-xs opacity-60">soon</span>}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -96,10 +119,7 @@ export function RegelnHub() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{active === 'teacher-unavailable-day' && <TeacherUnavailableDayEditor />}
|
<Editor />
|
||||||
{active === 'teacher-unavailable-window' && <TeacherUnavailableWindowEditor />}
|
|
||||||
{active === 'subject-max-consecutive' && <SubjectMaxConsecutiveEditor />}
|
|
||||||
{active === 'subject-preferred-period' && <SubjectPreferredPeriodEditor />}
|
|
||||||
</div>
|
</div>
|
||||||
</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'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
|
||||||
import { subjectMaxConsecutiveApi, subjectsApi } from '@/lib/stundenplan/api'
|
import { subjectMaxConsecutiveApi, subjectsApi } from '@/lib/stundenplan/api'
|
||||||
import type { SubjectMaxConsecutive, TimetableSubject } from '@/app/stundenplan/types'
|
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'>
|
type FormState = Omit<SubjectMaxConsecutive, 'id' | 'created_by_user_id' | 'created_at'>
|
||||||
|
|
||||||
const initialForm: FormState = {
|
const initialForm: FormState = {
|
||||||
subject_id: '',
|
subject_id: '', max_consecutive: 2, is_hard: true, weight: 100, active: true, note: '',
|
||||||
max_consecutive: 2,
|
|
||||||
is_hard: true,
|
|
||||||
weight: 100,
|
|
||||||
active: true,
|
|
||||||
note: '',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubjectMaxConsecutiveEditor() {
|
export function SubjectMaxConsecutiveEditor() {
|
||||||
const { isDark } = useTheme()
|
const styles = useShellStyles()
|
||||||
const [items, setItems] = useState<SubjectMaxConsecutive[]>([])
|
const crud = useConstraintCrud<SubjectMaxConsecutive, FormState>(subjectMaxConsecutiveApi, initialForm)
|
||||||
const [subjects, setSubjects] = useState<TimetableSubject[]>([])
|
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 () => {
|
useEffect(() => { subjectsApi.list().then(setSubjects).catch(() => setSubjects([])) }, [])
|
||||||
setLoading(true); setError(null)
|
|
||||||
try {
|
|
||||||
const [rules, s] = await Promise.all([subjectMaxConsecutiveApi.list(), subjectsApi.list()])
|
|
||||||
setItems(rules || [])
|
|
||||||
setSubjects(s || [])
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
|
||||||
} finally { setLoading(false) }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setSubmitting(true); setError(null)
|
|
||||||
try {
|
|
||||||
await subjectMaxConsecutiveApi.create(form)
|
|
||||||
setForm(initialForm); setShowForm(false); await load()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
|
|
||||||
} finally { setSubmitting(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
if (!confirm('Regel wirklich loeschen?')) return
|
|
||||||
try { await subjectMaxConsecutiveApi.remove(id); await load() }
|
|
||||||
catch (err) { setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen') }
|
|
||||||
}
|
|
||||||
|
|
||||||
const sLabel = (id: string): string => {
|
const sLabel = (id: string): string => {
|
||||||
const s = subjects.find(x => x.id === id)
|
const s = subjects.find(x => x.id === id)
|
||||||
return s ? `${s.name} (${s.short_code})` : id.slice(0, 8) + '…'
|
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 (
|
return (
|
||||||
<div className="space-y-4" data-testid="subject-max-consecutive-editor">
|
<ConstraintShell
|
||||||
<div className="flex items-center justify-between">
|
testId="subject-max-consecutive-editor"
|
||||||
<div>
|
title="Fach: Max. Stunden am Stueck"
|
||||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
description="Beispiel: „Mathe nicht mehr als 2 Stunden am Stueck" (keine Dreifach-Stunde)."
|
||||||
Fach: Max. Stunden am Stueck
|
newLabel="+ Neue Regel"
|
||||||
</h3>
|
newDisabled={subjects.length === 0}
|
||||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
prereqWarning={subjects.length === 0 ? 'Zuerst Faecher anlegen.' : null}
|
||||||
Beispiel: „Mathe nicht mehr als 2 Stunden am Stueck" (keine Dreifach-Stunde).
|
emptyText="Keine Regeln vorhanden."
|
||||||
</p>
|
tableHeaders={['Fach', 'Max. Stunden', 'Hart', 'Weight']}
|
||||||
</div>
|
state={crud}
|
||||||
<button
|
formBody={
|
||||||
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 className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Fach</label>
|
<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}`}>
|
<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>
|
<option value="">— bitte waehlen —</option>
|
||||||
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Max. Stunden (1-5)</label>
|
<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}`} />
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<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>
|
<label htmlFor="is_hard_mc" className="text-sm">Harte Regel</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
|
<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>
|
||||||
<div className="md:col-span-2 flex items-end">
|
<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">
|
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>
|
||||||
{submitting ? 'Speichert...' : 'Anlegen'}
|
{crud.submitting ? 'Speichert...' : 'Anlegen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
}
|
||||||
)}
|
renderRow={(item) => {
|
||||||
|
const c = item as SubjectMaxConsecutive
|
||||||
{loading ? (
|
return (
|
||||||
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
|
<tr key={c.id} className={styles.rowClass}>
|
||||||
) : 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 font-medium">{sLabel(c.subject_id)}</td>
|
||||||
<td className="px-4 py-3">{c.max_consecutive}</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.is_hard ? '✓' : '—'}</td>
|
||||||
<td className="px-4 py-3">{c.weight}</td>
|
<td className="px-4 py-3">{c.weight}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<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>
|
<button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)
|
||||||
</tbody>
|
}}
|
||||||
</table>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,169 +1,87 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
|
||||||
import { subjectPreferredPeriodApi, subjectsApi } from '@/lib/stundenplan/api'
|
import { subjectPreferredPeriodApi, subjectsApi } from '@/lib/stundenplan/api'
|
||||||
import type { SubjectPreferredPeriod, TimetableSubject } from '@/app/stundenplan/types'
|
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'>
|
type FormState = Omit<SubjectPreferredPeriod, 'id' | 'created_by_user_id' | 'created_at'>
|
||||||
|
|
||||||
const initialForm: FormState = {
|
const initialForm: FormState = {
|
||||||
subject_id: '',
|
subject_id: '', period_from: 1, period_to: 4, is_hard: false, weight: 40, active: true, note: '',
|
||||||
period_from: 1,
|
|
||||||
period_to: 4,
|
|
||||||
is_hard: false,
|
|
||||||
weight: 40,
|
|
||||||
active: true,
|
|
||||||
note: '',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubjectPreferredPeriodEditor() {
|
export function SubjectPreferredPeriodEditor() {
|
||||||
const { isDark } = useTheme()
|
const styles = useShellStyles()
|
||||||
const [items, setItems] = useState<SubjectPreferredPeriod[]>([])
|
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 [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 () => {
|
useEffect(() => { subjectsApi.list().then(setSubjects).catch(() => setSubjects([])) }, [])
|
||||||
setLoading(true); setError(null)
|
|
||||||
try {
|
|
||||||
const [rules, s] = await Promise.all([subjectPreferredPeriodApi.list(), subjectsApi.list()])
|
|
||||||
setItems(rules || [])
|
|
||||||
setSubjects(s || [])
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
|
||||||
} finally { setLoading(false) }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (form.period_to < form.period_from) {
|
|
||||||
setError('"Bis"-Stunde darf nicht kleiner sein als "Von"-Stunde.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSubmitting(true); setError(null)
|
|
||||||
try {
|
|
||||||
await subjectPreferredPeriodApi.create(form)
|
|
||||||
setForm(initialForm); setShowForm(false); await load()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
|
|
||||||
} finally { setSubmitting(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
if (!confirm('Regel wirklich loeschen?')) return
|
|
||||||
try { await subjectPreferredPeriodApi.remove(id); await load() }
|
|
||||||
catch (err) { setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen') }
|
|
||||||
}
|
|
||||||
|
|
||||||
const sLabel = (id: string): string => {
|
const sLabel = (id: string): string => {
|
||||||
const s = subjects.find(x => x.id === id)
|
const s = subjects.find(x => x.id === id)
|
||||||
return s ? `${s.name} (${s.short_code})` : id.slice(0, 8) + '…'
|
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 (
|
return (
|
||||||
<div className="space-y-4" data-testid="subject-preferred-period-editor">
|
<ConstraintShell
|
||||||
<div className="flex items-center justify-between">
|
testId="subject-preferred-period-editor"
|
||||||
<div>
|
title="Fach: Bevorzugter Stunden-Bereich"
|
||||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
description="Beispiel: „Hauptfaecher lieber in den ersten 4 Stunden" (Soft-Regel)."
|
||||||
Fach: Bevorzugter Stunden-Bereich
|
newLabel="+ Neue Regel"
|
||||||
</h3>
|
newDisabled={subjects.length === 0}
|
||||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
prereqWarning={subjects.length === 0 ? 'Zuerst Faecher anlegen.' : null}
|
||||||
Beispiel: „Hauptfaecher lieber in den ersten 4 Stunden" (Soft-Regel).
|
emptyText="Keine Regeln vorhanden."
|
||||||
</p>
|
tableHeaders={['Fach', 'Bereich', 'Hart', 'Weight']}
|
||||||
</div>
|
state={crud}
|
||||||
<button
|
formBody={
|
||||||
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 className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Fach</label>
|
<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}`}>
|
<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>
|
<option value="">— bitte waehlen —</option>
|
||||||
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Von Stunde</label>
|
<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}`} />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Bis Stunde</label>
|
<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}`} />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<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>
|
<label htmlFor="is_hard_pp" className="text-sm">Harte Regel (selten sinnvoll hier)</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-3 flex items-end">
|
<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">
|
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>
|
||||||
{submitting ? 'Speichert...' : 'Anlegen'}
|
{crud.submitting ? 'Speichert...' : 'Anlegen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
}
|
||||||
)}
|
renderRow={(item) => {
|
||||||
|
const c = item as SubjectPreferredPeriod
|
||||||
{loading ? (
|
return (
|
||||||
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
|
<tr key={c.id} className={styles.rowClass}>
|
||||||
) : 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 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">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.is_hard ? '✓' : '—'}</td>
|
||||||
<td className="px-4 py-3">{c.weight}</td>
|
<td className="px-4 py-3">{c.weight}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<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>
|
<button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)
|
||||||
</tbody>
|
}}
|
||||||
</table>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
|
||||||
import { teacherUnavailableDayApi, teachersApi } from '@/lib/stundenplan/api'
|
import { teacherUnavailableDayApi, teachersApi } from '@/lib/stundenplan/api'
|
||||||
import type { TeacherUnavailableDay, TimetableTeacher } from '@/app/stundenplan/types'
|
import type { TeacherUnavailableDay, TimetableTeacher } from '@/app/stundenplan/types'
|
||||||
|
import { useConstraintCrud, ConstraintShell, useShellStyles, DAYS, dayLabel } from './_shell'
|
||||||
const DAYS = [
|
|
||||||
{ v: 1, label: 'Montag' },
|
|
||||||
{ v: 2, label: 'Dienstag' },
|
|
||||||
{ v: 3, label: 'Mittwoch' },
|
|
||||||
{ v: 4, label: 'Donnerstag' },
|
|
||||||
{ v: 5, label: 'Freitag' },
|
|
||||||
{ v: 6, label: 'Samstag' },
|
|
||||||
{ v: 7, label: 'Sonntag' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type FormState = Omit<TeacherUnavailableDay, 'id' | 'created_by_user_id' | 'created_at'>
|
type FormState = Omit<TeacherUnavailableDay, 'id' | 'created_by_user_id' | 'created_at'>
|
||||||
|
|
||||||
@@ -27,164 +17,84 @@ const initialForm: FormState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TeacherUnavailableDayEditor() {
|
export function TeacherUnavailableDayEditor() {
|
||||||
const { isDark } = useTheme()
|
const styles = useShellStyles()
|
||||||
const [items, setItems] = useState<TeacherUnavailableDay[]>([])
|
const crud = useConstraintCrud<TeacherUnavailableDay, FormState>(teacherUnavailableDayApi, initialForm)
|
||||||
const [teachers, setTeachers] = useState<TimetableTeacher[]>([])
|
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 () => {
|
useEffect(() => { teachersApi.list().then(setTeachers).catch(() => setTeachers([])) }, [])
|
||||||
setLoading(true); setError(null)
|
|
||||||
try {
|
|
||||||
const [rules, t] = await Promise.all([teacherUnavailableDayApi.list(), teachersApi.list()])
|
|
||||||
setItems(rules || [])
|
|
||||||
setTeachers(t || [])
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
|
||||||
} finally { setLoading(false) }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setSubmitting(true); setError(null)
|
|
||||||
try {
|
|
||||||
await teacherUnavailableDayApi.create(form)
|
|
||||||
setForm(initialForm)
|
|
||||||
setShowForm(false)
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
|
|
||||||
} finally { setSubmitting(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
if (!confirm('Regel wirklich loeschen?')) return
|
|
||||||
try {
|
|
||||||
await teacherUnavailableDayApi.remove(id)
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const teacherLabel = (id: string): string => {
|
const teacherLabel = (id: string): string => {
|
||||||
const t = teachers.find(x => x.id === id)
|
const t = teachers.find(x => x.id === id)
|
||||||
return t ? `${t.last_name}, ${t.first_name} (${t.short_code})` : id.slice(0, 8) + '…'
|
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 (
|
return (
|
||||||
<div className="space-y-4" data-testid="teacher-unavailable-day-editor">
|
<ConstraintShell
|
||||||
<div className="flex items-center justify-between">
|
testId="teacher-unavailable-day-editor"
|
||||||
<div>
|
title="Lehrer: Tag nicht verfuegbar"
|
||||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
description="Beispiel: „Lehrer X kann Montags nie"."
|
||||||
Lehrer: Tag nicht verfuegbar
|
newLabel="+ Neue Regel"
|
||||||
</h3>
|
newDisabled={teachers.length === 0}
|
||||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
prereqWarning={teachers.length === 0 ? 'Zuerst Lehrer anlegen, dann koennen Regeln vergeben werden.' : null}
|
||||||
Beispiel: „Lehrer X kann Montags nie".
|
emptyText="Keine Regeln vorhanden."
|
||||||
</p>
|
tableHeaders={['Lehrer', 'Tag', 'Hart', 'Weight', 'Aktiv', 'Notiz']}
|
||||||
</div>
|
state={crud}
|
||||||
<button
|
formBody={
|
||||||
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}`}>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Lehrer</label>
|
<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>
|
<option value="">— bitte waehlen —</option>
|
||||||
{teachers.map(t => <option key={t.id} value={t.id}>{t.last_name}, {t.first_name}</option>)}
|
{teachers.map(t => <option key={t.id} value={t.id}>{t.last_name}, {t.first_name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Wochentag</label>
|
<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>)}
|
{DAYS.map(d => <option key={d.v} value={d.v}>{d.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Gewichtung (0-100)</label>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<label htmlFor="is_hard" className="text-sm">Harte Regel (muss eingehalten werden)</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<label htmlFor="active" className="text-sm">Aktiv</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end">
|
<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">
|
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>
|
||||||
{submitting ? 'Speichert...' : 'Anlegen'}
|
{crud.submitting ? 'Speichert...' : 'Anlegen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<label className="block text-sm mb-1 opacity-70">Begruendung (optional)</label>
|
<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>
|
</div>
|
||||||
</form>
|
</>
|
||||||
)}
|
}
|
||||||
|
renderRow={(item) => {
|
||||||
{loading ? (
|
const c = item as TeacherUnavailableDay
|
||||||
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
|
return (
|
||||||
) : items.length === 0 ? (
|
<tr key={c.id} className={styles.rowClass}>
|
||||||
<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 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">{dayLabel(c.day_of_week)}</td>
|
||||||
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</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.weight}</td>
|
||||||
<td className="px-4 py-3">{c.active ? '✓' : '—'}</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-sm opacity-70">{c.note || '—'}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<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>
|
<button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)
|
||||||
</tbody>
|
}}
|
||||||
</table>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,183 +1,99 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
|
||||||
import { teacherUnavailableWindowApi, teachersApi } from '@/lib/stundenplan/api'
|
import { teacherUnavailableWindowApi, teachersApi } from '@/lib/stundenplan/api'
|
||||||
import type { TeacherUnavailableWindow, TimetableTeacher } from '@/app/stundenplan/types'
|
import type { TeacherUnavailableWindow, TimetableTeacher } from '@/app/stundenplan/types'
|
||||||
|
import { useConstraintCrud, ConstraintShell, useShellStyles, DAYS, dayLabel } from './_shell'
|
||||||
const DAYS = [
|
|
||||||
{ v: 1, label: 'Montag' }, { v: 2, label: 'Dienstag' }, { v: 3, label: 'Mittwoch' },
|
|
||||||
{ v: 4, label: 'Donnerstag' }, { v: 5, label: 'Freitag' }, { v: 6, label: 'Samstag' }, { v: 7, label: 'Sonntag' },
|
|
||||||
]
|
|
||||||
|
|
||||||
type FormState = Omit<TeacherUnavailableWindow, 'id' | 'created_by_user_id' | 'created_at'>
|
type FormState = Omit<TeacherUnavailableWindow, 'id' | 'created_by_user_id' | 'created_at'>
|
||||||
|
|
||||||
const initialForm: FormState = {
|
const initialForm: FormState = {
|
||||||
teacher_id: '',
|
teacher_id: '', day_of_week: 2, start_time: '13:00', end_time: '17:00',
|
||||||
day_of_week: 2,
|
is_hard: true, weight: 100, active: true, note: '',
|
||||||
start_time: '13:00',
|
|
||||||
end_time: '17:00',
|
|
||||||
is_hard: true,
|
|
||||||
weight: 100,
|
|
||||||
active: true,
|
|
||||||
note: '',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TeacherUnavailableWindowEditor() {
|
export function TeacherUnavailableWindowEditor() {
|
||||||
const { isDark } = useTheme()
|
const styles = useShellStyles()
|
||||||
const [items, setItems] = useState<TeacherUnavailableWindow[]>([])
|
const crud = useConstraintCrud<TeacherUnavailableWindow, FormState>(teacherUnavailableWindowApi, initialForm)
|
||||||
const [teachers, setTeachers] = useState<TimetableTeacher[]>([])
|
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 () => {
|
useEffect(() => { teachersApi.list().then(setTeachers).catch(() => setTeachers([])) }, [])
|
||||||
setLoading(true); setError(null)
|
|
||||||
try {
|
|
||||||
const [rules, t] = await Promise.all([teacherUnavailableWindowApi.list(), teachersApi.list()])
|
|
||||||
setItems(rules || [])
|
|
||||||
setTeachers(t || [])
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
|
|
||||||
} finally { setLoading(false) }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
const teacherLabel = (id: string): string => {
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setSubmitting(true); setError(null)
|
|
||||||
try {
|
|
||||||
await teacherUnavailableWindowApi.create(form)
|
|
||||||
setForm(initialForm); setShowForm(false); await load()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
|
|
||||||
} finally { setSubmitting(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
if (!confirm('Regel wirklich loeschen?')) return
|
|
||||||
try { await teacherUnavailableWindowApi.remove(id); await load() }
|
|
||||||
catch (err) { setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen') }
|
|
||||||
}
|
|
||||||
|
|
||||||
const tLabel = (id: string): string => {
|
|
||||||
const t = teachers.find(x => x.id === id)
|
const t = teachers.find(x => x.id === id)
|
||||||
return t ? `${t.last_name}, ${t.first_name}` : id.slice(0, 8) + '…'
|
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 (
|
return (
|
||||||
<div className="space-y-4" data-testid="teacher-unavailable-window-editor">
|
<ConstraintShell
|
||||||
<div className="flex items-center justify-between">
|
testId="teacher-unavailable-window-editor"
|
||||||
<div>
|
title="Lehrer: Zeitfenster nicht verfuegbar"
|
||||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
description="Beispiel: „Lehrer Z Dienstags 13:00–17:00 nicht"."
|
||||||
Lehrer: Zeitfenster nicht verfuegbar
|
newLabel="+ Neue Regel"
|
||||||
</h3>
|
newDisabled={teachers.length === 0}
|
||||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
prereqWarning={teachers.length === 0 ? 'Zuerst Lehrer anlegen.' : null}
|
||||||
Beispiel: „Lehrer Z Dienstags 13:00–17:00 nicht".
|
emptyText="Keine Regeln vorhanden."
|
||||||
</p>
|
tableHeaders={['Lehrer', 'Tag', 'Zeitfenster', 'Hart', 'Notiz']}
|
||||||
</div>
|
state={crud}
|
||||||
<button
|
formBody={
|
||||||
onClick={() => setShowForm(s => !s)}
|
<>
|
||||||
disabled={teachers.length === 0}
|
|
||||||
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
|
|
||||||
>
|
|
||||||
{showForm ? 'Abbrechen' : '+ Neue Regel'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{teachers.length === 0 && !loading && (
|
|
||||||
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
|
|
||||||
Zuerst Lehrer anlegen.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
|
|
||||||
|
|
||||||
{showForm && (
|
|
||||||
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Lehrer</label>
|
<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>
|
<option value="">— bitte waehlen —</option>
|
||||||
{teachers.map(t => <option key={t.id} value={t.id}>{t.last_name}, {t.first_name}</option>)}
|
{teachers.map(t => <option key={t.id} value={t.id}>{t.last_name}, {t.first_name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Wochentag</label>
|
<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>)}
|
{DAYS.map(d => <option key={d.v} value={d.v}>{d.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Von</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Bis</label>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<label htmlFor="is_hard_w" className="text-sm">Harte Regel</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
|
<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>
|
||||||
<div className="md:col-span-2 flex items-end">
|
<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">
|
<button type="submit" disabled={crud.submitting} className={styles.submitBtn}>
|
||||||
{submitting ? 'Speichert...' : 'Anlegen'}
|
{crud.submitting ? 'Speichert...' : 'Anlegen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<label className="block text-sm mb-1 opacity-70">Begruendung (optional)</label>
|
<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>
|
</div>
|
||||||
</form>
|
</>
|
||||||
)}
|
}
|
||||||
|
renderRow={(item) => {
|
||||||
{loading ? (
|
const c = item as TeacherUnavailableWindow
|
||||||
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
|
return (
|
||||||
) : items.length === 0 ? (
|
<tr key={c.id} className={styles.rowClass}>
|
||||||
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Keine Regeln vorhanden.</div>
|
<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>
|
||||||
<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.start_time}–{c.end_time}</td>
|
||||||
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</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-sm opacity-70">{c.note || '—'}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<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>
|
<button onClick={() => crud.remove(c.id)} className={styles.deleteBtn}>Loeschen</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)
|
||||||
</tbody>
|
}}
|
||||||
</table>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -78,8 +78,19 @@ async function mockSchoolApi(page: Page, opts: MockOpts = {}) {
|
|||||||
for (const path of [
|
for (const path of [
|
||||||
'constraints/teacher/unavailable-day',
|
'constraints/teacher/unavailable-day',
|
||||||
'constraints/teacher/unavailable-window',
|
'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/max-consecutive',
|
||||||
'constraints/subject/preferred-period',
|
'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, [])
|
await staticList(path, [])
|
||||||
}
|
}
|
||||||
@@ -314,10 +325,37 @@ test.describe('Stundenplan — Regeln Hub', () => {
|
|||||||
await expect(page.getByTestId('subject-preferred-period-editor')).toBeVisible()
|
await expect(page.getByTestId('subject-preferred-period-editor')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('unimplemented rules are disabled in the sidebar', async ({ page }) => {
|
test('all unique-labelled constraint editors mount when selected', async ({ page }) => {
|
||||||
const soonBtn = page.getByRole('button', { name: /Min\. Tagesabstand/ })
|
// Skip 'Max. Stunden / Tag' (teacher + class both use it) — covered separately below.
|
||||||
await expect(soonBtn).toBeDisabled()
|
const cases: { label: string; testId: string }[] = [
|
||||||
await expect(page.getByText('soon').first()).toBeVisible()
|
{ 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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user