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
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>
219 lines
7.2 KiB
TypeScript
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)
|
|
}
|