From f21ecf293b7c78d387f0c2e72c3804d8b41a5339 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 21 May 2026 22:25:18 +0200 Subject: [PATCH] Add Stundenplan frontend scaffolding in studio-v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 — initial UI for the timetable scheduler: - app/stundenplan/page.tsx with tab navigation (Klassen / Lehrer / Faecher / Raeume / Zeitraster / Stundentafel / Lehrauftraege / Regeln) and a dev-mode JWT entry to authenticate against school-service until full auth is wired up. - app/stundenplan/_components/KlassenManager.tsx as the working prototype for one entity (list / create / delete). Pattern can be copied for the other 6 stammdaten + 15 constraint editors. - lib/stundenplan/api.ts exposing typed clients for all 22 endpoints (7 stammdaten + 15 constraint tables). Constraints use a factory to keep the file tight. - app/api/school/[...path]/route.ts proxies the browser through Next.js to school-service so HTTPS studio-v2 can reach the plain HTTP backend. - Sidebar.tsx gains a Stundenplan entry with 26-language labels. - docker-compose.yml exposes SCHOOL_SERVICE_URL to studio-v2 and declares the school-service dependency. Co-Authored-By: Claude Opus 4.7 (1M context) --- docker-compose.yml | 2 + studio-v2/app/api/school/[...path]/route.ts | 65 +++++ .../_components/KlassenManager.tsx | 196 +++++++++++++++ studio-v2/app/stundenplan/page.tsx | 121 ++++++++++ studio-v2/app/stundenplan/types.ts | 226 ++++++++++++++++++ studio-v2/components/Sidebar.tsx | 7 + studio-v2/lib/stundenplan/api.ts | 140 +++++++++++ 7 files changed, 757 insertions(+) create mode 100644 studio-v2/app/api/school/[...path]/route.ts create mode 100644 studio-v2/app/stundenplan/_components/KlassenManager.tsx create mode 100644 studio-v2/app/stundenplan/page.tsx create mode 100644 studio-v2/app/stundenplan/types.ts create mode 100644 studio-v2/lib/stundenplan/api.ts diff --git a/docker-compose.yml b/docker-compose.yml index 5a238c5..d4e3507 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -109,8 +109,10 @@ services: environment: NODE_ENV: production BACKEND_URL: http://backend-lehrer:8001 + SCHOOL_SERVICE_URL: http://school-service:8084 depends_on: - backend-lehrer + - school-service restart: unless-stopped networks: - breakpilot-network diff --git a/studio-v2/app/api/school/[...path]/route.ts b/studio-v2/app/api/school/[...path]/route.ts new file mode 100644 index 0000000..57c14dd --- /dev/null +++ b/studio-v2/app/api/school/[...path]/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server' + +/** + * Proxy for the school-service (Go/Gin, port 8084). The browser cannot call + * the backend directly because studio-v2 is served over HTTPS and the backend + * is plain HTTP; this Next.js route bridges them server-side. + */ + +const BACKEND_URL = process.env.SCHOOL_SERVICE_URL || 'http://school-service:8084' + +async function proxyRequest( + request: NextRequest, + params: { path: string[] } +): Promise { + const path = params.path.join('/') + const url = `${BACKEND_URL}/api/v1/school/${path}` + + try { + const headers: HeadersInit = { 'Content-Type': 'application/json' } + const authHeader = request.headers.get('authorization') + if (authHeader) headers['Authorization'] = authHeader + + const fetchOptions: RequestInit = { + method: request.method, + headers, + } + + if (['POST', 'PUT', 'PATCH'].includes(request.method)) { + fetchOptions.body = await request.text() + } + + const response = await fetch(url, fetchOptions) + const contentType = response.headers.get('content-type') + const data = contentType?.includes('application/json') + ? await response.text() + : await response.arrayBuffer() + + return new NextResponse(data, { + status: response.status, + headers: { 'Content-Type': contentType || 'application/json' }, + }) + } catch (error) { + console.error(`Failed to proxy ${request.method} ${url}:`, error) + return NextResponse.json( + { error: 'school-service nicht erreichbar', details: error instanceof Error ? error.message : 'Unknown error' }, + { status: 502 }, + ) + } +} + +export async function GET(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + return proxyRequest(request, await context.params) +} + +export async function POST(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + return proxyRequest(request, await context.params) +} + +export async function PUT(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + return proxyRequest(request, await context.params) +} + +export async function DELETE(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + return proxyRequest(request, await context.params) +} diff --git a/studio-v2/app/stundenplan/_components/KlassenManager.tsx b/studio-v2/app/stundenplan/_components/KlassenManager.tsx new file mode 100644 index 0000000..06a5755 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/KlassenManager.tsx @@ -0,0 +1,196 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useTheme } from '@/lib/ThemeContext' +import { classesApi } from '@/lib/stundenplan/api' +import type { TimetableClass, CreateTimetableClass } from '@/app/stundenplan/types' + +export function KlassenManager() { + const { isDark } = useTheme() + const [classes, setClasses] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showForm, setShowForm] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [form, setForm] = useState({ + name: '', + grade_level: 5, + student_count: 0, + notes: '', + }) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await classesApi.list() + setClasses(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 classesApi.create(form) + setForm({ name: '', grade_level: 5, student_count: 0, notes: '' }) + setShowForm(false) + await load() + } catch (err) { + setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen') + } finally { + setSubmitting(false) + } + } + + const handleDelete = async (id: string) => { + if (!confirm('Klasse wirklich loeschen?')) return + try { + await classesApi.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 placeholder-white/40' + : 'bg-white border-slate-300 text-slate-900 placeholder-slate-400' + + return ( +
+
+

