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
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
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 { 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<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 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}
|
||||
</h2>
|
||||
<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={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>
|
||||
@@ -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 (
|
||||
<div
|
||||
key={i}
|
||||
data-testid={`day-${iso}`}
|
||||
onClick={() => 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
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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<SchoolCalendarConfig | null>(null)
|
||||
const [holidays, setHolidays] = useState<PublicEvent[]>([])
|
||||
const [schoolEvents, setSchoolEvents] = useState<SchoolEvent[]>([])
|
||||
const [configLoading, setConfigLoading] = useState(true)
|
||||
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 () => {
|
||||
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 ? (
|
||||
<BundeslandWizard onSave={handleSaveBundesland} />
|
||||
) : (
|
||||
<>
|
||||
<MonthView
|
||||
year={year}
|
||||
month={month}
|
||||
holidays={holidays}
|
||||
schoolEvents={schoolEvents}
|
||||
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>
|
||||
</main>
|
||||
|
||||
@@ -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<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 }[] = [
|
||||
{ code: 'DE-BW', name: 'Baden-Wuerttemberg' },
|
||||
{ code: 'DE-BY', name: 'Bayern' },
|
||||
|
||||
@@ -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<Record<string, unknown>>
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
@@ -31,4 +32,21 @@ export const calendarApi = {
|
||||
getConfig: () => apiFetch<SchoolCalendarConfig | null>('/calendar/config'),
|
||||
upsertConfig: (data: UpsertSchoolCalendarConfig) =>
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user