diff --git a/studio-v2/app/stundenplan/_components/plan/PlanHub.tsx b/studio-v2/app/stundenplan/_components/plan/PlanHub.tsx new file mode 100644 index 0000000..5f99f4a --- /dev/null +++ b/studio-v2/app/stundenplan/_components/plan/PlanHub.tsx @@ -0,0 +1,40 @@ +'use client' + +import { useState } from 'react' +import { useTheme } from '@/lib/ThemeContext' +import { SolutionList } from './SolutionList' +import { PlanView } from './PlanView' + +export function PlanHub() { + const { isDark } = useTheme() + const [selected, setSelected] = useState(null) + + return ( +
+ + + {selected ? ( +
+
+

+ Plan-Ansicht +

+ +
+ +
+ ) : ( +
+ Waehle einen abgeschlossenen Plan oben, um die Wochenansicht zu sehen. +
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/_components/plan/PlanView.tsx b/studio-v2/app/stundenplan/_components/plan/PlanView.tsx new file mode 100644 index 0000000..5319297 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/plan/PlanView.tsx @@ -0,0 +1,206 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { useTheme } from '@/lib/ThemeContext' +import { solutionsApi, subjectsApi } from '@/lib/stundenplan/api' +import type { TimetableLesson, TimetableSubject } from '@/app/stundenplan/types' + +interface PlanViewProps { + solutionId: string +} + +const DAYS = [ + { v: 1, label: 'Mo' }, + { v: 2, label: 'Di' }, + { v: 3, label: 'Mi' }, + { v: 4, label: 'Do' }, + { v: 5, label: 'Fr' }, +] + +type Perspective = 'class' | 'teacher' | 'room' + +const PERSPECTIVE_LABEL: Record = { + class: 'Klasse', + teacher: 'Lehrer', + room: 'Raum', +} + +interface Resource { + id: string + label: string +} + +export function PlanView({ solutionId }: PlanViewProps) { + const { isDark } = useTheme() + const [lessons, setLessons] = useState([]) + const [subjects, setSubjects] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [perspective, setPerspective] = useState('class') + const [selectedResource, setSelectedResource] = useState('') + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const [ls, sub] = await Promise.all([ + solutionsApi.lessons(solutionId), + subjectsApi.list(), + ]) + setLessons(ls || []) + setSubjects(sub || []) + } catch (e) { + setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen') + } finally { + setLoading(false) + } + }, [solutionId]) + + useEffect(() => { load() }, [load]) + + // Unique resources for the chosen perspective. + const resources: Resource[] = useMemo(() => { + const seen = new Map() + for (const l of lessons) { + let id = '' + let label = '' + if (perspective === 'class') { + id = l.class_id + label = l.class_name || id.slice(0, 8) + } else if (perspective === 'teacher') { + id = l.teacher_id + label = l.teacher_name || id.slice(0, 8) + } else if (perspective === 'room') { + id = l.room_id || 'kein-raum' + label = l.room_name || (l.room_id ? l.room_id.slice(0, 8) : '— kein Raum —') + } + if (!seen.has(id)) seen.set(id, { id, label }) + } + return Array.from(seen.values()).sort((a, b) => a.label.localeCompare(b.label)) + }, [lessons, perspective]) + + // Reset selected resource when perspective changes or list refreshes. + useEffect(() => { + if (resources.length > 0 && !resources.some(r => r.id === selectedResource)) { + setSelectedResource(resources[0].id) + } + }, [resources, selectedResource]) + + const visibleLessons = useMemo(() => { + if (!selectedResource) return [] + return lessons.filter(l => { + if (perspective === 'class') return l.class_id === selectedResource + if (perspective === 'teacher') return l.teacher_id === selectedResource + return (l.room_id || 'kein-raum') === selectedResource + }) + }, [lessons, perspective, selectedResource]) + + const subjectColor = useCallback((id: string): string => { + const s = subjects.find(x => x.id === id) + return s?.color || (isDark ? '#475569' : '#cbd5e1') + }, [subjects, isDark]) + + const periodIndices = useMemo(() => { + const set = new Set() + for (const l of lessons) set.add(l.period_index) + return Array.from(set).sort((a, b) => a - b) + }, [lessons]) + + const cellLesson = (day: number, periodIdx: number): TimetableLesson | undefined => + visibleLessons.find(l => l.day_of_week === day && l.period_index === periodIdx) + + const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900' + const selectClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900' + + return ( +
+
+
+
+ +
+ {(['class', 'teacher', 'room'] as Perspective[]).map(p => ( + + ))} +
+
+
+ + +
+
+
+ + {error &&
{error}
} + + {loading ? ( +
Laedt…
+ ) : lessons.length === 0 ? ( +
+ Keine Lessons in diesem Plan. +
+ ) : ( +
+ + + + + {DAYS.map(d => ( + + ))} + + + + {periodIndices.map(idx => ( + + + {DAYS.map(d => { + const lesson = cellLesson(d.v, idx) + if (!lesson) { + return + } + const color = subjectColor(lesson.subject_id) + return ( + + ) + })} + + ))} + +
Stunde{d.label}
{idx}. +
+
{lesson.subject_name || '?'}
+ {perspective !== 'class' && lesson.class_name && ( +
{lesson.class_name}
+ )} + {perspective !== 'teacher' && lesson.teacher_name && ( +
{lesson.teacher_name.split(',')[0]}
+ )} + {perspective !== 'room' && lesson.room_name && ( +
{lesson.room_name}
+ )} +
+
+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/_components/plan/SolutionList.tsx b/studio-v2/app/stundenplan/_components/plan/SolutionList.tsx new file mode 100644 index 0000000..75e8b70 --- /dev/null +++ b/studio-v2/app/stundenplan/_components/plan/SolutionList.tsx @@ -0,0 +1,169 @@ +'use client' + +import { useState, useEffect, useCallback, useRef } from 'react' +import { useTheme } from '@/lib/ThemeContext' +import { solutionsApi } from '@/lib/stundenplan/api' +import type { TimetableSolution, SolutionStatus } from '@/app/stundenplan/types' + +interface SolutionListProps { + onView: (solutionId: string) => void + selectedId?: string | null +} + +const STATUS_LABEL: Record = { + pending: 'Wartet', + running: 'Laeuft', + completed: 'Fertig', + failed: 'Fehler', + infeasible: 'Nicht loesbar', +} + +const STATUS_BADGE: Record = { + pending: 'bg-slate-500/30 text-slate-200', + running: 'bg-blue-500/30 text-blue-200', + completed: 'bg-emerald-500/30 text-emerald-200', + failed: 'bg-red-500/30 text-red-200', + infeasible: 'bg-amber-500/30 text-amber-200', +} + +export function SolutionList({ onView, selectedId }: SolutionListProps) { + const { isDark } = useTheme() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [name, setName] = useState('') + const pollingRef = useRef | null>(null) + + const load = useCallback(async () => { + try { + const data = await solutionsApi.list() + setItems(data || []) + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { load() }, [load]) + + // Poll every 4 s while at least one solution is pending/running. + useEffect(() => { + const inFlight = items.some(s => s.status === 'pending' || s.status === 'running') + if (inFlight && pollingRef.current === null) { + pollingRef.current = setInterval(load, 4000) + } else if (!inFlight && pollingRef.current !== null) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + } + }, [items, load]) + + const handleSolve = async () => { + setSubmitting(true) + setError(null) + try { + await solutionsApi.create({ name: name || `Plan ${new Date().toLocaleString('de-DE')}` }) + setName('') + await load() + } catch (err) { + setError(err instanceof Error ? err.message : 'Solve fehlgeschlagen') + } finally { + setSubmitting(false) + } + } + + const handleDelete = async (id: string) => { + if (!confirm('Plan wirklich loeschen? Alle Lessons gehen verloren.')) return + try { + await solutionsApi.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 ( +
+
+
+
+ + setName(e.target.value)} placeholder="z.B. Schuljahr 26/27 Variante 1" className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} /> +
+ +
+

+ Solver laeuft im Hintergrund (bis zu 60 Sekunden). Status erscheint in der Liste. +

+
+ + {error &&
{error}
} + + {loading ? ( +
Laedt…
+ ) : items.length === 0 ? ( +
Noch keine Plaene generiert.
+ ) : ( +
+ + + + + + + + + + + + {items.map(sol => { + const isSelected = sol.id === selectedId + return ( + + + + + + + + ) + })} + +
NameStatusScoreErstellt
{sol.name || sol.id.slice(0, 8) + '…'} + {STATUS_LABEL[sol.status]} + {sol.error_message && } + + {sol.hard_score !== null && sol.hard_score !== undefined + ? `${sol.hard_score}H / ${sol.soft_score ?? 0}S` + : '—'} + {new Date(sol.created_at).toLocaleString('de-DE')} + {sol.status === 'completed' && ( + + )} + +
+
+ )} +
+ ) +} diff --git a/studio-v2/app/stundenplan/page.tsx b/studio-v2/app/stundenplan/page.tsx index 44a0c4c..24fcd45 100644 --- a/studio-v2/app/stundenplan/page.tsx +++ b/studio-v2/app/stundenplan/page.tsx @@ -13,11 +13,13 @@ import { PeriodsManager } from './_components/PeriodsManager' import { CurriculumManager } from './_components/CurriculumManager' import { AssignmentsManager } from './_components/AssignmentsManager' import { RegelnHub } from './_components/regeln/RegelnHub' +import { PlanHub } from './_components/plan/PlanHub' import { setStundenplanToken, getStundenplanToken } from '@/lib/stundenplan/api' -type Tab = 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln' +type Tab = 'plan' | 'klassen' | 'lehrer' | 'faecher' | 'raeume' | 'periods' | 'curriculum' | 'assignments' | 'regeln' const TABS: { id: Tab; label: string }[] = [ + { id: 'plan', label: 'Plan' }, { id: 'klassen', label: 'Klassen' }, { id: 'lehrer', label: 'Lehrer' }, { id: 'faecher', label: 'Faecher' }, @@ -30,7 +32,7 @@ const TABS: { id: Tab; label: string }[] = [ export default function StundenplanPage() { const { isDark } = useTheme() - const [tab, setTab] = useState('klassen') + const [tab, setTab] = useState('plan') const [token, setToken] = useState(getStundenplanToken()) const handleSaveToken = () => { @@ -108,6 +110,7 @@ export default function StundenplanPage() {
+ {tab === 'plan' && } {tab === 'klassen' && } {tab === 'lehrer' && } {tab === 'faecher' && } diff --git a/studio-v2/app/stundenplan/types.ts b/studio-v2/app/stundenplan/types.ts index 7c661b1..0f59de8 100644 --- a/studio-v2/app/stundenplan/types.ts +++ b/studio-v2/app/stundenplan/types.ts @@ -224,3 +224,41 @@ export interface RoomUnavailable extends ConstraintBase { day_of_week: number period_index: number } + +// ---------- Solutions (Phase 5+) ---------- + +export type SolutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'infeasible' + +export interface TimetableSolution { + id: string + created_by_user_id: string + name?: string + status: SolutionStatus + hard_score?: number | null + soft_score?: number | null + error_message?: string + started_at?: string | null + finished_at?: string | null + created_at: string +} + +export interface TimetableLesson { + id: string + solution_id: string + class_id: string + subject_id: string + teacher_id: string + room_id?: string | null + day_of_week: number + period_index: number + pinned: boolean + created_at: string + class_name?: string + subject_name?: string + teacher_name?: string + room_name?: string +} + +export interface CreateTimetableSolution { + name?: string +} diff --git a/studio-v2/e2e/_helpers.ts b/studio-v2/e2e/_helpers.ts new file mode 100644 index 0000000..5cc59ca --- /dev/null +++ b/studio-v2/e2e/_helpers.ts @@ -0,0 +1,131 @@ +import { Page } from '@playwright/test' + +/** + * Shared mock helper for the /stundenplan suite. Intercepts every endpoint + * the school-service proxy serves so tests stay hermetic. + */ + +export const MOCK_TEACHER_ID = '11111111-1111-1111-1111-111111111111' +export const MOCK_SUBJECT_ID = '22222222-2222-2222-2222-222222222222' +export const MOCK_CLASS_ID = '33333333-3333-3333-3333-333333333333' + +export interface MockClass { + id: string + name: string + grade_level: number + student_count: number + notes?: string + created_by_user_id: string + created_at: string +} + +export interface MockOpts { + classes?: MockClass[] + teachers?: unknown[] + subjects?: unknown[] + rooms?: unknown[] + periods?: unknown[] + curriculum?: unknown[] + assignments?: unknown[] + solutions?: unknown[] + lessons?: unknown[] +} + +export async function mockSchoolApi(page: Page, opts: MockOpts = {}) { + const classes = opts.classes ?? [] + const teachers = opts.teachers ?? [] + const subjects = opts.subjects ?? [] + const rooms = opts.rooms ?? [] + const periods = opts.periods ?? [] + const curriculum = opts.curriculum ?? [] + const assignments = opts.assignments ?? [] + const solutions = opts.solutions ?? [] + const lessons = opts.lessons ?? [] + + await page.route('**/api/school/timetable/classes', async (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(classes) }) + } + if (route.request().method() === 'POST') { + const body = JSON.parse(route.request().postData() || '{}') + const created: MockClass = { + id: 'new-class-id', + name: body.name, + grade_level: body.grade_level, + student_count: body.student_count ?? 0, + notes: body.notes, + created_by_user_id: 'test-user', + created_at: new Date().toISOString(), + } + classes.push(created) + return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(created) }) + } + return route.fulfill({ status: 405 }) + }) + + const staticList = (path: string, data: unknown) => + page.route(`**/api/school/timetable/${path}`, async (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) })) + + await staticList('teachers', teachers) + await staticList('subjects', subjects) + await staticList('rooms', rooms) + await staticList('periods', periods) + await staticList('curriculum', curriculum) + await staticList('assignments', assignments) + + for (const path of [ + 'constraints/teacher/unavailable-day', + 'constraints/teacher/unavailable-window', + 'constraints/teacher/max-hours-day', + 'constraints/teacher/max-hours-week', + 'constraints/teacher/excluded-subject', + 'constraints/teacher/excluded-room', + 'constraints/subject/max-consecutive', + 'constraints/subject/preferred-period', + 'constraints/subject/min-day-gap', + 'constraints/subject/contiguous-when-repeated', + 'constraints/subject/double-lesson', + 'constraints/class/max-hours-day', + 'constraints/class/no-gaps', + 'constraints/room/requires-type', + 'constraints/room/unavailable', + ]) { + await staticList(path, []) + } + + await page.route('**/api/school/timetable/solutions', async (route) => { + if (route.request().method() === 'GET') { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(solutions) }) + } + if (route.request().method() === 'POST') { + const body = JSON.parse(route.request().postData() || '{}') + const created = { + id: 'new-solution-id', + created_by_user_id: 'test-user', + name: body.name || 'Plan', + status: 'pending', + hard_score: null, + soft_score: null, + created_at: new Date().toISOString(), + } + ;(solutions as unknown[]).push(created) + return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(created) }) + } + return route.fulfill({ status: 405 }) + }) + + await page.route(/\/api\/school\/timetable\/solutions\/[^/]+\/lessons$/, async (route) => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(lessons) }) + }) + await page.route(/\/api\/school\/timetable\/solutions\/[^/]+$/, async (route) => { + if (route.request().method() === 'DELETE') { + return route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"deleted"}' }) + } + const url = route.request().url() + const id = url.split('/').pop() ?? '' + const sol = (solutions as Array<{ id: string }>).find(s => s.id === id) + if (!sol) return route.fulfill({ status: 404 }) + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(sol) }) + }) +} diff --git a/studio-v2/e2e/stundenplan.spec.ts b/studio-v2/e2e/stundenplan.spec.ts index 247ff79..a1cd2af 100644 --- a/studio-v2/e2e/stundenplan.spec.ts +++ b/studio-v2/e2e/stundenplan.spec.ts @@ -1,101 +1,14 @@ import { test, expect, Page } from '@playwright/test' +import { mockSchoolApi, MOCK_TEACHER_ID, MOCK_SUBJECT_ID, MOCK_CLASS_ID } from './_helpers' /** * E2E tests for /stundenplan * * Backend calls go through /api/school/* (Next.js proxy → school-service). - * For most tests we intercept those routes so the suite is hermetic and does - * not depend on a populated database or a valid JWT. + * Tests intercept those routes via mockSchoolApi() from _helpers.ts so the + * suite stays hermetic. */ -const MOCK_TEACHER_ID = '11111111-1111-1111-1111-111111111111' -const MOCK_SUBJECT_ID = '22222222-2222-2222-2222-222222222222' -const MOCK_CLASS_ID = '33333333-3333-3333-3333-333333333333' - -interface MockClass { - id: string - name: string - grade_level: number - student_count: number - notes?: string - created_by_user_id: string - created_at: string -} - -interface MockOpts { - classes?: MockClass[] - teachers?: unknown[] - subjects?: unknown[] - rooms?: unknown[] - periods?: unknown[] - curriculum?: unknown[] - assignments?: unknown[] -} - -async function mockSchoolApi(page: Page, opts: MockOpts = {}) { - const classes = opts.classes ?? [] - const teachers = opts.teachers ?? [] - const subjects = opts.subjects ?? [] - const rooms = opts.rooms ?? [] - const periods = opts.periods ?? [] - const curriculum = opts.curriculum ?? [] - const assignments = opts.assignments ?? [] - - await page.route('**/api/school/timetable/classes', async (route) => { - if (route.request().method() === 'GET') { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(classes) }) - } - if (route.request().method() === 'POST') { - const body = JSON.parse(route.request().postData() || '{}') - const created: MockClass = { - id: 'new-class-id', - name: body.name, - grade_level: body.grade_level, - student_count: body.student_count ?? 0, - notes: body.notes, - created_by_user_id: 'test-user', - created_at: new Date().toISOString(), - } - classes.push(created) - return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(created) }) - } - return route.fulfill({ status: 405 }) - }) - - // Helper to mount a read-only endpoint with a static list. - const staticList = (path: string, data: unknown) => - page.route(`**/api/school/timetable/${path}`, async (route) => - route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) })) - - await staticList('teachers', teachers) - await staticList('subjects', subjects) - await staticList('rooms', rooms) - await staticList('periods', periods) - await staticList('curriculum', curriculum) - await staticList('assignments', assignments) - - // Constraint endpoints — all empty by default. - for (const path of [ - 'constraints/teacher/unavailable-day', - 'constraints/teacher/unavailable-window', - 'constraints/teacher/max-hours-day', - 'constraints/teacher/max-hours-week', - 'constraints/teacher/excluded-subject', - 'constraints/teacher/excluded-room', - 'constraints/subject/max-consecutive', - 'constraints/subject/preferred-period', - 'constraints/subject/min-day-gap', - 'constraints/subject/contiguous-when-repeated', - 'constraints/subject/double-lesson', - 'constraints/class/max-hours-day', - 'constraints/class/no-gaps', - 'constraints/room/requires-type', - 'constraints/room/unavailable', - ]) { - await staticList(path, []) - } -} - test.describe('Stundenplan — Page Shell', () => { test.beforeEach(async ({ page }) => { await mockSchoolApi(page) @@ -108,16 +21,16 @@ test.describe('Stundenplan — Page Shell', () => { await expect(page.getByText('Stammdaten und Regeln fuer den Solver')).toBeVisible() }) - test('shows all 8 tabs', async ({ page }) => { + test('shows all 9 tabs', async ({ page }) => { // Sidebar entries collide with tab labels for 'Lehrer' — scope to
. const tabs = page.locator('main nav') - for (const label of ['Klassen', 'Lehrer', 'Faecher', 'Raeume', 'Zeitraster', 'Stundentafel', 'Lehrauftraege', 'Regeln (Constraints)']) { + for (const label of ['Plan', 'Klassen', 'Lehrer', 'Faecher', 'Raeume', 'Zeitraster', 'Stundentafel', 'Lehrauftraege', 'Regeln (Constraints)']) { await expect(tabs.getByRole('button', { name: label, exact: true })).toBeVisible() } }) - test('Klassen tab is active by default', async ({ page }) => { - await expect(page.getByTestId('klassen-manager')).toBeVisible() + test('Plan tab is active by default', async ({ page }) => { + await expect(page.getByTestId('plan-hub')).toBeVisible() }) test('JWT dev field exists and persists into localStorage', async ({ page }) => { @@ -137,9 +50,10 @@ test.describe('Stundenplan — Tab navigation', () => { await page.waitForLoadState('networkidle') }) - test('all 8 tabs render their manager', async ({ page }) => { + test('all 9 tabs render their manager', async ({ page }) => { const tabs = page.locator('main nav') const cases: { label: string; testId: string }[] = [ + { label: 'Klassen', testId: 'klassen-manager' }, { label: 'Lehrer', testId: 'lehrer-manager' }, { label: 'Faecher', testId: 'faecher-manager' }, { label: 'Raeume', testId: 'raeume-manager' }, @@ -147,6 +61,7 @@ test.describe('Stundenplan — Tab navigation', () => { { label: 'Stundentafel', testId: 'curriculum-manager' }, { label: 'Lehrauftraege', testId: 'assignments-manager' }, { label: 'Regeln (Constraints)', testId: 'regeln-hub' }, + { label: 'Plan', testId: 'plan-hub' }, ] for (const c of cases) { await tabs.getByRole('button', { name: c.label, exact: true }).click() @@ -156,10 +71,16 @@ test.describe('Stundenplan — Tab navigation', () => { }) test.describe('Stundenplan — Klassen CRUD', () => { - test('empty state shows when no classes', async ({ page }) => { - await mockSchoolApi(page, { classes: [] }) + const gotoKlassen = async (page: Page) => { await page.goto('/stundenplan') await page.waitForLoadState('networkidle') + // Plan is now the default tab; switch to Klassen first. + await page.locator('main nav').getByRole('button', { name: 'Klassen', exact: true }).click() + } + + test('empty state shows when no classes', async ({ page }) => { + await mockSchoolApi(page, { classes: [] }) + await gotoKlassen(page) await expect(page.getByText('Noch keine Klassen angelegt.')).toBeVisible() }) @@ -170,8 +91,7 @@ test.describe('Stundenplan — Klassen CRUD', () => { { id: 'c2', name: '5b', grade_level: 5, student_count: 23, created_by_user_id: 'u', created_at: '2026-05-21T10:00:00Z' }, ], }) - await page.goto('/stundenplan') - await page.waitForLoadState('networkidle') + await gotoKlassen(page) await expect(page.getByText('Klassen (2)')).toBeVisible() await expect(page.getByRole('cell', { name: '5a' })).toBeVisible() await expect(page.getByRole('cell', { name: '5b' })).toBeVisible() @@ -179,8 +99,7 @@ test.describe('Stundenplan — Klassen CRUD', () => { test('+ Neue Klasse toggles the form', async ({ page }) => { await mockSchoolApi(page) - await page.goto('/stundenplan') - await page.waitForLoadState('networkidle') + await gotoKlassen(page) await expect(page.getByPlaceholder('z.B. 5a')).toHaveCount(0) await page.getByRole('button', { name: '+ Neue Klasse' }).click() @@ -191,8 +110,7 @@ test.describe('Stundenplan — Klassen CRUD', () => { test('form submission appends a new class to the list', async ({ page }) => { await mockSchoolApi(page, { classes: [] }) - await page.goto('/stundenplan') - await page.waitForLoadState('networkidle') + await gotoKlassen(page) await page.getByRole('button', { name: '+ Neue Klasse' }).click() await page.getByPlaceholder('z.B. 5a').fill('7c') @@ -368,3 +286,118 @@ test.describe('Stundenplan — Sidebar entry', () => { await expect(sidebar.getByText(/Stundenplan|Timetable/).first()).toBeVisible() }) }) + +// ========================================================================== +// Phase 6 — Plan-Ansicht +// ========================================================================== + +test.describe('Stundenplan — Plan tab + SolutionList', () => { + test('empty state when no solutions exist', async ({ page }) => { + await mockSchoolApi(page, { solutions: [] }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await expect(page.getByText('Noch keine Plaene generiert.')).toBeVisible() + await expect(page.getByTestId('solve-trigger')).toBeEnabled() + }) + + test('renders solutions returned by the backend', async ({ page }) => { + await mockSchoolApi(page, { + solutions: [ + { id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: -42, created_at: '2026-05-22T10:00:00Z' }, + { id: 's2', created_by_user_id: 'u', name: 'Plan B', status: 'failed', error_message: 'no lessons', created_at: '2026-05-22T11:00:00Z' }, + ], + }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await expect(page.getByRole('cell', { name: 'Plan A' })).toBeVisible() + await expect(page.getByRole('cell', { name: 'Plan B' })).toBeVisible() + await expect(page.getByText('Fertig').first()).toBeVisible() + await expect(page.getByText('Fehler').first()).toBeVisible() + await expect(page.getByText('0H / -42S')).toBeVisible() + }) + + test('completed solutions expose an Anzeigen button; failed ones do not', async ({ page }) => { + await mockSchoolApi(page, { + solutions: [ + { id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' }, + { id: 's2', created_by_user_id: 'u', name: 'Plan B', status: 'failed', created_at: '2026-05-22T11:00:00Z' }, + ], + }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + const planA = page.getByRole('row', { name: /Plan A/ }) + const planB = page.getByRole('row', { name: /Plan B/ }) + await expect(planA.getByRole('button', { name: 'Anzeigen' })).toBeVisible() + await expect(planB.getByRole('button', { name: 'Anzeigen' })).toHaveCount(0) + }) + + test('triggering Solve calls POST /solutions and reloads the list', async ({ page }) => { + await mockSchoolApi(page, { solutions: [] }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + + await page.getByTestId('solve-trigger').click() + await expect(page.getByText('Plan').first()).toBeVisible() + }) +}) + +test.describe('Stundenplan — PlanView grid', () => { + test('shows placeholder hint until a solution is selected', async ({ page }) => { + await mockSchoolApi(page, { + solutions: [ + { id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' }, + ], + }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await expect(page.getByText('Waehle einen abgeschlossenen Plan oben')).toBeVisible() + await expect(page.getByTestId('plan-view')).toHaveCount(0) + }) + + test('clicking Anzeigen mounts the PlanView with mocked lessons', async ({ page }) => { + await mockSchoolApi(page, { + solutions: [ + { id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' }, + ], + lessons: [ + { id: 'l1', solution_id: 's1', class_id: 'c1', subject_id: 'sub1', teacher_id: 't1', room_id: 'r1', day_of_week: 1, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Mathe', teacher_name: 'Schmidt, Anna', room_name: 'A101' }, + { id: 'l2', solution_id: 's1', class_id: 'c1', subject_id: 'sub2', teacher_id: 't1', room_id: 'r1', day_of_week: 2, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Deutsch', teacher_name: 'Schmidt, Anna', room_name: 'A101' }, + ], + subjects: [ + { id: 'sub1', name: 'Mathematik', short_code: 'M', color: '#3b82f6', is_main_subject: true, created_by_user_id: 'u', created_at: '' }, + { id: 'sub2', name: 'Deutsch', short_code: 'D', color: '#ef4444', is_main_subject: true, created_by_user_id: 'u', created_at: '' }, + ], + }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + + await page.getByRole('button', { name: 'Anzeigen' }).click() + await expect(page.getByTestId('plan-view')).toBeVisible() + await expect(page.getByTestId('cell-1-1')).toBeVisible() + await expect(page.getByTestId('cell-2-1')).toBeVisible() + }) + + test('switching perspective updates the resource selector', async ({ page }) => { + await mockSchoolApi(page, { + solutions: [ + { id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' }, + ], + lessons: [ + { id: 'l1', solution_id: 's1', class_id: 'c1', subject_id: 'sub1', teacher_id: 't1', room_id: 'r1', day_of_week: 1, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Mathe', teacher_name: 'Schmidt, Anna', room_name: 'A101' }, + ], + }) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await page.getByRole('button', { name: 'Anzeigen' }).click() + await expect(page.getByTestId('plan-view')).toBeVisible() + + // Default perspective is 'class' → selector shows '5a'. + await expect(page.locator('select').last()).toHaveValue('c1') + + await page.getByTestId('perspective-teacher').click() + await expect(page.locator('select').last()).toHaveValue('t1') + + await page.getByTestId('perspective-room').click() + await expect(page.locator('select').last()).toHaveValue('r1') + }) +}) diff --git a/studio-v2/lib/stundenplan/api.ts b/studio-v2/lib/stundenplan/api.ts index 55034dc..7c9caed 100644 --- a/studio-v2/lib/stundenplan/api.ts +++ b/studio-v2/lib/stundenplan/api.ts @@ -16,6 +16,7 @@ import type { SubjectPreferredPeriod, SubjectDoubleLesson, ClassMaxHoursDay, ClassNoGaps, RoomRequiresType, RoomUnavailable, + TimetableSolution, TimetableLesson, CreateTimetableSolution, } from '@/app/stundenplan/types' const TOKEN_KEY = 'bp_stundenplan_jwt' @@ -138,3 +139,16 @@ export const classNoGapsApi = constraintApi>('room/requires-type') export const roomUnavailableApi = constraintApi>('room/unavailable') + +// ---------- Solutions ---------- + +export const solutionsApi = { + list: () => apiFetch('/timetable/solutions'), + get: (id: string) => apiFetch(`/timetable/solutions/${id}`), + create: (data: CreateTimetableSolution) => + apiFetch('/timetable/solutions', { method: 'POST', body: JSON.stringify(data) }), + remove: (id: string) => + apiFetch(`/timetable/solutions/${id}`, { method: 'DELETE' }), + lessons: (id: string) => + apiFetch(`/timetable/solutions/${id}/lessons`), +}