Phase 9b: Schul-Events CRUD + Schuljahres-Rollover
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 28s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m38s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 26s

Backend (school-service):
  - calendar_events.go — Create/List/Delete on cal_school_event with
    UUID[] handling for affected_class_ids. Default lead-days [7,1]
    if caller omits the array.
  - calendar_rollover.go — single-transaction promotion: graduating
    classes (grade >= 13) get deleted first so the +1 update doesn't
    bump them to invalid grade 14. defaultSchoolYearDates() picks the
    next Aug-Jul pair when the caller doesn't specify.
  - Handlers + routes: GET/POST /calendar/events,
    DELETE /calendar/events/:id, POST /calendar/school-year-rollover.

Frontend (studio-v2):
  - EventModal: form with Title / Typ / Datum/Zeit / unterrichtsfrei /
    Beschreibung / Sichtbarkeit + Notification-Checkboxen. Per-Type
    Farb-Mapping in types.ts.
  - DayDetail: Modal das beim Klick auf einen Kalender-Tag aufgeht und
    Feiertage + Schulferien + Schul-Events fuer diesen Tag listet,
    inkl. Loeschen-Button pro Event.
  - RolloverWizard: zwei-Schritt-Dialog mit Datums-Auswahl + Tipp-
    Bestaetigung ("SCHULJAHR WECHSELN") gegen versehentliche Auslo-
    sung, danach Ergebnis-Card mit promoted/graduated-Counts.
  - MonthView gewinnt onDayClick + onAddEvent + onRollover Props,
    rendert farb-codierte Punkte fuer School-Events am Tagesrand.
  - Page laed Events parallel mit Holidays und reicht alle Handler
    nach unten.

Tests:
  - Go: 3 neue Tests fuer defaultSchoolYearDates + parseClassIDs.
    Validator-Test fuer CreateSchoolEventRequest existiert bereits.
    80 Subtests gesamt, alle gruen.
  - Playwright: mockCalendarApi gewinnt Routes fuer events GET/POST/
    DELETE und school-year-rollover. 6 neue Tests (EventModal open,
    submit, DayDetail open, Rollover-Trigger, Confirm-Schutz,
    Ergebnis-Anzeige).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-22 10:32:33 +02:00
