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
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>
174 lines
7.8 KiB
TypeScript
174 lines
7.8 KiB
TypeScript
'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>
|
||
)
|
||
}
|