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/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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user