Files
breakpilot-lehrer/studio-v2/app/eltern/page.tsx
T
Benjamin Admin d9858084dd
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 2m36s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 26s
Phase 9c: Parent accounts, magic-link login + parent timetable view
Backend (school-service):
  - parent_account, parent_child, parent_magic_link, parent_session
    tables. Tokens are sha256-hashed in DB; raw goes back exactly
    once to the inviting teacher.
  - InviteParent upserts the parent account, links a child to a tt_
    class, mints a 7-day magic link. Returns the link path so the
    teacher can paste it into Matrix/Email.
  - RedeemMagicLink validates + marks used + mints a 30-day session,
    sets HttpOnly bp_parent_session cookie.
  - ParentSessionMiddleware reads the cookie and resolves the parent.
    Lives in its own router group /api/v1/parent — totally separate
    from the teacher JWT path.
  - ParentMe returns the account + list of children (with class name).
  - ParentTimetable returns the latest completed tt_solution's lessons
    for the requested child's class, with full authorization check
    (parent must own a child in that class).

Frontend (studio-v2):
  - lib/calendar/subject-i18n.ts maps 22 German subject names to 8
    parent locales (de/en/tr/ar/uk/ru/pl/fr). Falls back to German
    for custom subjects.
  - ParentManager component on the Schulkalender page lets the teacher
    invite parents via email + child name + class + language. Newly
    minted magic-link is shown with a copy-to-clipboard button.
  - app/api/parent/[...path]/route.ts proxies parent-side endpoints
    via the cookie so HttpOnly survives the Next.js round-trip.
  - /eltern/login?token=… redeems and redirects to /eltern.
  - /eltern shows a Wochengrid with German days + translated subject
    names in the parent's preferred language. Headings and weekday
    labels also localised (de/en/tr/ar/uk/ru/pl/fr).

Tests:
  - 3 new Go unit tests (random token, hash stability, invite-request
    validator). 83 subtests gesamt.
  - studio-v2: e2e/eltern.spec.ts mit 7 tests across ParentManager,
    /eltern/login, /eltern overview, subject-i18n end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:50:35 +02:00

