Files
breakpilot-lehrer/studio-v2/app/stundenplan/_components/PeriodsManager.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
7.4 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 { periodsApi } from '@/lib/stundenplan/api'
import type { TimetablePeriod, CreateTimetablePeriod } from '@/app/stundenplan/types'
const DAYS = [
{ v: 1, label: 'Mo' },
{ v: 2, label: 'Di' },
{ v: 3, label: 'Mi' },
{ v: 4, label: 'Do' },
{ v: 5, label: 'Fr' },
{ v: 6, label: 'Sa' },
{ v: 7, label: 'So' },
]
const initialForm: CreateTimetablePeriod = {
day_of_week: 1,
period_index: 1,
start_time: '08:00',
end_time: '08:45',
is_break: false,
label: '',
}
export function PeriodsManager() {
const { isDark } = useTheme()
const [items, setItems] = useState<TimetablePeriod[]>([])
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<CreateTimetablePeriod>(initialForm)
const load = useCallback(async () => {
setLoading(true); setError(null)
try {
const data = await periodsApi.list()
setItems(data || [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
} finally { setLoading(false) }
}, [])
useEffect(() => { load() }, [load])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true); setError(null)
try {
await periodsApi.create(form)
setForm({ ...initialForm, day_of_week: form.day_of_week, period_index: form.period_index + 1 })
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally { setSubmitting(false) }
}
const handleDelete = async (id: string) => {
if (!confirm('Zeitraster-Eintrag wirklich loeschen?')) return
try {
await periodsApi.remove(id)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen')
}
}
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
// Group periods by period_index for an at-a-glance week grid.
const periodIndices = Array.from(new Set(items.map(i => i.period_index))).sort((a, b) => a - b)
const periodByDay = (day: number, idx: number) => items.find(p => p.day_of_week === day && p.period_index === idx)
return (
<div className="space-y-4" data-testid="periods-manager">
<div className="flex items-center justify-between">
<div>
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Zeitraster ({items.length})
</h2>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Pro Wochentag die Stunden-Slots (z.B. 1. Stunde 08:0008:45).
</p>
</div>
<button
onClick={() => setShowForm(s => !s)}
className={`px-4 py-2 rounded-xl font-medium transition-colors ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}
>
{showForm ? 'Abbrechen' : '+ Neuer Slot'}
</button>
</div>
{error && <div className="p-3 rounded-xl bg-red-500/20 border border-red-500/40 text-red-300">{error}</div>}
{showForm && (
<form onSubmit={handleSubmit} className={`p-4 rounded-2xl border backdrop-blur-xl ${cardClass}`}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">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}`}>
{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={form.period_index} onChange={e => setForm({ ...form, period_index: parseInt(e.target.value) || 1 })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="is_break" checked={!!form.is_break} onChange={e => setForm({ ...form, is_break: e.target.checked })} className="w-5 h-5" />
<label htmlFor="is_break" className="text-sm">Pause</label>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Startzeit</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}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Endzeit</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}`} />
</div>
<div className="flex items-end">
<button type="submit" disabled={submitting} className="w-full px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white font-medium disabled:opacity-50">
{submitting ? 'Speichert...' : 'Anlegen (+1)'}
</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'}`}>Noch kein Zeitraster definiert.</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">Stunde</th>
{DAYS.map(d => <th key={d.v} className="text-left px-4 py-3 text-sm font-medium opacity-70">{d.label}</th>)}
</tr>
</thead>
<tbody>
{periodIndices.map(idx => (
<tr key={idx} className={isDark ? 'border-t border-white/10' : 'border-t border-slate-200'}>
<td className="px-4 py-3 font-medium">{idx}.</td>
{DAYS.map(d => {
const p = periodByDay(d.v, idx)
if (!p) return <td key={d.v} className="px-4 py-3 opacity-30"></td>
return (
<td key={d.v} className="px-4 py-3">
<div className={`text-sm ${p.is_break ? 'italic opacity-60' : ''}`}>
{p.start_time}{p.end_time}
</div>
<button onClick={() => handleDelete(p.id)} className="text-xs text-red-400 hover:text-red-300 mt-1">×</button>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}