+ Klassen ({classes.length}) +

+ +
+ + {error && ( +
+ {error} +
+ )} + + {showForm && ( +
+
+
+ + setForm({ ...form, name: e.target.value })} + placeholder="z.B. 5a" + className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} + /> +
+
+ + setForm({ ...form, grade_level: parseInt(e.target.value) || 0 })} + className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} + /> +
+
+ + setForm({ ...form, student_count: parseInt(e.target.value) || 0 })} + className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} + /> +
+
+ +
+
+
+ + setForm({ ...form, notes: e.target.value })} + className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} + /> +
+
+ )} + + {loading ? ( +
+ Laedt… +
+ ) : classes.length === 0 ? ( +
+ Noch keine Klassen angelegt. +
+ ) : ( +
+ + + + + + + + + + + + {classes.map(c => ( + + + + + + + + ))} + +
NameStufeSchuelerNotizen
{c.name}{c.grade_level}{c.student_count}{c.notes || '—'} + +
+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/page.tsx b/studio-v2/app/stundenplan/page.tsx new file mode 100644 index 0000000..09e9c68 --- /dev/null +++ b/studio-v2/app/stundenplan/page.tsx @@ -0,0 +1,121 @@ +'use client' + +import { useState } from 'react' +import { useTheme } from '@/lib/ThemeContext' +import { Sidebar } from '@/components/Sidebar' +import { ThemeToggle } from '@/components/ThemeToggle' +import { LanguageDropdown } from '@/components/LanguageDropdown' +import { KlassenManager } from './_components/KlassenManager' +import { setStundenplanToken, getStundenplanToken } from '@/lib/stundenplan/api' + +type Tab = 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln' + +const TABS: { id: Tab; label: string }[] = [ + { id: 'klassen', label: 'Klassen' }, + { id: 'lehrer', label: 'Lehrer' }, + { id: 'faecher', label: 'Faecher' }, + { id: 'raeume', label: 'Raeume' }, + { id: 'periods', label: 'Zeitraster' }, + { id: 'curriculum', label: 'Stundentafel' }, + { id: 'assignments', label: 'Lehrauftraege' }, + { id: 'regeln', label: 'Regeln (Constraints)' }, +] + +export default function StundenplanPage() { + const { isDark } = useTheme() + const [tab, setTab] = useState('klassen') + const [token, setToken] = useState(getStundenplanToken()) + + const handleSaveToken = () => { + setStundenplanToken(token) + alert('Token gespeichert. Seite neu laden um die Aenderung zu uebernehmen.') + } + + return ( +
+ + +
+
+
+

+ Stundenplan +

+

+ Stammdaten und Regeln fuer den Solver +

+
+
+ + +
+
+ +
+
+ + Dev: JWT-Token setzen (Anmeldung noch nicht integriert) + +
+ setToken(e.target.value)} + placeholder="Bearer-Token" + className={`flex-1 px-3 py-1.5 rounded-lg text-sm ${ + isDark ? 'bg-white/10 border border-white/20 text-white' : 'bg-white border border-slate-300 text-slate-900' + }`} + /> + +
+
+
+ + + +
+ {tab === 'klassen' && } + {tab !== 'klassen' && ( +
+

Noch nicht implementiert: {TABS.find(t => t.id === tab)?.label}

+

+ Das Klassen-Modul ist der Prototyp. Die anderen Stammdaten und alle 15 Constraints folgen dem gleichen Muster. +

+
+ )} +
+
+
+ ) +} diff --git a/studio-v2/app/stundenplan/types.ts b/studio-v2/app/stundenplan/types.ts new file mode 100644 index 0000000..7c661b1 --- /dev/null +++ b/studio-v2/app/stundenplan/types.ts @@ -0,0 +1,226 @@ +// Mirror of school-service/internal/models/timetable*.go. +// Field names in JSON come from the Go struct json:"…" tags. + +// ---------- Stammdaten (7) ---------- + +export interface TimetableClass { + id: string + created_by_user_id: string + name: string + grade_level: number + student_count: number + notes?: string + created_at: string +} + +export interface TimetablePeriod { + id: string + created_by_user_id: string + day_of_week: number + period_index: number + start_time: string + end_time: string + is_break: boolean + label?: string + created_at: string +} + +export interface TimetableRoom { + id: string + created_by_user_id: string + name: string + room_type?: string + capacity: number + floor_level: number + has_elevator: boolean + notes?: string + created_at: string +} + +export interface TimetableSubject { + id: string + created_by_user_id: string + name: string + short_code: string + color?: string + is_main_subject: boolean + required_room_type?: string + created_at: string +} + +export interface TimetableTeacher { + id: string + created_by_user_id: string + first_name: string + last_name: string + short_code: string + employment_percentage: number + max_hours_week: number + notes?: string + created_at: string +} + +export interface TimetableCurriculum { + id: string + class_id: string + subject_id: string + weekly_hours: number + created_at: string + subject_name?: string + class_name?: string +} + +export interface TimetableAssignment { + id: string + teacher_id: string + class_id: string + subject_id: string + created_at: string + teacher_name?: string + class_name?: string + subject_name?: string +} + +// Create-request DTOs + +export interface CreateTimetableClass { + name: string + grade_level: number + student_count?: number + notes?: string +} + +export interface CreateTimetablePeriod { + day_of_week: number + period_index: number + start_time: string + end_time: string + is_break?: boolean + label?: string +} + +export interface CreateTimetableRoom { + name: string + room_type?: string + capacity?: number + floor_level?: number + has_elevator?: boolean + notes?: string +} + +export interface CreateTimetableSubject { + name: string + short_code: string + color?: string + is_main_subject?: boolean + required_room_type?: string +} + +export interface CreateTimetableTeacher { + first_name: string + last_name: string + short_code: string + employment_percentage?: number + max_hours_week?: number + notes?: string +} + +export interface CreateTimetableCurriculum { + class_id: string + subject_id: string + weekly_hours: number +} + +export interface CreateTimetableAssignment { + teacher_id: string + class_id: string + subject_id: string +} + +// ---------- Constraints (15) ---------- + +interface ConstraintBase { + id: string + created_by_user_id: string + is_hard: boolean + weight: number + active: boolean + note?: string + created_at: string +} + +export interface TeacherUnavailableDay extends ConstraintBase { + teacher_id: string + day_of_week: number +} + +export interface TeacherUnavailableWindow extends ConstraintBase { + teacher_id: string + day_of_week: number + start_time: string + end_time: string +} + +export interface TeacherMaxHoursDay extends ConstraintBase { + teacher_id: string + max_hours: number +} + +export interface TeacherMaxHoursWeek extends ConstraintBase { + teacher_id: string + max_hours: number +} + +export interface TeacherExcludedSubject extends ConstraintBase { + teacher_id: string + subject_id: string +} + +export interface TeacherExcludedRoom extends ConstraintBase { + teacher_id: string + room_id: string +} + +export interface SubjectMinDayGap extends ConstraintBase { + subject_id: string + min_gap_days: number +} + +export interface SubjectMaxConsecutive extends ConstraintBase { + subject_id: string + max_consecutive: number +} + +export interface SubjectContiguousWhenRepeated extends ConstraintBase { + subject_id: string +} + +export interface SubjectPreferredPeriod extends ConstraintBase { + subject_id: string + period_from: number + period_to: number +} + +export interface SubjectDoubleLesson extends ConstraintBase { + subject_id: string +} + +export interface ClassMaxHoursDay extends ConstraintBase { + class_id: string + max_hours: number +} + +export interface ClassNoGaps extends ConstraintBase { + class_id: string +} + +export interface RoomRequiresType extends ConstraintBase { + subject_id: string + room_type: string +} + +export interface RoomUnavailable extends ConstraintBase { + room_id: string + day_of_week: number + period_index: number +} diff --git a/studio-v2/components/Sidebar.tsx b/studio-v2/components/Sidebar.tsx index c6a25b6..532c950 100644 --- a/studio-v2/components/Sidebar.tsx +++ b/studio-v2/components/Sidebar.tsx @@ -17,6 +17,7 @@ const NAV_LABELS: Record> = { nav_eltern: { de: 'Eltern', en: 'Parents', tr: 'Ebeveyn', ar: '\u0627\u0644\u0648\u0627\u0644\u062f\u064a\u0646', uk: '\u0411\u0430\u0442\u044c\u043a\u0438', ru: '\u0420\u043e\u0434\u0438\u0442\u0435\u043b\u0438', pl: 'Rodzice', fr: 'Parents', es: 'Padres', it: 'Genitori', pt: 'Pais', nl: 'Ouders', ro: 'Parinti', el: '\u0393\u03bf\u03bd\u03b5\u03af\u03c2', bg: '\u0420\u043e\u0434\u0438\u0442\u0435\u043b\u0438', hr: 'Roditelji', cs: 'Rodice', hu: 'Szulok', sv: 'Foraldrar', da: 'Foraeldre', fi: 'Vanhemmat', sk: 'Rodicia', sl: 'Starsi', lt: 'Tevai', lv: 'Vecaki', et: 'Vanemad' }, nav_woerterbuch: { de: 'Woerterbuch', en: 'Dictionary', tr: 'Sozluk', ar: '\u0627\u0644\u0642\u0627\u0645\u0648\u0633', uk: '\u0421\u043b\u043e\u0432\u043d\u0438\u043a', ru: '\u0421\u043b\u043e\u0432\u0430\u0440\u044c', pl: 'Slownik', fr: 'Dictionnaire', es: 'Diccionario', it: 'Dizionario', pt: 'Dicionario', nl: 'Woordenboek', ro: 'Dictionar', el: '\u039b\u03b5\u03be\u03b9\u03ba\u03cc', bg: '\u0420\u0435\u0447\u043d\u0438\u043a', hr: 'Rjecnik', cs: 'Slovnik', hu: 'Szotar', sv: 'Ordbok', da: 'Ordbog', fi: 'Sanakirja', sk: 'Slovnik', sl: 'Slovar', lt: 'Zodynas', lv: 'Vardnica', et: 'Sonaraamat' }, nav_meet: { de: 'Videokonferenz', en: 'Video Call', tr: 'Gorusme', ar: '\u0645\u0643\u0627\u0644\u0645\u0629', uk: '\u0412\u0456\u0434\u0435\u043e\u0434\u0437\u0432\u0456\u043d\u043e\u043a', ru: '\u0412\u0438\u0434\u0435\u043e\u0437\u0432\u043e\u043d\u043e\u043a', pl: 'Wideorozmowa', fr: 'Visioconference', es: 'Videollamada', it: 'Videochiamata', pt: 'Videochamada', nl: 'Videogesprek', ro: 'Videoconferinta', el: '\u0392\u03b9\u03bd\u03c4\u03b5\u03bf\u03ba\u03bb\u03ae\u03c3\u03b7', bg: '\u0412\u0438\u0434\u0435\u043e\u0440\u0430\u0437\u0433\u043e\u0432\u043e\u0440', hr: 'Videopoziv', cs: 'Videohovor', hu: 'Videohivas', sv: 'Videosamtal', da: 'Videoopkald', fi: 'Videopuhelu', sk: 'Videohovor', sl: 'Videoklic', lt: 'Vaizdo skambutis', lv: 'Videozvans', et: 'Videokoone' }, + nav_stundenplan: { de: 'Stundenplan', en: 'Timetable', tr: 'Ders Programi', ar: 'جدول حصص', uk: 'Розклад', ru: 'Расписание', pl: 'Plan lekcji', fr: 'Emploi du temps', es: 'Horario', it: 'Orario', pt: 'Horario', nl: 'Rooster', ro: 'Orar', el: 'Πρόγραμμα', bg: 'Разписание', hr: 'Raspored', cs: 'Rozvrh', hu: 'Orarend', sv: 'Schema', da: 'Skema', fi: 'Lukujarjestys', sk: 'Rozvrh', sl: 'Urnik', lt: 'Tvarkarastis', lv: 'Stundu saraksts', et: 'Tunniplaan' }, nav_companion: { de: 'KI-Assistent', en: 'AI Assistant', tr: 'Yapay Zeka', ar: '\u0645\u0633\u0627\u0639\u062f \u0630\u0643\u064a', uk: '\u0428\u0406-\u0430\u0441\u0438\u0441\u0442\u0435\u043d\u0442', ru: '\u0418\u0418-\u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442', pl: 'Asystent AI', fr: 'Assistant IA', es: 'Asistente IA', it: 'Assistente IA', pt: 'Assistente IA', nl: 'AI-assistent', ro: 'Asistent AI', el: 'AI \u0392\u03bf\u03b7\u03b8\u03cc\u03c2', bg: 'AI \u0430\u0441\u0438\u0441\u0442\u0435\u043d\u0442', hr: 'AI pomoenik', cs: 'AI asistent', hu: 'AI asszisztens', sv: 'AI-assistent', da: 'AI-assistent', fi: 'Tekoalyavustaja', sk: 'AI asistent', sl: 'AI pomoenik', lt: 'DI asistentas', lv: 'MI paligs', et: 'Tehisabiabi' }, } @@ -105,6 +106,11 @@ export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps )}, + { id: 'stundenplan', labelKey: 'nav_stundenplan', href: '/stundenplan', icon: ( + + + + )}, { id: 'companion', labelKey: 'nav_companion', href: '/companion', icon: ( @@ -151,6 +157,7 @@ export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps if (pathname === '/alerts-b2b') return 'alerts-b2b' if (pathname === '/messages') return 'messages' if (pathname?.startsWith('/korrektur')) return 'korrektur' + if (pathname?.startsWith('/stundenplan')) return 'stundenplan' return selectedTab } diff --git a/studio-v2/lib/stundenplan/api.ts b/studio-v2/lib/stundenplan/api.ts new file mode 100644 index 0000000..55034dc --- /dev/null +++ b/studio-v2/lib/stundenplan/api.ts @@ -0,0 +1,140 @@ +/** + * Stundenplan API client. All requests go through /api/school/* which proxies + * to the school-service Gin server (port 8084). Auth token, if available, is + * passed via Authorization: Bearer; for now no token = upstream 401. + */ + +import type { + TimetableClass, TimetablePeriod, TimetableRoom, TimetableSubject, + TimetableTeacher, TimetableCurriculum, TimetableAssignment, + CreateTimetableClass, CreateTimetablePeriod, CreateTimetableRoom, + CreateTimetableSubject, CreateTimetableTeacher, + CreateTimetableCurriculum, CreateTimetableAssignment, + TeacherUnavailableDay, TeacherUnavailableWindow, TeacherMaxHoursDay, + TeacherMaxHoursWeek, TeacherExcludedSubject, TeacherExcludedRoom, + SubjectMinDayGap, SubjectMaxConsecutive, SubjectContiguousWhenRepeated, + SubjectPreferredPeriod, SubjectDoubleLesson, + ClassMaxHoursDay, ClassNoGaps, + RoomRequiresType, RoomUnavailable, +} from '@/app/stundenplan/types' + +const TOKEN_KEY = 'bp_stundenplan_jwt' + +export function setStundenplanToken(token: string): void { + if (typeof window !== 'undefined') localStorage.setItem(TOKEN_KEY, token) +} + +export function getStundenplanToken(): string { + if (typeof window === 'undefined') return '' + return localStorage.getItem(TOKEN_KEY) || '' +} + +async function apiFetch(endpoint: string, options: RequestInit = {}): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record | undefined), + } + const token = getStundenplanToken() + if (token) headers['Authorization'] = `Bearer ${token}` + + const res = await fetch(`/api/school${endpoint}`, { ...options, headers }) + if (!res.ok) { + const errData = await res.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(errData.error || errData.detail || `HTTP ${res.status}`) + } + if (res.status === 204) return undefined as T + return res.json() +} + +// ---------- Stammdaten ---------- + +export const classesApi = { + list: () => apiFetch('/timetable/classes'), + create: (data: CreateTimetableClass) => + apiFetch('/timetable/classes', { method: 'POST', body: JSON.stringify(data) }), + remove: (id: string) => + apiFetch(`/timetable/classes/${id}`, { method: 'DELETE' }), +} + +export const periodsApi = { + list: () => apiFetch('/timetable/periods'), + create: (data: CreateTimetablePeriod) => + apiFetch('/timetable/periods', { method: 'POST', body: JSON.stringify(data) }), + remove: (id: string) => + apiFetch(`/timetable/periods/${id}`, { method: 'DELETE' }), +} + +export const roomsApi = { + list: () => apiFetch('/timetable/rooms'), + create: (data: CreateTimetableRoom) => + apiFetch('/timetable/rooms', { method: 'POST', body: JSON.stringify(data) }), + remove: (id: string) => + apiFetch(`/timetable/rooms/${id}`, { method: 'DELETE' }), +} + +export const subjectsApi = { + list: () => apiFetch('/timetable/subjects'), + create: (data: CreateTimetableSubject) => + apiFetch('/timetable/subjects', { method: 'POST', body: JSON.stringify(data) }), + remove: (id: string) => + apiFetch(`/timetable/subjects/${id}`, { method: 'DELETE' }), +} + +export const teachersApi = { + list: () => apiFetch('/timetable/teachers'), + create: (data: CreateTimetableTeacher) => + apiFetch('/timetable/teachers', { method: 'POST', body: JSON.stringify(data) }), + remove: (id: string) => + apiFetch(`/timetable/teachers/${id}`, { method: 'DELETE' }), +} + +export const curriculumApi = { + list: () => apiFetch('/timetable/curriculum'), + create: (data: CreateTimetableCurriculum) => + apiFetch('/timetable/curriculum', { method: 'POST', body: JSON.stringify(data) }), + remove: (id: string) => + apiFetch(`/timetable/curriculum/${id}`, { method: 'DELETE' }), +} + +export const assignmentsApi = { + list: () => apiFetch('/timetable/assignments'), + create: (data: CreateTimetableAssignment) => + apiFetch('/timetable/assignments', { method: 'POST', body: JSON.stringify(data) }), + remove: (id: string) => + apiFetch(`/timetable/assignments/${id}`, { method: 'DELETE' }), +} + +// ---------- Constraints ---------- + +/** + * Factory that builds a list/create/remove triple for a constraint endpoint. + * The 15 constraint tables share the same CRUD shape; only TItem differs. + */ +function constraintApi(path: string) { + return { + list: () => apiFetch(`/timetable/constraints/${path}`), + create: (data: TCreate) => + apiFetch(`/timetable/constraints/${path}`, { method: 'POST', body: JSON.stringify(data) }), + remove: (id: string) => + apiFetch(`/timetable/constraints/${path}/${id}`, { method: 'DELETE' }), + } +} + +export const teacherUnavailableDayApi = constraintApi>('teacher/unavailable-day') +export const teacherUnavailableWindowApi = constraintApi>('teacher/unavailable-window') +export const teacherMaxHoursDayApi = constraintApi>('teacher/max-hours-day') +export const teacherMaxHoursWeekApi = constraintApi>('teacher/max-hours-week') +export const teacherExcludedSubjectApi = constraintApi>('teacher/excluded-subject') +export const teacherExcludedRoomApi = constraintApi>('teacher/excluded-room') + +export const subjectMinDayGapApi = constraintApi>('subject/min-day-gap') +export const subjectMaxConsecutiveApi = constraintApi>('subject/max-consecutive') +export const subjectContiguousWhenRepeatedApi = constraintApi>('subject/contiguous-when-repeated') +export const subjectPreferredPeriodApi = constraintApi>('subject/preferred-period') +export const subjectDoubleLessonApi = constraintApi>('subject/double-lesson') + +export const classMaxHoursDayApi = constraintApi>('class/max-hours-day') +export const classNoGapsApi = constraintApi>('class/no-gaps') + +export const roomRequiresTypeApi = constraintApi>('room/requires-type') +export const roomUnavailableApi = constraintApi>('room/unavailable')