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_statusbleibt dort editierbar).lib/school-year/planning.ts→applySingleStudentPlan(Planungsaktionen).
PromotionStatus (Enum, unverändert)
| Wert | Bedeutung |
|---|---|
pending | Ausstehend (noch keine Entscheidung) |
will_advance | Wird versetzt (nächste Klassenstufe) |
will_repeat | Wiederholt das Schuljahr |
will_graduate | Abgang/Abschluss |
Zieljahr-Auflösung — lib/school-year/target-year.ts
Gemeinsam genutzt von planning.ts und overview.ts:
getCurrentSchoolYear(tenantId)— das Schuljahr mitis_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
resolveTargetSchoolYeargefundene Jahr, sonst legt es ein neues mitname/start_date/end_datean (is_current: false). Ein gefundenes Kandidatenjahr mit bereits zugeordneten Schülern (Student.school_class_idoderStudentClassHistoryauf 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 wennsourceClass.grade === gradegilt (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_idwird 1:1 übernommen (keineStatus.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 übersubject_idauf die bereits für die Zielklasse angelegteClassSubject-Zeile abgebildet; ohne Treffer bleibt das Fach leer, der Zeitslot wird trotzdem übernommen.stats.timetableEntriesCreatedzä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, siehelib/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.activeist (tenant-gescoped), sonstnull. Die reine Zuordnungslogik liegt inresolveClassLeadership(keine Datenbankzugriffe, einzeln testbar);prepareSchoolYearbaut dafüroldClassesByGradeLetter(Schlüsselgrade-letter→teacher_id) undactiveTeacherIdsauf und ruft die Funktion pro neu angelegter Klasse auf. stats.classesWithoutLeadershipzählt die neu angelegten Klassen ohne Klassenleitung; die Sammelzeile im Audit-Log (auditSummary) enthält zusätzlichleadershipModeimcontext.- 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_advanceerwartetgrade+1+ gleicher Buchstabe,will_repeaterwartetgrade+ gleicher Buchstabe) und Überbelegung (max_students). setClassPlanwirkt auf alle aktiven Schüler der Klasse oder nur aufstudentIds; einegroupIdpro Aufruf verbindet dieauditUpdate-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 mitwill_advance/will_repeatohneplanned_class_id; eine gesetzte Zielklasse gehört nicht (mehr) zum Zieljahr. - Schüler mit
pendingbleiben ohne Klasse nur, wennconfirmUnplanned: true— sonstsuccess: falsemitstudentsWithoutClass-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).
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:
| Funktion | Audit-Muster |
|---|---|
prepareSchoolYear | eine auditSummary-Zeile (Entität SchoolYear) mit Zählern — keine Einzelzeilen für angelegte Stufen/Klassen/Fächerzuordnungen. |
setStudentPlan | ein auditUpdate am Schüler. |
setClassPlan, suggestTargetClasses | ein auditUpdate pro betroffenem Schüler, alle mit derselben groupId (einmal pro Aufruf erzeugt). |
executeSchoolYearTransition | eine 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_repeatohne 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):
- Übersicht (
overview-tab.tsx) — Bereitschaftsprüfung, Kennzahlen, Schuljahr-Gegenüberstellung. - 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. - Versetzungsplanung (
planning-tab.tsx) — Klassen des aktuellen Jahres aufklappbar (Collapsible) bis zum einzelnen Schüler, Entscheidung/Zielklasse je Schüler (Direktspeicherung übersetStudentPlan), Sammelaktionen pro Klasse (setClassPlan), globaler "Zielklassen vorschlagen"-Button (suggestTargetClasses), Filter (offene Entscheidungen/ohne Zielklasse/mit Warnungen, rein clientseitig über die geladenen Daten). - Durchführen (
execute-tab.tsx) — Zusammenfassung aus der Bereitschaftsprüfung, Aktivieren-Switch (Default an), Bestätigungsdialog (AlertDialog) mit Namensliste beipending-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 durchexecuteSchoolYearTransitionbzw. die neue Übersicht.performSchoolYearTransitionAction,getTransitionPreviewAction(lib/school-year/actions.ts) und das alte Einzelformularschool-year-transition-form.tsx— ersetzt durch die neue Seite.- Die Buchstabenregel beim Durchführen (
gradeLetterToNewClass) — lebt nur noch insuggestTargetClasses.
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ücksichtigtschool_year_id, damit dieselbe Klasse in mehreren Schuljahren existieren kann (Voraussetzung für die manuelle Vorplanung).lib/student/db.ts—updateStudentById: Rücksetzregel fürplanned_class_id.lib/student-class-history/— Klassenverlauf (closeStudentClass,assignStudentToClass).lib/permissions-registry.ts—school-years:transition.components/ui/school-year-transition/— UI.