Files
breakpilot-lehrer/studio-v2/app/stundenplan/_components/regeln/_shell.tsx
T
Benjamin Admin 7c96d89927
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
Stundenplan Phase 3d: all 15 constraint editors via shared shell
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>
2026-05-21 23:27:34 +02:00

219 lines
7.2 KiB
TypeScript

'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)
}