parent 3b8df0d294
commit 33409352ee
14 changed files with 1072 additions and 14 deletions
+6
View File
@@ -252,6 +252,12 @@ func main() {
api.GET("/calendar/holidays", handler.ListCalendarHolidays) api.GET("/calendar/holidays", handler.ListCalendarHolidays)
api.GET("/calendar/config", handler.GetCalendarConfig) api.GET("/calendar/config", handler.GetCalendarConfig)
api.PUT("/calendar/config", handler.UpsertCalendarConfig) api.PUT("/calendar/config", handler.UpsertCalendarConfig)
// Phase 9b: school-events CRUD + Schuljahres-Rollover.
api.GET("/calendar/events", handler.ListSchoolEvents)
api.POST("/calendar/events", handler.CreateSchoolEvent)
api.DELETE("/calendar/events/:id", handler.DeleteSchoolEvent)
api.POST("/calendar/school-year-rollover", handler.RolloverSchoolYear)
} }
// Start server // Start server
@@ -74,3 +74,72 @@ func (h *Handler) UpsertCalendarConfig(c *gin.Context) {
} }
respondCreated(c, cfg) respondCreated(c, cfg)
} }
// ---------- School Events (Phase 9b) ----------
func (h *Handler) CreateSchoolEvent(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.CreateSchoolEventRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
ev, err := h.calendarService.CreateEvent(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to create event: "+err.Error())
return
}
respondCreated(c, ev)
}
func (h *Handler) ListSchoolEvents(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
events, err := h.calendarService.ListEvents(c.Request.Context(), uid, c.Query("from"), c.Query("to"))
if err != nil {
respondError(c, http.StatusInternalServerError, "Failed to list events: "+err.Error())
return
}
if events == nil {
events = []models.SchoolEvent{}
}
respondSuccess(c, events)
}
func (h *Handler) DeleteSchoolEvent(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
if err := h.calendarService.DeleteEvent(c.Request.Context(), c.Param("id"), uid); err != nil {
respondError(c, http.StatusInternalServerError, "Failed to delete event: "+err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"message": "Event deleted"})
}
func (h *Handler) RolloverSchoolYear(c *gin.Context) {
uid := getUserID(c)
if uid == "" {
respondError(c, http.StatusUnauthorized, "User not authenticated")
return
}
var req models.SchoolYearRolloverRequest
// Body is optional — empty defaults to next-Aug rollover.
_ = c.ShouldBindJSON(&req)
result, err := h.calendarService.RolloverSchoolYear(c.Request.Context(), uid, &req)
if err != nil {
respondError(c, http.StatusInternalServerError, "Rollover failed: "+err.Error())
return
}
respondSuccess(c, result)
}
@@ -78,3 +78,20 @@ type CreateSchoolEventRequest struct {
NotifyStudents bool `json:"notify_students"` NotifyStudents bool `json:"notify_students"`
NotificationLeadDays []int `json:"notification_lead_days"` NotificationLeadDays []int `json:"notification_lead_days"`
} }
// SchoolYearRolloverRequest moves all classes up by one grade and updates
// the config's school-year dates. Optional date pair, otherwise defaults
// to next Aug 01 → following Jul 31.
type SchoolYearRolloverRequest struct {
NewYearStart *string `json:"new_year_start,omitempty"` // YYYY-MM-DD
NewYearEnd *string `json:"new_year_end,omitempty"`
}
// SchoolYearRolloverResult is what the endpoint returns so the UI can show
// "promoted 8 classes, removed 2 graduating ones".
type SchoolYearRolloverResult struct {
ClassesPromoted int `json:"classes_promoted"`
ClassesGraduated int `json:"classes_graduated"`
NewYearStart string `json:"new_year_start"`
NewYearEnd string `json:"new_year_end"`
}
@@ -0,0 +1,134 @@
package services
import (
"context"
"fmt"
"github.com/breakpilot/school-service/internal/models"
"github.com/google/uuid"
)
// School-event CRUD. Ownership is per-user via created_by_user_id. Public
// holiday/Ferien data lives in cal_public_event and is handled by
// calendar_service.go.
func (s *CalendarService) CreateEvent(ctx context.Context, userID string, req *models.CreateSchoolEventRequest) (*models.SchoolEvent, error) {
classIDs, err := parseClassIDs(req.AffectedClassIDs)
if err != nil {
return nil, err
}
var e models.SchoolEvent
leadDays := req.NotificationLeadDays
if leadDays == nil {
leadDays = []int{7, 1}
}
row := s.db.QueryRow(ctx, `
INSERT INTO cal_school_event
(created_by_user_id, title, description, event_type, is_school_free,
start_date, end_date, start_time, end_time, affected_class_ids,
visible_to_parents, notify_parents, notify_students, notification_lead_days)
VALUES ($1, $2, $3, $4, $5,
$6::date, $7::date, NULLIF($8, '')::time, NULLIF($9, '')::time,
$10, $11, $12, $13, $14)
RETURNING id, created_by_user_id, title, COALESCE(description,''), event_type,
is_school_free, start_date::text, end_date::text,
start_time::text, end_time::text, affected_class_ids,
visible_to_parents, notify_parents, notify_students,
notification_lead_days, created_at, updated_at
`, userID, req.Title, req.Description, req.EventType, req.IsSchoolFree,
req.StartDate, req.EndDate, strOrEmpty(req.StartTime), strOrEmpty(req.EndTime),
classIDs, req.VisibleToParents, req.NotifyParents, req.NotifyStudents, leadDays)
if err := scanEvent(row, &e); err != nil {
return nil, err
}
return &e, nil
}
func (s *CalendarService) ListEvents(ctx context.Context, userID, from, to string) ([]models.SchoolEvent, error) {
if from == "" {
from = "1900-01-01"
}
if to == "" {
to = "2100-12-31"
}
rows, err := s.db.Query(ctx, `
SELECT id, created_by_user_id, title, COALESCE(description,''), event_type,
is_school_free, start_date::text, end_date::text,
start_time::text, end_time::text, affected_class_ids,
visible_to_parents, notify_parents, notify_students,
notification_lead_days, created_at, updated_at
FROM cal_school_event
WHERE created_by_user_id = $1
AND end_date >= $2::date
AND start_date <= $3::date
ORDER BY start_date, start_time NULLS FIRST, title
`, userID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.SchoolEvent
for rows.Next() {
var e models.SchoolEvent
if err := scanEvent(rows, &e); err != nil {
return nil, err
}
out = append(out, e)
}
return out, nil
}
func (s *CalendarService) DeleteEvent(ctx context.Context, id, userID string) error {
res, err := s.db.Exec(ctx, `
DELETE FROM cal_school_event WHERE id = $1 AND created_by_user_id = $2
`, id, userID)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
return fmt.Errorf("event not found or not owned")
}
return nil
}
// parseClassIDs validates the array of UUID strings the request sent.
// Returns a typed []uuid.UUID so asyncpg/pgx encodes it correctly into the
// UUID[] column.
func parseClassIDs(in []string) ([]uuid.UUID, error) {
out := make([]uuid.UUID, 0, len(in))
for _, s := range in {
if s == "" {
continue
}
u, err := uuid.Parse(s)
if err != nil {
return nil, fmt.Errorf("invalid class_id %q: %w", s, err)
}
out = append(out, u)
}
return out, nil
}
// row interface so the same scan logic works for both QueryRow and Rows.
type rowScanner interface {
Scan(dest ...any) error
}
func scanEvent(r rowScanner, e *models.SchoolEvent) error {
var startTime, endTime *string
if err := r.Scan(
&e.ID, &e.CreatedByUserID, &e.Title, &e.Description, &e.EventType,
&e.IsSchoolFree, &e.StartDate, &e.EndDate,
&startTime, &endTime, &e.AffectedClassIDs,
&e.VisibleToParents, &e.NotifyParents, &e.NotifyStudents,
&e.NotificationLeadDays, &e.CreatedAt, &e.UpdatedAt,
); err != nil {
return err
}
e.StartTime = startTime
e.EndTime = endTime
return nil
}
@@ -0,0 +1,85 @@
package services
import (
"context"
"fmt"
"time"
"github.com/breakpilot/school-service/internal/models"
)
// RolloverSchoolYear advances every tt_class for this user by one grade
// level, removes graduating classes (grade > 13), and updates the
// cal_school_config school-year dates. Operates as a single transaction.
//
// Stammdaten (teachers, subjects, rooms, periods) bleiben unveraendert —
// es aendern sich nur Klassen-Stufen.
func (s *CalendarService) RolloverSchoolYear(ctx context.Context, userID string, req *models.SchoolYearRolloverRequest) (*models.SchoolYearRolloverResult, error) {
newStart, newEnd := defaultSchoolYearDates(req)
tx, err := s.db.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// 1. Remove the graduating cohort first so they don't get bumped to 14.
gradRes, err := tx.Exec(ctx, `
DELETE FROM tt_class WHERE created_by_user_id = $1 AND grade_level >= 13
`, userID)
if err != nil {
return nil, fmt.Errorf("delete graduating: %w", err)
}
// 2. Promote everyone else.
promRes, err := tx.Exec(ctx, `
UPDATE tt_class SET grade_level = grade_level + 1
WHERE created_by_user_id = $1
`, userID)
if err != nil {
return nil, fmt.Errorf("promote classes: %w", err)
}
// 3. Update the school-year dates in the config (creates a row if the
// user never picked a Bundesland — but that's an edge case; in normal
// flow the wizard has run before rollover).
_, err = tx.Exec(ctx, `
UPDATE cal_school_config
SET school_year_start = $1::date,
school_year_end = $2::date,
updated_at = NOW()
WHERE user_id = $3
`, newStart, newEnd, userID)
if err != nil {
return nil, fmt.Errorf("update config: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &models.SchoolYearRolloverResult{
ClassesPromoted: int(promRes.RowsAffected()),
ClassesGraduated: int(gradRes.RowsAffected()),
NewYearStart: newStart,
NewYearEnd: newEnd,
}, nil
}
// defaultSchoolYearDates returns the dates from the request if both set,
// otherwise the next school year starting Aug 1 of "this year or next"
// and ending Jul 31 the year after.
func defaultSchoolYearDates(req *models.SchoolYearRolloverRequest) (string, string) {
if req != nil && req.NewYearStart != nil && req.NewYearEnd != nil {
return *req.NewYearStart, *req.NewYearEnd
}
now := time.Now()
startYear := now.Year()
// If we're past August, the "new" year refers to the next calendar year.
if int(now.Month()) >= 8 {
startYear++
}
start := time.Date(startYear, 8, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(startYear+1, 7, 31, 0, 0, 0, 0, time.UTC)
return start.Format("2006-01-02"), end.Format("2006-01-02")
}
@@ -62,3 +62,41 @@ func TestNewCalendarService_Constructs(t *testing.T) {
t.Fatal("expected non-nil service") t.Fatal("expected non-nil service")
} }
} }
func TestDefaultSchoolYearDates_FallbackFormat(t *testing.T) {
// No override → deterministic YYYY-MM-DD strings with end > start.
start, end := defaultSchoolYearDates(nil)
if len(start) != 10 || len(end) != 10 {
t.Fatalf("expected YYYY-MM-DD strings, got %q %q", start, end)
}
if end <= start {
t.Errorf("end %q must be after start %q", end, start)
}
}
func TestDefaultSchoolYearDates_ExplicitOverride(t *testing.T) {
s, e := "2030-09-01", "2031-06-30"
req := &models.SchoolYearRolloverRequest{NewYearStart: &s, NewYearEnd: &e}
gotS, gotE := defaultSchoolYearDates(req)
if gotS != s || gotE != e {
t.Errorf("override ignored: got %q/%q want %q/%q", gotS, gotE, s, e)
}
}
func TestParseClassIDs_AcceptsValidAndRejectsGarbage(t *testing.T) {
good := []string{"00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"}
out, err := parseClassIDs(good)
if err != nil || len(out) != 2 {
t.Fatalf("expected 2 parsed UUIDs, got %v err=%v", out, err)
}
if _, err := parseClassIDs([]string{"not-a-uuid"}); err == nil {
t.Errorf("expected error for invalid uuid")
}
// Empty strings are silently dropped (curl convenience).
out, err = parseClassIDs([]string{"", "00000000-0000-0000-0000-000000000003", ""})
if err != nil || len(out) != 1 {
t.Errorf("expected 1 parsed UUID, got %v err=%v", out, err)
}
}
@@ -0,0 +1,102 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
import { EVENT_TYPE_COLOR, EVENT_TYPE_LABEL } from '@/app/schulkalender/types'
interface DayDetailProps {
iso: string
holidays: PublicEvent[]
events: SchoolEvent[]
onClose: () => void
onDeleted: () => void
}
export function DayDetail({ iso, holidays, events, onClose, onDeleted }: DayDetailProps) {
const { isDark } = useTheme()
const handleDelete = async (id: string) => {
if (!confirm('Termin wirklich loeschen?')) return
try {
await calendarApi.deleteEvent(id)
onDeleted()
} catch {
// best-effort
}
}
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
const dayHolidays = holidays.filter(h => iso >= h.start_date && iso <= h.end_date)
const dayEvents = events.filter(e => iso >= e.start_date && iso <= e.end_date)
const formattedDate = new Date(iso).toLocaleDateString('de-DE', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
})
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="day-detail">
<div className={`w-full max-w-lg rounded-2xl border p-6 space-y-4 max-h-[90vh] overflow-y-auto ${cardClass}`}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{formattedDate}</h2>
<button onClick={onClose} className="opacity-60 hover:opacity-100"></button>
</div>
{dayHolidays.length === 0 && dayEvents.length === 0 && (
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Keine Eintraege fuer diesen Tag.
</p>
)}
{dayHolidays.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium opacity-80">Bundesweite Eintraege</h3>
{dayHolidays.map(h => (
<div key={h.id} className={`p-2 rounded-lg text-sm ${h.event_type === 'public_holiday' ? (isDark ? 'bg-rose-500/20' : 'bg-rose-50') : (isDark ? 'bg-amber-500/20' : 'bg-amber-50')}`}>
<div className="font-medium">{h.name_de}</div>
<div className="text-xs opacity-70">{h.event_type === 'public_holiday' ? 'Feiertag' : 'Schulferien'} · {h.start_date}{h.start_date !== h.end_date ? ` ${h.end_date}` : ''}</div>
</div>
))}
</section>
)}
{dayEvents.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium opacity-80">Schul-Termine</h3>
{dayEvents.map(e => (
<div
key={e.id}
className={`p-3 rounded-lg ${isDark ? 'bg-white/10' : 'bg-slate-50'}`}
style={{ borderLeft: `4px solid ${EVENT_TYPE_COLOR[e.event_type]}` }}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="font-medium">{e.title}</div>
<div className="text-xs opacity-70 mt-0.5">
{EVENT_TYPE_LABEL[e.event_type]}
{e.start_time && ` · ${e.start_time}${e.end_time ? `${e.end_time}` : ''}`}
{e.is_school_free && ' · unterrichtsfrei'}
</div>
{e.description && <div className="text-sm opacity-90 mt-1">{e.description}</div>}
<div className="text-xs opacity-60 mt-1.5">
{e.visible_to_parents && '👨‍👩‍👧 sichtbar fuer Eltern'}
{e.notify_parents && ' · 📧 Eltern erinnern'}
{e.notify_students && ' · 💬 Schueler erinnern'}
</div>
</div>
<button
onClick={() => handleDelete(e.id)}
className="text-xs text-red-400 hover:text-red-300"
>
Loeschen
</button>
</div>
</div>
))}
</section>
)}
</div>
</div>
)
}
@@ -0,0 +1,178 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { CreateSchoolEvent, SchoolEventType } from '@/app/schulkalender/types'
import { EVENT_TYPE_LABEL } from '@/app/schulkalender/types'
interface EventModalProps {
defaultDate: string // YYYY-MM-DD
onClose: () => void
onCreated: () => void
}
const initial = (date: string): CreateSchoolEvent => ({
title: '',
event_type: 'fortbildung',
is_school_free: false,
start_date: date,
end_date: date,
visible_to_parents: true,
notify_parents: false,
notify_students: false,
notification_lead_days: [7, 1],
})
export function EventModal({ defaultDate, onClose, onCreated }: EventModalProps) {
const { isDark } = useTheme()
const [form, setForm] = useState<CreateSchoolEvent>(initial(defaultDate))
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setError(null)
try {
await calendarApi.createEvent(form)
onCreated()
} catch (err) {
setError(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen')
} finally {
setSaving(false)
}
}
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="event-modal">
<form
onSubmit={handleSubmit}
className={`w-full max-w-2xl rounded-2xl border p-6 space-y-3 max-h-[90vh] overflow-y-auto ${cardClass}`}
>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Neuer Termin</h2>
<button type="button" onClick={onClose} className="opacity-60 hover:opacity-100"></button>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Titel</label>
<input
required
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
placeholder="z.B. SCHILF: Digitale Tafeln"
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
data-testid="event-title"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Typ</label>
<select
value={form.event_type}
onChange={e => setForm({ ...form, event_type: e.target.value as SchoolEventType })}
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
data-testid="event-type"
>
{(Object.keys(EVENT_TYPE_LABEL) as SchoolEventType[]).map(k => (
<option key={k} value={k}>{EVENT_TYPE_LABEL[k]}</option>
))}
</select>
</div>
<div className="flex items-end gap-2">
<input
type="checkbox"
id="is-school-free"
checked={form.is_school_free || false}
onChange={e => setForm({ ...form, is_school_free: e.target.checked })}
className="w-5 h-5"
/>
<label htmlFor="is-school-free" className="text-sm">Unterrichtsfrei</label>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Von</label>
<input type="date" required value={form.start_date} onChange={e => setForm({ ...form, start_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Bis</label>
<input type="date" required value={form.end_date} onChange={e => setForm({ ...form, end_date: e.target.value })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Startzeit (optional)</label>
<input type="time" value={form.start_time || ''} onChange={e => setForm({ ...form, start_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Endzeit (optional)</label>
<input type="time" value={form.end_time || ''} onChange={e => setForm({ ...form, end_time: e.target.value || null })} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Beschreibung (optional)</label>
<textarea
value={form.description || ''}
onChange={e => setForm({ ...form, description: e.target.value })}
rows={2}
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
<div className="space-y-2 pt-2 border-t border-white/10">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.visible_to_parents ?? true}
onChange={e => setForm({ ...form, visible_to_parents: e.target.checked })}
className="w-5 h-5"
/>
Eltern sehen diesen Termin
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.notify_parents ?? false}
onChange={e => setForm({ ...form, notify_parents: e.target.checked })}
className="w-5 h-5"
/>
Eltern per Mail/Chat erinnern
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.notify_students ?? false}
onChange={e => setForm({ ...form, notify_students: e.target.checked })}
className="w-5 h-5"
/>
Schueler per Chat erinnern
</label>
</div>
{error && (
<div className="p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
)}
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={saving}
data-testid="event-save"
className="flex-1 px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium disabled:opacity-50"
>
{saving ? 'Speichert…' : 'Anlegen'}
</button>
<button type="button" onClick={onClose} className={`px-4 py-2 rounded-lg ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-100 hover:bg-slate-200 text-slate-700'}`}>
Abbrechen
</button>
</div>
</form>
</div>
)
}
@@ -2,15 +2,20 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTheme } from '@/lib/ThemeContext' import { useTheme } from '@/lib/ThemeContext'
import type { PublicEvent } from '@/app/schulkalender/types' import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
import { EVENT_TYPE_COLOR } from '@/app/schulkalender/types'
interface MonthViewProps { interface MonthViewProps {
year: number year: number
month: number // 1-12 month: number // 1-12
holidays: PublicEvent[] holidays: PublicEvent[]
schoolEvents?: SchoolEvent[]
onPrev: () => void onPrev: () => void
onNext: () => void onNext: () => void
onToday: () => void onToday: () => void
onDayClick?: (iso: string) => void
onAddEvent?: () => void
onRollover?: () => void
} }
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
@@ -59,10 +64,26 @@ function buildMonthGrid(year: number, month: number, holidays: PublicEvent[]): C
return cells return cells
} }
export function MonthView({ year, month, holidays, onPrev, onNext, onToday }: MonthViewProps) { export function MonthView({ year, month, holidays, schoolEvents = [], onPrev, onNext, onToday, onDayClick, onAddEvent, onRollover }: MonthViewProps) {
const { isDark } = useTheme() const { isDark } = useTheme()
const cells = useMemo(() => buildMonthGrid(year, month, holidays), [year, month, holidays]) const cells = useMemo(() => buildMonthGrid(year, month, holidays), [year, month, holidays])
// School events per ISO date — quick lookup during cell render.
const schoolEventsByDate = useMemo(() => {
const map = new Map<string, SchoolEvent[]>()
for (const ev of schoolEvents) {
const start = new Date(ev.start_date)
const end = new Date(ev.end_date)
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
const iso = d.toISOString().slice(0, 10)
const arr = map.get(iso) || []
arr.push(ev)
map.set(iso, arr)
}
}
return map
}, [schoolEvents])
const headerClass = isDark ? 'text-white' : 'text-slate-900' const headerClass = isDark ? 'text-white' : 'text-slate-900'
const subtleText = isDark ? 'text-white/40' : 'text-slate-400' const subtleText = isDark ? 'text-white/40' : 'text-slate-400'
const cardClass = isDark ? 'bg-white/10 border-white/20' : 'bg-white/80 border-black/10' const cardClass = isDark ? 'bg-white/10 border-white/20' : 'bg-white/80 border-black/10'
@@ -79,6 +100,12 @@ export function MonthView({ year, month, holidays, onPrev, onNext, onToday }: Mo
{MONTHS_DE[month - 1]} {year} {MONTHS_DE[month - 1]} {year}
</h2> </h2>
<div className="flex gap-2"> <div className="flex gap-2">
{onAddEvent && (
<button onClick={onAddEvent} data-testid="add-event" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}>+ Termin</button>
)}
{onRollover && (
<button onClick={onRollover} data-testid="rollover-trigger" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-amber-500/30 hover:bg-amber-500/50 text-amber-100' : 'bg-amber-100 hover:bg-amber-200 text-amber-900'}`}>Schuljahr wechseln</button>
)}
<button onClick={onPrev} data-testid="month-prev" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}></button> <button onClick={onPrev} data-testid="month-prev" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}></button>
<button onClick={onToday} data-testid="month-today" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>Heute</button> <button onClick={onToday} data-testid="month-today" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>Heute</button>
<button onClick={onNext} data-testid="month-next" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}></button> <button onClick={onNext} data-testid="month-next" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}></button>
@@ -102,11 +129,16 @@ export function MonthView({ year, month, holidays, onPrev, onNext, onToday }: Mo
if (schoolHoliday) bg = isDark ? 'bg-amber-500/20' : 'bg-amber-100' if (schoolHoliday) bg = isDark ? 'bg-amber-500/20' : 'bg-amber-100'
if (publicHoliday) bg = isDark ? 'bg-rose-500/25' : 'bg-rose-100' if (publicHoliday) bg = isDark ? 'bg-rose-500/25' : 'bg-rose-100'
const dayEvents = schoolEventsByDate.get(iso) || []
return ( return (
<div <div
key={i} key={i}
data-testid={`day-${iso}`} data-testid={`day-${iso}`}
onClick={() => c.inMonth && onDayClick?.(iso)}
className={`relative aspect-square rounded-lg p-2 text-sm border ${ className={`relative aspect-square rounded-lg p-2 text-sm border ${
onDayClick && c.inMonth ? 'cursor-pointer hover:ring-2 hover:ring-indigo-300/50' : ''
} ${
isDark ? 'border-white/10' : 'border-black/5' isDark ? 'border-white/10' : 'border-black/5'
} ${c.inMonth ? bg : (isDark ? 'bg-transparent' : 'bg-transparent')} ${ } ${c.inMonth ? bg : (isDark ? 'bg-transparent' : 'bg-transparent')} ${
isToday ? (isDark ? 'ring-2 ring-indigo-400' : 'ring-2 ring-indigo-500') : '' isToday ? (isDark ? 'ring-2 ring-indigo-400' : 'ring-2 ring-indigo-500') : ''
@@ -137,6 +169,18 @@ export function MonthView({ year, month, holidays, onPrev, onNext, onToday }: Mo
)} )}
</div> </div>
)} )}
{dayEvents.length > 0 && (
<div className="absolute bottom-1 left-1 right-1 flex flex-wrap gap-0.5">
{dayEvents.slice(0, 4).map(ev => (
<span
key={ev.id}
title={ev.title}
className="inline-block w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: EVENT_TYPE_COLOR[ev.event_type] }}
/>
))}
</div>
)}
</div> </div>
) )
})} })}
@@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { SchoolYearRolloverResult } from '@/app/schulkalender/types'
interface RolloverWizardProps {
onClose: () => void
onDone: () => void
}
function nextSchoolYearISO(): { start: string; end: string } {
const now = new Date()
let y = now.getFullYear()
if (now.getMonth() + 1 >= 8) y++ // Aug → bumped to next year
return { start: `${y}-08-01`, end: `${y + 1}-07-31` }
}
export function RolloverWizard({ onClose, onDone }: RolloverWizardProps) {
const { isDark } = useTheme()
const defaults = nextSchoolYearISO()
const [start, setStart] = useState(defaults.start)
const [end, setEnd] = useState(defaults.end)
const [confirm, setConfirm] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<SchoolYearRolloverResult | null>(null)
const expected = 'SCHULJAHR WECHSELN'
const handleSubmit = async () => {
setSaving(true)
setError(null)
try {
const r = await calendarApi.rolloverSchoolYear(start, end)
setResult(r)
} catch (e) {
setError(e instanceof Error ? e.message : 'Rollover fehlgeschlagen')
} finally {
setSaving(false)
}
}
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
const inputClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="rollover-wizard">
<div className={`w-full max-w-xl rounded-2xl border p-6 space-y-4 max-h-[90vh] overflow-y-auto ${cardClass}`}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Schuljahres-Wechsel</h2>
<button onClick={onClose} className="opacity-60 hover:opacity-100"></button>
</div>
{result ? (
<div className="space-y-3" data-testid="rollover-result">
<div className={`p-4 rounded-xl ${isDark ? 'bg-emerald-500/20 border border-emerald-500/40' : 'bg-emerald-50 border border-emerald-200'}`}>
<div className="font-medium mb-2">Rollover erfolgreich</div>
<ul className="text-sm space-y-1 opacity-90">
<li>{result.classes_promoted} Klassen um eine Stufe aufgerueckt</li>
<li>{result.classes_graduated} Abschlussklassen entfernt</li>
<li>Neues Schuljahr: {result.new_year_start} {result.new_year_end}</li>
</ul>
</div>
<button onClick={onDone} className="w-full px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium">
Schliessen
</button>
</div>
) : (
<>
<div className={`p-3 rounded-lg text-sm ${isDark ? 'bg-amber-500/10 border border-amber-500/30 text-amber-100' : 'bg-amber-50 border border-amber-200 text-amber-900'}`}>
<p className="font-medium mb-1">Was passiert?</p>
<ul className="list-disc list-inside space-y-1 opacity-90">
<li>Alle Klassen ruecken eine Stufe hoeher (5a 6, 6a 7, )</li>
<li>Abschlussklassen (Stufe 13) werden entfernt</li>
<li>Lehrer, Faecher, Raeume, Zeitraster bleiben unveraendert</li>
<li>Vorhandene Stundenplaene bleiben als Historie erhalten</li>
</ul>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm mb-1 opacity-70">Schuljahr-Beginn</label>
<input type="date" value={start} onChange={e => setStart(e.target.value)} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
<div>
<label className="block text-sm mb-1 opacity-70">Schuljahr-Ende</label>
<input type="date" value={end} onChange={e => setEnd(e.target.value)} className={`w-full px-3 py-2 rounded-lg border ${inputClass}`} />
</div>
</div>
<div>
<label className="block text-sm mb-1 opacity-70">
Bestaetigung tippe <code className={`px-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>{expected}</code> zur Bestaetigung
</label>
<input
value={confirm}
onChange={e => setConfirm(e.target.value)}
data-testid="rollover-confirm"
className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
/>
</div>
{error && (
<div className="p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">{error}</div>
)}
<div className="flex gap-2">
<button
onClick={handleSubmit}
disabled={saving || confirm !== expected}
data-testid="rollover-submit"
className="flex-1 px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium disabled:opacity-30"
>
{saving ? 'Wechselt…' : 'Schuljahr wechseln'}
</button>
<button onClick={onClose} className={`px-4 py-2 rounded-lg ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-100 hover:bg-slate-200'}`}>
Abbrechen
</button>
</div>
</>
)}
</div>
</div>
)
}
+54 -12
View File
@@ -6,10 +6,13 @@ import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle' import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown' import { LanguageDropdown } from '@/components/LanguageDropdown'
import { calendarApi } from '@/lib/schulkalender/api' import { calendarApi } from '@/lib/schulkalender/api'
import type { PublicEvent, SchoolCalendarConfig } from './types' import type { PublicEvent, SchoolCalendarConfig, SchoolEvent } from './types'
import { BUNDESLAENDER } from './types' import { BUNDESLAENDER } from './types'
import { MonthView } from './_components/MonthView' import { MonthView } from './_components/MonthView'
import { BundeslandWizard } from './_components/BundeslandWizard' import { BundeslandWizard } from './_components/BundeslandWizard'
import { EventModal } from './_components/EventModal'
import { DayDetail } from './_components/DayDetail'
import { RolloverWizard } from './_components/RolloverWizard'
function monthRange(year: number, month: number): { from: string; to: string } { function monthRange(year: number, month: number): { from: string; to: string } {
// Render the visible 6-week grid worth of holidays (covers prev/next month edges). // Render the visible 6-week grid worth of holidays (covers prev/next month edges).
@@ -27,8 +30,12 @@ export default function SchulkalenderPage() {
const [month, setMonth] = useState(today.getMonth() + 1) const [month, setMonth] = useState(today.getMonth() + 1)
const [config, setConfig] = useState<SchoolCalendarConfig | null>(null) const [config, setConfig] = useState<SchoolCalendarConfig | null>(null)
const [holidays, setHolidays] = useState<PublicEvent[]>([]) const [holidays, setHolidays] = useState<PublicEvent[]>([])
const [schoolEvents, setSchoolEvents] = useState<SchoolEvent[]>([])
const [configLoading, setConfigLoading] = useState(true) const [configLoading, setConfigLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [openDay, setOpenDay] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [showRollover, setShowRollover] = useState(false)
const loadConfig = useCallback(async () => { const loadConfig = useCallback(async () => {
setConfigLoading(true) setConfigLoading(true)
@@ -49,11 +56,15 @@ export default function SchulkalenderPage() {
if (!config?.bundesland) return if (!config?.bundesland) return
const { from, to } = monthRange(year, month) const { from, to } = monthRange(year, month)
try { try {
const data = await calendarApi.listHolidays(config.bundesland, from, to) const [hd, ev] = await Promise.all([
setHolidays(data || []) calendarApi.listHolidays(config.bundesland, from, to),
calendarApi.listEvents(from, to),
])
setHolidays(hd || [])
setSchoolEvents(ev || [])
setError(null) setError(null)
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Ferien laden fehlgeschlagen') setError(e instanceof Error ? e.message : 'Ferien/Events laden fehlgeschlagen')
} }
}, [config, year, month]) }, [config, year, month])
@@ -119,14 +130,45 @@ export default function SchulkalenderPage() {
) : !config ? ( ) : !config ? (
<BundeslandWizard onSave={handleSaveBundesland} /> <BundeslandWizard onSave={handleSaveBundesland} />
) : ( ) : (
<MonthView <>
year={year} <MonthView
month={month} year={year}
holidays={holidays} month={month}
onPrev={goPrev} holidays={holidays}
onNext={goNext} schoolEvents={schoolEvents}
onToday={goToday} onPrev={goPrev}
/> onNext={goNext}
onToday={goToday}
onDayClick={(iso) => setOpenDay(iso)}
onAddEvent={() => setShowAddModal(true)}
onRollover={() => setShowRollover(true)}
/>
{openDay && (
<DayDetail
iso={openDay}
holidays={holidays}
events={schoolEvents}
onClose={() => setOpenDay(null)}
onDeleted={() => { loadHolidays(); setOpenDay(null) }}
/>
)}
{showAddModal && (
<EventModal
defaultDate={openDay || new Date().toISOString().slice(0, 10)}
onClose={() => setShowAddModal(false)}
onCreated={() => { setShowAddModal(false); loadHolidays() }}
/>
)}
{showRollover && (
<RolloverWizard
onClose={() => setShowRollover(false)}
onDone={() => { setShowRollover(false); loadHolidays() }}
/>
)}
</>
)} )}
</div> </div>
</main> </main>
+69
View File
@@ -27,6 +27,75 @@ export interface UpsertSchoolCalendarConfig {
school_year_end?: string | null school_year_end?: string | null
} }
export type SchoolEventType =
| 'fortbildung'
| 'schulfeier'
| 'klassenfahrt'
| 'projekttag'
| 'eltern_info'
| 'andere'
export interface SchoolEvent {
id: string
created_by_user_id: string
title: string
description?: string
event_type: SchoolEventType
is_school_free: boolean
start_date: string
end_date: string
start_time?: string | null
end_time?: string | null
affected_class_ids: string[]
visible_to_parents: boolean
notify_parents: boolean
notify_students: boolean
notification_lead_days: number[]
created_at?: string
updated_at?: string
}
export interface CreateSchoolEvent {
title: string
description?: string
event_type: SchoolEventType
is_school_free?: boolean
start_date: string
end_date: string
start_time?: string | null
end_time?: string | null
affected_class_ids?: string[]
visible_to_parents?: boolean
notify_parents?: boolean
notify_students?: boolean
notification_lead_days?: number[]
}
export interface SchoolYearRolloverResult {
classes_promoted: number
classes_graduated: number
new_year_start: string
new_year_end: string
}
export const EVENT_TYPE_LABEL: Record<SchoolEventType, string> = {
fortbildung: 'Fortbildung',
schulfeier: 'Schulfeier',
klassenfahrt: 'Klassenfahrt',
projekttag: 'Projekttag',
eltern_info: 'Eltern-Info',
andere: 'Andere',
}
export const EVENT_TYPE_COLOR: Record<SchoolEventType, string> = {
fortbildung: '#0ea5e9',
schulfeier: '#a855f7',
klassenfahrt: '#22c55e',
projekttag: '#f59e0b',
eltern_info: '#ec4899',
andere: '#64748b',
}
export const BUNDESLAENDER: { code: string; name: string }[] = [ export const BUNDESLAENDER: { code: string; name: string }[] = [
{ code: 'DE-BW', name: 'Baden-Wuerttemberg' }, { code: 'DE-BW', name: 'Baden-Wuerttemberg' },
{ code: 'DE-BY', name: 'Bayern' }, { code: 'DE-BY', name: 'Bayern' },
+129
View File
@@ -9,10 +9,12 @@ import { test, expect, Page } from '@playwright/test'
interface MockOpts { interface MockOpts {
config?: { user_id: string; bundesland: string } | null config?: { user_id: string; bundesland: string } | null
holidays?: unknown[] holidays?: unknown[]
events?: unknown[]
} }
async function mockCalendarApi(page: Page, opts: MockOpts = {}) { async function mockCalendarApi(page: Page, opts: MockOpts = {}) {
let config = opts.config ?? null let config = opts.config ?? null
const events = (opts.events ?? []) as Array<Record<string, unknown>>
await page.route('**/api/school/calendar/config', async (route) => { await page.route('**/api/school/calendar/config', async (route) => {
if (route.request().method() === 'GET') { if (route.request().method() === 'GET') {
@@ -38,6 +40,49 @@ async function mockCalendarApi(page: Page, opts: MockOpts = {}) {
body: JSON.stringify(opts.holidays ?? []), body: JSON.stringify(opts.holidays ?? []),
}) })
}) })
// Phase 9b: school events + rollover.
await page.route(/\/api\/school\/calendar\/events(\?.*)?$/, async (route) => {
const method = route.request().method()
if (method === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(events) })
}
if (method === 'POST') {
const body = JSON.parse(route.request().postData() || '{}')
const created = {
id: `new-${events.length}`,
created_by_user_id: 'dev',
affected_class_ids: [],
visible_to_parents: true,
notify_parents: false,
notify_students: false,
notification_lead_days: [7, 1],
is_school_free: false,
...body,
}
events.push(created)
return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(created) })
}
return route.fulfill({ status: 405 })
})
await page.route(/\/api\/school\/calendar\/events\/[^/]+$/, async (route) => {
if (route.request().method() === 'DELETE') {
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"ok"}' })
}
return route.fulfill({ status: 405 })
})
await page.route(/\/api\/school\/calendar\/school-year-rollover$/, async (route) => {
if (route.request().method() !== 'POST') return route.fulfill({ status: 405 })
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({
classes_promoted: 8, classes_graduated: 2,
new_year_start: '2026-08-01', new_year_end: '2027-07-31',
}),
})
})
} }
test.describe('Schulkalender — Bundesland Wizard', () => { test.describe('Schulkalender — Bundesland Wizard', () => {
@@ -120,3 +165,87 @@ test.describe('Schulkalender — Sidebar entry', () => {
await expect(sidebar.getByText(/Schulkalender|School Calendar/).first()).toBeVisible() await expect(sidebar.getByText(/Schulkalender|School Calendar/).first()).toBeVisible()
}) })
}) })
// ==========================================================================
// Phase 9b — Schul-Events + Schuljahres-Rollover
// ==========================================================================
test.describe('Schulkalender — School Event CRUD', () => {
test('+ Termin button opens the event modal', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' }, events: [] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('add-event').click()
await expect(page.getByTestId('event-modal')).toBeVisible()
await expect(page.getByText('Neuer Termin')).toBeVisible()
})
test('submitting the form creates an event and closes the modal', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' }, events: [] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('add-event').click()
await page.getByTestId('event-title').fill('SCHILF: Digitale Tafeln')
await page.getByTestId('event-type').selectOption('fortbildung')
await page.getByTestId('event-save').click()
await expect(page.getByTestId('event-modal')).toHaveCount(0)
})
test('clicking a day opens the DayDetail with its events', async ({ page }) => {
const todayIso = new Date().toISOString().slice(0, 10)
await mockCalendarApi(page, {
config: { user_id: 'dev', bundesland: 'DE-NI' },
events: [{
id: 'e1', created_by_user_id: 'dev',
title: 'Pruefe Test-Event', event_type: 'projekttag',
is_school_free: false,
start_date: todayIso, end_date: todayIso,
affected_class_ids: [], visible_to_parents: true,
notify_parents: false, notify_students: false,
notification_lead_days: [7, 1],
}],
})
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId(`day-${todayIso}`).click()
await expect(page.getByTestId('day-detail')).toBeVisible()
await expect(page.getByText('Pruefe Test-Event')).toBeVisible()
})
})
test.describe('Schulkalender — Schuljahres-Rollover', () => {
test('Schuljahr-wechseln button opens the wizard', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' } })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('rollover-trigger').click()
await expect(page.getByTestId('rollover-wizard')).toBeVisible()
})
test('confirm-typing protects against accidental submit', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' } })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('rollover-trigger').click()
const submit = page.getByTestId('rollover-submit')
await expect(submit).toBeDisabled()
await page.getByTestId('rollover-confirm').fill('falsch')
await expect(submit).toBeDisabled()
await page.getByTestId('rollover-confirm').fill('SCHULJAHR WECHSELN')
await expect(submit).toBeEnabled()
})
test('successful rollover shows summary numbers', async ({ page }) => {
await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' } })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('rollover-trigger').click()
await page.getByTestId('rollover-confirm').fill('SCHULJAHR WECHSELN')
await page.getByTestId('rollover-submit').click()
await expect(page.getByTestId('rollover-result')).toBeVisible()
await expect(page.getByText('8 Klassen um eine Stufe aufgerueckt')).toBeVisible()
await expect(page.getByText('2 Abschlussklassen entfernt')).toBeVisible()
})
})
+18
View File
@@ -6,6 +6,7 @@
import { getStundenplanToken } from '@/lib/stundenplan/api' import { getStundenplanToken } from '@/lib/stundenplan/api'
import type { import type {
PublicEvent, SchoolCalendarConfig, UpsertSchoolCalendarConfig, PublicEvent, SchoolCalendarConfig, UpsertSchoolCalendarConfig,
SchoolEvent, CreateSchoolEvent, SchoolYearRolloverResult,
} from '@/app/schulkalender/types' } from '@/app/schulkalender/types'
async function apiFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> { async function apiFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
@@ -31,4 +32,21 @@ export const calendarApi = {
getConfig: () => apiFetch<SchoolCalendarConfig | null>('/calendar/config'), getConfig: () => apiFetch<SchoolCalendarConfig | null>('/calendar/config'),
upsertConfig: (data: UpsertSchoolCalendarConfig) => upsertConfig: (data: UpsertSchoolCalendarConfig) =>
apiFetch<SchoolCalendarConfig>('/calendar/config', { method: 'PUT', body: JSON.stringify(data) }), apiFetch<SchoolCalendarConfig>('/calendar/config', { method: 'PUT', body: JSON.stringify(data) }),
// School events
listEvents: (from: string, to: string) =>
apiFetch<SchoolEvent[]>(`/calendar/events?from=${from}&to=${to}`),
createEvent: (data: CreateSchoolEvent) =>
apiFetch<SchoolEvent>('/calendar/events', { method: 'POST', body: JSON.stringify(data) }),
deleteEvent: (id: string) =>
apiFetch<void>(`/calendar/events/${id}`, { method: 'DELETE' }),
rolloverSchoolYear: (newYearStart?: string, newYearEnd?: string) =>
apiFetch<SchoolYearRolloverResult>('/calendar/school-year-rollover', {
method: 'POST',
body: JSON.stringify({
new_year_start: newYearStart,
new_year_end: newYearEnd,
}),
}),
} }