Zum Hauptinhalt springen

Schuljahreswechsel

Eigener Bereich "Schuljahreswechsel" (/school-years/transition) mit drei getrennten Schritten — Zielstruktur vorbereiten, Versetzung planen, Durchführen — statt eines einzelnen Ein-Schritt-Formulars. Keine stille Automatik mehr: Jede Zielklasse wird vorher ausdrücklich gesetzt (von Hand oder per Vorschlagsfunktion) und beim Durchführen nur noch ausgewertet.

Grundlage: docs/TASK-school-year-transition-page.md. Die Klassenverlauf-Historisierung (StudentClassHistory) ist Teil von lib/student-class-history/ (eigenes Modul, siehe dessen Quellcode/Tests — valid_from/valid_to/outcome/next_class_id, höchstens ein offener Eintrag pro Schüler).

Datenmodell​

Student.planned_class_id​

model Student {
// …
planned_class_id String? @map("planned_class_id")
planned_class SchoolClass? @relation("StudentPlannedClass", fields: [planned_class_id], references: [id], onDelete: SetNull)
}

Die vom Nutzer gesetzte Zielklasse für den nächsten Schuljahreswechsel (nullable). Gilt nur für ein Zieljahr auf einmal — mehrjährige Vorausplanung ist bewusst nicht unterstützt (siehe "Später" in der Taskdoku).

Rücksetzregel (serverseitig, an zwei Stellen identisch umgesetzt): Wird promotion_status auf pending oder will_graduate gesetzt (direkt oder indirekt, z. B. wenn ein Schüler über status: inactive/archived aus dem Wechsel genommen wird), wird planned_class_id im selben Schreibvorgang auf null gesetzt.

  • lib/student/db.ts → updateStudentById (Schülerformular, promotion_status bleibt dort editierbar).
  • lib/school-year/planning.ts → applySingleStudentPlan (Planungsaktionen).

PromotionStatus (Enum, unverändert)​

WertBedeutung
pendingAusstehend (noch keine Entscheidung)
will_advanceWird versetzt (nächste Klassenstufe)
will_repeatWiederholt das Schuljahr
will_graduateAbgang/Abschluss

Zieljahr-Auflösung — lib/school-year/target-year.ts​

Gemeinsam genutzt von planning.ts und overview.ts:

  • getCurrentSchoolYear(tenantId) — das Schuljahr mit is_current: true.
  • resolveTargetSchoolYear(tenantId, currentYear) — das chronologisch nächste Schuljahr nach dem aktuellen, das nicht aktuell und nicht archiviert ist (start_date-Sortierung). Existiert keines, null.

executeSchoolYearTransition bekommt das Zieljahr dagegen explizit als targetSchoolYearId übergeben (nicht über den Resolver), damit die aufrufende UI-Seite entscheidet, welches Jahr gemeint ist.

Server-Logik​

lib/school-year/planning.ts​

