diff --git a/school-service/cmd/server/main.go b/school-service/cmd/server/main.go
index 0757d34..9cbb022 100644
--- a/school-service/cmd/server/main.go
+++ b/school-service/cmd/server/main.go
@@ -252,6 +252,12 @@ func main() {
api.GET("/calendar/holidays", handler.ListCalendarHolidays)
api.GET("/calendar/config", handler.GetCalendarConfig)
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
diff --git a/school-service/internal/handlers/calendar_handlers.go b/school-service/internal/handlers/calendar_handlers.go
index 719478f..f7928c7 100644
--- a/school-service/internal/handlers/calendar_handlers.go
+++ b/school-service/internal/handlers/calendar_handlers.go
@@ -74,3 +74,72 @@ func (h *Handler) UpsertCalendarConfig(c *gin.Context) {
}
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)
+}
diff --git a/school-service/internal/models/calendar.go b/school-service/internal/models/calendar.go
index 3c0aa91..6abf42b 100644
--- a/school-service/internal/models/calendar.go
+++ b/school-service/internal/models/calendar.go
@@ -78,3 +78,20 @@ type CreateSchoolEventRequest struct {
NotifyStudents bool `json:"notify_students"`
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"`
+}
diff --git a/school-service/internal/services/calendar_events.go b/school-service/internal/services/calendar_events.go
new file mode 100644
index 0000000..7b5fbd0
--- /dev/null
+++ b/school-service/internal/services/calendar_events.go
@@ -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
+}
diff --git a/school-service/internal/services/calendar_rollover.go b/school-service/internal/services/calendar_rollover.go
new file mode 100644
index 0000000..32b1ec0
--- /dev/null
+++ b/school-service/internal/services/calendar_rollover.go
@@ -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")
+}
diff --git a/school-service/internal/services/calendar_service_test.go b/school-service/internal/services/calendar_service_test.go
index 2a4186c..b8e4f2d 100644
--- a/school-service/internal/services/calendar_service_test.go
+++ b/school-service/internal/services/calendar_service_test.go
@@ -62,3 +62,41 @@ func TestNewCalendarService_Constructs(t *testing.T) {
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)
+ }
+}
diff --git a/studio-v2/app/schulkalender/_components/DayDetail.tsx b/studio-v2/app/schulkalender/_components/DayDetail.tsx
new file mode 100644
index 0000000..365430f
--- /dev/null
+++ b/studio-v2/app/schulkalender/_components/DayDetail.tsx
@@ -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 (
+
+
+
+
{formattedDate}
+
+
+
+ {dayHolidays.length === 0 && dayEvents.length === 0 && (
+
+ Keine Eintraege fuer diesen Tag.
+
+ )}
+
+ {dayHolidays.length > 0 && (
+
+ Bundesweite Eintraege
+ {dayHolidays.map(h => (
+
+
{h.name_de}
+
{h.event_type === 'public_holiday' ? 'Feiertag' : 'Schulferien'} · {h.start_date}{h.start_date !== h.end_date ? ` – ${h.end_date}` : ''}
+
+ ))}
+
+ )}
+
+ {dayEvents.length > 0 && (
+
+ Schul-Termine
+ {dayEvents.map(e => (
+
+
+
+
{e.title}
+
+ {EVENT_TYPE_LABEL[e.event_type]}
+ {e.start_time && ` · ${e.start_time}${e.end_time ? `–${e.end_time}` : ''}`}
+ {e.is_school_free && ' · unterrichtsfrei'}
+
+ {e.description &&
{e.description}
}
+
+ {e.visible_to_parents && '👨👩👧 sichtbar fuer Eltern'}
+ {e.notify_parents && ' · 📧 Eltern erinnern'}
+ {e.notify_students && ' · 💬 Schueler erinnern'}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/studio-v2/app/schulkalender/_components/EventModal.tsx b/studio-v2/app/schulkalender/_components/EventModal.tsx
new file mode 100644
index 0000000..6d1f222
--- /dev/null
+++ b/studio-v2/app/schulkalender/_components/EventModal.tsx
@@ -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(initial(defaultDate))
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState(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 (
+
+ )
+}
diff --git a/studio-v2/app/schulkalender/_components/MonthView.tsx b/studio-v2/app/schulkalender/_components/MonthView.tsx
index 2b3cdd9..65801e7 100644
--- a/studio-v2/app/schulkalender/_components/MonthView.tsx
+++ b/studio-v2/app/schulkalender/_components/MonthView.tsx
@@ -2,15 +2,20 @@
import { useMemo } from 'react'
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 {
year: number
month: number // 1-12
holidays: PublicEvent[]
+ schoolEvents?: SchoolEvent[]
onPrev: () => void
onNext: () => void
onToday: () => void
+ onDayClick?: (iso: string) => void
+ onAddEvent?: () => void
+ onRollover?: () => void
}
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
}
-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 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()
+ 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 subtleText = isDark ? 'text-white/40' : 'text-slate-400'
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}
+ {onAddEvent && (
+
+ )}
+ {onRollover && (
+
+ )}
@@ -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 (publicHoliday) bg = isDark ? 'bg-rose-500/25' : 'bg-rose-100'
+ const dayEvents = schoolEventsByDate.get(iso) || []
+
return (
c.inMonth && onDayClick?.(iso)}
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'
} ${c.inMonth ? bg : (isDark ? 'bg-transparent' : 'bg-transparent')} ${
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
)}
)}
+ {dayEvents.length > 0 && (
+
+ {dayEvents.slice(0, 4).map(ev => (
+
+ ))}
+
+ )}
)
})}
diff --git a/studio-v2/app/schulkalender/_components/RolloverWizard.tsx b/studio-v2/app/schulkalender/_components/RolloverWizard.tsx
new file mode 100644
index 0000000..1263622
--- /dev/null
+++ b/studio-v2/app/schulkalender/_components/RolloverWizard.tsx
@@ -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(null)
+ const [result, setResult] = useState(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 (
+
+
+
+
Schuljahres-Wechsel
+
+
+
+ {result ? (
+
+
+
Rollover erfolgreich
+
+ - {result.classes_promoted} Klassen um eine Stufe aufgerueckt
+ - {result.classes_graduated} Abschlussklassen entfernt
+ - Neues Schuljahr: {result.new_year_start} – {result.new_year_end}
+
+
+
+
+ ) : (
+ <>
+
+
Was passiert?
+
+ - Alle Klassen ruecken eine Stufe hoeher (5a → 6, 6a → 7, …)
+ - Abschlussklassen (Stufe 13) werden entfernt
+ - Lehrer, Faecher, Raeume, Zeitraster bleiben unveraendert
+ - Vorhandene Stundenplaene bleiben als Historie erhalten
+
+
+
+
+
+
+
+ setConfirm(e.target.value)}
+ data-testid="rollover-confirm"
+ className={`w-full px-3 py-2 rounded-lg border ${inputClass}`}
+ />
+
+
+ {error && (
+
{error}
+ )}
+
+
+
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/studio-v2/app/schulkalender/page.tsx b/studio-v2/app/schulkalender/page.tsx
index 2f8bbe2..e59bac6 100644
--- a/studio-v2/app/schulkalender/page.tsx
+++ b/studio-v2/app/schulkalender/page.tsx
@@ -6,10 +6,13 @@ import { Sidebar } from '@/components/Sidebar'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
import { calendarApi } from '@/lib/schulkalender/api'
-import type { PublicEvent, SchoolCalendarConfig } from './types'
+import type { PublicEvent, SchoolCalendarConfig, SchoolEvent } from './types'
import { BUNDESLAENDER } from './types'
import { MonthView } from './_components/MonthView'
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 } {
// 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 [config, setConfig] = useState(null)
const [holidays, setHolidays] = useState([])
+ const [schoolEvents, setSchoolEvents] = useState([])
const [configLoading, setConfigLoading] = useState(true)
const [error, setError] = useState(null)
+ const [openDay, setOpenDay] = useState(null)
+ const [showAddModal, setShowAddModal] = useState(false)
+ const [showRollover, setShowRollover] = useState(false)
const loadConfig = useCallback(async () => {
setConfigLoading(true)
@@ -49,11 +56,15 @@ export default function SchulkalenderPage() {
if (!config?.bundesland) return
const { from, to } = monthRange(year, month)
try {
- const data = await calendarApi.listHolidays(config.bundesland, from, to)
- setHolidays(data || [])
+ const [hd, ev] = await Promise.all([
+ calendarApi.listHolidays(config.bundesland, from, to),
+ calendarApi.listEvents(from, to),
+ ])
+ setHolidays(hd || [])
+ setSchoolEvents(ev || [])
setError(null)
} catch (e) {
- setError(e instanceof Error ? e.message : 'Ferien laden fehlgeschlagen')
+ setError(e instanceof Error ? e.message : 'Ferien/Events laden fehlgeschlagen')
}
}, [config, year, month])
@@ -119,14 +130,45 @@ export default function SchulkalenderPage() {
) : !config ? (
) : (
-
+ <>
+ setOpenDay(iso)}
+ onAddEvent={() => setShowAddModal(true)}
+ onRollover={() => setShowRollover(true)}
+ />
+
+ {openDay && (
+ setOpenDay(null)}
+ onDeleted={() => { loadHolidays(); setOpenDay(null) }}
+ />
+ )}
+
+ {showAddModal && (
+ setShowAddModal(false)}
+ onCreated={() => { setShowAddModal(false); loadHolidays() }}
+ />
+ )}
+
+ {showRollover && (
+ setShowRollover(false)}
+ onDone={() => { setShowRollover(false); loadHolidays() }}
+ />
+ )}
+ >
)}
diff --git a/studio-v2/app/schulkalender/types.ts b/studio-v2/app/schulkalender/types.ts
index ba8ad7c..a0c4a66 100644
--- a/studio-v2/app/schulkalender/types.ts
+++ b/studio-v2/app/schulkalender/types.ts
@@ -27,6 +27,75 @@ export interface UpsertSchoolCalendarConfig {
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 = {
+ fortbildung: 'Fortbildung',
+ schulfeier: 'Schulfeier',
+ klassenfahrt: 'Klassenfahrt',
+ projekttag: 'Projekttag',
+ eltern_info: 'Eltern-Info',
+ andere: 'Andere',
+}
+
+export const EVENT_TYPE_COLOR: Record = {
+ fortbildung: '#0ea5e9',
+ schulfeier: '#a855f7',
+ klassenfahrt: '#22c55e',
+ projekttag: '#f59e0b',
+ eltern_info: '#ec4899',
+ andere: '#64748b',
+}
+
export const BUNDESLAENDER: { code: string; name: string }[] = [
{ code: 'DE-BW', name: 'Baden-Wuerttemberg' },
{ code: 'DE-BY', name: 'Bayern' },
diff --git a/studio-v2/e2e/schulkalender.spec.ts b/studio-v2/e2e/schulkalender.spec.ts
index f2c9c2e..db4c650 100644
--- a/studio-v2/e2e/schulkalender.spec.ts
+++ b/studio-v2/e2e/schulkalender.spec.ts
@@ -9,10 +9,12 @@ import { test, expect, Page } from '@playwright/test'
interface MockOpts {
config?: { user_id: string; bundesland: string } | null
holidays?: unknown[]
+ events?: unknown[]
}
async function mockCalendarApi(page: Page, opts: MockOpts = {}) {
let config = opts.config ?? null
+ const events = (opts.events ?? []) as Array>
await page.route('**/api/school/calendar/config', async (route) => {
if (route.request().method() === 'GET') {
@@ -38,6 +40,49 @@ async function mockCalendarApi(page: Page, opts: MockOpts = {}) {
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', () => {
@@ -120,3 +165,87 @@ test.describe('Schulkalender — Sidebar entry', () => {
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()
+ })
+})
diff --git a/studio-v2/lib/schulkalender/api.ts b/studio-v2/lib/schulkalender/api.ts
index 3d21d13..25b8551 100644
--- a/studio-v2/lib/schulkalender/api.ts
+++ b/studio-v2/lib/schulkalender/api.ts
@@ -6,6 +6,7 @@
import { getStundenplanToken } from '@/lib/stundenplan/api'
import type {
PublicEvent, SchoolCalendarConfig, UpsertSchoolCalendarConfig,
+ SchoolEvent, CreateSchoolEvent, SchoolYearRolloverResult,
} from '@/app/schulkalender/types'
async function apiFetch(endpoint: string, options: RequestInit = {}): Promise {
@@ -31,4 +32,21 @@ export const calendarApi = {
getConfig: () => apiFetch('/calendar/config'),
upsertConfig: (data: UpsertSchoolCalendarConfig) =>
apiFetch('/calendar/config', { method: 'PUT', body: JSON.stringify(data) }),
+
+ // School events
+ listEvents: (from: string, to: string) =>
+ apiFetch(`/calendar/events?from=${from}&to=${to}`),
+ createEvent: (data: CreateSchoolEvent) =>
+ apiFetch('/calendar/events', { method: 'POST', body: JSON.stringify(data) }),
+ deleteEvent: (id: string) =>
+ apiFetch(`/calendar/events/${id}`, { method: 'DELETE' }),
+
+ rolloverSchoolYear: (newYearStart?: string, newYearEnd?: string) =>
+ apiFetch('/calendar/school-year-rollover', {
+ method: 'POST',
+ body: JSON.stringify({
+ new_year_start: newYearStart,
+ new_year_end: newYearEnd,
+ }),
+ }),
}