Files
breakpilot-lehrer/studio-v2/app/stundenplan/_components/regeln/SubjectPreferredPeriodEditor.tsx
T
Benjamin Admin c2c09e1cd9
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 31s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 3m31s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 22s
Stundenplan Phase 3c: complete Stammdaten + RegelnHub with 4 editors
Frontend additions in studio-v2:
  - PeriodsManager renders the weekly grid as a Mo–So table with one
    row per period_index. New entries auto-increment period_index so
    the user can hit Anlegen repeatedly for a full day's slots.
  - CurriculumManager joins classes + subjects; new entries refuse to
    open when either prerequisite list is empty (banner instead).
  - AssignmentsManager joins teacher × class × subject with the same
    prerequisite-banner pattern.
  - regeln/RegelnHub: vertical sidebar grouping all 15 constraint
    types by parent entity (Lehrer/Fach/Klasse/Raum). Implemented
    editors are clickable, the other 11 are visibly disabled with
    a 'soon' tag.
  - Three new editors:
      TeacherUnavailableWindowEditor (time-window pattern),
      SubjectMaxConsecutiveEditor (number-input pattern),
      SubjectPreferredPeriodEditor (number range pattern).
  - page.tsx wires every tab to its manager; the not-implemented
    placeholder is gone (no more empty tabs).

Test coverage:
  - e2e/stundenplan.spec.ts rewritten: 23 tests across 7 suites,
    covering all 8 tabs, the new managers' prerequisite banners,
    sub-tab switching in the RegelnHub, and the disabled state of
    not-yet-implemented constraint rules. Each test mocks the
    backend via page.route() so the suite stays hermetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:08:15 +02:00

170 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { subjectPreferredPeriodApi, subjectsApi } from '@/lib/stundenplan/api'
import type { SubjectPreferredPeriod, TimetableSubject } from '@/app/stundenplan/types'
type FormState = Omit<SubjectPreferredPeriod, 'id' | 'created_by_user_id' | 'created_at'>
const initialForm: FormState = {
subject_id: '',
period_from: 1,
period_to: 4,
is_hard: false,
weight: 40,
active: true,
note: '',
}
export function SubjectPreferredPeriodEditor() {
const { isDark } = useTheme()
const [items, setItems] = useState<SubjectPreferredPeriod[]>([])
const [subjects, setSubjects] = useState<TimetableSubject[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [form, setForm] = useState<FormState>(initialForm)
const load = useCallback(async () => {
setLoading(true); setError(null)
try {
const [rules, s] = await Promise.all([subjectPreferredPeriodApi.list(), subjectsApi.list()])
setItems(rules || [])
setSubjects(s || [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
} finally { setLoading(false) }
}, [])
useEffect(() => { load() }, [load])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (form.period_to < form.period_from) {
setError('"Bis"-Stunde darf nicht kleiner sein als "Von"-Stunde.')
return
}
setSubmitting(true); setError(null)
try {
await subjectPreferredPeriodApi.create(form)
setForm(initialForm); setShowForm(false); await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally { setSubmitting(false) }
}
const handleDelete = async (id: string) => {
if (!confirm('Regel wirklich loeschen?')) return
try { await subjectPreferredPeriodApi.remove(id); await load() }
catch (err) { setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen') }
}
const sLabel = (id: string): string => {
const s = subjects.find(x => x.id === id)
return s ? `${s.name} (${s.short_code})` : id.slice(0, 8) + '…'
}
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className="space-y-4" data-testid="subject-preferred-period-editor">
<div className="flex items-center justify-between">
<div>
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Fach: Bevorzugter Stunden-Bereich
</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Beispiel: Hauptfaecher lieber in den ersten 4 Stunden" (Soft-Regel).
</p>
</div>
<button
onClick={() => setShowForm(s => !s)}
disabled={subjects.length === 0}
className={`px-4 py-2 rounded-xl font-medium transition-colors disabled:opacity-50 ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{showForm ? 'Abbrechen' : '+ Neue Regel'}
</button>
</div>
{subjects.length === 0 && !loading && (
<div className={`p-3 rounded-xl text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-200' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
Zuerst Faecher anlegen.
</div>
)}
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
{showForm && (
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Fach</label>
<select required value={form.subject_id} onChange={e => setForm({ ...form, subject_id: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}>
<option value="">— bitte waehlen —</option>
{subjects.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Von Stunde</label>
<input type="number" min={1} max={12} required value={form.period_from} onChange={e => setForm({ ...form, period_from: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Bis Stunde</label>
<input type="number" min={1} max={12} required value={form.period_to} onChange={e => setForm({ ...form, period_to: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Weight (0-100)</label>
<input type="number" min={0} max={100} value={form.weight} onChange={e => setForm({ ...form, weight: parseInt(e.target.value) || 0 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_hard_pp" checked={form.is_hard} onChange={e => setForm({ ...form, is_hard: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_hard_pp" className="text-sm">Harte Regel (selten sinnvoll hier)</label>
</div>
<div className="md:col-span-3 flex items-end">
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Speichert...' : 'Anlegen'}
</button>
</div>
</div>
</form>
)}
{loading ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Laedt…</div>
) : items.length === 0 ? (
<div className={`text-center py-8 opacity-60 ${isDark ? 'text-white' : 'text-slate-700'}`}>Keine Regeln vorhanden.</div>
) : (
<div className={`rounded-2xl border backdrop-blur-xl overflow-hidden ${cardClass}`}>
<table className="w-full">
<thead className={isDark ? 'bg-white/5' : 'bg-slate-100'}>
<tr>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Fach</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Bereich</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Hart</th>
<th className="text-left px-4 py-3 text-sm font-medium opacity-70">Weight</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{items.map(c => (
<tr key={c.id} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="px-4 py-3 font-medium">{sLabel(c.subject_id)}</td>
<td className="px-4 py-3">Stunde {c.period_from}{c.period_to}</td>
<td className="px-4 py-3">{c.is_hard ? '✓' : '—'}</td>
<td className="px-4 py-3">{c.weight}</td>
<td className="px-4 py-3 text-right">
<button onClick={() => handleDelete(c.id)} className="text-red-400 hover:text-red-300 text-sm">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}