174 lines
7.8 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, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { elternApi, type ParentMeResponse, type ParentLesson } from '@/lib/eltern/api'
import { translateSubject } from '@/lib/calendar/subject-i18n'
const DAY_LABELS: Record<string, string[]> = {
de: ['Mo', 'Di', 'Mi', 'Do', 'Fr'],
en: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
tr: ['Pzt', 'Sal', 'Çar', 'Per', 'Cum'],
ar: ['الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة'],
uk: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт'],
ru: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт'],
pl: ['Pon', 'Wt', 'Śr', 'Czw', 'Pt'],
fr: ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven'],
}
const HEADINGS: Record<string, { greeting: string; selectChild: string; period: string; logout: string; noPlan: string }> = {
de: { greeting: 'Willkommen', selectChild: 'Kind auswählen', period: 'Stunde', logout: 'Abmelden', noPlan: 'Noch kein Stundenplan veröffentlicht.' },
en: { greeting: 'Welcome', selectChild: 'Select child', period: 'Period', logout: 'Sign out', noPlan: 'No timetable published yet.' },
tr: { greeting: 'Hoş geldiniz', selectChild: 'Çocuk seç', period: 'Ders', logout: 'Çıkış', noPlan: 'Henüz ders programı yayımlanmadı.' },
ar: { greeting: 'مرحبًا', selectChild: 'اختر الطفل', period: 'حصة', logout: 'خروج', noPlan: 'لم يتم نشر جدول حصص بعد.' },
uk: { greeting: 'Ласкаво просимо', selectChild: 'Виберіть дитину', period: 'Урок', logout: 'Вийти', noPlan: 'Розклад ще не опубліковано.' },
ru: { greeting: 'Добро пожаловать', selectChild: 'Выберите ребёнка', period: 'Урок', logout: 'Выйти', noPlan: 'Расписание ещё не опубликовано.' },
pl: { greeting: 'Witamy', selectChild: 'Wybierz dziecko', period: 'Lekcja', logout: 'Wyloguj', noPlan: 'Plan lekcji nie jest jeszcze opublikowany.' },
fr: { greeting: 'Bienvenue', selectChild: 'Choisir un enfant', period: 'Cours', logout: 'Déconnexion', noPlan: 'Aucun emploi du temps publié.' },
}
function t(lang: string, key: keyof typeof HEADINGS['de']): string {
const code = (lang || 'de').slice(0, 2)
return HEADINGS[code]?.[key] ?? HEADINGS.de[key]
}
export default function ElternPage() {
const router = useRouter()
const [me, setMe] = useState<ParentMeResponse | null>(null)
const [selected, setSelected] = useState<string>('')
const [lessons, setLessons] = useState<ParentLesson[]>([])
const [error, setError] = useState<string | null>(null)
const lang = me?.parent.preferred_language || 'de'
const dayLabels = DAY_LABELS[lang.slice(0, 2)] || DAY_LABELS.de
const loadMe = useCallback(async () => {
try {
const data = await elternApi.me()
setMe(data)
if (data.children.length > 0) setSelected(data.children[0].tt_class_id)
} catch (e) {
// Not logged in → redirect to login.
if (e instanceof Error && /session/i.test(e.message)) {
router.replace('/eltern/login')
return
}
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
}
}, [router])
useEffect(() => { loadMe() }, [loadMe])
const loadTimetable = useCallback(async () => {
if (!selected) return
try {
const data = await elternApi.timetable(selected)
setLessons(data || [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Stundenplan laden fehlgeschlagen')
}
}, [selected])
useEffect(() => { loadTimetable() }, [loadTimetable])
const periodIndices = useMemo(() => {
const set = new Set<number>()
for (const l of lessons) set.add(l.PeriodIndex)
return Array.from(set).sort((a, b) => a - b)
}, [lessons])
const cell = (day: number, idx: number) =>
lessons.find(l => l.DayOfWeek === day && l.PeriodIndex === idx)
const handleLogout = async () => {
try { await elternApi.logout() } catch { /* ignore */ }
router.replace('/eltern/login')
}
if (!me) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 text-white">
{error ? <span className="text-red-200">{error}</span> : <span className="opacity-70">Laedt </span>}
</div>
)
}
const activeChild = me.children.find(c => c.tt_class_id === selected)
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 text-white p-6" data-testid="eltern-page">
<div className="max-w-5xl mx-auto">
<header className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold">{t(lang, 'greeting')}, {me.parent.email}</h1>
<p className="text-sm text-white/60 mt-1">
{activeChild ? `${activeChild.first_name} ${activeChild.last_name} · ${activeChild.class_name}` : ''}
</p>
</div>
<button onClick={handleLogout} data-testid="eltern-logout" className="px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 text-sm">
{t(lang, 'logout')}
</button>
</header>
{me.children.length > 1 && (
<div className="mb-4">
<label className="block text-sm mb-1 opacity-70">{t(lang, 'selectChild')}</label>
<select
value={selected}
onChange={e => setSelected(e.target.value)}
data-testid="child-selector"
className="px-3 py-2 rounded-lg border bg-white/10 border-white/20 text-white"
>
{me.children.map(c => (
<option key={c.id} value={c.tt_class_id} className="text-slate-900">
{c.first_name} {c.last_name} ({c.class_name})
</option>
))}
</select>
</div>
)}
{error && <div className="mb-3 p-3 rounded-lg bg-red-500/20 border border-red-500/40 text-red-200">{error}</div>}
{periodIndices.length === 0 ? (
<div className="rounded-2xl bg-white/10 border border-white/20 p-8 text-center opacity-70">
{t(lang, 'noPlan')}
</div>
) : (
<div className="rounded-2xl bg-white/10 border border-white/20 overflow-hidden">
<table className="w-full">
<thead className="bg-white/5">
<tr>
<th className="text-left px-3 py-3 text-sm font-medium opacity-70 w-16">{t(lang, 'period')}</th>
{dayLabels.map(d => <th key={d} className="text-left px-3 py-3 text-sm font-medium opacity-70">{d}</th>)}
</tr>
</thead>
<tbody>
{periodIndices.map(idx => (
<tr key={idx} className="border-t border-white/10">
<td className="px-3 py-2 font-medium text-sm">{idx}.</td>
{[1, 2, 3, 4, 5].map(d => {
const l = cell(d, idx)
if (!l) return <td key={d} className="px-3 py-2 opacity-20 text-xs"></td>
return (
<td key={d} className="px-2 py-1" data-testid={`eltern-cell-${d}-${idx}`}>
<div className="rounded-md p-2 text-xs space-y-0.5 bg-indigo-500/30 border-l-2 border-indigo-300">
<div className="font-semibold">{translateSubject(l.SubjectName, lang)}</div>
<div className="opacity-80">{l.TeacherName.split(',')[0]}</div>
{l.RoomName && <div className="opacity-60">{l.RoomName}</div>}
</div>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}