prepareSchoolYear({ name, start_date, end_date, leadershipMode? })​

  • Zieljahr: wiederverwendet das über resolveTargetSchoolYear gefundene Jahr, sonst legt es ein neues mit name/start_date/end_date an (is_current: false). Ein gefundenes Kandidatenjahr mit bereits zugeordneten Schülern (Student.school_class_id oder StudentClassHistory auf Klassen dieses Jahres) wird abgelehnt, kein Fallback auf Neuanlage.
  • Leitet Klassenstufen/Klassen aus dem aktuellen Jahr ab — gleiches "zwei Zielstufen"-Muster wie vormals in createSchoolYearTransition (jede Stufe wird auf derselben UND der nächsten Stufe fortgeführt, Buchstaben werden zusammengeführt).
  • Nur fehlende Stufen/Klassen werden angelegt (Abgleich: Stufe über die Zahl im Namen, Klasse über grade+letter). Vorhandenes bleibt unverändert — inklusive Klassenleitung, Raum, max_students.
  • Fächerzuordnungen (GradeLevelSubject, ClassSubject) werden nur für neu angelegte Stufen/Klassen erzeugt.
  • Soll-Stundenplan (Timetable): copySameGradeTimetable (Issue #43) kopiert die Stundenplan-Einträge der Quellklasse auf eine neu angelegte Klasse, aber nur wenn sourceClass.grade === grade gilt (die Quellklasse also dieselbe Stufe hat wie die neue Klasse). Bei einer Höherstufung (sourceClass.grade === grade - 1) passen Fächer/Wochenstunden der alten Stufe fachlich nicht zur neuen, deshalb wird dort nichts kopiert. teacher_id wird 1:1 übernommen (keine Status.active-Prüfung wie bei der Klassenleitung, keine Konfliktprüfung — Neuvergabe der Fachlehrer ist ein eigenes späteres Thema). Das Fach (class_subject_id) wird über subject_id auf die bereits für die Zielklasse angelegte ClassSubject-Zeile abgebildet; ohne Treffer bleibt das Fach leer, der Zeitslot wird trotzdem übernommen. stats.timetableEntriesCreated zählt die kopierten Einträge.
  • Idempotent: zweiter Aufruf meldet alles als existing, keine Duplikate. Ändert nie das aktuelle Jahr, dessen Klassen oder Schüler.
  • leadershipMode (ClassLeadershipMode, siehe lib/school-year/leadership-mode.ts) bestimmt die Klassenleitung neu angelegter Klassen — im Zieljahr bereits vorhandene Klassen bleiben unverändert (siehe oben):
    • unassigned (Voreinstellung): keine Klassenleitung.
    • stay_in_grade: Klassenleitung der alten Klasse (grade, letter) — die Lehrkraft bleibt in der Klassenstufe.
    • follow_class: Klassenleitung der alten Klasse (grade - 1, letter) — die Lehrkraft geht mit der Klasse mit.
    • In beiden Fällen nur, wenn die Lehrkraft noch Status.active ist (tenant-gescoped), sonst null. Die reine Zuordnungslogik liegt in resolveClassLeadership (keine Datenbankzugriffe, einzeln testbar); prepareSchoolYear baut dafür oldClassesByGradeLetter (Schlüssel grade-letter → teacher_id) und activeTeacherIds auf und ruft die Funktion pro neu angelegter Klasse auf.
    • stats.classesWithoutLeadership zählt die neu angelegten Klassen ohne Klassenleitung; die Sammelzeile im Audit-Log (auditSummary) enthält zusätzlich leadershipMode im context.
    • Ersetzt das frühere Mischverhalten (Vorrang gleiche Stufe, Fallback Vorstufe), das unabhängig vom Anwenderwunsch je nach Klassenstruktur unterschiedlich ausfiel.

getLeadershipPreviewAction(mode)​

Nur lesend (keine Schreibzugriffe, kein $transaction): liefert für den übergebenen Modus die Liste der Klassen, die ein nachfolgender prepareSchoolYear-Aufruf anlegen bzw. unverändert lassen würde — { name, teacherName: string | null, warning?: string }[]. Im Zieljahr bereits vorhandene Klassen erscheinen mit ihrer unveränderten Klassenleitung; neue Klassen nutzen dieselbe resolveClassLeadership-Logik wie prepareSchoolYear. Warnungen: Lehrkraft der Quellklasse inaktiv/archiviert, Lehrkraft mehrfach zugeordnet, Klasse bleibt ohne Klassenleitung.

setStudentPlan({ studentId, promotionStatus?, plannedClassId? }) / setClassPlan({ classId, promotionStatus?, plannedClassId?, studentIds? })​

Teilen sich die Validierung/Warnlogik über die interne Hilfsfunktion applySingleStudentPlan:

  • Zielklasse nur zulässig zusammen mit will_advance/will_repeat (sonst Fehler).
  • Zielklasse muss zum vorbereiteten Zieljahr gehören (school_year_id + status: active), sonst Fehler "Zuerst die Zielstruktur vorbereiten" bzw. "gehört nicht zum vorbereiteten Zieljahr".
  • Warnungen (kein Fehler, werden in der Rückgabe mitgeliefert): unerwartete Stufe (will_advance erwartet grade+1 + gleicher Buchstabe, will_repeat erwartet grade + gleicher Buchstabe) und Überbelegung (max_students).
  • setClassPlan wirkt auf alle aktiven Schüler der Klasse oder nur auf studentIds; eine groupId pro Aufruf verbindet die auditUpdate-Zeilen aller betroffenen Schüler.

suggestTargetClasses({ classId? })​

Der einzige verbleibende Ort der Buchstabenregel: füllt planned_class_id für Schüler mit will_advance/will_repeat und leerem Feld in den Klassen des Zieljahres vor. Überschreibt nie einen bereits gesetzten Wert. Rückgabe: Anzahl gesetzt, Liste der Schüler ohne passende Zielklasse. Eine groupId pro Aufruf.

lib/school-year/transition.ts​

executeSchoolYearTransition({ targetSchoolYearId, confirmUnplanned })​

Ersetzt das frühere createSchoolYearTransition vollständig (entfernt). Verwendet ausschließlich planned_class_id — keine Buchstabenregel mehr.

Vorbedingungen (Lesezugriffe vor der Transaktion, kein Blocker führt zu Schreibzugriffen):

  • Zieljahr existiert, ist nicht aktuell, enthält noch keine Schüler.
  • Blocker (success: false, blockers: string[], nichts geändert): Schüler mit will_advance/will_repeat ohne planned_class_id; eine gesetzte Zielklasse gehört nicht (mehr) zum Zieljahr.
  • Schüler mit pending bleiben ohne Klasse nur, wenn confirmUnplanned: true — sonst success: false mit studentsWithoutClass-Liste (für den Bestätigungsdialog der UI).

Transaktion: pro Schüler closeStudentClass/assignStudentToClass (Klassenverlauf, je nach promotion_status), Zurücksetzen von promotion_status/planned_class_id, Archivierung bei will_graduate, Archivierung der alten Klassen/Klassenstufen, immer Aktivierung des Zieljahres über die gemeinsame Hilfsfunktion applyCurrentSchoolYearChange (auch von activateSchoolYear genutzt).

Durchführen aktiviert immer, keine separate Option mehr

Es gibt kein activate-Parameter/keine Checkbox mehr. Produktentscheidung (Nutzer-Feedback): Der eigentliche "Switch" der Daten (Schüler verschieben, alte Klassen archivieren) soll genau dann passieren, wenn das neue Schuljahr tatsächlich beginnt — nicht vorher (z. B. während der Sommerferien bleiben Schüler bewusst in ihrer alten Klasse, solange nicht durchgeführt wurde). Eine Entkopplung von "Durchführen" und "Aktivieren" hätte sonst zu dem in der Praxis verwirrenden Zwischenzustand geführt, dass Schüler schon auf Klassen des neuen Jahres zeigen, während das alte Jahr weiterhin als aktuell markiert ist und dessen Klassen bereits archiviert und leer sind. is_current wird innerhalb derselben Transaktion umgeschaltet. Für ein isoliertes Aktivieren ohne Durchführung (z. B. nachträgliche Korrektur) weiterhin activateSchoolYear einzeln aufrufen.

activateSchoolYear(schoolYearId)​

Unverändert in der Funktion, intern aber auf applyCurrentSchoolYearChange umgestellt (eigene Transaktion, eigene groupId, unabhängig von einer Durchführung).

Audit-Log​

Siehe lib/audit/db.ts (auditCreate/auditUpdate/auditSummary, newAuditGroupId) und den allgemeinen Mechanismus in Audit-Log. Für diesen Bereich gilt zusätzlich:

FunktionAudit-Muster
prepareSchoolYeareine auditSummary-Zeile (Entität SchoolYear) mit Zählern — keine Einzelzeilen für angelegte Stufen/Klassen/Fächerzuordnungen.
setStudentPlanein auditUpdate am Schüler.
setClassPlan, suggestTargetClassesein auditUpdate pro betroffenem Schüler, alle mit derselben groupId (einmal pro Aufruf erzeugt).
executeSchoolYearTransitioneine auditSummary-Zeile (Statistik) plus die Einzelzeilen aus closeStudentClass/assignStudentToClass/Student-Reset pro Schüler — alle mit derselben groupId (einmal pro Durchführung erzeugt, auch bei Aktivierung weitergereicht).

lib/audit/__tests__/write-coverage.test.ts (statischer Abdeckungstest, #46) enthält passende EXEMPTIONS-Einträge für prepareSchoolYear und executeSchoolYearTransition, da dort Rohschreibzugriffe bewusst nur über die Sammelzeile auditiert werden.

Übersicht — lib/school-year/overview.ts​

Nur lesend, tenant-gescoped, keine Schreibzugriffe.

getTransitionOverviewAction()​

Liefert aktuelles/Zieljahr, eine Bereitschaftsprüfung mit Schweregrad (error/warning/info, jeweils mit code und deutschsprachiger message), den Planungsstand je Klasse des aktuellen Jahres (classPlanningStatus) und die Zielstruktur mit Belegung (targetStructure, inkl. Herkunfts-Aufschlüsselung wie "aus 5a: 22, Wiederholer aus 6a: 2").

Schweregrade:

  • Fehler (blockiert Durchführen): kein aktuelles Jahr; kein Zieljahr oder Zieljahr enthält bereits Schüler; Schüler mit will_advance/will_repeat ohne Zielklasse.
  • Warnung: pending-Schüler; Zielklasse mit unerwarteter Stufe; Überbelegung; neue Klassen ohne Klassenleitung; Klassen im Zieljahr ohne Fächer-Zuordnung.
  • Hinweis: Schüler ohne Klasse; Klassen des Zieljahres, in die niemand geplant ist.

getPlanningTableData()​

Daten für die Versetzungsplanungs-Tabelle der UI: Klassen des aktuellen Jahres mit ihren Schülern (Name, Entscheidung, Zielklasse, Warnungen) sowie die Auswahlliste der Zielklassen (id, name, Belegung, maxStudents) für die Zielklassen-Felder.

Permission​

school-years:transition (lib/permissions-registry.ts) — eigene Berechtigung für den gesamten Bereich, zusätzlich zu den bestehenden school-years:read|create|write|delete. Die Planungsaktionen brauchen nur diese eine Berechtigung, nicht zusätzlich students:write (sie ändern ausschließlich promotion_status/planned_class_id). Das Schülerformular selbst verlangt weiterhin students:write.

Serverseitig geprüft in: prepareSchoolYear, setStudentPlan, setClassPlan, suggestTargetClasses, executeSchoolYearTransition, getTransitionOverviewAction, getPlanningTableData sowie activateSchoolYearAction (lib/school-year/actions.ts). Jede dieser Funktionen prüft die Permission selbst — es gibt keine separate Action-Schicht dazwischen, die Funktionen aus planning.ts/transition.ts/overview.ts werden direkt aus den Client-Komponenten aufgerufen ('use server'-Dateien, typisierte Objekte statt FormData, siehe components-ui-Rule).

permission-matrix-form.tsx: Da transition nicht ins read/create/write/delete-Raster der Rechtematrix-Tabelle passt, wird es (und künftige ähnliche Sonderrechte) in einer eigenen "Sonderrechte"-Sektion unterhalb der Tabelle gerendert.

UI — app/(backend)/school-years/transition/​

Server Component page.tsx (Permission-Gate wie bei anderen List-Pages: redirect('/login')/redirect('/dashboard?no_access=1')), lädt getTransitionOverviewAction() und getPlanningTableData() parallel und reicht sie an die Client-Komponente SchoolYearTransitionWorkspace (components/ui/school-year-transition/) weiter.

Vier Tabs (components/ui/tabs.tsx):

  1. Übersicht (overview-tab.tsx) — Bereitschaftsprüfung, Kennzahlen, Schuljahr-Gegenüberstellung.
  2. Zielstruktur (prepare-tab.tsx) — Formular für ein neues Zieljahr (nur wenn noch keines existiert) bzw. Button "Struktur aus aktuellem Jahr ableiten", Belegungstabelle, Links zur Klassen-/Klassenstufenverwaltung.
  3. Versetzungsplanung (planning-tab.tsx) — Klassen des aktuellen Jahres aufklappbar (Collapsible) bis zum einzelnen Schüler, Entscheidung/Zielklasse je Schüler (Direktspeicherung über setStudentPlan), Sammelaktionen pro Klasse (setClassPlan), globaler "Zielklassen vorschlagen"-Button (suggestTargetClasses), Filter (offene Entscheidungen/ohne Zielklasse/mit Warnungen, rein clientseitig über die geladenen Daten).
  4. Durchführen (execute-tab.tsx) — Zusammenfassung aus der Bereitschaftsprüfung, Aktivieren-Switch (Default an), Bestätigungsdialog (AlertDialog) mit Namensliste bei pending-Schülern, Ergebnis nach erfolgreicher Durchführung.

Mutationen rufen die 'use server'-Funktionen direkt auf (kein useActionState, sondern useState/useTransition, siehe forms-dialog-pattern-Rule) und lösen serverseitig revalidatePath('/school-years/transition') aus; die Client-Komponenten rufen danach router.refresh() auf, um die aktualisierten Server-Props zu laden.

Menüeintrag "Schuljahreswechsel" (ArrowRightLeftIcon) direkt unter "Schuljahre" in components/ui/navigation/nav-links.ts, gated über permission: { resource: 'school-years', action: 'transition' }. Der bestehende Button auf /school-years verlinkt weiterhin auf diesen Bereich.

Entfernt​

  • createSchoolYearTransition, previewSchoolYearTransition (lib/school-year/transition.ts) — ersetzt durch executeSchoolYearTransition bzw. die neue Übersicht.
  • performSchoolYearTransitionAction, getTransitionPreviewAction (lib/school-year/actions.ts) und das alte Einzelformular school-year-transition-form.tsx — ersetzt durch die neue Seite.
  • Die Buchstabenregel beim Durchführen (gradeLetterToNewClass) — lebt nur noch in suggestTargetClasses.

Verwandte Stellen​

  • lib/school-year/planning.ts — prepareSchoolYear, setStudentPlan, setClassPlan, suggestTargetClasses.
  • lib/school-year/transition.ts — executeSchoolYearTransition, activateSchoolYear.
  • lib/school-year/overview.ts — getTransitionOverviewAction, getPlanningTableData.
  • lib/school-year/target-year.ts — gemeinsame Zieljahr-Auflösung.
  • lib/school-class/db.ts — createSchoolClass/updateSchoolClassById: Duplikatprüfung berücksichtigt school_year_id, damit dieselbe Klasse in mehreren Schuljahren existieren kann (Voraussetzung für die manuelle Vorplanung).
  • lib/student/db.ts — updateStudentById: Rücksetzregel für planned_class_id.
  • lib/student-class-history/ — Klassenverlauf (closeStudentClass, assignStudentToClass).
  • lib/permissions-registry.ts — school-years:transition.
  • components/ui/school-year-transition/ — UI.