16 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5.5 4611974494 Fassung 1.0.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:53:10 +02:00
Florian WildeandClaude Opus 5.5 7d171f149d Inhalte: Wochen 5 bis 8
Vier neue Wochen nach Rahmenplan, im bestehenden Format:

- Woche 5 Ausbildung, Betrieb und Zusammenarbeit (C1, C2, C5)
- Woche 6 IT-Sicherheit und Datenschutz (A6, LF4)
- Woche 7 Skripting mit PowerShell (A10, A4)
- Woche 8 Datensicherung und Speicher (A9)

12 Handouts, 38 Merkkarten, 4 Praxisaufgaben, 2 Checklisten,
2 Lernzielkontrollen, 22 Glossareintraege, zwei neue Themenbloecke.
Jede Berufsbildposition ist jetzt von mindestens einem Inhalt beruehrt.

Die Praxisaufgaben bauen aufeinander auf: Die Abteilungsuebersicht aus
5.1 ist Grundlage der Schutzbedarfsfeststellung in 6.1, und deren
Ergebnis ist Massstab fuer die Sicherungspruefung in 8.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:53:10 +02:00
Florian WildeandClaude Opus 5.5 052419dd43 Fassung 1.0.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:25:04 +02:00
Florian WildeandClaude Opus 5.5 31d7779f45 Bremse gegen Passwortraten
Nach fuenf Fehlversuchen wird eine E-Mail-Adresse gesperrt, erst eine
Minute, dann doppelt so lang, hoechstens 15 Minuten. Gezaehlt wird je
Adresse, auch fuer unbekannte - sonst verriete die Sperre, welche Konten
es gibt. Waehrend der Sperre kommt auch das richtige Passwort nicht durch.

Jeder Versuch zaehlt sofort und wird bei Erfolg zurueckgenommen. Zwischen
Sperrpruefung und Zaehlen liegt damit kein await; gleichzeitige Anfragen
kommen so nicht alle an der Sperre vorbei.

Der Zaehler liegt im Speicher des einen Prozesses. Ein Neustart setzt ihn
zurueck - der Notausgang, falls sich das einzige Ausbilderkonto aussperrt.
Beim Aufraeumen wird eine laufende Sperre nie verdraengt.

Die Containerabnahme prueft die Sperre im laufenden Abbild. Das
Abnahmeprotokoll ist gegen diesen Stand neu erzeugt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:25:04 +02:00
Florian WildeandClaude Opus 5.5 5fbf1ac4de Fassung 1.0.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:50:59 +02:00
Florian WildeandClaude Opus 5.5 adb93a722f Kontowerkzeug ins Abbild, Containerabnahme
Die README nannte `node werkzeuge/konto.js`, im Abbild lag aber nur das
Sicherungswerkzeug, und tsx gibt es dort nicht. Ein frisch aufgesetzter
Server stand ohne jedes Konto da. Das Kontoskript wird jetzt beim Bau mit
esbuild zu einer Datei gebuendelt; die nativen Module bleiben aussen vor,
sie bringt die Standalone-Ausgabe mit.

Aufgefallen ist das nicht, weil der Abnahmelauf gegen `next start` prueft.
Neu ist deshalb `npm run abnahme:container`: Abbild bauen, mit leerem
Volume starten, erstes Konto anlegen, anmelden, Passwort neu setzen,
sichern.

Ausserdem trennt saubererName Windows-Pfade jetzt auch unter Linux ab
(path.win32.basename). Der Test dazu war nur unter Windows gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:50:59 +02:00
Florian WildeandClaude Opus 5 6181ec4bd7 Pruefskripte: wiederholbar und ehrlich machen
Die Dichtheitspruefung der Musterloesungen las die Server-Nutzlast aus
self.__next_f - das Feld leert Next nach der Verarbeitung, es war also
immer leer. Die Pruefung lief an dieser Stelle ins Leere, ohne dass es
auffiel; nur der HTML-Quelltext wurde wirklich geprueft. Gelesen wird
jetzt der Quelltext der eingebetteten Skripte. Damit zeigt die Ausgabe,
was sie zeigen soll: vor der Abgabe steht fuenfmal "musterloesung":null
in der Nutzlast, danach stehen dort die Loesungen.

Ausserdem stellt das Skript seine Ausgangslage selbst her und laeuft
gegen den Port, der ueber LERNBAR_BASIS eingestellt ist - vorher war
3311 fest eingetragen und ein zweiter Lauf scheiterte daran, dass die
Kontrolle schon abgegeben war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:36:29 +02:00
Florian WildeandClaude Opus 5 c61d5711e0 Baustein 10b: Anleitung mit Screenshots, Abnahmelauf, Fassung 1.0.0
docs/ANLEITUNG.md: 20 Abschnitte, 27 Bildschirmfotos, getrennt fuer
Ausbilder und Azubi, dazu "Wenn etwas nicht geht". Die Bilder entstehen
automatisiert ueber npm run screenshots - was dort steht, ist gemessen
und nicht gemalt.

scripts/pruefungen/abnahme.mjs (npm run abnahme) laeuft gegen eine
frische, leere Datenbank in einem Wegwerfverzeichnis: aufsetzen, Konten
anlegen, arbeiten, abnehmen, sichern, beschaedigen, zurueckspielen. Eine
Sicherung, die nie zurueckgespielt wurde, ist keine Sicherung. 33 von 33
Pruefungen erfuellt, Protokoll in docs/ABNAHME.md.

Dabei drei echte Fehler gefunden, alle in Wegen, die nie ausgefuehrt
worden waren:

- Die Sicherungsskripte liefen nicht in Git Bash, obwohl das in ihrem
  Kopf stand: tar deutet den Doppelpunkt in "C:\..." als Rechnernamen.
  Beide wechseln jetzt erst ins Verzeichnis und uebergeben einen blossen
  Dateinamen.
- Auf einer frischen Datenbank gibt es noch kein Wochenblatt; der Weg
  jedes neuen Azubis war nie durchlaufen worden.
- Der Abnahmelauf selbst liess unter Windows seinen Server ueberleben
  (kill() beendet nur die cmd.exe) und prueft deshalb jetzt vorher, ob
  der Port frei ist - sonst haette er gegen fremde Daten geprueft.

Fassung 1.0.0: package.json stand noch auf 0.1.0, waehrend das
Aenderungsprotokoll bei 0.9.0 war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:26:34 +02:00
Florian WildeandClaude Opus 5 2babef9293 Baustein 10a: PWA, Offlinebetrieb, Druckansichten
Installierbar: Manifest, Symbole aus dem eigenen Zeichen erzeugt
(scripts/symbole.ts, kein Bilddienst), Verknuepfungen zu Lernmodus,
Berichtsheft und Handouts.

Offline gilt eine Regel, und die steht im Dienstarbeiter: Nur das
Lernmaterial liegt im Browserspeicher - Handouts, Merkkarten, Glossar,
die eigene Woche. Lernzielkontrollen, Berichtsheft, Bewertungen,
Feedbackgespraeche, Anhaenge und jedes PDF niemals. Das ist keine
Sparsamkeit: Was im Browsercache liegt, ist an der serverseitigen
Rechtepruefung vorbei.

Bewertungen ohne Netz gehen in eine Warteschlange und werden
nachgereicht. Die Schlange haengt an der Benutzerkennung, nicht am
Geraet - auf einem geteilten Werkstattgeraet bekaeme sonst der Zweite
die offenen Bewertungen des Ersten aufs Konto. Der Tag der Bewertung
kommt vom Geraet und wird auf heute gedeckelt (tagDerBewertung, 4
Tests): Eine vorgestellte Uhr darf das Wiederholen nicht wegstellen.

Druck: Wer die dunkle Fassung eingestellt hatte, druckte bisher schwarze
Flaechen. Im Druck wird jetzt die komplette helle Palette gesetzt, dazu
A4-Raender, keine Umbrueche mitten in Karten und Zeilen, aufgeklappte
details. Druckknoepfe bei Handout, Glossar und Auswertung.

Durchstich in scripts/pruefungen/baustein-10.mjs mit echtem Abschalten
des Netzes: 33 Pruefungen, darunter der Nachweis, dass nichts
Bewertungsrelevantes im Speicher landet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:47:06 +02:00
Florian WildeandClaude Opus 5 be2a674c7a Baustein 9: Auswertung, Rahmenplanabdeckung, Feedbackgespraech
Die Abdeckung des Ausbildungsrahmenplans ist die Ansicht, die gegenueber
der IHK zaehlt. Sie unterscheidet vier Lagen statt "erledigt/offen":

  vermittelt   eine abgenommene Aufgabe oder ein abgeschlossener
               Checklisten-Durchlauf, oder zwei gelesene Handouts
  angefangen   etwas erledigt, aber noch nicht genug
  geplant      Inhalt vorhanden, nichts davon bearbeitet
  ohne Inhalt  zu dieser Position gibt es im Lehrgang gar nichts

Die vierte Lage ist die wichtigste: Sie ist keine Luecke beim Azubi,
sondern eine im Lehrgang, und wird getrennt ausgewiesen. Die Regel steht
als reine Funktion in src/lib/auswertung/abdeckung.ts, mit 17 Tests.

Das Feedbackgespraech laesst beide Seiten getrennt bewerten. Wo sie um
zwei Stufen oder mehr auseinanderliegen, wird das hervorgehoben - in der
Ansicht, in der Uebersicht und im PDF, alle drei ueber dieselbe Funktion
liegtWeitAuseinander(). Nach beidseitiger Bestaetigung ist das Gespraech
unveraenderlich und wird nur noch gelesen.

Durchstich in scripts/pruefungen/baustein-9.mjs: alle fuenf Luecken
erkannt, getrennte Spalten, Sperre serverseitig, PDF nur mit Anmeldung,
ein fremder Azubi bekommt 404 - auch auf das PDF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:08:49 +02:00
Florian WildeandClaude Opus 5 d226a6bae8 Baustein 8: Lernzielkontrollen mit Freigabe, Abgabe und Korrektur
- Ausbilder gibt frei; vorher sieht der Azubi die Fragen nicht
- Freigabe laesst sich zuruecknehmen, solange nicht abgegeben wurde
- Antworten werden beim Tippen je Frage zwischengespeichert
- Musterloesungen erst nach dem Absenden: sichtbareKontrolle() entfernt sie
  serverseitig, bevor die Daten in die Ausgabe gelangen
- Korrektur mit Punkten und Anmerkung je Frage, Summe und Note laufen mit
- Notenschluessel der IHK; solange eine Frage offen ist, bleibt das Ergebnis
  vorlaeufig und die Kontrolle gilt nicht als korrigiert
- Ergebnis bei beiden sichtbar, Schnitt ueber alle Kontrollen
- Abgegebene Kontrollen unter "Was auf dich wartet" und im Wochenfortschritt
- scripts/pruefungen/musterloesung-dichtheit.mjs prueft die Sperre gegen den
  echten Wortlaut aus der Inhaltsdatei im rohen Seitenquelltext

36 neue Tests, davon 21 fuer die Sichtbarkeit der Musterloesung.
Insgesamt 277.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 08:57:00 +02:00
Florian WildeandClaude Opus 5 7c4a051347 Baustein 7: Berichtsheft mit Vorschlaegen, Pruefung, Abzeichnen und PDF
- Taegliche Stichworte, je Tag ein eigenes Formular
- Wochenblatt mit Betrieb, Berufsschule und Sonstiges, je mit Stunden
- Vorschlaege aus Tagesnotizen, Aufgaben, Checklisten und Handouts
- Pruefung mitlaufend beim Tippen: fehlende Stunden, Floskeln,
  IP- und MAC-Adressen, E-Mail, Zugangsdaten, Kundennamen
- Warnung, keine Sperre - Einreichen ist immer moeglich
- Formulierungshilfe mit Taetigkeitsverben und Gegenueberstellungen
- Einreichen, zurueckgeben mit Anmerkung, abzeichnen; danach gesperrt
- Fehlende Wochen werden benannt und lassen sich nachtragen; die laufende
  Nummer richtet sich nach dem Zeitraum, nicht nach dem Anlegen
- PDF im IHK-Layout, einzeln oder als ganzer Zeitraum

Behoben: Im PDF-Kopffeld liefen die Spalten auseinander (dokument.y als
Bezugspunkt in der Schleife). Das Abzeichnen-Formular hatte keine action -
gefunden ueber die ESLint-Meldung zur ungenutzten Serveraktion.

55 neue Tests, insgesamt 241.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 23:09:54 +02:00
Florian WildeandClaude Opus 5 b9d6041f3b Baustein 6: Checklisten mit Durchlaeufen und PDF-Export
- Durchlauf aus einer Vorlage, mit Datum, Ticket und Kunde
- Schritt fuer Schritt abhaken, Bemerkung je Schritt
- Fortschritt gesamt und je Gruppe (Vorbereiten/Durchfuehren/Pruefen/Abschliessen)
- Abschliessen erst, wenn alles abgehakt ist; Meldung nennt die offenen Schritte
- Abgeschlossene Durchlaeufe sind gesperrt und bleiben als Nachweis
- Titel und Schritttexte werden beim Start kopiert, nicht verwiesen
- PDF-Export mit gezeichneten Ankreuzkaestchen und Unterschriftenzeilen
- Gemeinsame PDF-Grundlage fuer alle kuenftigen Ausgaben
- Ausbilder liest mit und darf das PDF abrufen, hakt aber nicht ab
- Abgeschlossene Durchlaeufe zaehlen im Wochenfortschritt mit

Behoben beim Durchstich: Die Fusszeile wurde unterhalb des Satzspiegels
gesetzt, wodurch PDFKit je Fusszeile eine leere Seite anhaengte - aus zwei
Seiten wurden sechs. Ein Test zaehlt jetzt die Seitenobjekte im PDF.

29 neue Tests, insgesamt 186.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:00:30 +02:00
Florian WildeandClaude Opus 5 ffbca01bdc Baustein 5: Praxisaufgaben mit Anhaengen und Abnahme
- Status offen / in Arbeit / zur Abnahme / abgenommen, mit Zeitstempeln
- Notizfeld fuer den Azubi, Dateianhaenge bis 25 MB
- Speicherpfad nur aus selbst erzeugter UUID, nie aus dem Dateinamen
- Auslieferung ueber /anhaenge/<kennung> mit Rechtepruefung bei jedem Abruf,
  CSP sandbox und nosniff; SVG ausdruecklich nicht erlaubt
- Anhaenge haengen an Aufgabe UND Azubi
- Ausbilder hakt Kriterien einzeln ab, Note und Rueckmeldung, Abnahme sperrt
- Zurueckgeben zur Nacharbeit
- "Was auf dich wartet" zeigt offene Abnahmen
- Abgenommene Aufgaben zaehlen im Wochenfortschritt mit

Behoben: Next.js deckelt Serveraktionen ab Werk bei 1 MB - damit waere kein
Handyfoto durchgekommen. bodySizeLimit jetzt 30 MB, Vorabpruefung im Browser,
client_max_body_size im Proxy-Beispiel auf 30m.

Geaendert: Seiten verweigern fremde Daten mit 404 statt mit einem Fehler.
Eigene Seiten fuer "Nicht gefunden" und Stoerfaelle.

24 neue Tests, insgesamt 157.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 12:18:54 +02:00
Florian WildeandClaude Opus 5 bb592ad2bd Baustein 4: Merkkarten mit Wiederholung nach Zeitabstand
- Abgespecktes SM-2 mit drei Bewertungsstufen statt sechs
- Abstaende 1, 3, dann Abstand mal Leichtigkeit; gedeckelt bei 180 Tagen
- "nochmal" bringt die Karte in derselben Sitzung wieder
- Taeglicher Stapel, gedeckelt auf 20 Karten, ueberfaellige zuerst
- Fuenf Faecher zur Anzeige, abgeleitet aus dem Abstand
- Filter nach Themenblock fuer gezieltes Ueben
- Lernmodus fuers Handy: grosse Flaechen, Antippen, Wischen, kein Hover;
  am Rechner zusaetzlich Leertaste und Tasten 1/2/3
- Tabellen karte und karten_verlauf, jede Bewertung wird mitgeschrieben
- Merkkarten zaehlen im Wochenfortschritt mit, gelernt ab Fach 4
- Kopfzeile am Handy einzeilig, Tipp-Hervorhebung abgeschaltet
- 29 neue Tests, insgesamt 133

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:16:40 +02:00
Florian WildeandClaude Opus 5 103a603eb4 Baustein 3: Wochenplan als Startseite, Fortschritt, Wochen verschieben
- Wochenplan ist die Startseite des Azubis: Thema, Leitfrage, fuenf Tage
  mit Theorie und Praxis, Fortschrittsbalken, was noch offen ist
- Ausbilder sieht je Azubi die laufende Woche mit Stand und offenen Punkten
- /woche/<nr> fuer jede Woche, Ausbilder waehlt ueber ?azubi= den Stand;
  Zugriff wird serverseitig bei jedem Aufruf geprueft
- Tabelle wochenplanung mit geplantem und tatsaechlichem Datum, Zustand
  und Notiz fuer den Azubi
- Verschieben per Datumsfeld, wahlweise mit allen folgenden Wochen
- Reihenfolge per Ziehen und Ablegen sowie per Tastatur (Alt + Pfeil)
- Warnung bei sich ueberlappenden Terminen
- Plan entsteht automatisch beim Anlegen eines Azubikontos
- Planungs- und Fortschrittslogik als reine Funktionen, 36 Tests dafuer
- Insgesamt 104 Tests

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:04:39 +02:00
199 changed files with 21042 additions and 85 deletions
+1
View File
@@ -1,6 +1,7 @@
node_modules/
.next/
out/
build/
next-env.d.ts
*.tsbuildinfo
+419
View File
@@ -26,6 +26,417 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
## [Unveröffentlicht]
## [1.0.3] – 2026-10-04
Vier neue Wochen Lehrgang. Damit ist jede Position des Ausbildungsrahmenplans
von mindestens einem Inhalt berührt.
### Hinzugefügt
- **Woche 5 · Ausbildung, Betrieb und Zusammenarbeit** – Handouts A4 (Rechte
und Pflichten, Aufbau der Abschlussprüfung), A5 (Aufbau- und
Ablauforganisation, Mitbestimmung), A6 (Kanalwahl, Ablage, Besprechungen),
Praxisaufgabe 5.1 (den Betrieb aus Sicht der IT kartieren),
Lernzielkontrolle. Schließt C1, C2 und C5
- **Woche 6 · IT-Sicherheit und Datenschutz** – Handouts I1 (Schutzziele und
Schutzbedarf nach BSI-Standard 200-2), I2 (Passwörter, zweiter Faktor,
Phishing), I3 (DSGVO in der Administration), Praxisaufgabe 6.1
(Schutzbedarfsfeststellung), Checkliste „Sicherheit eines Arbeitsplatzes
prüfen"
- **Woche 7 · Skripting mit PowerShell** – Handouts P1 (Pipeline und
Objekte), P2 (Variablen, Bedingungen, Schleifen), P3 (Skripte, die man
betreuen kann), Praxisaufgabe 7.1 (Inventarskript), Lernzielkontrolle.
Schließt A4 und A10
- **Woche 8 · Datensicherung und Speicher** – Handouts S3 (Sicherungsarten,
3-2-1, RPO und RTO), S4 (Wiederherstellung testen), S5 (RAID, NAS, SAN),
Praxisaufgabe 8.1 (Sicherung eines Bereichs prüfen und zurückspielen),
Checkliste „Wiederherstellung testen"
- Themenblöcke „IT-Sicherheit und Datenschutz" (I) und „Skripting und
Automatisierung" (P)
- 22 Glossareinträge, 38 Merkkarten
Bestehende Installationen: Die neuen Wochen erscheinen im Wochenplan unter
„noch nicht eingeplant" und werden dort einmal eingeplant.
## [1.0.2] – 2026-10-04
### Hinzugefügt
- **Bremse gegen Passwortraten.** Nach fünf Fehlversuchen wird eine
E-Mail-Adresse gesperrt, erst eine Minute, dann doppelt so lang, höchstens
15 Minuten. Das gilt auch für Adressen, die es nicht gibt – die Sperre
verrät also nicht, welche Konten existieren. Während der Sperre kommt auch
das richtige Passwort nicht durch. Ausgesperrt? Ein Neustart des Containers
setzt die Zähler zurück. Siehe E42
- Die Containerabnahme prüft die Sperre im laufenden Abbild
## [1.0.1] – 2026-10-03
Das Docker-Abbild ist jetzt aus dem Nichts aufsetzbar.
### Behoben
- **Im Docker-Abbild ließ sich kein erstes Konto anlegen.** Die README nannte
`node werkzeuge/konto.js`, das Abbild enthielt aber nur die Sicherungsskripte,
und `tsx` gibt es dort nicht. Ein frisch aufgesetzter Server stand vor einer
Anmeldeseite ohne Konto. Das Kontowerkzeug wird jetzt beim Bau mit esbuild zu
einer Datei gebündelt (`npm run werkzeuge:bauen`) und liegt unter
`/app/werkzeuge/konto.js`
- **Windows-Pfade in Dateinamen von Anhängen** wurden unter Linux nicht
abgetrennt – aus `C:\Users\x\foto.jpg` wurde `C:Usersxfoto.jpg`. Nur die
Anzeige war betroffen, gespeichert wird unter der UUID. Der Test dazu war
unter Windows grün und unter Linux rot
### Hinzugefügt
- `npm run abnahme:container` – Abnahmelauf gegen das gebaute Docker-Abbild:
Lebenszeichen, Betrieb ohne root, erstes Konto, Anmeldung, Passwort neu
setzen, Sicherung im Volume
## [1.0.0] – 2026-09-11
Die erste abnahmefähige Fassung. Alle zehn Bausteine sind fertig, die
Abnahmekriterien einzeln durchgespielt und protokolliert.
### Hinzugefügt
- **Installierbar** als Anwendung auf dem Startbildschirm: Manifest, Symbole
in drei Fassungen (darunter eine für den Beschnitt unter Android), erzeugt
aus dem eigenen Zeichen mit `npm run symbole` – ohne Bilddienst, ohne Netz
- **Offlinebetrieb** für den Fall, für den er gedacht ist: der Azubi im
Serverraum, im Keller, im Zug
- Merkkarten und schon geöffnete Handouts funktionieren weiter
- Bewertungen gehen in eine Warteschlange und werden nachgereicht, sobald
die Verbindung wieder steht. Angerechnet wird der Tag, an dem **gelernt**
wurde, nicht der Tag des Absendens
- Eine schmale Leiste sagt, wie viele Bewertungen warten – und meldet sich,
wenn sie angekommen sind
- **Nichts Bewertungsrelevantes liegt im Browserspeicher.** Weder
Lernzielkontrollen noch Berichtsheft, Bewertungen, Feedbackgespräche,
Anhänge oder PDFs. Was im Cache liegt, ist an der serverseitigen
Rechteprüfung vorbei – deshalb liegt es dort nicht
- Beim Abmelden wird alles Zwischengespeicherte weggeräumt
- **Druckansichten** mit Knöpfen bei Handout, Glossar und Auswertung
- **`docs/ANLEITUNG.md`** – Anleitung mit 27 Bildschirmfotos, für Ausbilder
und Azubi getrennt, dazu ein Abschnitt „Wenn etwas nicht geht"
- **`scripts/pruefungen/abnahme.mjs`** – der Abnahmelauf gegen eine frische,
leere Datenbank in einem Wegwerfverzeichnis: aufsetzen, Konten anlegen,
arbeiten, abnehmen, sichern, beschädigen, zurückspielen. Das Protokoll
landet in `docs/ABNAHME.md`
- `scripts/pruefungen/baustein-10.mjs` – Durchstich mit echtem Abschalten des
Netzes
### Behoben
- **Im Druck wurde die dunkle Fassung mitgedruckt.** Wer dunkel eingestellt
hatte, bekam schwarze Flächen aufs Papier. Für den Druck wird jetzt die
vollständige helle Palette gesetzt, dazu A4-Ränder, keine Umbrüche mitten in
Karten und Zeilen, und aufklappbare Abschnitte drucken aufgeklappt
- **Die Sicherungsskripte scheiterten an Pfaden mit Doppelpunkt.** `tar` deutet
einen Doppelpunkt im Dateinamen als Rechnernamen – aus `C:\...` wurde der
Rechner `C`. Beide Skripte wechseln jetzt erst ins Verzeichnis und
übergeben einen blossen Dateinamen. Im Kopf der Skripte stand, sie liefen
auch in Git Bash; das stimmte bis jetzt nicht
- Das Skript für die Bildschirmfotos suchte Wochenblatt und
Checklisten-Durchlauf auf der Kontrollseite, wo es sie nicht gibt – zwei
Bilder waren seit Wochen veraltet
## [0.9.0] – 2026-09-11
Auswertung, Rahmenplanabdeckung und das Feedbackgespräch.
### Hinzugefügt
- **Stand des Azubis**: gelesene Handouts, sitzende Merkkarten, abgenommene
Praxisaufgaben, abgezeichnete Berichtsheftblätter – vier Kacheln, keine
Diagramme um ihrer selbst willen
- **Abdeckung des Ausbildungsrahmenplans** – die Ansicht, die gegenüber der IHK
zählt. Statt „erledigt / offen" gibt es vier Lagen:
- **vermittelt** – es gibt eine abgenommene Praxisaufgabe oder einen
abgeschlossenen Checklisten-Durchlauf, oder mindestens zwei gelesene Handouts
- **angefangen** – ein gelesenes Handout allein genügt bewusst nicht
- **geplant** – Inhalt vorhanden, aber unbearbeitet
- **ohne Inhalt** – zu dieser Position gibt es im Lehrgang überhaupt nichts.
Das ist keine Lücke beim Azubi, sondern eine im Lehrgang, und wird getrennt
hervorgehoben
- Je Position sind die einzelnen Nachweise aufgeführt, mit Kürzel und Zustand –
im Zweifel ist damit nachvollziehbar, worauf sich die Bewertung stützt. Jede
Position trägt ihre amtliche Fundstelle in der Verordnung
- **Lernfeldabdeckung** der Berufsschule nach Ausbildungsjahr, nach derselben Regel
- **Ergebnisverlauf** der Lernzielkontrollen mit Schnitt, bester und
schlechtester Arbeit
- **Feedbackgespräch**: Gesprächsleitfaden mit vier Abschnitten, Bewertungsbogen
mit acht Kriterien auf einer Skala von 1 bis 5, Stärken, Entwicklungsfelder,
vereinbarte Ziele
- **Beide Seiten bewerten getrennt.** Wo Einschätzung und Selbsteinschätzung
um zwei Stufen oder mehr auseinanderliegen, wird das hervorgehoben – genau
darüber lohnt das Gespräch
- Jede Seite trägt nur ihre eigene Spalte ein; der Ausbilder schreibt die
Freitexte, der Azubi liest sie
- Nach Bestätigung durch **beide** ist das Gespräch unveränderlich
- PDF für die Ausbildungsakte, mit beiden Einschätzungen nebeneinander
- `content/feedbackgespraech.yaml` – Leitfaden, Kriterien und Skala als
Inhaltsdatei, damit der Ausbilder sie an seinen Betrieb anpassen kann.
Ein geführtes Gespräch behält seine Fassung, auch wenn die Vorlage sich ändert
- 33 Tests für die Abdeckungsregel und den Gesprächsinhalt
### Geändert
- Ein abgeschlossenes Gespräch wird nur noch gelesen: keine gesperrten
Eingabefelder, keine leeren Bemerkungszeilen, kein zweites Datumsfeld –
ein Blatt für die Akte soll wie ein Dokument aussehen, nicht wie ein Formular
## [0.8.0] – 2026-09-11
Lernzielkontrollen – und die strengste Zusage des ganzen Auftrags eingelöst.
### Hinzugefügt
- **Freigabe durch den Ausbilder**: Vorher sieht der Azubi die Fragen gar
nicht. Eine Freigabe lässt sich zurücknehmen, solange nicht abgegeben wurde
- **Fragebogen** mit offenen Fragen und Punktzahl je Frage; die Antworten
werden beim Tippen zwischengespeichert, mit Zeitstempel in der Kopfzeile.
Eine Kontrolle dauert 45 Minuten – ein verlorener Text wäre nicht
wiedergutzumachen
- **Musterlösungen erst nach dem Absenden.** Die Sperre sitzt serverseitig in
`sichtbareKontrolle()`: Die Lösung wird aus den Daten entfernt, **bevor** sie
in die Ausgabe gelangt – nicht erst in der Oberfläche versteckt. Wer den
Seitenquelltext liest oder die Serverantwort mitschneidet, findet nichts
- **Korrektur**: Antwort des Azubis und Musterlösung nebeneinander, Punktefeld
und Anmerkung je Frage, Anmerkung zur ganzen Kontrolle. Summe, Prozentsatz
und Note laufen beim Verteilen der Punkte mit
- **Notenschlüssel der IHK** (92/81/67/50/30 Prozent). Solange eine Frage ohne
Punkte ist, gilt das Ergebnis als vorläufig und die Kontrolle nicht als
korrigiert
- Ergebnis erscheint bei beiden; die Übersicht des Azubis zeigt zusätzlich den
Schnitt über alle korrigierten Kontrollen
- Abgegebene, noch nicht korrigierte Kontrollen erscheinen beim Ausbilder unter
„Was auf dich wartet"
- Abgegebene Kontrollen zählen im Wochenfortschritt mit
- 36 Tests, davon 21 allein für die Sichtbarkeit der Musterlösung – für jeden
Betrachter und jeden Zustand einzeln
### Geprüft
Der Wortlaut der Musterlösungen wurde im rohen Seitenquelltext gesucht, in
jedem Zustand. Vor der Abgabe: kein einziger Treffer. Nach der Abgabe: alle
fünf. Der Prüfaufbau weist sich damit selbst als tauglich aus – er findet die
Lösungen, wenn sie da sein dürfen.
## [0.7.0] – 2026-09-10
Das Berichtsheft – der Teil, ohne den es keine Zulassung zur Abschlussprüfung
gibt.
### Hinzugefügt
- **Tägliche Stichworte**: je Werktag ein eigenes kleines Formular mit Text und
Stundenzahl. Bewusst je Tag getrennt absendbar, damit es am Handy in zwei
Minuten geht und nicht die ganze Woche neu geschrieben wird
- **Wochenblatt** mit Berichtszeitraum, betrieblichen Tätigkeiten und
Unterweisungen (je mit Stundenangabe), Berufsschule (Lernfeld, Themen,
Stunden) und Sonstiges; Zeilen lassen sich hinzufügen und entfernen
- **Vorschläge aus dem System**: aus den Tagesnotizen der Woche, den in dieser
Woche abgegebenen oder abgenommenen Praxisaufgaben, den abgeschlossenen
Checklisten-Durchläufen und den gelesenen Handouts. Ein Klick übernimmt den
Vorschlag ins Blatt, wo er bearbeitet wird
- **Prüfung vor dem Absenden**, mitlaufend beim Tippen:
- fehlende oder unglaubwürdige Stundenangaben, Wochensumme
- Floskeln wie „diverse Arbeiten" oder „wie in der Vorwoche" – aber nur, wenn
die Floskel den Eintrag ausmacht oder kein Tätigkeitsverb vorkommt
- IP- und MAC-Adressen, E-Mail-Adressen, Zugangsdaten, Telefonnummern und
Kundennamen (erkannt an der Rechtsform)
- **Warnung, keine Sperre.** Der Knopf zum Einreichen bleibt immer bedienbar
- **Formulierungshilfe**: Tätigkeitsverben nach Einsatzfeld gruppiert, sechs
Regeln und acht Gegenüberstellungen „so besser nicht / so ist es richtig"
samt Begründung
- **Abzeichnen**: Der Ausbilder sieht eingereichte Blätter unter „Was auf dich
wartet", liest sie in einer Nur-Lese-Ansicht mit derselben Prüfung und kann
abzeichnen oder zur Nacharbeit zurückgeben. Zurückgeben verlangt eine
Anmerkung – ohne sie weiß der Azubi nicht, was zu ändern ist
- Abgezeichnete Blätter sind **gesperrt**: keine Bearbeitung, keine Vorschläge,
kein Einreichen mehr
- **Fehlende Wochen** werden benannt: Für jede vergangene Woche ohne Blatt gibt
es einen Knopf zum Nachtragen. Die laufende Nummer richtet sich nach dem
Zeitraum, nicht nach dem Anlegen – wer nachträgt, bekommt die richtige Nummer
- **PDF im Layout eines üblichen IHK-Ausbildungsnachweises**: Kopffeld mit Name,
Beruf, Ausbildungsjahr und Berichtszeitraum, gerahmte Kästen mit
Stundenspalte und Zwischensummen, Anmerkung des Ausbilders,
Unterschriftenzeilen für Auszubildenden und Ausbilder. Einzeln oder als
ganzer Zeitraum mit einem Blatt je Seite
- Das Ausbildungsjahr wird aus dem Ausbildungsbeginn gerechnet
- 55 Tests für Prüfung, Vorschläge und PDF-Erzeugung
### Behoben
- Im Kopffeld des PDF liefen die beiden Spalten auseinander: `dokument.y`
wandert bei jedem Schreibvorgang weiter und taugte nicht als Bezugspunkt
innerhalb der Schleife
- Das Abzeichnen-Formular hatte keine `action` – der Knopf hätte nichts getan.
Aufgefallen ist das nur, weil ESLint die ungenutzte Serveraktion gemeldet hat
## [0.6.0] – 2026-09-10
Checklisten mit Durchläufen – und der erste PDF-Export.
### Hinzugefügt
- Aus einer Checklistenvorlage wird ein **Durchlauf**, der zu einem Datum und
wahlweise zu einem Ticket und einem Kunden oder Bereich gehört
- Schritt für Schritt abhaken, je Schritt eine Bemerkung
- Fortschritt gesamt **und je Gruppe** (Vorbereiten, Durchführen, Prüfen,
Abschließen), jeweils als Balken mit Zahlen
- Ein Durchlauf lässt sich erst abschließen, wenn jeder Schritt abgehakt ist;
die Meldung nennt, wie viele Schritte noch offen sind
- Abgeschlossene Durchläufe sind gesperrt und lassen sich nicht löschen – sie
sind der Nachweis. Wieder öffnen geht, verwerfen nur, solange nichts abgehakt ist
- **PDF-Export** eines Durchlaufs: Kopfangaben, alle Gruppen mit gezeichneten
Ankreuzkästchen, Bemerkungen, Unterschriftenzeilen für Bearbeiter und
Ausbilder, Fußzeile mit Herkunft und Seitenzahl
- Gemeinsame PDF-Grundlage (`src/lib/pdf/grundlage.ts`) für alle künftigen
Ausgaben – das Berichtsheft setzt darauf auf
- Der Ausbilder sieht die Durchläufe seiner Azubis und kann das PDF abrufen,
hakt aber nichts für sie ab: Im Nachweis soll keine Arbeit stehen, die der
Azubi nicht gemacht hat
- Titel und Schritttexte werden beim Start aus der Vorlage **kopiert**. Ein
abgeschlossener Durchlauf bleibt damit lesbar, auch wenn die Vorlage im
Repository später geändert oder gelöscht wird
- Abgeschlossene Durchläufe zählen im Wochenfortschritt mit
- 29 Tests für Gruppenfortschritt, Abschlussregel, PDF-Erzeugung und
Zeichenvorrat
### Angemerkt
- Die PDF-Ausgabe nutzt die Standardschriften jedes Betrachters (Helvetica).
Das spart eine eingebettete Schriftdatei, beschränkt den Zeichenvorrat aber
auf WinAnsi. Umlaute, ß, €, § und ° sind enthalten; Zeichen wie ✓, ○ oder ▲
nicht. `fuerPdf()` ersetzt sie, Ankreuzkästchen und Haken werden **gezeichnet**
## [0.5.0] – 2026-09-10
Praxisaufgaben: bearbeiten, belegen, abnehmen.
### Hinzugefügt
- Bearbeitungsstand je Aufgabe: *offen*, *in Arbeit*, *zur Abnahme*,
*abgenommen*; Beginn und Abgabe werden mit Zeitstempel vermerkt
- Notizfeld für den Azubi (bis 8000 Zeichen) – dieselben Notizen helfen später
beim Berichtsheft
- **Dateianhänge**: Fotos vom Aufbau, Bildschirmfotos, ein Netzplan als PDF.
Bis 25 MB je Datei, Positivliste erlaubter Typen (JPEG, PNG, WebP, GIF, HEIC,
PDF, Text, CSV)
- Der Speicherpfad wird ausschließlich aus einer selbst erzeugten UUID
gebildet, nie aus dem übermittelten Dateinamen
- Ausgeliefert wird nur über `/anhaenge/<kennung>`, mit Rechteprüfung bei
**jedem** Abruf und `Content-Security-Policy: sandbox`, damit ein Anhang
nicht im Kontext der Anwendung ausgeführt werden kann
- SVG ist ausdrücklich nicht erlaubt – es kann Skripte enthalten
- Der Ausbilder hakt die Bewertungskriterien einzeln ab, vergibt eine Note oder
kurze Bewertung und schreibt eine Rückmeldung
- Abnehmen sperrt die Aufgabe für den Azubi; Zurückgeben setzt sie auf
„in Arbeit", damit er nacharbeiten kann
- „Was auf dich wartet" auf der Ausbilderstartseite zeigt die offenen Abnahmen
mit Azubi und Abgabezeitpunkt
- Abgenommene Aufgaben zählen im Wochenfortschritt mit
- Eigene Seite „Nicht gefunden" für den angemeldeten Bereich
- 24 Tests für Dateiprüfung, Namenssäuberung, Speicherpfad und Pfadausbruch
### Behoben
- **Anhänge über 1 MB scheiterten stillschweigend.** Next.js deckelt
Serveraktionen ab Werk bei 1 MB – damit wäre kein einziges Handyfoto
durchgekommen, obwohl die Anwendung 25 MB zusagt. `bodySizeLimit` steht jetzt
auf 30 MB, und zu große Dateien werden schon im Browser abgefangen, bevor sie
übertragen werden. Im Beispiel für den Reverse Proxy steht
`client_max_body_size` entsprechend auf 30m
### Geändert
- Seiten verweigern fremde Daten jetzt mit **404** statt mit einem Fehler.
Damit verrät die Anwendung nicht, ob es die angefragten Daten überhaupt gibt –
und der Benutzer sieht einen deutschen Satz statt „Application error".
Serveraktionen brechen weiterhin hart ab
## [0.4.0] – 2026-09-10
Merkkarten mit Wiederholung nach Zeitabstand – der tägliche kleine Stapel
statt aller Karten auf einmal.
### Hinzugefügt
- **Wiederholungsverfahren** als abgespecktes SM-2: drei Bewertungsstufen
statt sechs (*sitzt*, *unsicher*, *nochmal*), weil im Alltag niemand
zwischen „fast richtig" und „mit Mühe richtig" unterscheidet
- Erste beiden Abstände fest bei 1 und 3 Tagen, danach greift die
Leichtigkeit (1,3 bis 2,8)
- Abstände wachsen zu 1, 3, 8, 22, 62, 174 Tagen, gedeckelt bei einem
halben Jahr
- „nochmal" setzt die Zählung zurück und bringt die Karte **noch in
derselben Sitzung** wieder – sonst wäre die Bewertung folgenlos
- Täglich fälliger Stapel, gedeckelt auf 20 Karten. Überfällige zuerst, nie
gelernte zuletzt: Wer drei Wochen ausgesetzt hat, findet nicht 52 Karten vor,
sondern die dringendsten 20
- Fünf Fächer im Sinne der Leitner-Kartei, nur zur Anzeige – abgeleitet aus dem
Abstand, damit niemand über Leichtigkeitswerte nachdenken muss
- Filter nach Themenblock, für gezieltes Üben vor einer Lernzielkontrolle
- **Lernmodus fürs Handy**: ganzflächige Karte, Antippen zum Umdrehen, Wischen
nach rechts für „sitzt" und nach links für „nochmal", drei Knöpfe mit
mindestens 76 Pixeln Höhe, kein Überfahren mit der Maus
- Am Rechner zusätzlich Leertaste zum Umdrehen und die Tasten 1, 2, 3
- Kartenübersicht: heute fällig, Stand je Themenblock, Verteilung über die
Fächer, die nächsten fünf fälligen Karten
- Tabellen `karte` (Lernstand) und `karten_verlauf` (jede einzelne Bewertung
mit Zeitstempel, Grundlage der späteren Auswertung)
- Merkkarten zählen im Wochenfortschritt mit; als gelernt gilt eine Karte ab
Fach 4, also wenn sie mindestens eine Woche weggelegt wurde
- 29 Tests für Abstände, Leichtigkeitsgrenzen, Fächer und Stapelauswahl
### Geändert
- Kopfzeile auf schmalen Bildschirmen: Marke und Bedienung in einer Zeile,
Navigation darunter in einer schiebbaren Zeile statt über drei Zeilen umbrechend
- `-webkit-tap-highlight-color` abgeschaltet – der Browser legte beim Antippen
eine blaue Fläche über die Lernkarte
## [0.3.0] – 2026-09-10
Der Wochenplan wird zur Startseite, und der Ausbilder kann ihn umplanen.
### Hinzugefügt
- **Wochenplan als Startseite des Azubis**: Woche, Thema, Leitfrage, Wochenziel,
die fünf Tage mit Theorie- und Praxisteil, hervorgehobener heutiger Tag,
Fortschrittsbalken und eine Liste dessen, was noch offen ist
- Der Ausbilder sieht auf seiner Startseite je Azubi die laufende Woche mit
Fortschritt und offenen Punkten, mit Sprung in die Wocheneinzelansicht
- `/woche/<nr>` zeigt jede beliebige Woche; der Ausbilder wählt über `?azubi=`,
wessen Stand er sieht – geprüft wird das serverseitig bei jedem Aufruf
- Tabelle `wochenplanung` mit geplantem und tatsächlichem Datum, Zustand
(`geplant` / `laufend` / `abgeschlossen`) und einer Notiz für den Azubi
- Wochenplanseite für den Ausbilder:
- Plan aus den Wochen des Repositorys erzeugen und fehlende Wochen anhängen
- Woche auf ein neues Datum verlegen, wahlweise mit allen folgenden Wochen;
ein beliebiges Datum wird auf den Montag seiner Woche gerundet
- Reihenfolge per Ziehen und Ablegen ändern – die Terminfolge bleibt fest,
nur die Zuordnung wechselt, damit der Plan lückenlos bleibt
- Dasselbe mit der Tastatur über Alt + Pfeil hoch/runter, damit die Funktion
nicht auf die Maus angewiesen ist
- Zustand je Woche setzen; beim Start wird das tatsächliche Datum vermerkt
- Notiz je Woche, die der Azubi in seiner Wochenansicht sieht
- Warnung, wenn sich nach einem Einzelverschieben Termine überlappen
- Der Wochenplan entsteht automatisch, sobald ein Azubikonto angelegt wird
- Fortschrittsrechnung als reine Funktion über Zählwerte, damit sie mit den
weiteren Bausteinen wachsen kann, ohne sich zu verzweigen
- 36 Tests für Planerzeugung, Verschieben, Umsortieren, „welche Woche ist dran"
und die Fortschrittsrechnung
### Angemerkt
- Praxisaufgaben, Checklisten und Lernzielkontrollen erscheinen im
Fortschritt bereits mit ihrer Sollzahl, lassen sich aber erst mit den
Bausteinen 5 bis 8 abhaken. Das ist Absicht: Die Woche zeigt, was ansteht,
auch wenn die Anwendung es noch nicht mitzählen kann
## [0.2.0] – 2026-09-10
Die Inhalte kommen ins Spiel: Handouts, Suche, Glossar – alles aus Dateien
@@ -105,5 +516,13 @@ anmelden und verwalten.
- `npm run konto:anlegen` für das erste Ausbilderkonto
[Unveröffentlicht]: #unveröffentlicht
[1.0.0]: #100--2026-09-11
[0.9.0]: #090--2026-09-11
[0.8.0]: #080--2026-09-11
[0.7.0]: #070--2026-09-10
[0.6.0]: #060--2026-09-10
[0.5.0]: #050--2026-09-10
[0.4.0]: #040--2026-09-10
[0.3.0]: #030--2026-09-10
[0.2.0]: #020--2026-09-10
[0.1.0]: #010--2026-09-10
+308
View File
@@ -7,6 +7,314 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
---
## 2026-09-11 · Baustein 10 – PWA, Offline, Druck, Anleitung
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 14:20 | 14:50 | 0:30 | Symbole, Manifest, Dienstarbeiter mit Speicherregel |
| 14:50 | 15:10 | 0:20 | Warteschlange, Schnittstelle, Netzleiste |
| 15:10 | 15:30 | 0:20 | Druckpalette, Seitenumbrüche, Druckknöpfe |
| 15:30 | 15:50 | 0:20 | Durchstich mit echtem Abschalten des Netzes |
| 15:50 | 16:20 | 0:30 | Anleitung mit Bildschirmfotos |
| 16:20 | 17:40 | 1:20 | Abnahmelauf – und die Suche nach einem Fehler, der meiner war |
**Summe Baustein 10: rund 3 h 20 min**
### Was aufgefallen ist
- Beim Offlinebetrieb ist die interessante Frage nicht, **wie** man
zwischenspeichert, sondern **was**. Ein Dienstarbeiter, der alles ablegt,
hebelt die Rechteprüfung aus: Was im Browsercache liegt, wird nie wieder
serverseitig geprüft. Deshalb gibt es eine ausdrückliche Liste – Handouts,
Merkkarten, Glossar, eigene Woche – und sonst nichts. Der Durchstich prüft
das nach, indem er eine Kontrollseite öffnet und danach im Speicher
nachsieht.
- Die Warteschlange gehört an die **Benutzerkennung**, nicht an das Gerät. Ein
Werkstattrechner wird geteilt; unter einem festen Schlüssel bekäme der
Zweite die offenen Bewertungen des Ersten aufs Konto.
- Der Tag einer nachgereichten Bewertung kommt vom Gerät – sonst verliert
jemand, der freitags im Keller lernt und sich montags verbindet, seinen
Freitag. Er wird aber **auf heute gedeckelt**: Sonst ließe sich das
Wiederholen wegstellen, indem man die Uhr vordatiert.
- Beim Druck lag ein echter Fehler: Wer die dunkle Fassung eingestellt hatte,
druckte schwarze Flächen. Aufgefallen ist das erst, weil der Durchstich die
Farben im Druckmodus ausliest, statt nur zu prüfen, dass die Navigation
verschwindet.
- **Die längste Suche des Tages war ein Fehler in meinem Prüfaufbau, nicht in
der Anwendung.** Der Abnahmelauf startet einen eigenen Server auf Port 3399.
Unter Windows beendet `kill()` bei einem über die Eingabeaufforderung
gestarteten Prozess nur diese, nicht Node darunter. Der alte Server überlebte,
belegte den Port, und der nächste Lauf prüfte gegen den **alten**
Datenbestand: Der Azubi stand in der Oberfläche, aber nicht in der Datenbank,
die ich las. Jetzt wird ohne Eingabeaufforderung gestartet, der ganze
Prozessbaum beendet – und der Lauf bricht mit einer klaren Meldung ab, wenn
der Port schon belegt ist.
- Die Sicherungsskripte behaupteten im Kopf, sie liefen auch in Git Bash. Das
stimmte nicht: `tar` deutet den Doppelpunkt in `C:\...` als Rechnernamen.
Aufgefallen ist das erst, als die Sicherung zum ersten Mal wirklich
zurückgespielt wurde – eine Sicherung, die man nie zurückspielt, ist keine.
---
## 2026-09-11 · Baustein 9 – Auswertung und Feedbackgespräch
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 11:10 | 11:25 | 0:15 | Abdeckungsregel als reine Funktion, 17 Tests |
| 11:25 | 11:35 | 0:10 | Datensammlung, Feedback-Vorlage als Inhaltsdatei |
| 11:35 | 11:50 | 0:15 | Datenmodell, Repository, Gesprächsinhalt mit 13 Tests |
| 11:50 | 12:10 | 0:20 | PDF, Auswertungsseite, Gesprächsbogen |
| 12:10 | 12:40 | 0:30 | Prüfen und Durchstich – stark ausgebremst durch die Maschine |
| 12:40 | 13:30 | 0:50 | Durchstichskript, Rechteprüfung mit zweitem Azubikonto |
| 13:30 | 14:15 | 0:45 | Feinschliff am abgeschlossenen Bogen, Abweichung im PDF |
**Summe Baustein 9: rund 3 h**
### Was aufgefallen ist
- Die eigentliche Entscheidung dieses Bausteins ist die Frage, **wann eine
Berufsbildposition als vermittelt gilt.** Eine naive Zählung („Inhalt
vorhanden") wäre wertlos: Dann wäre die Abdeckung von Anfang an voll. Ein
gelesenes Handout allein genügt ebenfalls nicht – sonst wäre sie nach dem
ersten Klick voll. Jetzt zählt eine abgenommene Aufgabe oder ein
abgeschlossener Checklisten-Durchlauf, oder zwei gelesene Handouts.
- Die vierte Lage – **„ohne Inhalt"** – ist die wichtigste und fiel mir erst
beim Schreiben auf. Eine Position, zu der es im ganzen Lehrgang kein Material
gibt, ist keine Lücke beim Azubi, sondern eine im Lehrgang. Ohne eigene
Kennzeichnung würde sie zwischen den unbearbeiteten Positionen verschwinden.
Im Beispielinhalt betrifft das fünf Positionen (A4, A10, C1, C2, C5).
- Beim Feedbackgespräch war die getrennte Bewertung beider Seiten die beste
Entscheidung. Der Wert des Blattes liegt nicht in den Zahlen, sondern in den
Stellen, wo sie auseinandergehen.
- Beim Blick auf den **abgeschlossenen** Bogen fiel auf, dass er wie ein
Formular aussah statt wie ein Dokument: gesperrte Eingabefelder, leere
Bemerkungszeilen mit Platzhaltern, ein zweites Datumsfeld neben dem Datum im
Kopf – und neben „bestätigt" stand ein offener Kreis. Ein Blatt für die Akte
soll nach Abschluss nur noch gelesen werden; jetzt sieht es auch so aus.
- Die Zwei-Stufen-Regel für Abweichungen stand an zwei Stellen und sollte im
PDF eine dritte bekommen. Sie ist jetzt `liegtWeitAuseinander()` in
`src/lib/feedback/inhalt.ts`, mit eigenen Tests – Ansicht, Übersicht und PDF
fragen dieselbe Funktion.
- **Die Maschine war der Engpass, nicht die Arbeit.** Docker Desktop hatte über
fünf Stunden CPU-Zeit verbraucht und der Defender-Echtzeitschutz läuft über
`node_modules`; ein einzelner Testlauf brauchte 4605 Sekunden bei 283 ms
reiner Rechenzeit. Ich habe das Vorgehen umgestellt: erst alles schreiben,
dann **einmal** prüfen, statt nach jedem Schritt.
---
## 2026-09-11 · Baustein 8 – Lernzielkontrollen
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 06:25 | 06:35 | 0:10 | Notenschlüssel und Auswertung als reine Rechnung, 15 Tests |
| 06:35 | 06:45 | 0:10 | Sichtbarkeitsschicht für die Musterlösung, 21 Tests |
| 06:45 | 06:55 | 0:10 | Datenmodell, Migration, Repository, Serveraktionen |
| 06:55 | 07:15 | 0:20 | Fragebogen mit Zwischenspeichern, Korrekturansicht, Übersichten |
| 07:15 | 08:50 | 1:35 | Bau und Durchstich – davon über eine Stunde Wartezeit (siehe unten) |
**Summe Baustein 8: rund 2 h 25 min, davon knapp 1 h reine Wartezeit**
### Was aufgefallen ist
- Der Kern dieses Bausteins ist eine einzige Funktion: `sichtbareKontrolle()`
entfernt die Musterlösung, bevor die Daten in die Ausgabe gelangen. Sie nimmt
bewusst den ganzen Betrachter und den Bearbeitungsstand entgegen statt eines
vorberechneten Wahrheitswerts – so kann kein Aufrufer versehentlich `true`
übergeben.
- Beim Lecktest zunächst ein Fehlalarm: Die Prüfung meldete
„Discover, Offer, Request, Acknowledge" im Quelltext. Das war meine **eigene
Testantwort**, die ich zuvor eingetippt hatte. Danach ein sauberer Aufbau,
der die Musterlösungen direkt aus der Inhaltsdatei liest und gegen Text
prüft, den ich nicht selbst eingebe. Ergebnis: vor der Abgabe null Treffer,
nach der Abgabe alle fünf – damit ist auch belegt, dass die Suche überhaupt
finden kann.
- **Eine Stunde an der Umgebung verloren.** Erst hing `next build` endlos, weil
der laufende Server `.next` festhielt und ich ihn mitten im Schreiben
abgeschossen hatte. Nach `Remove-Item .next -Recurse` lief der Bau in acht
Sekunden. Danach brauchte `next start` **2822 Sekunden** bis „Ready" – das
Dateisystem war zu dem Zeitpunkt extrem langsam, vermutlich ein Virenscanner
über dem frisch gebauten Verzeichnis. Lehre: Vor einem Bau immer erst den
Server stoppen, und ein hängender Start ist nicht zwingend ein Codefehler.
- Die laufende Summe beim Korrigieren war nachträglich die beste Idee an der
Oberfläche: Man sieht beim Verteilen der Punkte, auf welcher Note man landet,
statt es hinterher zu erfahren.
---
## 2026-09-10 · Baustein 7 – Berichtsheft
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 21:50 | 22:05 | 0:15 | Prüflogik: Stunden, Floskeln, schützenswerte Angaben – mit 31 Tests |
| 22:05 | 22:15 | 0:10 | Vorschläge aus dem System, Formulierungshilfe, 15 Tests |
| 22:15 | 22:25 | 0:10 | Datenmodell, Migration, Repository |
| 22:25 | 22:40 | 0:15 | PDF im IHK-Layout mit Kästen, Stundenspalten und Unterschriften |
| 22:40 | 23:00 | 0:20 | Seiten, Formulare, Abzeichnen, Tagesnotizen |
| 23:00 | 23:15 | 0:15 | Durchstich über den ganzen Weg, zwei Fehler behoben |
**Summe Baustein 7: rund 1 h 25 min**
### Was aufgefallen ist
- Die Floskelprüfung war der kniffligste Teil. „Diverse Arbeiten" ist eine
Floskel, „Diverse Patchkabel konfektioniert und beschriftet" nicht. Gelöst
über zwei Bedingungen: Die Prüfung schlägt an, wenn die Floskel den Eintrag
im Wesentlichen ausmacht **oder** wenn im ganzen Eintrag kein einziges
Tätigkeitsverb vorkommt.
- Bei den Kundennamen hat ein Test einen echten Fehler gefunden: „Beispiel
e. V." wurde nicht erkannt. Ursache war ein `\b` am Ende des Musters – nach
einem Punkt gibt es vor einem Leerzeichen keine Wortgrenze. Ersetzt durch
einen negativen Vorausblick.
- ESLint hat wieder einen echten Bug gefunden: Das Abzeichnen-Formular hatte
gar keine `action`, der Knopf hätte nichts bewirkt. Sichtbar wurde das nur
über die Meldung „Serveraktion zugewiesen, aber nie benutzt".
- Im PDF-Kopffeld liefen die beiden Spalten auseinander, weil ich `dokument.y`
als Bezugspunkt in der Schleife benutzt habe – der Wert wandert aber bei
jedem Schreibvorgang weiter. Wieder etwas, das erst beim Ansehen auffiel.
- Beim Prüfen des PDF-Zugriffs ohne Anmeldung kam zunächst 200. Erst der
zweite Blick mit `maxRedirects: 0` zeigte: 307 auf /anmelden, 0 Byte, kein
PDF. Playwright war der Weiterleitung gefolgt. Merke: Bei
Berechtigungsprüfungen nie den Weiterleitungen folgen lassen.
- Die laufende Nummer eines Wochenblatts richtet sich nach dem Zeitraum, nicht
nach der Reihenfolge des Anlegens. Wer eine Woche nachträgt, soll Nr. 3
bekommen und nicht Nr. 12 – die dahinterliegenden Blätter rücken auf.
---
## 2026-09-10 · Baustein 6 – Checklisten und PDF
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 20:35 | 20:42 | 0:07 | Datenmodell, Migration, Schema |
| 20:42 | 20:48 | 0:06 | Gruppenfortschritt als reine Rechnung, 16 Tests |
| 20:48 | 20:52 | 0:04 | PDF-Grundlage und Checklisten-PDF |
| 20:52 | 21:00 | 0:08 | Repository, Aktionen, Seiten, PDF-Route |
| 21:00 | 21:10 | 0:10 | Durchstich; PDF gerendert und den Fußzeilenfehler gefunden |
| 21:10 | 21:20 | 0:10 | Fehler behoben, Regressionstest, Dokumente |
**Summe Baustein 6: rund 45 min**
### Was aufgefallen ist
- **Das PDF hatte sechs Seiten, vier davon leer.** Aufgefallen ist das erst
beim Ansehen – die Prüfung „Status 200, Content-Type application/pdf" war
grün. Ursache: Die Fußzeile wird unterhalb des Satzspiegels gesetzt, und
PDFKit hält das für überlaufenden Text und legt eine neue Seite an. Bei zwei
Seiten mal zwei Textaufrufen macht das vier leere Seiten. Behoben, indem der
untere Rand für die Dauer der Fußzeile auf 0 gesetzt wird. Ein Test zählt
jetzt die Seitenobjekte im PDF und hätte den Fehler gefunden.
- Headless-Chromium rendert keine PDFs. Für den Blick aufs Ergebnis musste
der Browser mit Fenster starten (`headless: false`) – auf diesem Rechner
geht das, auf einem Server nicht. Für die spätere Prüfstrecke heißt das:
Seitenzahl und Inhalt maschinell prüfen, das Aussehen von Hand.
- Titel und Schritttexte werden beim Start des Durchlaufs kopiert statt
verwiesen. Ein Nachweis, der sich rückwirkend ändert, wenn jemand die
Vorlage anfasst, wäre keiner.
- Der Ausbilder darf das PDF abrufen und mitlesen, aber nicht abhaken. Erst
beim Schreiben der Aktion ist mir aufgefallen, dass „darf sehen" und „darf
bearbeiten" hier auseinanderfallen – daher zwei getrennte Prüffunktionen.
---
## 2026-09-10 · Baustein 5 – Praxisaufgaben
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 11:55 | 12:05 | 0:10 | Datenmodell für Aufgabenstand und Anhänge, Migration, Schema |
| 12:05 | 12:20 | 0:15 | Dateiregeln (Typen, Größe, Namenssäuberung, Speicherpfad) und 24 Tests dazu |
| 12:20 | 12:35 | 0:15 | Repositories, Serveraktionen, Ausliefer-Route mit Rechteprüfung |
| 12:35 | 13:00 | 0:25 | Aufgabenliste, Aufgabenseite, Bewertungsformular, Abnahme |
| 13:00 | 13:20 | 0:20 | Durchstich – dabei den 1-MB-Deckel gefunden und behoben |
| 13:20 | 13:35 | 0:15 | Fremdzugriff geprüft, Seiten auf 404 umgestellt, Screenshots, Dokumente |
**Summe Baustein 5: rund 1 h 40 min**
### Was aufgefallen ist
- **Der wichtigste Fund des Tages:** Next.js begrenzt Serveraktionen ab Werk auf
1 MB Rumpfgröße. Meine eigene Grenze von 25 MB war damit nie erreichbar –
jedes Handyfoto (2 bis 5 MB) wäre stillschweigend gescheitert, mit einem
Fehler nur im Serverprotokoll. Genau das Abnahmekriterium „eine Praxisaufgabe
mit Foto abgeben" hätte nicht funktioniert. Gefunden hat den Fehler nur der
Durchstich mit einer echten Datei, nicht die Testdatei mit 200 Byte.
Gegenprobe danach: 3,8-MB-Foto hochgeladen, kommt mit 3 987 781 Byte zurück.
- Die eigene Fehlerseite für „kein Zugriff" hat nicht funktioniert, weil
Next.js Fehlermeldungen im Produktionsbetrieb schwärzt – der Text kommt nie
im Browser an. Statt daran herumzubasteln liefern die Seiten jetzt 404. Das
ist ohnehin die bessere Antwort: Ob es die Daten gibt, geht einen Unbefugten
nichts an. Die Anhang-Route machte das von Anfang an so.
- Anhänge hängen an Aufgabe **und** Azubi. Beim ersten Entwurf hätte ein
zweiter Azubi die Fotos des ersten an derselben Aufgabe gesehen.
- Geprüft mit einem eigens angelegten zweiten Ausbilderkonto: fremder Anhang
404, eigener 200, der zuständige Ausbilder 200. Danach wieder gelöscht.
---
## 2026-09-10 · Baustein 4 – Merkkarten
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 11:00 | 11:20 | 0:20 | Wiederholungsverfahren: SM-2 auf drei Bewertungsstufen eingedampft, Fächer, Stapelauswahl |
| 11:20 | 11:35 | 0:15 | 29 Tests dazu – einer schlug an, der Test war falsch, nicht der Code (siehe unten) |
| 11:35 | 11:50 | 0:15 | Migration, Schema, Repository, Serveraktion für die Bewertung |
| 11:50 | 12:15 | 0:25 | Lernmodus: Karte, Umdrehen, Wischen, Tastatur; Kartenübersicht mit Fächern |
| 12:15 | 12:30 | 0:15 | Durchstich am Rechner und am Handy, zwei Mängel am Handy behoben |
| 12:30 | 12:40 | 0:10 | Karten in den Wochenfortschritt gehängt, Dokumente nachgezogen |
**Summe Baustein 4: rund 1 h 40 min**
### Was aufgefallen ist
- Ein Test schlug fehl: „gibt einen leeren Stapel zurück, wenn nichts fällig
ist". Der Code lieferte eine nie gelernte Karte zurück – und hatte recht.
Eine Karte ohne Fälligkeitsdatum muss immer fällig sein, sonst käme sie nie
dran. Ich habe die Erwartung korrigiert und den Fall in zwei Tests getrennt.
- „nochmal" muss die Karte **in derselben Sitzung** wiederbringen. Sie nur auf
morgen zu legen, wäre folgenlos gewesen – man drückt „nochmal" ja gerade,
weil man sie jetzt noch einmal sehen will.
- Am Handy legte der Browser beim Antippen eine blaue Fläche über die ganze
Lernkarte. `-webkit-tap-highlight-color: transparent` behebt das; ohne einen
Blick auf ein echtes Gerätemaß wäre es nie aufgefallen.
- Die Kopfzeile brauchte am Handy drei Zeilen und schob die Karte nach unten.
Jetzt: Marke und Bedienung in einer Zeile, Navigation darunter zum Schieben.
- Der Stapel wird bei 20 Karten gedeckelt. Ohne Deckel sitzt jemand nach drei
Wochen Pause vor 52 Karten und macht es gar nicht.
---
## 2026-09-10 · Baustein 3 – Wochenplan
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 09:05 | 09:20 | 0:15 | Datenmodell der Wochenplanung, Migration, Drizzle-Schema |
| 09:20 | 09:45 | 0:25 | Planungslogik als reine Funktionen: erzeugen, verschieben, umsortieren, „welche Woche ist dran", Überschneidungen; dazu die Fortschrittsrechnung |
| 09:45 | 09:55 | 0:10 | 36 Tests dafür – liefen beim ersten Anlauf durch |
| 09:55 | 10:10 | 0:15 | Repository-Schicht, Wochensicht aus Inhalt + Planung + Fortschritt |
| 10:10 | 10:35 | 0:25 | Wochenansicht, Startseiten für beide Rollen, Wocheneinzelansicht |
| 10:35 | 10:55 | 0:20 | Wochenplanseite: verlegen, umsortieren mit Ziehen und Tastatur, Zustand, Notizen |
| 10:55 | 11:10 | 0:15 | Durchstich im Browser, Screenshots, Dokumente nachgezogen |
**Summe Baustein 3: rund 2 h 05 min**
### Was aufgefallen ist
- Beim Umsortieren war die erste Idee, die Termine mitzuziehen. Falsch: Dann
entstehen Lücken und Überschneidungen. Richtig ist, die Folge der
Kalenderwochen als festen Rahmen zu behandeln und nur die Zuordnung zu
ändern – der Plan bleibt so immer lückenlos.
- Der Lint-Fehler „azubiId is defined but never used" im Verlegen-Formular war
kein Stilproblem, sondern ein echter Bug: Dem Formular fehlte das versteckte
Feld, das die Serveraktion ausliest. Ein Lauf hätte den Fehler zwar auch
gezeigt, aber erst nach dem Klick.
- Ziehen und Ablegen allein reicht nicht. Wer mit der Tastatur arbeitet, kommt
damit nicht weiter, deshalb tun es die Pfeilknöpfe und Alt + Pfeil genauso.
- Die Erfolgsmeldung nach „Wochen einplanen" ist nicht zu sehen, weil das
Formular danach verschwindet – die erscheinende Planliste ist die
Rückmeldung. Kein Fehler, aber es hat mich im Testskript zunächst gekostet.
---
## 2026-09-10 · Baustein 2 – Inhalte
| Von | Bis | Dauer | Tätigkeit |
+5 -1
View File
@@ -24,6 +24,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=abhaengigkeiten /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Das Kontowerkzeug als eine einzige JS-Datei – im Abbild gibt es kein tsx.
# Die nativen Module bleiben draußen, sie bringt die Standalone-Ausgabe mit.
RUN npm run werkzeuge:bauen
# --- Laufzeit -------------------------------------------------------------
FROM node:${NODE_VERSION} AS laufzeit
@@ -51,8 +54,9 @@ COPY --from=bau /app/public ./public
COPY --from=bau /app/content ./content
COPY --from=bau /app/src/lib/db/migrationen ./migrationen
# Werkzeuge für Sicherung und Wiederherstellung im Container.
# Werkzeuge für Sicherung, Wiederherstellung und das erste Konto.
COPY --from=bau /app/scripts/im-container ./werkzeuge
COPY --from=bau /app/build/werkzeuge/konto.js ./werkzeuge/konto.js
RUN chmod +x ./werkzeuge/*.sh
RUN mkdir -p /daten && chown -R node:node /daten /app
+358
View File
@@ -135,3 +135,361 @@ Gründe: `gray-matter` merkt sich Ergebnisse und liefert beim zweiten Einlesen
desselben Textes ein Objekt ohne das Feld `matter` – im Betrieb fällt das nie
auf, im Test sofort. Und der Zeilenversatz für die Fehlermeldungen ist so
nachweislich richtig statt nachgebaut.
## E14 · SM-2 mit drei Bewertungsstufen statt sechs
Das Original arbeitet mit einer Skala von 0 bis 5. In der Praxis unterscheidet
niemand ehrlich zwischen „fast richtig" und „mit Mühe richtig" – und eine
Selbstbewertung, die man nicht ehrlich abgeben kann, verfälscht die Rechnung.
Deshalb: **sitzt** / **unsicher** / **nochmal**. Die Leichtigkeit bewegt sich
weiterhin zwischen 1,3 und 2,8, die Abstände folgen 1, 3, dann Abstand mal
Leichtigkeit. Gedeckelt wird bei 180 Tagen – die Ausbildung dauert drei Jahre,
weiter zu planen bringt nichts.
## E15 · „nochmal" bringt die Karte in derselben Sitzung wieder
Erste Fassung legte sie nur auf den nächsten Tag. Das ist falsch: Wer „nochmal"
drückt, will die Karte *jetzt* noch einmal sehen. Sie wird deshalb hinten an den
laufenden Stapel gehängt.
## E16 · Der tägliche Stapel wird bei 20 Karten gedeckelt
Ohne Deckel sitzt jemand nach drei Wochen Pause vor 52 fälligen Karten und
macht es gar nicht. Sortiert wird nach Überfälligkeit; nie gelernte Karten
kommen zuletzt, damit zuerst der Rückstand abgebaut wird.
## E17 · Als „gelernt" gilt eine Karte ab Fach 4
Fach 4 heißt: Abstand über drei Tage, also mindestens dreimal in Folge gekonnt.
Das ist die Schwelle, ab der die Karte im Wochenfortschritt als erledigt zählt.
Wer sie einmal richtig hatte, hat sie noch nicht gelernt.
## E18 · Anhänge: Pfad aus UUID, Auslieferung nur über eine geprüfte Route
Drei Regeln, die zusammengehören:
1. Der **Speicherpfad** ist `<jahr>/<uuid><endung>`. Der übermittelte Dateiname
landet nur als Anzeigename in der Datenbank und wird nie zum Bilden eines
Pfades benutzt. `speicherpfad()` wirft, wenn die Kennung keine UUID ist.
2. **Ausgeliefert** wird ausschließlich über `/anhaenge/<kennung>`. Es gibt
keinen Weg, über die Adresse eine beliebige Datei anzufordern, und die
Berechtigung wird bei jedem Abruf neu geprüft – nicht nur beim Hochladen.
3. Der Antwortkopf trägt `Content-Security-Policy: sandbox` und
`X-Content-Type-Options: nosniff`. Anhänge sind Daten, kein Programm.
SVG ist trotz `image/` **nicht** erlaubt: Es ist XML und kann Skripte enthalten.
## E19 · Anhänge hängen an Aufgabe **und** Azubi
Der Bezug lautet `<aufgabeId>::<azubiId>`. Beim ersten Entwurf hing er nur an
der Aufgabe – bei zwei Azubis hätte der zweite die Fotos des ersten gesehen.
## E20 · Kriterien werden über ihre Position gespeichert
`kriterien_erfuellt` ist eine JSON-Liste der Positionen in der Aufgabendatei,
etwa `[0,2,3]`. Wird die Reihenfolge der Kriterien nachträglich geändert,
verschiebt sich die Zuordnung. Neue Kriterien gehören deshalb ans Ende –
dieselbe Regel wie bei den Merkkarten (siehe E11).
## E21 · Fehlende Berechtigung ist ein 404, kein 403
Seiten rufen `erfordereSichtAufAzubi()` auf, das mit „nicht gefunden" endet.
Ein 403 würde bestätigen, dass es die angefragten Daten gibt. Serveraktionen
brechen weiterhin mit einem Fehler ab – dort wäre eine Weiterleitung sinnlos.
Nebeneffekt: Next.js schwärzt Fehlermeldungen im Produktionsbetrieb. Eine eigene
„Kein Zugriff"-Seite ließe sich anhand des Fehlertextes gar nicht erkennen.
## E22 · Der Deckel für Serveraktionen liegt über der eigenen Grenze
`serverActions.bodySizeLimit` steht auf 30 MB, die eigene Prüfung auf 25 MB.
Der Abstand ist Absicht: Eine zu große Datei soll die **eigene**, verständliche
Meldung bekommen und nicht den Rumpfgrößenfehler des Gerüsts, der nur im
Serverprotokoll auftaucht. Zusätzlich prüft der Browser vorab, damit am
Mobilfunk nicht erst 30 MB übertragen und dann abgelehnt werden.
## E23 · PDF mit Standardschriften statt eingebetteter Schrift
Die PDF-Ausgabe benutzt Helvetica, also eine der Schriften, die jeder
PDF-Betrachter mitbringt. Das spart eine eingebettete Schriftdatei im
Repository und hält die Dateien klein (ein Checklistenblatt wiegt rund 7 KB).
Der Preis ist der Zeichenvorrat **WinAnsi**. Enthalten sind alle deutschen
Umlaute, ß, €, § und °. Nicht enthalten sind Zeichen wie ✓, ○, ▲ oder Emoji.
Deshalb:
- `fuerPdf()` ersetzt typografische Feinheiten (Anführungszeichen,
Auslassungspunkte, geschützte Leerzeichen) und wirft nicht Darstellbares weg.
- Ankreuzkästchen und Haken werden **gezeichnet**, nicht gesetzt. Das sieht in
jedem Betrachter gleich aus und ist unabhängig von der Schrift.
Sollte später ein Zeichen außerhalb von WinAnsi wirklich gebraucht werden, ist
der Weg das Einbetten einer freien Schrift – nicht das Aufweichen dieser Regel.
## E24 · Fußzeilen brauchen einen aufgehobenen unteren Rand
PDFKit legt eine neue Seite an, sobald Text unterhalb des Satzspiegels
geschrieben wird. Eine Fußzeile steht dort per Definition. Ohne Gegenmaßnahme
entsteht je Seite und je Textaufruf eine leere Folgeseite.
`fusszeilen()` setzt deshalb `page.margins.bottom` kurzzeitig auf 0 und stellt
den Wert danach wieder her. Ein Test zählt die Seitenobjekte im erzeugten PDF –
damit fällt ein Rückfall sofort auf.
## E25 · Ein Durchlauf kopiert die Texte seiner Vorlage
`checkliste_lauf.titel` und `checkliste_schritt.text` werden beim Start aus der
Vorlage übernommen, statt zur Anzeigezeit nachgeschlagen zu werden.
Grund: Ein abgeschlossener Durchlauf ist ein Nachweis. Er muss auch in zwei
Jahren noch dasselbe aussagen – auch wenn die Vorlage unter `content/`
inzwischen umformuliert, erweitert oder gelöscht wurde. Die Seite zeigt einen
Hinweis, wenn es die Vorlage nicht mehr gibt, bleibt aber vollständig lesbar.
## E26 · Mitlesen und Bearbeiten sind getrennte Rechte
Beim Checklisten-Durchlauf fallen die beiden Rechte auseinander:
- **Lesen** darf der Eigentümer und sein Ausbilder (`laufZumLesen`).
- **Bearbeiten** darf nur der Eigentümer (`laufZumBearbeiten`).
Der Ausbilder hakt also nichts für seinen Azubi ab. Im Nachweis soll keine
Arbeit stehen, die der Azubi nicht selbst gemacht hat. Für die Abnahme gibt es
die Praxisaufgaben – dort ist es genau andersherum.
## E27 · Die Berichtsheftprüfung warnt, sie sperrt nicht
`pruefeWochenblatt()` liefert eine Liste von Warnungen und nichts anderes. Es
gibt bewusst **kein** `darfEinreichen()`.
Grund: Eine Anwendung, die das Einreichen verhindert, erzieht dazu, Unsinn
einzutragen, bis die Prüfung zufrieden ist. Der Azubi soll die Warnung sehen –
was er damit macht, entscheidet er. Der Knopf heißt dann eben
„Trotz 3 Warnungen einreichen".
Die Prüfung läuft im Browser beim Tippen mit und benutzt dieselbe Funktion wie
der Server. Sie ist keine Sicherheitsmaßnahme, sondern eine Hilfe.
## E28 · Floskeln werden am Zusammenhang erkannt, nicht am Wort
„Diverse Arbeiten" ist eine Floskel. „Diverse Patchkabel konfektioniert,
gemessen und im Verteiler beschriftet" ist keine. Ein reiner Wortabgleich würde
beide gleich behandeln und wäre nach dem dritten Fehlalarm nur noch lästig.
Die Prüfung schlägt deshalb nur an, wenn
- die Floskel den Eintrag im Wesentlichen ausmacht (höchstens 20 Zeichen
Rest), **oder**
- im ganzen Eintrag kein einziges Tätigkeitsverb vorkommt.
Dieselbe Verbliste trägt auch die Formulierungshilfe.
## E29 · Die laufende Nummer richtet sich nach dem Zeitraum
Wird eine vergessene Woche nachgetragen, bekommt sie die Nummer, die ihr
zeitlich zusteht; alle späteren Blätter rücken eine Nummer weiter.
Der Grund ist das Papierformular: Ein Ausbildungsnachweis wird chronologisch
abgeheftet, und Nr. 12 zwischen Nr. 3 und Nr. 4 wäre gegenüber der IHK nicht
erklärbar.
## E30 · Kundennamen werden an der Rechtsform erkannt
Personennamen lassen sich nicht zuverlässig erkennen, ohne ständig Fehlalarme
zu erzeugen. Firmennamen dagegen schon: Sie enden fast immer auf GmbH, AG, KG,
mbH, e. V. und Ähnliches.
Das Muster schließt bewusst mit einem negativen Vorausblick statt mit `\b` –
Rechtsformen wie „e. V." enden auf einen Punkt, und dort gibt es vor einem
Leerzeichen keine Wortgrenze. Genau dieser Fall war zuerst durchgerutscht.
## E31 · Die Formulierungshilfe steht im Quelltext, nicht unter content/
Tätigkeitsverben und die Gegenüberstellungen sind Bedienhilfe der Anwendung,
kein Ausbildungsinhalt. Sie ändern sich nicht mit dem Lehrgang und gehören
deshalb nicht in dieselbe Ablage wie Handouts und Aufgaben.
## E32 · Die Musterlösung wird entfernt, nicht versteckt
Der Auftrag verlangt: Musterlösungen dürfen dem Azubi erst nach dem Absenden
angezeigt werden und **niemals vorher über die Schnittstelle abrufbar** sein.
Umgesetzt ist das in `src/lib/kontrollen/sicht.ts`. `sichtbareKontrolle()`
baut die Fassung, die den Browser erreichen darf; ist die Lösung nicht
freigegeben, steht dort `null`. Es gibt keine Stelle, an der die vollständige
Kontrolle an die Oberfläche gereicht und dort ausgeblendet wird.
Zwei Feinheiten:
- Die Funktion nimmt den **ganzen Betrachter** und den Bearbeitungsstand
entgegen, nicht ein vorberechnetes `darfSehen`. Ein Aufrufer kann also nicht
aus Versehen `true` übergeben.
- Die Zwischenspeicher-Aktion gibt nur einen Zeitstempel zurück. Sie reicht
bewusst keinen Datensatz durch, in dem die Lösung mitreisen könnte.
Geprüft wird das doppelt: 21 Tests über alle Betrachter und Zustände, und ein
Durchstich, der den Wortlaut aus der Inhaltsdatei im rohen Seitenquelltext
sucht.
## E33 · Notenschlüssel der IHK, und ein vorläufiges Ergebnis bleibt vorläufig
Gerechnet wird mit dem 100-Punkte-Schlüssel der IHK (ab 92 / 81 / 67 / 50 / 30
Prozent). Wer sich daran gewöhnt, erlebt in der Abschlussprüfung keine
Überraschung.
Solange eine Frage ohne Punkte ist, gilt die Kontrolle **nicht** als
korrigiert: `korrigiert_am` bleibt leer, das Ergebnis heißt „vorläufig", und
die Kontrolle bleibt in der Liste „Was auf dich wartet". Ein halb korrigierter
Fragebogen, der als fertig erscheint, wäre schlimmer als gar keine Anzeige.
## E34 · Antworten werden beim Tippen gespeichert, je Frage einzeln
Eine Lernzielkontrolle dauert 45 Minuten. Ein Browserabsturz oder ein
versehentlich geschlossener Reiter darf nicht 45 Minuten kosten.
Gespeichert wird je Frage einzeln mit knapp einer Sekunde Verzögerung – nicht
das ganze Formular auf einmal. So bleibt jeder Schreibvorgang klein, und eine
lange Antwort auf Frage 1 wird nicht bei jedem Tastendruck in Frage 6 neu
geschrieben.
## E35 · Wann eine Berufsbildposition als vermittelt gilt
Die Regel steht an genau einer Stelle: `stufeAus()` in
`src/lib/auswertung/abdeckung.ts`.
**Vermittelt** ist eine Position, wenn es dazu gibt:
- mindestens einen **belastbaren** Nachweis – eine abgenommene Praxisaufgabe
oder einen abgeschlossenen Checklisten-Durchlauf –, **oder**
- mindestens **zwei stützende** Nachweise, also gelesene Handouts.
Die Unterscheidung ist der Kern: Bei einer abgenommenen Aufgabe hat jemand
etwas getan und ein anderer hat es gesehen. Ein gelesenes Handout sagt nur,
dass der Stoff behandelt wurde. Ein einzelnes genügt deshalb nicht – sonst wäre
die Abdeckung nach dem ersten Klick voll und die Ansicht wertlos.
Vier Lagen statt zwei:
| Lage | Bedeutung |
|---|---|
| vermittelt | die Regel oben ist erfüllt |
| angefangen | etwas erledigt, aber noch nicht genug |
| geplant | Inhalt vorhanden, nichts davon erledigt |
| **ohne Inhalt** | es gibt im ganzen Lehrgang nichts dazu |
Die vierte Lage ist die wichtigste. Sie ist keine Lücke beim Azubi, sondern
eine im Lehrgang, und wird deshalb getrennt und rot ausgewiesen. Dort hilft
kein Lernen, sondern nur ein neues Handout oder eine neue Aufgabe unter
`content/`.
Damit die Bewertung nachvollziehbar bleibt, führt die Ansicht je Position die
einzelnen Nachweise mit Kürzel und Zustand auf – und die amtliche Fundstelle
in der Verordnung.
## E36 · Das Feedbackgespräch kopiert seine Vorlage
`feedbackgespraech.inhalt` enthält die verwendeten Kriterien und die Skala,
nicht nur die Bewertungen.
Grund: Ein bestätigtes Gespräch gehört in die Ausbildungsakte. Ändert der
Ausbilder später `content/feedbackgespraech.yaml`, bleibt ein geführtes
Gespräch genau so, wie es geführt wurde. Dieselbe Überlegung wie bei den
Checklisten-Durchläufen (siehe E25).
## E37 · Beide Seiten bewerten getrennt
Ausbilder und Azubi tragen je eine eigene Spalte ein. Die Serveraktion schreibt
immer nur die Spalte dessen, der gerade angemeldet ist – eine
Selbsteinschätzung, die der Ausbilder eintragen könnte, wäre keine.
Liegen beide um zwei Stufen oder mehr auseinander, wird das Kriterium
hervorgehoben. Das ist der eigentliche Wert des Bogens: nicht die Zahlen,
sondern die Stellen, an denen sie auseinandergehen.
Bestätigen können beide einzeln; erst wenn **beide** bestätigt haben, ist das
Gespräch unveränderlich. Solange nur eine Seite bestätigt hat, lässt sich die
Bestätigung zurücknehmen.
## E38 · Was offline liegen darf – und was niemals
Die Regel steht in `public/sw.js`, in einer einzigen Funktion:
`darfOfflineLiegen()`.
**Offline liegt nur Lernmaterial:** Handouts, Merkkarten, Glossar, die eigene
Woche. **Nicht** Lernzielkontrollen, Berichtsheft, Bewertungen,
Feedbackgespräche, Anhänge und kein PDF.
Der Grund ist keine Sparsamkeit, sondern eine Zusage aus dem Pflichtenheft:
Musterlösungen und Bewertungsdaten stehen hinter einer eigenen Prüfung. Diese
Prüfung findet auf dem Server statt. Was im Browserspeicher liegt, wird beim
nächsten Zugriff nicht mehr geprüft – es wird ausgeliefert. Ein Dienstarbeiter,
der „alles" zwischenspeichert, hebelt damit genau die Zusage aus, die am
schwersten wiegt.
Seiten werden nach dem Muster **erst das Netz, dann der Speicher** geholt:
Nach einer Aktualisierung sieht man sofort die neue Fassung, und nur bei
fehlendem Netz die alte. Dateien unter `/_next/static/` tragen eine Prüfsumme
im Namen und werden zuerst aus dem Speicher bedient.
Beim Abmelden wird alles Persönliche weggeräumt – ein Werkstattgerät wird
geteilt.
## E39 · Die Warteschlange gehört zur Anmeldung, nicht zum Gerät
Offline entstandene Bewertungen liegen unter
`lernbar-warteschlange-<benutzerId>` im `localStorage`.
Unter einem festen Schlüssel bekäme auf einem geteilten Gerät der Zweite die
offenen Bewertungen des Ersten auf sein Konto geschrieben – und der Erste hätte
sie nie. Beim Abmelden wird die Schlange deshalb auch **nicht** gelöscht: Sie
gehört zur Anmeldung und wird beim nächsten Mal nachgereicht. Verworfen würde
sonst eine Lernleistung, die jemand erbracht hat.
`localStorage` und nicht IndexedDB: Es geht um eine Handvoll Einträge zwischen
zwei Verbindungen. Geht der Speicher verloren, ist eine Karte falsch
terminiert – dafür lohnt kein zweites Datenmodell.
## E40 · Der Tag einer nachgereichten Bewertung
`tagDerBewertung()` in `src/lib/offline/nachtrag.ts`, mit eigenen Tests.
Der Tag kommt vom **Gerät**, weil nur dort bekannt ist, wann tatsächlich
gelernt wurde: Wer freitags im Keller lernt und sich montags wieder verbindet,
soll seinen Freitag angerechnet bekommen.
Er wird auf **heute gedeckelt**. Sonst ließe sich das Wiederholen wegstellen,
indem man die Uhr des Geräts vordatiert – die Karte käme dann erst in ferner
Zukunft wieder. Unlesbares gilt als heute.
## E41 · Im Druck gilt immer die helle Fassung
Die vollständige helle Palette wird unter `@media print` noch einmal gesetzt,
und zwar auch für `:root[data-thema="dunkel"]`.
Der Grund ist banal und war ein echter Fehler: Wer die dunkle Fassung
eingestellt hatte, druckte schwarze Flächen. Eine einzelne Regel für `body`
genügt nicht, weil die Karten, Ränder und Marken ihre Farben aus Merkmalen
beziehen.
## E42 · Bremse gegen Passwortraten im Speicher, je E-Mail-Adresse
Nach fünf Fehlversuchen wird eine Adresse gesperrt, erst eine Minute, dann
jeweils doppelt so lang, höchstens 15 Minuten. Eine Stunde ohne Fehlversuch
oder eine erfolgreiche Anmeldung setzt den Zähler zurück.
- **Je Adresse, nicht je Konto.** Auch eine Adresse, die es nicht gibt, wird
gesperrt. Sonst verriete die Sperre, welche Konten existieren (E6).
- **Nicht je IP-Adresse.** Hinter dem Reverse Proxy käme die Adresse aus
`X-Forwarded-For`, und darauf ist nur Verlass, wenn der Proxy richtig
eingestellt ist. Bei einer Handvoll Konten lohnt das Durchprobieren vieler
Konten mit wenigen Passwörtern ohnehin nicht.
- **Obergrenze 15 Minuten.** Wer die Adresse des Ausbilders kennt, soll ihn
nicht für Stunden aussperren können.
- **Im Speicher, nicht in der Datenbank.** Es läuft genau ein Prozess. Ein
Neustart setzt die Zähler zurück – das kann ein Angreifer nicht auslösen,
der Betreiber aber schon, wenn er sich selbst ausgesperrt hat
(`docker compose restart lernbar`).
- **Gezählt wird vor der Prüfung.** Jeder Versuch gilt sofort als Fehlversuch
und wird bei Erfolg zurückgenommen. Zwischen Sperrprüfung und Zählen liegt
so kein `await`; sonst kämen gleichzeitige Anfragen alle an der Sperre vorbei.
+61 -14
View File
@@ -9,9 +9,12 @@ und – der wichtigste Teil – den gesetzlich vorgeschriebenen Ausbildungsnachw
Gebaut für **einen Ausbilder und ein bis drei Auszubildende**. Kein
Lernplattform-Baukasten, keine Mandanten, keine Cloud.
> **Stand:** Baustein 1 von 10 ist fertig (Fundament). Der
> [Fahrplan](ROADMAP.md) zeigt, was folgt, das
> [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
> **Stand:** Alle zehn Bausteine sind fertig – Fassung 1.0.0.
>
> - [**Anleitung mit Bildschirmfotos**](docs/ANLEITUNG.md) – wie man damit arbeitet
> - [Abnahmeprotokoll](docs/ABNAHME.md) – die Abnahmekriterien, einzeln durchgespielt
> - [Änderungsprotokoll](CHANGELOG.md) · [Fahrplan](ROADMAP.md) ·
> [Entscheidungen](ENTSCHEIDUNGEN.md)
---
@@ -36,14 +39,13 @@ Die Anwendung lauscht danach auf `127.0.0.1:3000`. Erstes Ausbilderkonto
anlegen:
```bash
docker compose exec lernbar node -e "1" >/dev/null # Container erreichbar?
docker compose exec lernbar sh -c \
'node werkzeuge/konto.js --name "Vorname Nachname" --email ausbilder@betrieb.de --rolle ausbilder'
docker compose exec lernbar node werkzeuge/konto.js \
--name "Vorname Nachname" --email ausbilder@betrieb.de --rolle ausbilder
```
> Solange das Konto-Werkzeug im Abbild noch nicht liegt (Baustein 1), geht es
> vom Entwicklungsrechner aus:
> `npm run konto:anlegen -- --name "…" --email … --rolle ausbilder`
Ohne `--passwort` wird eines erzeugt und ausgegeben. Gibt es die Adresse
schon, setzt derselbe Aufruf das Passwort neu – das ist zugleich der Weg, wenn
das einzige Ausbilderkonto sein Passwort vergessen hat.
Danach im Browser anmelden, unter **Verwaltung** das Azubikonto anlegen und
das Ausbildungsverhältnis mit Beginndatum einrichten.
@@ -75,7 +77,7 @@ server {
ssl_certificate /etc/ssl/lernbar.crt;
ssl_certificate_key /etc/ssl/lernbar.key;
client_max_body_size 25m; # Fotos vom Aufbau als Anhang
client_max_body_size 30m; # Fotos vom Aufbau als Anhang (Grenze der Anwendung: 25 MB)
location / {
proxy_pass http://127.0.0.1:3000;
@@ -103,6 +105,7 @@ content/
checklisten/ 02-windows-installieren.yaml …
lernzielkontrollen/ woche-03.yaml …
glossar.yaml
feedbackgespraech.yaml Leitfaden und Kriterien für das Gespräch
rahmenplan.yaml Berufsbildpositionen und Lernfelder
```
@@ -136,8 +139,6 @@ Datei und Stelle. Prüfen ohne Start:
npm run inhalte:pruefen
```
*(Wird mit Baustein 2 scharf geschaltet.)*
---
## Datensicherung
@@ -240,10 +241,56 @@ Protokollzeilen.
```bash
npm run typecheck # TypeScript
npm run lint # ESLint
npm run test # Vitest – Logik, die wehtut
npm run e2e # Playwright – der Hauptweg von Anfang bis Ende
npm run test # Vitest – Logik, die wehtut (315 Tests)
npm run abnahme # der Abnahmelauf, siehe unten
npm run abnahme:container # derselbe Weg gegen das Docker-Abbild
```
### Der Abnahmelauf
`npm run abnahme` ist die Prüfung, auf die es ankommt. Er legt ein
**Wegwerfverzeichnis** an, startet die Anwendung mit einer **frischen, leeren
Datenbank** und spielt den ganzen Weg durch:
1. Migrationen laufen an, das erste Ausbilderkonto entsteht auf der Kommandozeile
2. Die Inhaltsprüfung wird mit einem absichtlich eingebauten Fehler auf die
Probe gestellt – sie muss Datei, Zeile und Wert nennen
3. Der Ausbilder legt ein Azubikonto an; das Passwort darf danach nirgends mehr
auftauchen, in der Datenbank steht ein Argon2id-Hash
4. Der Azubi liest ein Handout, lernt eine Karte, meldet eine Aufgabe zur Abnahme
5. Der Ausbilder nimmt sie ab; sie schlägt in der Rahmenplanabdeckung durch
6. Das Wochenblatt wird angelegt und als PDF ausgegeben
7. **Gesichert, absichtlich beschädigt, zurückgespielt.** Eine Sicherung, die
nie zurückgespielt wurde, ist keine Sicherung
Das Protokoll landet in [`docs/ABNAHME.md`](docs/ABNAHME.md).
Voraussetzung: `npm run build` ist gelaufen, und Port 3399 ist frei – sonst
bricht der Lauf mit einer Meldung ab, statt gegen einen fremden Datenbestand zu
prüfen.
### Der Containerlauf
`npm run abnahme` prüft über `next start`, betrieben wird aber der Container.
`npm run abnahme:container` baut deshalb das Abbild, startet es mit leerem
Volume und geht den Weg aus dieser README: Lebenszeichen, erstes Konto über
`werkzeuge/konto.js`, Anmeldung, Passwort neu setzen, Sperre nach
Fehlversuchen, Sicherung im Container.
Danach werden Container, Volume und Abbild wieder entfernt.
Voraussetzung: Docker läuft, Port 3398 ist frei (`LERNBAR_CONTAINER_PORT`).
### Einzelne Zusagen
```bash
node scripts/pruefungen/musterloesung-dichtheit.mjs # Musterlösung dicht?
node scripts/pruefungen/baustein-9.mjs # Abdeckung, Feedback
node scripts/pruefungen/baustein-10.mjs # PWA, Offline, Druck
```
Diese drei brauchen eine **laufende** Anwendung unter `http://localhost:3000`
mit den Beispieldaten.
---
## Aufbau
+71 -11
View File
@@ -35,27 +35,87 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
## 0.3 – Woche und Wiederholung
- [ ] **B3 · Wochenplan als Startseite, Fortschritt, Wochen verschieben**
- [ ] **B4 · Merkkarten mit Wiederholung nach Zeitabstand (SM-2)**
- [x] **B3 · Wochenplan als Startseite, Fortschritt, Wochen verschieben**
- [x] Wochenplan ist die Startseite des Azubis: Thema, Leitfrage, fünf Tage
mit Theorie und Praxis, Fortschrittsbalken, was noch offen ist
- [x] Ausbilder sieht dieselbe Woche plus den Stand seiner Azubis
- [x] Geplantes und tatsächliches Datum je Woche
- [x] Verschieben per Datumsfeld, wahlweise mit allen folgenden Wochen
- [x] Reihenfolge per Ziehen und Ablegen **und** per Tastatur
- [x] Zustand je Woche (geplant / läuft / abgeschlossen) und Notiz für den Azubi
- [x] Warnung bei sich überlappenden Terminen
- [x] **B4 · Merkkarten mit Wiederholung nach Zeitabstand (SM-2)**
- [x] Vereinfachtes SM-2 mit drei Bewertungsstufen statt sechs
- [x] Täglich fälliger Stapel, gedeckelt auf 20 Karten
- [x] Fünf Fächer zur Anzeige, abgeleitet aus dem Abstand
- [x] Filter nach Themenblock für gezieltes Üben vor der Kontrolle
- [x] Lernmodus fürs Handy: große Flächen, Antippen, Wischen, kein Hover
- [x] Am Rechner zusätzlich Leertaste und Tasten 1 / 2 / 3
- [x] Jede Bewertung wird mitgeschrieben (Verlauf für die spätere Auswertung)
- [x] Merkkarten zählen im Wochenfortschritt mit
## 0.4 – Nachweise
- [ ] **B5 · Praxisaufgaben mit Anhängen und Abnahme**
- [ ] **B6 · Checklisten mit Durchläufen und PDF**
- [ ] **B7 · Berichtsheft: Tagesnotizen, Vorschläge, Prüfung, Abzeichnen, PDF**
- [x] **B5 · Praxisaufgaben mit Anhängen und Abnahme**
- [x] Status offen / in Arbeit / zur Abnahme / abgenommen
- [x] Notizen des Azubis, Dateianhänge (Fotos, Bildschirmfotos, PDF)
- [x] Ausbilder hakt Kriterien einzeln ab, Note und Rückmeldung
- [x] Abnehmen sperrt die Aufgabe, Zurückgeben zur Nacharbeit
- [x] Anhänge nur über Kennung, Berechtigung bei jedem Abruf geprüft
- [x] „Was auf dich wartet" zeigt offene Abnahmen
- [x] Aufgaben zählen im Wochenfortschritt mit
- [x] **B6 · Checklisten mit Durchläufen und PDF**
- [x] Durchlauf aus einer Vorlage, mit Datum, Ticket und Kunde
- [x] Schritt für Schritt abhaken, Bemerkung je Schritt
- [x] Fortschritt je Gruppe (Vorbereiten / Durchführen / Prüfen / Abschließen)
- [x] Abschließen erst, wenn alles abgehakt ist
- [x] Abgeschlossene Durchläufe bleiben als Nachweis und sind gesperrt
- [x] PDF-Export mit Unterschriftenzeilen
- [x] Ausbilder liest mit, hakt aber nicht für den Azubi ab
- [x] **B7 · Berichtsheft: Tagesnotizen, Vorschläge, Prüfung, Abzeichnen, PDF**
- [x] Tägliche Stichworte, je Tag ein eigenes kleines Formular
- [x] Vorschläge aus Tagesnotizen, Aufgaben, Checklisten und Handouts
- [x] Formulierungshilfe: Tätigkeitsverben und „so besser nicht / so ist es richtig"
- [x] Prüfung auf fehlende Stunden, Floskeln und schützenswerte Angaben
- [x] Warnung, keine Sperre – Einreichen ist immer möglich
- [x] Einreichen, zurückgeben mit Anmerkung, abzeichnen; danach gesperrt
- [x] Fehlende Wochen werden benannt und lassen sich nachtragen
- [x] PDF im IHK-Layout, einzeln oder als ganzer Zeitraum
## 0.5 – Bewertung
- [ ] **B8 · Lernzielkontrollen mit Freigabe, Abgabe, Korrektur**
- [ ] **B9 · Auswertung, Rahmenplanabdeckung, Feedbackgespräch**
- [x] **B8 · Lernzielkontrollen mit Freigabe, Abgabe, Korrektur**
- [x] Ausbilder gibt frei, kann die Freigabe vor der Abgabe zurücknehmen
- [x] Fragen erst ab der Freigabe sichtbar
- [x] Antworten werden beim Tippen zwischengespeichert
- [x] Musterlösungen erst nach dem Absenden – serverseitig entfernt,
nicht in der Oberfläche versteckt
- [x] Korrektur mit Punkten je Frage, Anmerkung je Frage und gesamt
- [x] Notenschlüssel der IHK, Zwischenstand beim Korrigieren sichtbar
- [x] Ergebnis bei beiden sichtbar, Verlauf über alle Kontrollen
- [x] **B9 · Auswertung, Rahmenplanabdeckung, Feedbackgespräch**
- [x] Stand des Azubis: Handouts, Merkkarten, Aufgaben, Berichtsheft
- [x] Rahmenplanabdeckung mit vier Lagen statt „erledigt/offen"
- [x] Lücken im Lehrgang werden getrennt ausgewiesen
- [x] Lernfeldabdeckung nach Ausbildungsjahr
- [x] Ergebnisverlauf der Lernzielkontrollen
- [x] Feedbackgespräch mit Leitfaden und Bewertungsbogen
- [x] Beide Seiten bewerten getrennt, Abweichungen werden hervorgehoben
- [x] Nach beidseitiger Bestätigung unveränderlich, PDF für die Akte
## 1.0 – Abrundung
- [ ] **B10 · PWA, Offlinebetrieb, Druckansichten, Feinschliff**
- [ ] Anleitung mit Screenshots (`docs/ANLEITUNG.md`), Bilder automatisiert
- [x] **B10 · PWA, Offlinebetrieb, Druckansichten, Feinschliff**
- [x] Installierbar: Manifest, Symbole aus dem eigenen Zeichen erzeugt
- [x] Offline: Merkkarten und gelesene Handouts, Bewertungen in einer
Warteschlange, nachgereicht bei Verbindung
- [x] Nichts Bewertungsrelevantes im Browserspeicher – nachgewiesen
- [x] Druckansichten, auch bei eingestellter dunkler Fassung
- [x] Anleitung mit Screenshots (`docs/ANLEITUNG.md`), Bilder automatisiert
über `npm run screenshots`
- [ ] Ende-zu-Ende-Test über den Hauptweg
- [ ] Abnahmekriterien einzeln durchgespielt und protokolliert
- [x] Ende-zu-Ende-Test über den Hauptweg (`scripts/pruefungen/abnahme.mjs`)
- [x] Abnahmekriterien einzeln durchgespielt und protokolliert
(`docs/ABNAHME.md`)
---
@@ -0,0 +1,49 @@
---
id: "5.1"
titel: Den Betrieb aus Sicht der IT kartieren
woche: 5
block: arbeitsalltag
ziel: Du erstellst eine Übersicht aller Abteilungen mit Ansprechpartner, wichtigsten Anwendungen und kritischen Zeiten und legst sie so ab, dass das ganze IT-Team damit arbeiten kann.
zeitrahmen: 6 Stunden, verteilt auf zwei Tage
rahmenplan: [C2, C5, A1]
lernfeld: LF1
material:
- Aktuelles Organigramm (bei der Personalabteilung oder im Intranet)
- Geräteliste und Ticketsystem (lesend)
- Zugang zur gemeinsamen Dokumentation der IT
kriterien:
- Jede Abteilung aus dem Organigramm ist erfasst, keine fehlt
- Je Abteilung sind Ansprechpartner, die drei wichtigsten Anwendungen und die kritischen Zeiten benannt
- Die Angaben stammen aus Gesprächen mit den Abteilungen, nicht nur aus vorhandenen Unterlagen
- Abhängigkeiten zwischen Abteilungen (Datenaustausch, gemeinsame Anwendungen) sind eingezeichnet
- Die Übersicht liegt am vereinbarten Ort in der IT-Dokumentation, mit Datum und Verfasser
- Mindestens drei Verbesserungsvorschläge aus den Gesprächen sind als Tickets angelegt
---
## Auftrag
Die IT kennt die Rechner, aber nicht immer die Arbeit, die darauf läuft. Diese Lücke
schließt du.
1. Besorge dir das aktuelle Organigramm und lege für jede Abteilung einen Abschnitt an.
2. Vereinbare mit jeder Abteilung ein Gespräch von höchstens 15 Minuten. Stell die Fragen aus
der Übersicht im Handout A5 – nicht mehr, sonst wird es ein Verhör.
3. Halte fest: Ansprechpartner, die drei wichtigsten Anwendungen, Zeiten, in denen nichts
ausfallen darf, und mit wem die Abteilung Daten austauscht.
4. Zeichne die Abhängigkeiten ein: Welche Abteilung bekommt Daten von welcher, über welche
Anwendung?
5. Lege das Ergebnis in der IT-Dokumentation ab. Benenne die Datei nach der Regel aus
Handout A6.
6. Was dir in den Gesprächen an Beschwerden und Wünschen begegnet, legst du als Ticket an.
## Worauf es ankommt
Die Gespräche sind der eigentliche Wert dieser Aufgabe. Eine Übersicht, die nur aus
vorhandenen Listen abgeschrieben ist, enthält genau die Fehler, die schon in den Listen
stehen. Und: Nach dieser Woche kennen dich die Abteilungen. Das spart dir in drei Jahren
Ausbildung viele Erklärungen.
## Zeit
Zwei halbe Tage. Wenn eine Abteilung keine Zeit hat, notiere das und frag nach einem
Termin in der nächsten Woche – die Aufgabe kann trotzdem zur Abnahme.
@@ -0,0 +1,51 @@
---
id: "6.1"
titel: Den Schutzbedarf eines Arbeitsbereichs feststellen
woche: 6
block: sicherheit
ziel: Du stellst für einen Arbeitsbereich des Betriebs den Schutzbedarf der Informationen, Anwendungen und IT-Systeme fest, begründest jede Einstufung und leitest daraus drei konkrete Maßnahmen ab.
zeitrahmen: 5 Stunden
rahmenplan: [A6, A5, B1]
lernfeld: LF4
material:
- Übersicht der Abteilungen aus Praxisaufgabe 5.1
- Geräteliste und Netzplan
- Vorlage für die Schutzbedarfsfeststellung (Tabelle beim Ausbilder)
- Gesprächstermin mit der Leitung des gewählten Bereichs
kriterien:
- Die wichtigsten Informationen und Geschäftsabläufe des Bereichs sind erfasst
- Für jede Anwendung ist der Schutzbedarf je Schutzziel eingestuft
- Jede Einstufung ist mit einem konkreten Schadensszenario begründet
- Der Schutzbedarf der IT-Systeme ist nachvollziehbar aus den Anwendungen abgeleitet (Maximumprinzip, ggf. Kumulations- oder Verteilungseffekt)
- Drei Maßnahmen sind vorgeschlagen, jeweils mit dem Schutzziel, dem sie dienen
- Die Einstufung ist mit der Leitung des Bereichs abgestimmt
---
## Auftrag
Wähle mit dem Ausbilder einen Arbeitsbereich aus, etwa Personal, Buchhaltung, Lager oder
Vertrieb.
1. **Informationen und Abläufe:** Welche Informationen werden dort verarbeitet, und für
welche Abläufe? Nutze dafür deine Gesprächsnotizen aus Aufgabe 5.1.
2. **Anwendungen:** Welche Programme und Dienste verarbeiten diese Informationen?
3. **Schutzbedarf der Anwendungen:** Stufe jede Anwendung für Vertraulichkeit, Integrität
und Verfügbarkeit ein – normal, hoch oder sehr hoch. Begründe mit einem der sechs
Schadensszenarien aus Handout I1.
4. **IT-Systeme:** Auf welchen Servern, Clients und Verbindungen laufen die Anwendungen?
Leite den Schutzbedarf ab und schreib dazu, welches Prinzip du angewandt hast.
5. **Abstimmung:** Leg die Einstufung der Leitung des Bereichs vor. Sie kennt die Folgen
eines Ausfalls besser als die IT.
6. **Maßnahmen:** Schlage drei Maßnahmen vor, die dort ansetzen, wo der Bedarf hoch ist und
der Schutz heute nicht reicht.
## Worauf es ankommt
Die Begründung ist wichtiger als die Einstufung. „Sehr hoch" ohne Szenario ist wertlos;
„hoch, weil ein Ausfall von mehr als einem Tag die Lohnabrechnung verzögert und damit gegen
Arbeitsverträge verstößt" ist eine Aussage, mit der man arbeiten kann.
## Zeit
Fünf Stunden einschließlich Abstimmungsgespräch. Den Bereich lieber klein wählen und
gründlich bearbeiten als groß und oberflächlich.
+50
View File
@@ -0,0 +1,50 @@
---
id: "7.1"
titel: Ein Inventarskript schreiben
woche: 7
block: programmierung
ziel: Du schreibst ein PowerShell-Skript, das die wichtigsten Angaben eines Rechners erfasst und an eine gemeinsame CSV-Datei anhängt, und weist an drei Rechnern nach, dass es zuverlässig funktioniert.
zeitrahmen: 8 Stunden, verteilt auf zwei Tage
rahmenplan: [A10, A4, B3, A5]
lernfeld: LF5
material:
- Drei Testrechner mit Windows, darunter ein Notebook
- Eine Netzwerkfreigabe mit Schreibrecht für die CSV-Datei
- Zugang zum Git-Repository der IT
kriterien:
- Das Skript erfasst Rechnername, Hersteller, Modell, Seriennummer, Betriebssystem mit Version, Arbeitsspeicher in GB, freien Platz auf C in GB und den Zeitpunkt des letzten Starts
- Der Zielpfad der CSV-Datei ist ein Parameter, kein fester Wert im Code
- Ein zweiter Lauf auf demselben Rechner erzeugt keine doppelte Zeile, sondern aktualisiert sie
- Ist die Freigabe nicht erreichbar, bricht das Skript mit einer verständlichen Meldung ab
- Das Skript hat einen Hilfeblock mit Beschreibung und Beispielaufruf
- Die Ergebnisse von drei Rechnern liegen in der CSV-Datei und lassen sich in Excel öffnen
- Das Skript liegt mit mindestens drei sinnvoll beschriebenen Commits im Repository
---
## Auftrag
Die Geräteliste der IT wird von Hand gepflegt und ist deshalb nie ganz aktuell. Dein Skript
soll das ändern.
1. **Angaben finden:** Ermittle mit `Get-CimInstance`, wo die Angaben stecken. Hilfreich
sind die Klassen `Win32_ComputerSystem`, `Win32_BIOS`, `Win32_OperatingSystem` und
`Win32_LogicalDisk`. Nutze `Get-Member`, um die Eigenschaften zu finden.
2. **Ein Objekt bauen:** Fasse alles in einem `[PSCustomObject]` zusammen. Rechne Bytes in
Gigabyte um und runde auf eine Nachkommastelle.
3. **Ablegen:** Lies die vorhandene CSV-Datei ein, ersetze die Zeile des eigenen Rechners
oder hänge eine neue an, und schreib die Datei zurück.
4. **Absichern:** Zielpfad als Parameter, Prüfung mit `Test-Path`, Fehlerbehandlung mit
`try`/`catch`.
5. **Dokumentieren:** Hilfeblock oben im Skript, Commits im Repository.
6. **Nachweisen:** Lauf auf drei Rechnern, darunter ein zweiter Lauf auf demselben Rechner.
## Worauf es ankommt
Das Aktualisieren statt Anhängen ist der Teil, an dem die meisten hängen bleiben – und genau
der Teil, der das Skript im Alltag brauchbar macht. Überleg dir vorher, woran du einen
Rechner eindeutig erkennst. Der Rechnername ist es nicht immer; die Seriennummer schon eher.
## Zeit
Zwei Tage. Wenn du nach zwei Stunden nicht weißt, wo eine Angabe steckt, frag nach – das
Suchen in den Klassen gehört zur Übung, das Verzweifeln daran nicht.
+53
View File
@@ -0,0 +1,53 @@
---
id: "8.1"
titel: Die Sicherung eines Bereichs prüfen und zurückspielen
woche: 8
block: system
ziel: Du prüfst, ob die bestehende Datensicherung zum Schutzbedarf eines Arbeitsbereichs passt, spielst eine Sicherung auf eine Testumgebung zurück und vergleichst die gemessene Zeit mit dem, was der Bereich verkraftet.
zeitrahmen: 6 Stunden
rahmenplan: [A9, A5, A4, A8]
lernfeld: LF4
material:
- Ergebnis der Schutzbedarfsfeststellung aus Praxisaufgabe 6.1
- Lesender Zugang zur Sicherungssoftware und ihren Protokollen
- Ein Testserver oder eine virtuelle Maschine als Ziel der Wiederherstellung
- Checkliste „Wiederherstellung testen"
kriterien:
- RPO und RTO des Bereichs sind mit der Leitung abgestimmt und schriftlich festgehalten
- Die bestehende Sicherung ist beschrieben – Art, Häufigkeit, Aufbewahrung, Speicherorte
- Der Abgleich mit der 3-2-1-Regel und mit RPO und RTO ist vollständig, Lücken sind benannt
- Die Wiederherstellung wurde auf die Testumgebung durchgeführt, nicht auf das laufende System
- Die gemessene Dauer ist dokumentiert und dem RTO gegenübergestellt
- Das Ergebnis wurde inhaltlich geprüft, nicht nur die Erfolgsmeldung der Software
- Die Testumgebung ist anschließend bereinigt
- Für jede gefundene Lücke liegt ein konkreter Verbesserungsvorschlag vor
---
## Auftrag
Nimm den Bereich, den du in Aufgabe 6.1 untersucht hast. Du kennst seinen Schutzbedarf –
jetzt prüfst du, ob die Sicherung dazu passt.
1. **Anforderungen festhalten:** Wie viel Datenverlust (RPO) und wie viel Ausfallzeit (RTO)
verkraftet der Bereich? Stimme beides mit der Leitung ab.
2. **Bestand aufnehmen:** Was wird gesichert, wie oft, wie lange aufbewahrt, wohin? Lies dazu
Einstellungen und Protokolle der Sicherungssoftware.
3. **Abgleichen:** Erfüllt die Sicherung die 3-2-1-Regel? Gibt es eine Kopie, die offline oder
unveränderlich ist? Reicht die Häufigkeit für das RPO?
4. **Zurückspielen:** Stelle nach der Checkliste die Daten des Bereichs auf der
Testumgebung wieder her. Miss die Zeit vom Entschluss bis zur Arbeitsfähigkeit.
5. **Prüfen:** Öffne Dateien, starte die Anwendung, vergleiche den Stand mit dem
Sicherungszeitpunkt.
6. **Bewerten und vorschlagen:** Wo liegen Lücken? Was schlägst du vor?
## Worauf es ankommt
Die gemessene Zeit ist das wichtigste Ergebnis. Fast jeder Betrieb schätzt die Dauer einer
Wiederherstellung zu niedrig ein, weil er die Suche nach dem richtigen Sicherungsstand, das
Bereitstellen der Umgebung und das Prüfen nicht mitrechnet. Wenn deine Messung über dem RTO
liegt, ist das kein Misserfolg der Aufgabe – sondern genau der Befund, für den sie da ist.
## Zeit
Sechs Stunden. Arbeite in der Testumgebung, und frag vor dem Zurückspielen den Ausbilder,
ob Ziel und Umfang stimmen.
@@ -0,0 +1,54 @@
id: 05-arbeitsplatz-sicherheit-pruefen
titel: Sicherheit eines Arbeitsplatzes prüfen
block: sicherheit
worum: >-
Ein Rundgang mit der Liste in der Hand findet mehr als jede Richtlinie. Geprüft
wird, was tatsächlich eingestellt ist – nicht, was eingestellt sein sollte.
rahmenplan: [A6, A5]
lernfeld: LF4
mit_ticket: false
gruppen:
- id: vorbereiten
titel: Vorbereiten
schritte:
- id: v1
text: Arbeitsplatz und Nutzer mit Termin angekündigt, Zweck der Prüfung erklärt
- id: v2
text: Gerät in der Geräteliste nachgeschlagen, Soll-Zustand (Betriebssystem, Verschlüsselung, Schutzsoftware) notiert
- id: durchfuehren
titel: Durchführen
schritte:
- id: d1
text: Bildschirmsperre greift nach spätestens 10 Minuten, Sperren mit Windows-Taste + L ist bekannt
- id: d2
text: Betriebssystem und Anwendungen auf aktuellem Stand, keine ausstehenden Neustarts für Updates
- id: d3
text: Schutzsoftware aktiv, Signaturen aktuell, letzte vollständige Prüfung ohne Befund
- id: d4
text: Festplattenverschlüsselung aktiv (bei Notebooks Pflicht), Wiederherstellungsschlüssel zentral hinterlegt
- id: d5
text: Der Nutzer arbeitet ohne lokale Administratorrechte
- id: d6
text: Keine Passwörter auf Zetteln, unter der Tastatur oder in Textdateien auf dem Desktop
- id: d7
text: Zweiter Faktor für E-Mail und Fernzugang eingerichtet
- id: d8
text: Keine vertraulichen Ausdrucke offen am Platz oder im Drucker liegen gelassen
- id: pruefen
titel: Prüfen
schritte:
- id: p1
text: Abweichungen vom Soll-Zustand vollständig notiert, je mit Befund und Risiko
- id: p2
text: Mit dem Nutzer kurz besprochen, was gefunden wurde – ohne Vorwurf
- id: abschliessen
titel: Abschließen
schritte:
- id: a1
text: Für jede Abweichung ein Ticket angelegt oder die Abweichung sofort behoben und dokumentiert
- id: a2
text: Wiederkehrende Abweichungen an den Ausbilder gemeldet – sie deuten auf eine fehlende Regel hin
@@ -0,0 +1,60 @@
id: 06-wiederherstellung-testen
titel: Wiederherstellung testen
block: system
worum: >-
Der Test findet auf einer Testumgebung statt, wird gemessen und inhaltlich
geprüft. Die Erfolgsmeldung der Sicherungssoftware ist kein Ergebnis.
rahmenplan: [A9, A5]
lernfeld: LF4
mit_ticket: true
gruppen:
- id: vorbereiten
titel: Vorbereiten
schritte:
- id: v1
text: Umfang des Tests festgelegt (Datei, Ordner, Anwendung oder ganzes System)
- id: v2
text: RPO und RTO des betroffenen Bereichs nachgeschlagen
- id: v3
text: Testumgebung bereitgestellt und vom Produktivnetz getrennt
- id: v4
text: Sicherungsstand gewählt und notiert (Datum, Uhrzeit, Art der Sicherung)
- id: durchfuehren
titel: Durchführen
schritte:
- id: d1
text: Startzeit notiert
- id: d2
text: Wiederherstellung auf die Testumgebung gestartet – nicht auf das laufende System
- id: d3
text: Auftretende Meldungen und Wartezeiten mitgeschrieben
- id: d4
text: Endzeit notiert, sobald mit den Daten gearbeitet werden kann
- id: pruefen
titel: Prüfen
schritte:
- id: p1
text: Stichprobe von mindestens fünf Dateien geöffnet, Inhalt lesbar
- id: p2
text: Stand entspricht dem gewählten Sicherungszeitpunkt
- id: p3
text: Berechtigungen sind mit zurückgekommen
- id: p4
text: Anwendung startet und arbeitet mit den zurückgespielten Daten (falls Teil des Tests)
- id: p5
text: Gemessene Dauer dem RTO gegenübergestellt
- id: abschliessen
titel: Abschließen
schritte:
- id: a1
text: Testprotokoll mit Datum, Umfang, Sicherungsstand, Dauer und Ergebnis abgelegt
- id: a2
text: Testumgebung bereinigt, zurückgespielte Daten gelöscht
- id: a3
text: Abweichungen und Auffälligkeiten als Tickets angelegt
- id: a4
text: Datum des nächsten Tests festgelegt
+75
View File
@@ -0,0 +1,75 @@
# ---------------------------------------------------------------------------
# Vorlage für das monatliche Feedbackgespräch.
#
# Liegt hier und nicht im Quelltext, weil der Ausbilder die Kriterien an seinen
# Betrieb anpassen soll. Wer etwas ändert, ändert es für künftige Gespräche –
# bereits bestätigte Gespräche behalten ihre Fassung (sie wird mitkopiert).
# ---------------------------------------------------------------------------
leitfaden:
- abschnitt: Rückblick
fragen:
- Was hat seit dem letzten Gespräch am meisten Spaß gemacht?
- Woran hast du gemerkt, dass du sicherer geworden bist?
- Wo hast du dich allein gelassen gefühlt?
- abschnitt: Fachliches
fragen:
- Welche Aufgabe war die schwierigste, und woran lag das?
- Was aus der Berufsschule konntest du im Betrieb anwenden?
- Wo fehlt dir Übung, um selbstständig arbeiten zu können?
- abschnitt: Zusammenarbeit
fragen:
- Wie läuft es mit den Kollegen und im Kundenkontakt?
- Bekommst du Rückmeldung, wenn etwas gut oder schlecht war?
- Traust du dich nachzufragen, wenn du nicht weiterkommst?
- abschnitt: Ausblick
fragen:
- Was willst du bis zum nächsten Gespräch erreichen?
- Was brauchst du dafür von mir?
# Die Kriterien werden auf einer Skala von 1 (sehr gut) bis 5 (deutlicher
# Nachholbedarf) bewertet – von beiden Seiten getrennt, damit ein
# unterschiedlicher Blick sichtbar wird und nicht untergeht.
kriterien:
- id: selbststaendigkeit
titel: Selbstständigkeit
erlaeuterung: Arbeitet Aufträge eigenständig ab, fragt gezielt nach, statt zu warten.
- id: sorgfalt
titel: Sorgfalt und Genauigkeit
erlaeuterung: Arbeitet sauber, prüft das Ergebnis, hinterlässt einen ordentlichen Arbeitsplatz.
- id: dokumentation
titel: Dokumentation
erlaeuterung: Tickets, Berichtsheft und Protokolle sind vollständig und nachvollziehbar.
- id: fachwissen
titel: Fachliches Verständnis
erlaeuterung: Versteht Zusammenhänge, nicht nur Handgriffe; kann erklären, was er tut.
- id: fehlersuche
titel: Systematische Fehlersuche
erlaeuterung: Grenzt ein statt zu raten, arbeitet von unten nach oben, dokumentiert den Weg.
- id: kundenkontakt
titel: Umgang mit Anwendern
erlaeuterung: Hört zu, fasst zusammen, sagt verbindlich zu und hält es ein.
- id: zuverlaessigkeit
titel: Zuverlässigkeit
erlaeuterung: Pünktlich, hält Absprachen ein, meldet sich, wenn etwas nicht klappt.
- id: lernbereitschaft
titel: Lernbereitschaft
erlaeuterung: Bereitet nach, nutzt die Merkkarten, kommt mit eigenen Fragen.
skala:
- wert: 1
text: sehr gut
- wert: 2
text: gut
- wert: 3
text: in Ordnung
- wert: 4
text: ausbaufähig
- wert: 5
text: deutlicher Nachholbedarf
hinweis: >-
Das Gespräch wird von beiden Seiten bestätigt und ist danach unveränderlich.
Es gehört in die Ausbildungsakte – deshalb: konkret bleiben, Beispiele nennen,
und die vereinbarten Ziele so formulieren, dass beim nächsten Mal feststellbar
ist, ob sie erreicht wurden.
+171
View File
@@ -182,3 +182,174 @@
SSDs durch Schreibvorgänge – die Kennzahlen für den Zustand sind deshalb
unterschiedlich.
siehe_auch: [SMART]
# --- Woche 5: Ausbildung, Betrieb, Zusammenarbeit -------------------------
- begriff: Probezeit
erklaerung: >-
Der erste Abschnitt der Ausbildung, mindestens einen und höchstens vier
Monate lang. In dieser Zeit können beide Seiten jederzeit ohne Frist und ohne
Angabe von Gründen kündigen, schriftlich.
- begriff: Ausbildungsnachweis
formen: [Berichtsheft, Ausbildungsnachweise, Ausbildungsnachweises]
erklaerung: >-
Die schriftliche oder elektronische Aufzeichnung dessen, was in der
Ausbildung tatsächlich gelernt und getan wurde. Ein ordnungsgemäß geführter
Nachweis ist Voraussetzung für die Zulassung zur Abschlussprüfung.
- begriff: Organigramm
erklaerung: >-
Die zeichnerische Darstellung der Aufbauorganisation eines Betriebs:
Abteilungen, Stellen und wer wem gegenüber weisungsbefugt ist.
- begriff: Betriebsrat
erklaerung: >-
Die gewählte Vertretung der Beschäftigten. Er hat unter anderem ein
Mitbestimmungsrecht, wenn technische Einrichtungen eingeführt werden, die
Verhalten oder Leistung überwachen können – also bei vielen IT-Projekten.
siehe_auch: [Jugend- und Auszubildendenvertretung]
- begriff: Jugend- und Auszubildendenvertretung
steht_fuer: JAV
formen: [JAV]
erklaerung: >-
Die Interessenvertretung der Beschäftigten unter 18 und der Auszubildenden
unter 25. Sie kann gewählt werden, wo es einen Betriebsrat und in der Regel
mindestens fünf solcher Beschäftigten gibt.
siehe_auch: [Betriebsrat]
# --- Woche 6: IT-Sicherheit und Datenschutz -------------------------------
- begriff: Schutzziele
formen: [Schutzziel, Schutzzielen]
erklaerung: >-
Die drei Grundwerte der Informationssicherheit: Vertraulichkeit (nur Befugte
sehen die Daten), Integrität (die Daten sind unverfälscht) und Verfügbarkeit
(sie stehen bereit, wenn sie gebraucht werden).
siehe_auch: [Schutzbedarf]
- begriff: Schutzbedarf
formen: [Schutzbedarfs, Schutzbedarfsfeststellung]
erklaerung: >-
Wie viel Schutz eine Information oder ein System braucht – gemessen am
Schaden, der entstünde, wenn ein Schutzziel verletzt würde. Der
IT-Grundschutz des BSI unterscheidet normal, hoch und sehr hoch.
siehe_auch: [Schutzziele, Maximumprinzip]
- begriff: Maximumprinzip
erklaerung: >-
Ein IT-System übernimmt den höchsten Schutzbedarf aller Anwendungen, die auf
ihm laufen. Eine einzige Anwendung mit sehr hohem Bedarf hebt also das ganze
System an.
siehe_auch: [Schutzbedarf]
- begriff: Phishing
formen: [Phishing-Mail, Phishing-Mails]
erklaerung: >-
Gefälschte Nachrichten oder Webseiten, die zur Eingabe von Zugangsdaten oder
zu einer Zahlung verleiten sollen. Erkennungszeichen sind Zeitdruck, ein
unpassender Absender und ein Link, dessen Ziel nicht zum Absender passt.
- begriff: Zwei-Faktor-Authentifizierung
steht_fuer: 2FA
formen: [2FA, MFA, zweiter Faktor, zweiten Faktor]
erklaerung: >-
Eine Anmeldung, die neben dem Passwort einen zweiten, unabhängigen Nachweis
verlangt – etwa einen Code aus einer App oder einen Sicherheitsschlüssel. Ein
gestohlenes Passwort allein genügt dann nicht mehr.
- begriff: DSGVO
steht_fuer: Datenschutz-Grundverordnung
formen: [Datenschutz-Grundverordnung]
erklaerung: >-
Die europäische Verordnung, die regelt, wie personenbezogene Daten erhoben,
verarbeitet und geschützt werden müssen. Sie gilt unmittelbar in allen
Mitgliedstaaten.
siehe_auch: [personenbezogene Daten]
- begriff: personenbezogene Daten
formen: [personenbezogener Daten, personenbezogenen Daten]
erklaerung: >-
Alle Angaben, die sich einer bestimmten oder bestimmbaren Person zuordnen
lassen – Name, E-Mail-Adresse, Personalnummer, aber auch eine IP-Adresse oder
ein Foto.
siehe_auch: [DSGVO]
# --- Woche 7: Skripting mit PowerShell ------------------------------------
- begriff: Cmdlet
formen: [Cmdlets]
erklaerung: >-
Ein eingebauter PowerShell-Befehl. Cmdlets heißen immer nach dem Muster
Verb-Substantiv, etwa Get-Service, und liefern Objekte statt Text.
siehe_auch: [Pipeline]
- begriff: Pipeline
erklaerung: >-
Die Verkettung von Befehlen mit dem senkrechten Strich. In PowerShell wandern
dabei Objekte von Befehl zu Befehl, die sich nach ihren Eigenschaften filtern,
sortieren und auswählen lassen.
siehe_auch: [Cmdlet]
# --- Woche 8: Datensicherung und Speicher ---------------------------------
- begriff: Vollsicherung
formen: [Vollsicherungen]
erklaerung: >-
Eine Sicherung aller ausgewählten Daten, unabhängig davon, ob sie sich
geändert haben. Grundlage für inkrementelle und differenzielle Sicherungen.
siehe_auch: [inkrementelle Sicherung, differenzielle Sicherung]
- begriff: inkrementelle Sicherung
formen: [inkrementellen Sicherung, inkrementellen Sicherungen, inkrementell]
erklaerung: >-
Sichert nur, was sich seit der letzten Sicherung gleich welcher Art geändert
hat. Schnell und platzsparend; zum Zurückspielen braucht man die
Vollsicherung und alle inkrementellen Sicherungen seitdem.
siehe_auch: [Vollsicherung, differenzielle Sicherung]
- begriff: differenzielle Sicherung
formen: [differenziellen Sicherung, differenziellen Sicherungen, differenziell]
erklaerung: >-
Sichert alles, was sich seit der letzten Vollsicherung geändert hat. Wächst
bis zur nächsten Vollsicherung; zum Zurückspielen genügen die Vollsicherung
und die letzte differenzielle Sicherung.
siehe_auch: [Vollsicherung, inkrementelle Sicherung]
- begriff: RPO
steht_fuer: Recovery Point Objective
erklaerung: >-
Der größte Datenverlust, den ein Bereich verkraftet, gemessen in Zeit. Ein
RPO von vier Stunden heißt: Es muss mindestens alle vier Stunden gesichert
werden.
siehe_auch: [RTO]
- begriff: RTO
steht_fuer: Recovery Time Objective
erklaerung: >-
Die größte Ausfallzeit, die ein Bereich verkraftet, bis nach einem Schaden
wieder gearbeitet werden kann. Ob sie eingehalten wird, zeigt nur ein
gemessener Wiederherstellungstest.
siehe_auch: [RPO]
- begriff: RAID
steht_fuer: Redundant Array of Independent Disks
erklaerung: >-
Ein Verbund mehrerer Festplatten, der je nach Stufe schneller ist oder den
Ausfall einzelner Platten übersteht. Ein RAID schützt nicht vor Löschen,
Verschlüsselung oder Beschädigung von Dateien – es ist keine Datensicherung.
- begriff: NAS
steht_fuer: Network Attached Storage
erklaerung: >-
Ein Speichergerät im Netzwerk, das Dateien über Freigaben bereitstellt, etwa
per SMB oder NFS.
siehe_auch: [SAN, RAID]
- begriff: SAN
steht_fuer: Storage Area Network
erklaerung: >-
Ein Speichernetz, das Servern Speicherblöcke bereitstellt, etwa per iSCSI.
Der Server sieht eine Festplatte und legt selbst ein Dateisystem darauf an.
siehe_auch: [NAS]
@@ -0,0 +1,69 @@
---
id: A4
titel: Rechte und Pflichten in der Ausbildung
block: arbeitsalltag
woche: 5
worum: Der Ausbildungsvertrag ist kein gewöhnlicher Arbeitsvertrag – das Berufsbildungsgesetz regelt, was beide Seiten einander schulden.
rahmenplan: [C1]
lernfeld: LF1
merksatz: Du schuldest das Lernen, der Betrieb schuldet das Ausbilden. Das Berichtsheft beweist beides.
karten:
- frage: Wie lang darf die Probezeit in der Ausbildung sein, und was gilt in dieser Zeit für die Kündigung?
antwort: Mindestens einen, höchstens vier Monate. In der Probezeit können beide Seiten jederzeit ohne Frist und ohne Angabe von Gründen kündigen – schriftlich.
- frage: Unter welchen Bedingungen kann ein Azubi nach der Probezeit kündigen?
antwort: Aus wichtigem Grund ohne Frist, oder mit vier Wochen Frist, wenn er die Ausbildung aufgeben oder einen anderen Beruf lernen will. Immer schriftlich und mit Angabe des Grundes.
- frage: Warum ist das Berichtsheft mehr als eine lästige Pflicht?
antwort: Ein ordnungsgemäß geführter Ausbildungsnachweis ist Voraussetzung für die Zulassung zur Abschlussprüfung. Ohne ihn keine Prüfung.
- frage: Aus welchen Teilen besteht die Abschlussprüfung zum Fachinformatiker Systemintegration?
antwort: Aus Teil 1 nach etwa anderthalb Jahren (Einrichten eines IT-gestützten Arbeitsplatzes, 20 %) und Teil 2 am Ende (Projektarbeit 50 %, zwei schriftliche Fachbereiche je 10 %, Wirtschafts- und Sozialkunde 10 %).
---
## Das Wesentliche
- **Das Berufsbildungsgesetz (BBiG) geht dem Vertrag vor.** Was dort zugunsten des Azubis
geregelt ist, lässt sich im Ausbildungsvertrag nicht wegverhandeln.
- **Deine Pflichten (§ 13 BBiG)** lassen sich in einem Satz zusammenfassen: dich bemühen,
den Beruf zu lernen. Dazu gehören die Berufsschule, die Weisungen im Rahmen der
Ausbildung, der pflegliche Umgang mit Geräten, Verschwiegenheit über Betriebs- und
Geschäftsgeheimnisse und das Führen des Ausbildungsnachweises.
- **Die Pflichten des Betriebs (§ 14 BBiG):** dir den Beruf planmäßig beibringen,
Ausbildungsmittel kostenlos stellen, dich zur Berufsschule freistellen, dir Zeit für das
Berichtsheft während der Arbeitszeit geben und es regelmäßig durchsehen. Und: dir nur
Aufgaben geben, die der Ausbildung dienen.
- **Berufsschule ist Arbeitszeit.** Ein Berufsschultag mit mehr als fünf Unterrichtsstunden
wird einmal pro Woche als voller Ausbildungstag angerechnet. Danach musst du nicht mehr in
den Betrieb.
- **Probezeit:** ein bis vier Monate. Danach kann der Betrieb nur noch aus wichtigem Grund
kündigen.
- **Vergütung, Urlaub und Arbeitszeit** stehen im Vertrag. Ob zusätzlich ein Tarifvertrag
gilt, hängt davon ab, ob der Betrieb tarifgebunden ist oder der Vertrag darauf verweist –
frag nach, statt zu raten.
- **Bei Problemen** sind Ausbilder, Betriebsrat bzw. Jugend- und Auszubildendenvertretung
und die Ausbildungsberatung der IHK die Ansprechpartner. In dieser Reihenfolge, aber nicht
zwingend.
## Übersicht
| Prüfungsteil | Zeitpunkt | Inhalt | Gewicht |
|---|---|---|---|
| Teil 1 | etwa nach 18 Monaten | Einrichten eines IT-gestützten Arbeitsplatzes (schriftlich, 90 Min.) | 20 % |
| Teil 2 | Ende der Ausbildung | Betriebliches Projekt der Systemintegration mit Dokumentation, Präsentation und Fachgespräch | 50 % |
| | | Konzeption und Administration von IT-Systemen (schriftlich) | 10 % |
| | | Analyse und Entwicklung von Netzwerken (schriftlich) | 10 % |
| | | Wirtschafts- und Sozialkunde (schriftlich) | 10 % |
Teil 1 zählt also schon zur Endnote – er ist keine Zwischenprüfung zum Üben.
## Prüfungsfrage
**Frage:** Ein Auszubildender wird nach der Probezeit regelmäßig zum Brötchenholen und
Autowaschen eingeteilt. Beurteilen Sie die Situation und nennen Sie zwei Wege, wie er
reagieren kann.
**Antwort:** Nach § 14 BBiG dürfen Auszubildenden nur Aufgaben übertragen werden, die dem
Ausbildungszweck dienen und ihren körperlichen Kräften angemessen sind. Regelmäßige
ausbildungsfremde Tätigkeiten sind damit unzulässig; gelegentliche Gefälligkeiten, die alle
im Team gleichermaßen übernehmen, sind dagegen kein Verstoß. Der Auszubildende kann
(1) das Gespräch mit dem Ausbilder suchen und die Tätigkeiten im Berichtsheft wahrheitsgemäß
eintragen, (2) sich an Betriebsrat oder Jugend- und Auszubildendenvertretung wenden oder
(3) die Ausbildungsberatung der zuständigen Kammer einschalten.
@@ -0,0 +1,59 @@
---
id: A5
titel: Den Betrieb verstehen – Aufbau, Abläufe, Ansprechpartner
block: arbeitsalltag
woche: 5
worum: Die IT ist für die anderen da. Wer nicht weiß, was eine Abteilung tut und wann es dort brennt, setzt die falschen Prioritäten.
rahmenplan: [C2, A1]
lernfeld: LF1
merksatz: Das Organigramm sagt, wer entscheidet. Die Abläufe sagen, wer leidet, wenn etwas ausfällt.
karten:
- frage: Was ist der Unterschied zwischen Aufbau- und Ablauforganisation?
antwort: Die Aufbauorganisation legt fest, wer wo steht – Abteilungen, Stellen, Weisungswege, sichtbar im Organigramm. Die Ablauforganisation legt fest, wie die Arbeit fließt – Reihenfolge, Zuständigkeiten und Übergaben in einem Prozess.
- frage: Warum ist die IT in den meisten Betrieben ein Unterstützungsprozess?
antwort: Weil sie nicht selbst das verkauft, womit der Betrieb sein Geld verdient, sondern die Kernprozesse – Fertigung, Vertrieb, Verwaltung – am Laufen hält. Ihre Kunden sind die anderen Abteilungen.
- frage: Ab wann kann in einem Betrieb eine Jugend- und Auszubildendenvertretung gewählt werden?
antwort: Wenn es einen Betriebsrat gibt und in der Regel mindestens fünf Beschäftigte unter 18 Jahren oder Auszubildende unter 25 Jahren dort arbeiten.
---
## Das Wesentliche
- **Aufbauorganisation** – wer wo steht. Das Organigramm zeigt Abteilungen, Stellen und
Weisungswege. Es beantwortet die Frage: Wer darf das entscheiden?
- **Ablauforganisation** – wie die Arbeit fließt. Ein Auftrag geht vom Vertrieb über die
Arbeitsvorbereitung in die Fertigung und zum Versand. Jede Übergabe ist eine Stelle, an
der IT gebraucht wird – und an der ein Ausfall auffällt.
- **Kernprozesse und Unterstützungsprozesse.** Kernprozesse bringen das Geld herein.
Die IT gehört fast immer zu den Unterstützungsprozessen: Ihre Kunden sitzen im selben Haus.
- **Linie, Stab, Matrix.** In der Linie hat jeder genau einen Vorgesetzten. Eine Stabsstelle
berät, entscheidet aber nicht (etwa der Datenschutz). In der Matrix gibt es zwei Richtungen
der Weisung, zum Beispiel Abteilung und Projekt.
- **Die Rechtsform** sagt, wer haftet und wer unterschreiben darf. In einer GmbH haftet die
Gesellschaft mit ihrem Vermögen; die Geschäftsführung vertritt sie nach außen.
- **Mitbestimmung:** Ab fünf ständig Beschäftigten kann ein Betriebsrat gewählt werden. Er
redet unter anderem mit, wenn Technik eingeführt wird, die Leistung oder Verhalten
überwachen kann – und das ist bei fast jeder neuen Software der Fall.
- **Für die IT zählt vor allem:** Wann wird wo gearbeitet, was darf wann nicht ausfallen, und
wer ist der Ansprechpartner, der für die Abteilung sprechen darf.
## Übersicht
| Frage an eine Abteilung | Warum die IT das wissen muss |
|---|---|
| Was ist euer wichtigstes Programm? | Bestimmt, was bei einer Störung zuerst wiederhergestellt wird |
| Wann darf nichts ausfallen? | Wartungsfenster legt man nicht auf den Monatsabschluss der Buchhaltung |
| Wer spricht für euch? | Änderungen werden mit einer Person abgestimmt, nicht mit allen |
| Mit wem tauscht ihr Daten aus? | Schnittstellen sind die häufigste Fehlerquelle nach Updates |
| Was nervt euch an der IT? | Die ehrlichste Liste für Verbesserungen, die es gibt |
## Prüfungsfrage
**Frage:** Die IT-Abteilung soll eine neue Software zur Zeiterfassung einführen. Welche
Stellen im Betrieb müssen Sie vor der Einführung einbeziehen, und warum?
**Antwort:** (1) Die Geschäftsführung bzw. die verantwortliche Führungskraft, weil sie über
Einführung und Budget entscheidet. (2) Den Betriebsrat, weil eine Zeiterfassung geeignet
ist, Verhalten oder Leistung zu überwachen; er hat dabei ein Mitbestimmungsrecht nach § 87
Abs. 1 Nr. 6 BetrVG. (3) Den Datenschutzbeauftragten, weil personenbezogene Daten verarbeitet
werden. (4) Die Personalabteilung bzw. Lohnbuchhaltung als fachlich verantwortliche Stelle,
die mit den Daten arbeitet und die Schnittstelle zur Abrechnung kennt.
@@ -0,0 +1,57 @@
---
id: A6
titel: Digital zusammenarbeiten – der richtige Kanal, der richtige Ort
block: arbeitsalltag
woche: 5
worum: Chat, E-Mail, Ticket, Wiki – jedes Werkzeug ist für etwas anderes gut. Wer alles überall bespricht, findet nichts wieder.
rahmenplan: [C5, A5]
lernfeld: LF1
merksatz: Aufträge ins Ticket, Wissen ins Wiki, Absprachen in den Chat – und was zählt, schriftlich.
karten:
- frage: Wohin gehört eine Absprache, die in drei Monaten noch nachvollziehbar sein muss – Chat, E-Mail oder Ticket?
antwort: Ins Ticket, wenn sie zu einem Auftrag gehört, sonst in die gemeinsame Dokumentation. Chats sind für schnelle Abstimmung gedacht und lassen sich später kaum noch durchsuchen.
- frage: Was gehört in ein Besprechungsprotokoll, damit es etwas nützt?
antwort: Die getroffenen Entscheidungen und die vereinbarten Aufgaben mit Verantwortlichem und Termin. Wer was gesagt hat, ist meist entbehrlich.
- frage: Warum schickt man einen Link auf die Datei statt die Datei als Anhang?
antwort: Weil sonst mehrere Fassungen entstehen und niemand mehr weiß, welche gilt. Der Link zeigt immer auf den aktuellen Stand und unterliegt den Zugriffsrechten der Ablage.
---
## Das Wesentliche
- **Den Kanal nach dem Zweck wählen, nicht nach der Gewohnheit.** Eine Frage, die in zwei
Minuten geklärt ist, gehört nicht in eine E-Mail mit fünf Empfängern – und eine Zusage an
einen Kunden nicht in den Chat.
- **Ein Ort der Wahrheit.** Jede Information hat genau einen Ablageort. Kopien per Mail,
auf dem Desktop oder im privaten Ordner sind am nächsten Tag veraltet.
- **Dateinamen, die sich sortieren:** `2026-10-05_Netzplan_Halle2_v3.pdf` statt
`Netzplan neu final(2).pdf`. Das Datum vorn in der Form JJJJ-MM-TT ordnet sich von selbst.
- **Besprechungen** haben eine Tagesordnung vorher und ein Protokoll danach. Im Protokoll
stehen Entscheidungen und Aufgaben – wer, was, bis wann.
- **Was nicht in den Chat gehört:** Passwörter, personenbezogene Daten von Kunden oder
Kollegen, Kundendaten aus Tickets. Chats werden mitgelesen, weitergeleitet und abfotografiert.
- **Erreichbarkeit ehrlich halten.** Status setzen, Abwesenheit eintragen, Vertretung nennen.
Wer nicht antwortet, ist für die anderen ein ungelöstes Ticket.
- **Videokonferenz:** Kamera an, wenn es um Abstimmung geht; Mikrofon aus, wenn man nicht
spricht; Bildschirm teilen nur mit geschlossenen privaten Fenstern.
## Übersicht
| Anliegen | Kanal | Warum |
|---|---|---|
| Störung oder Auftrag | Ticket | Nachvollziehbar, zuweisbar, messbar |
| Kurze Rückfrage an einen Kollegen | Chat | Schnell, wird nicht aufbewahrt |
| Zusage, Angebot, Absprache mit Externen | E-Mail | Nachweisbar und an einen Empfänger gerichtet |
| Eskalation, Konflikt, eilige Störung | Telefon oder persönlich | Tonfall und sofortige Rückfrage |
| Anleitung, Konfiguration, Netzplan | Wiki bzw. gemeinsame Dokumentation | Auffindbar für alle, auch in zwei Jahren |
## Prüfungsfrage
**Frage:** In Ihrem Team werden Konfigurationsänderungen bisher per Chat angekündigt und
dort besprochen. Bewerten Sie dieses Vorgehen und schlagen Sie eine Verbesserung vor.
**Antwort:** Der Chat eignet sich für die schnelle Abstimmung, nicht als Nachweis. Ankündigungen
gehen in anderen Nachrichten unter, sind später schwer auffindbar und enthalten weder
Begründung noch Rückfallplan in fester Form. Besser ist eine Änderungsanfrage im
Ticketsystem mit Beschreibung, Zeitpunkt, betroffenen Systemen, Rückfallplan und
Freigabe; der Chat dient dann nur noch als Hinweis mit Link auf das Ticket. Nach der Änderung
wird die Dokumentation im Wiki aktualisiert.
@@ -0,0 +1,60 @@
---
id: I1
titel: Schutzziele und Schutzbedarf – was ist wie viel wert?
block: sicherheit
woche: 6
worum: Nicht alles muss gleich gut geschützt werden. Die Schutzbedarfsfeststellung sagt, wo sich Aufwand lohnt – und begründet ihn.
rahmenplan: [A6]
lernfeld: LF4
merksatz: Erst fragen, was passiert, wenn es schiefgeht. Dann entscheiden, wie viel Schutz es braucht.
karten:
- frage: Welche drei Schutzziele kennt die Informationssicherheit, und was bedeuten sie?
antwort: Vertraulichkeit – nur Befugte sehen die Daten. Integrität – die Daten sind vollständig und unverfälscht. Verfügbarkeit – Daten und Systeme stehen bereit, wenn sie gebraucht werden.
- frage: Welche drei Schutzbedarfskategorien unterscheidet der IT-Grundschutz des BSI?
antwort: Normal – die Schadensauswirkungen sind begrenzt und überschaubar. Hoch – sie können beträchtlich sein. Sehr hoch – sie können existenzbedrohend oder katastrophal sein.
- frage: Was besagt das Maximumprinzip bei der Schutzbedarfsfeststellung?
antwort: Ein IT-System erbt den höchsten Schutzbedarf aller Anwendungen, die darauf laufen. Läuft eine Anwendung mit hohem Bedarf auf einem Server, hat der Server mindestens hohen Bedarf.
---
## Das Wesentliche
- **Drei Schutzziele:** Vertraulichkeit, Integrität, Verfügbarkeit. Jede Sicherheitsmaßnahme
dient mindestens einem davon. Oft kommt Authentizität hinzu: Man weiß sicher, von wem
etwas stammt.
- **Schutzbedarf ergibt sich aus dem möglichen Schaden**, nicht aus der Technik. Die Frage
lautet: Was passiert, wenn diese Information bekannt wird, verfälscht wird oder ausfällt?
- **Drei Kategorien:** normal, hoch, sehr hoch. Bewertet wird **je Schutzziel** – die
Lohnabrechnung hat einen sehr hohen Bedarf an Vertraulichkeit, aber nicht zwingend an
Verfügbarkeit in der Stunde.
- **Sechs Schadensszenarien** helfen beim Einordnen: Verstoß gegen Gesetze oder Verträge,
Verletzung von Persönlichkeitsrechten, Gefahr für Leib und Leben, Beeinträchtigung der
Aufgabenerfüllung, Ansehensverlust, finanzielle Folgen.
- **Von oben nach unten:** Erst die Geschäftsprozesse und Informationen bewerten, dann die
Anwendungen, dann die IT-Systeme, Räume und Verbindungen, auf denen sie liegen.
- **Vererbung:** Ein System übernimmt den höchsten Bedarf seiner Anwendungen
(Maximumprinzip). Viele kleine Schäden auf einem System können zusammen einen höheren
Bedarf ergeben (Kumulationseffekt). Ist eine Anwendung auf mehrere Systeme verteilt,
kann der Bedarf des einzelnen Systems niedriger liegen (Verteilungseffekt).
- **Jede Einstufung braucht eine Begründung.** „Hoch, weil wichtig" ist keine.
## Übersicht
| Kategorie | Bedeutung | Beispiel Verfügbarkeit |
|---|---|---|
| normal | begrenzte, überschaubare Auswirkungen | Ausfall des Intranets für einen Tag ist lästig, aber verkraftbar |
| hoch | beträchtliche Auswirkungen | Ausfall der Warenwirtschaft für einen Tag stoppt Versand und Rechnungen |
| sehr hoch | existenzbedrohende oder katastrophale Auswirkungen | Ausfall der Produktionssteuerung über Tage mit Vertragsstrafen |
## Prüfungsfrage
**Frage:** Auf einem Server laufen der Dateidienst der Verwaltung (Schutzbedarf
Vertraulichkeit: normal) und die Personaldatenbank (Vertraulichkeit: sehr hoch). Welchen
Schutzbedarf hat der Server hinsichtlich der Vertraulichkeit? Begründen Sie und nennen Sie
eine Alternative.
**Antwort:** Nach dem Maximumprinzip übernimmt der Server den höchsten Schutzbedarf der
darauf betriebenen Anwendungen, hier also „sehr hoch" für die Vertraulichkeit. Damit
müssen auch für den Dateidienst die strengeren Maßnahmen gelten – etwa beim Zugriff von
Administratoren und bei der Sicherung. Alternativ lässt sich die Personaldatenbank auf ein
eigenes System auslagern; dann kann der Dateiserver mit normalem Schutzbedarf betrieben
werden, und der hohe Aufwand beschränkt sich auf das System, das ihn wirklich braucht.
+61
View File
@@ -0,0 +1,61 @@
---
id: I2
titel: Konten schützen – Passwörter, zweiter Faktor, Phishing
block: sicherheit
woche: 6
worum: Die meisten Angriffe beginnen nicht mit einer Sicherheitslücke, sondern mit einem Konto, das sich jemand ausgeliehen hat.
rahmenplan: [A6, B3]
lernfeld: LF4
merksatz: Lang statt kompliziert, für jedes Konto ein eigenes, und wo es geht ein zweiter Faktor.
karten:
- frage: Was macht ein Passwort sicher – Länge oder Sonderzeichen?
antwort: Vor allem die Länge und dass es nirgends sonst verwendet wird. Eine lange Passphrase aus mehreren Wörtern ist stärker und besser zu merken als ein kurzes Passwort mit Sonderzeichen.
- frage: Aus welchen drei Arten von Faktoren kann eine Anmeldung bestehen?
antwort: Wissen (Passwort, PIN), Besitz (Smartphone mit App, Sicherheitsschlüssel, Chipkarte) und Sein (Fingerabdruck, Gesicht). Zwei Faktoren müssen aus verschiedenen Arten stammen.
- frage: Warum arbeiten Administratoren im Alltag nicht mit ihrem Administratorkonto?
antwort: Weil jede Schadsoftware, die sie versehentlich starten, sonst mit vollen Rechten läuft. Für den Alltag dient ein normales Konto, das Administratorkonto nur für die Aufgabe, die es braucht.
---
## Das Wesentliche
- **Länge schlägt Komplexität.** Eine Passphrase wie `Kaffee-Lager-Gabelstapler-Montag` ist
stärker als `K4ff33!` und lässt sich merken.
- **Für jedes Konto ein eigenes Passwort.** Wird ein Dienst gehackt, probieren Angreifer die
erbeuteten Zugangsdaten überall sonst aus. Ein Passwortmanager macht das handhabbar.
- **Kein Wechsel ohne Anlass.** Regelmäßiger Zwangswechsel führt zu `Sommer2026!`,
`Herbst2026!`. Gewechselt wird, wenn ein Verdacht besteht – dann aber sofort.
- **Zweiter Faktor:** Ein gestohlenes Passwort allein reicht dann nicht mehr. Pflicht für
E-Mail, Fernzugang und alle Administratorkonten.
- **Getrennte Konten für Verwaltungsaufgaben.** Surfen und Mails lesen mit dem normalen
Konto, administrieren mit dem Administratorkonto – und nur dafür.
- **Phishing erkennen:** Zeitdruck, ungewöhnlicher Absender, Link-Ziel passt nicht zum
Absender, Aufforderung zur Anmeldung oder Zahlung. Vor dem Klick auf den Link zeigen und
die Adresse lesen.
- **Wer geklickt hat, meldet es sofort.** Nicht schämen, nicht löschen, nicht abwarten. Je
früher die IT Bescheid weiß, desto kleiner der Schaden.
- **Bildschirm sperren**, sobald man den Platz verlässt: Windows-Taste + L.
## Übersicht
| Merkmal einer Phishing-Mail | Beispiel |
|---|---|
| Druck und Dringlichkeit | „Ihr Konto wird in 24 Stunden gesperrt" |
| Absender passt nicht | `support@micros0ft-konto.net` |
| Link-Ziel weicht ab | Text zeigt `microsoft.com`, Ziel ist eine fremde Adresse |
| Ungewöhnliche Bitte | Gutscheine kaufen, Kontodaten ändern, Anhang „aktivieren" |
| Allgemeine Anrede | „Sehr geehrter Kunde" statt des Namens |
## Prüfungsfrage
**Frage:** Eine Mitarbeiterin meldet, dass sie auf einen Link in einer verdächtigen Mail
geklickt und dort ihr Passwort eingegeben hat. Beschreiben Sie Ihr Vorgehen in der
richtigen Reihenfolge.
**Antwort:** (1) Sofort das Passwort des betroffenen Kontos ändern und bestehende
Anmeldesitzungen beenden, damit der Angreifer den Zugang nicht mehr nutzen kann. (2) Prüfen,
ob der zweite Faktor aktiv ist und ob seit dem Klick ungewöhnliche Anmeldungen,
Weiterleitungsregeln oder versendete Mails auftreten. (3) Die Mail sichern und andere
Empfänger warnen bzw. die Mail aus deren Postfächern entfernen. (4) Den Rechner auf
Schadsoftware prüfen, falls zusätzlich etwas heruntergeladen wurde. (5) Den Vorfall
dokumentieren und an die Verantwortlichen für Informationssicherheit und Datenschutz
melden – bei Zugriff auf personenbezogene Daten kann eine Meldepflicht bestehen.
@@ -0,0 +1,64 @@
---
id: I3
titel: Datenschutz in der IT – was du sehen kannst und was du anschauen darfst
block: sicherheit
woche: 6
worum: In der IT hat man Zugriff auf fast alles. Die DSGVO regelt, was man damit tun darf – und das ist deutlich weniger.
rahmenplan: [A6, C1]
lernfeld: LF4
merksatz: Zugriff ist kein Anlass. Hineingeschaut wird nur, wenn der Auftrag es verlangt.
karten:
- frage: Was sind personenbezogene Daten?
antwort: Alle Angaben, die sich auf eine bestimmte oder bestimmbare Person beziehen – Name, E-Mail-Adresse, Personalnummer, IP-Adresse, Standortdaten, aber auch ein Foto oder eine Stimmaufnahme.
- frage: Innerhalb welcher Frist muss eine Datenschutzverletzung der Aufsichtsbehörde gemeldet werden?
antwort: Innerhalb von 72 Stunden, nachdem sie bekannt geworden ist – es sei denn, sie führt voraussichtlich zu keinem Risiko für die Betroffenen. Melden muss der Betrieb; du meldest intern sofort.
- frage: Was bedeutet Datenminimierung?
antwort: Es werden nur die Daten erhoben und gespeichert, die für den Zweck wirklich nötig sind – und gelöscht, sobald der Zweck erfüllt ist.
---
## Das Wesentliche
- **Die DSGVO schützt Personen, nicht Daten.** Personenbezogen ist alles, was sich einer
bestimmten Person zuordnen lässt – auch eine IP-Adresse oder ein Kennzeichen.
- **Besonders schützenswert** sind Gesundheitsdaten, religiöse und politische
Überzeugungen, Gewerkschaftszugehörigkeit, biometrische Daten und weitere. In der IT
begegnen sie dir in Krankmeldungen, Personalakten und Zutrittssystemen.
- **Die Grundsätze:** rechtmäßig, zweckgebunden, so wenig wie nötig, richtig, nicht länger als
nötig gespeichert, sicher verarbeitet. Und der Betrieb muss nachweisen können, dass er sich
daran hält.
- **Technische und organisatorische Maßnahmen (TOM):** Zugriffsrechte, Verschlüsselung,
Protokollierung, Datensicherung, Schulungen. Ein großer Teil davon ist IT-Arbeit.
- **Als Administrator** siehst du Postfächer, Personalordner und Protokolle. Hineinschauen
darfst du nur, wenn ein Auftrag es erfordert – und dann nur so weit wie nötig. Was du
dabei siehst, bleibt bei dir.
- **Eine Datenpanne** – verlorenes Notebook, falsch versendete Mail, offener Ordner – meldest
du sofort intern. Der Betrieb hat 72 Stunden für die Meldung an die Aufsichtsbehörde, und
die Uhr läuft ab dem Moment, in dem jemand im Betrieb davon weiß.
- **Der Datenschutzbeauftragte** berät und kontrolliert. Er ist Pflicht, sobald in der Regel
mindestens 20 Personen ständig mit der automatisierten Verarbeitung personenbezogener
Daten beschäftigt sind.
## Übersicht
| Situation | Richtig |
|---|---|
| Ein Kollege bittet dich, „mal kurz" ins Postfach seiner Chefin zu schauen | Ablehnen – Zugriff nur mit Auftrag der berechtigten Stelle |
| Ein Notebook ist im Zug liegen geblieben | Sofort melden, Konto sperren, Verschlüsselung prüfen |
| Du siehst beim Support eine Krankmeldung auf dem Bildschirm | Nicht lesen, nicht erwähnen, Fenster schließen lassen |
| Alte Festplatten aus der Personalabteilung | Nachweisbar sicher löschen oder vernichten, Protokoll anlegen |
| Testdaten für eine neue Software gesucht | Keine echten Personaldaten, sondern erfundene oder unkenntlich gemachte |
## Prüfungsfrage
**Frage:** Ein Außendienstmitarbeiter verliert ein Notebook mit Kundendaten. Die Festplatte
ist mit BitLocker verschlüsselt. Muss der Vorfall der Aufsichtsbehörde gemeldet werden?
Begründen Sie.
**Antwort:** Grundsätzlich ist der Verlust eines Geräts mit personenbezogenen Daten eine
Verletzung des Schutzes dieser Daten und binnen 72 Stunden zu melden. Eine Meldung kann
entfallen, wenn die Verletzung voraussichtlich nicht zu einem Risiko für die Betroffenen
führt. Das ist bei einer wirksamen, dem Stand der Technik entsprechenden Verschlüsselung
regelmäßig der Fall – vorausgesetzt, das Gerät war ausgeschaltet oder gesperrt und der
Schlüssel ist nicht mit verloren gegangen. Die Entscheidung trifft der Verantwortliche mit
dem Datenschutzbeauftragten; der Vorfall wird in jedem Fall intern dokumentiert, und das
Konto des Mitarbeiters wird vorsorglich gesperrt bzw. das Passwort geändert.
@@ -0,0 +1,74 @@
---
id: P1
titel: PowerShell – Befehle, Pipeline und Objekte
block: programmierung
woche: 7
worum: PowerShell reicht keine Texte weiter, sondern Objekte mit Eigenschaften. Wer das verstanden hat, braucht kein Textschnipseln mehr.
rahmenplan: [A10, B3]
lernfeld: LF5
merksatz: Verb-Substantiv finden, Get-Member fragen, filtern, sortieren, auswählen – formatiert wird erst ganz am Ende.
karten:
- frage: Wie sind die Befehle (Cmdlets) in PowerShell benannt, und wie findet man einen unbekannten Befehl?
antwort: Nach dem Muster Verb-Substantiv, etwa Get-Service oder Stop-Process. Get-Command mit einem Suchbegriff findet Befehle, Get-Help erklärt sie mit Beispielen.
- frage: Was unterscheidet die Pipeline in PowerShell von der in einer klassischen Linux-Shell?
antwort: In PowerShell wandern Objekte mit Eigenschaften durch die Pipeline, nicht Textzeilen. Man filtert nach Eigenschaften wie Status oder Name, statt Spalten aus Text herauszuschneiden.
- frage: Wozu dient Get-Member?
antwort: Es zeigt, welche Eigenschaften und Methoden die Objekte haben, die ein Befehl liefert – also womit man in der Pipeline weiterarbeiten kann.
---
## Das Wesentliche
- **Verb-Substantiv:** `Get-Service`, `Get-Process`, `Get-ChildItem`, `Stop-Service`. Die
Verben sind festgelegt – `Get` liest, `Set` ändert, `New` legt an, `Remove` löscht.
- **Selbst finden statt googeln:** `Get-Command *service*` findet Befehle,
`Get-Help Get-Service -Examples` zeigt Beispiele. Einmal `Update-Help` als Administrator.
- **Objekte statt Text.** `Get-Service` liefert Dienstobjekte mit Eigenschaften wie `Name`,
`Status` und `StartType`. Welche es gibt, verrät `Get-Service | Get-Member`.
- **Die vier Werkzeuge der Pipeline:**
- `Where-Object` filtert: `Where-Object Status -eq 'Stopped'`
- `Sort-Object` sortiert: `Sort-Object Name`
- `Select-Object` wählt Eigenschaften oder Anzahl: `Select-Object Name, Status -First 10`
- `ForEach-Object` tut etwas mit jedem Objekt
- **Vergleiche** heißen `-eq`, `-ne`, `-gt`, `-lt`, `-like` (mit `*` als Platzhalter) und
`-match` (regulärer Ausdruck). Groß- und Kleinschreibung spielt dabei keine Rolle.
- **Ausgeben und weitergeben:** `Export-Csv` schreibt eine Tabelle für Excel,
`Out-File` eine Textdatei. `Format-Table` gehört nur ans Ende – danach sind es keine
verwertbaren Objekte mehr.
- **Erst lesen, dann ändern.** Jeder Befehl, der etwas verändert, kennt `-WhatIf`: Er sagt,
was er tun würde, und tut es nicht.
## Übersicht
```powershell
# Alle beendeten Dienste, die automatisch starten sollten
Get-Service |
Where-Object { $_.Status -eq 'Stopped' -and $_.StartType -eq 'Automatic' } |
Sort-Object Name |
Select-Object Name, DisplayName
# Die fünf Prozesse mit dem höchsten Speicherverbrauch
Get-Process | Sort-Object WorkingSet -Descending | Select-Object -First 5 Name, Id, WorkingSet
# Dateien im Downloadordner, älter als 90 Tage, als Tabelle für Excel
Get-ChildItem $HOME\Downloads -File |
Where-Object LastWriteTime -lt (Get-Date).AddDays(-90) |
Select-Object Name, Length, LastWriteTime |
Export-Csv alte-downloads.csv -NoTypeInformation -Encoding UTF8
```
`$_` steht innerhalb der geschweiften Klammern für das Objekt, das gerade durch die Pipeline
kommt.
## Prüfungsfrage
**Frage:** Schreiben Sie eine PowerShell-Befehlszeile, die alle laufenden Dienste
alphabetisch sortiert mit Name und Anzeigename in die Datei `dienste.csv` schreibt, und
erklären Sie jeden Teil.
**Antwort:**
`Get-Service | Where-Object Status -eq 'Running' | Sort-Object Name | Select-Object Name, DisplayName | Export-Csv dienste.csv -NoTypeInformation`
– `Get-Service` liefert alle Dienste als Objekte. `Where-Object` lässt nur die durch, deren
Eigenschaft `Status` den Wert `Running` hat. `Sort-Object Name` sortiert nach dem Namen.
`Select-Object` behält nur die beiden gewünschten Eigenschaften. `Export-Csv` schreibt das
Ergebnis als Tabelle; `-NoTypeInformation` unterdrückt die Typzeile, die ältere
PowerShell-Fassungen sonst an den Anfang setzen.
@@ -0,0 +1,81 @@
---
id: P2
titel: Variablen, Bedingungen und Schleifen
block: programmierung
woche: 7
worum: Drei Bausteine reichen für fast jedes Verwaltungsskript – sich etwas merken, eine Entscheidung treffen, etwas wiederholen.
rahmenplan: [A10, A4]
lernfeld: LF5
merksatz: Merken mit $, entscheiden mit if, wiederholen mit foreach. Der Rest ist Übung.
karten:
- frage: Was ist der Unterschied zwischen "Hallo $name" und 'Hallo $name' in PowerShell?
antwort: In doppelten Anführungszeichen wird die Variable durch ihren Wert ersetzt, also etwa „Hallo Anna". In einfachen Anführungszeichen bleibt der Text genau so stehen, wie er geschrieben ist.
- frage: Was gibt dieses Skript aus – $summe = 0; foreach ($z in 1..5) { if ($z % 2 -eq 0) { $summe += $z } }; $summe
antwort: 6. Die Schleife läuft über 1 bis 5, die Bedingung lässt nur gerade Zahlen durch (Rest bei Division durch 2 ist 0), also werden 2 und 4 addiert.
- frage: Wann nimmt man foreach als Schleife und wann ForEach-Object?
antwort: foreach für eine Liste, die schon in einer Variablen liegt – übersichtlich und schnell. ForEach-Object mitten in einer Pipeline, wenn die Objekte direkt aus einem Befehl kommen.
---
## Das Wesentliche
- **Variablen** beginnen mit `$`: `$pfad = 'D:\Daten'`. Sie können alles aufnehmen – Text,
Zahlen, Listen, ganze Objekte aus einem Befehl: `$dienste = Get-Service`.
- **Text:** In `"…"` werden Variablen eingesetzt, in `'…'` nicht. Für Eigenschaften im Text:
`"Freier Platz: $($laufwerk.FreeSpace)"`.
- **Listen** entstehen mit `@('PC-001', 'PC-002')` oder als Bereich `1..10`. Das erste Element
ist `$liste[0]`, die Anzahl `$liste.Count`.
- **Eigene Objekte** für Berichte: `[PSCustomObject]@{ Name = $n; Status = $s }`. Daraus
wird mit `Export-Csv` eine saubere Tabelle.
- **Bedingungen:** `if (…) { … } elseif (…) { … } else { … }`. In der Klammer steht ein
Vergleich mit `-eq`, `-gt`, `-like` usw. – **nicht** mit `=`, das ist eine Zuweisung.
- **Verknüpfen** mit `-and`, `-or`, `-not`.
- **Schleifen:** `foreach ($pc in $rechner) { … }` geht jedes Element durch.
`while (…) { … }` läuft, solange die Bedingung gilt – und braucht deshalb etwas, das sie
irgendwann falsch werden lässt.
- **Rechnen:** `+ - * /` und `%` für den Rest einer Division. `1GB` ist für PowerShell eine
Zahl, `$bytes / 1GB` rechnet also direkt in Gigabyte um.
## Übersicht
```powershell
$grenze = 15 # Prozent freier Platz, ab dem gewarnt wird
$laufwerke = Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType = 3'
foreach ($lw in $laufwerke) {
$frei = [math]::Round($lw.FreeSpace / $lw.Size * 100, 1)
if ($frei -lt $grenze) {
Write-Warning "$($lw.DeviceID) hat nur noch $frei % frei."
}
else {
Write-Output "$($lw.DeviceID): $frei % frei – in Ordnung."
}
}
```
`DriveType = 3` sind lokale Festplatten; USB-Sticks (2) und Netzlaufwerke (4) bleiben außen vor.
## Prüfungsfrage
**Frage:** Schreiben Sie ein kurzes PowerShell-Skript, das für eine Liste von drei
Rechnernamen prüft, ob sie per Ping erreichbar sind, und für jeden Rechner „erreichbar" oder
„nicht erreichbar" ausgibt.
**Antwort:**
```powershell
$rechner = @('PC-001', 'PC-002', 'PC-003')
foreach ($pc in $rechner) {
if (Test-Connection -ComputerName $pc -Count 1 -Quiet) {
Write-Output "$pc erreichbar"
}
else {
Write-Output "$pc nicht erreichbar"
}
}
```
Die Liste wird in einer Variablen gehalten, `foreach` geht jeden Namen durch.
`Test-Connection -Quiet` liefert statt einer ausführlichen Ausgabe nur wahr oder falsch,
was die `if`-Bedingung direkt auswertet.
@@ -0,0 +1,86 @@
---
id: P3
titel: Ein Skript schreiben, das man in einem Jahr noch versteht
block: programmierung
woche: 7
worum: Ein Skript, das nur sein Verfasser bedienen kann, ist eine Abhängigkeit. Ein gutes Skript erklärt sich, prüft seine Eingaben und meldet Fehler, statt sie zu verschweigen.
rahmenplan: [A10, A4, A5]
lernfeld: LF5
merksatz: Sagen, was es tut. Prüfen, was hineinkommt. Melden, was schiefgeht. Erst mit -WhatIf laufen lassen.
karten:
- frage: Wozu dient ein param-Block am Anfang eines Skripts?
antwort: Er legt fest, welche Eingaben das Skript erwartet, mit Typ und ob sie Pflicht sind. Werte stehen dann nicht fest im Code, und PowerShell fragt fehlende Pflichtangaben selbst ab.
- frage: Warum fängt ein try-catch-Block manche Fehler nicht ab, und wie behebt man das?
antwort: Viele Cmdlets melden Fehler nur und laufen weiter (nicht abbrechende Fehler). Mit -ErrorAction Stop am Befehl wird daraus ein abbrechender Fehler, den catch auffängt.
- frage: Was bewirkt die Ausführungsrichtlinie RemoteSigned?
antwort: Selbst geschriebene, lokale Skripte laufen; aus dem Internet heruntergeladene Skripte nur, wenn sie signiert sind. Sie schützt vor versehentlichem Ausführen, ist aber keine Sicherheitsgrenze.
---
## Das Wesentliche
- **Ein Kopf mit Hilfe:** Ein Block `<# .SYNOPSIS … .EXAMPLE … #>` ganz oben macht das
Skript für `Get-Help .\skript.ps1` lesbar. Darin steht, was es tut, was es braucht und ein
Beispielaufruf.
- **Eingaben als Parameter**, nicht fest im Code: Pfade, Rechnernamen und Grenzwerte gehören
in den `param()`-Block. `[Parameter(Mandatory)]` macht eine Angabe zur Pflicht.
- **Namen, die etwas sagen:** `$freierPlatzProzent` statt `$x`. Kommentare erklären das
**Warum**, nicht das Was – das Was steht im Code.
- **Fehler nicht verschlucken.** `try { … -ErrorAction Stop } catch { … }` fängt Fehler auf
und meldet sie verständlich. Ein leerer `catch`-Block ist schlimmer als gar keiner.
- **Erst trocken, dann echt.** Mit `[CmdletBinding(SupportsShouldProcess)]` bekommt das eigene
Skript `-WhatIf`. Ein Skript, das löscht, ändert oder verschiebt, läuft zuerst so.
- **Klein anfangen und testen.** Erst an einem Testrechner, dann an einem echten, dann an
allen. Jede Stufe mit Blick auf die Ausgabe.
- **Versionieren.** Skripte gehören in ein Git-Repository der IT, nicht auf den Desktop. So
sieht man, wer wann was geändert hat, und kommt zur letzten funktionierenden Fassung zurück.
- **Ausführungsrichtlinie:** Auf Clients ist meist `RemoteSigned` eingestellt. Sie verhindert
versehentliches Ausführen fremder Skripte, ist aber keine Sicherheitsgrenze.
## Übersicht
```powershell
<#
.SYNOPSIS
Löscht Protokolldateien, die älter als eine bestimmte Anzahl Tage sind.
.EXAMPLE
.\Protokolle-Aufraeumen.ps1 -Pfad D:\Logs -Tage 30 -WhatIf
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$Pfad,
[int]$Tage = 30
)
if (-not (Test-Path $Pfad)) {
throw "Den Pfad $Pfad gibt es nicht."
}
$stichtag = (Get-Date).AddDays(-$Tage)
$alt = Get-ChildItem $Pfad -Filter *.log -File | Where-Object LastWriteTime -lt $stichtag
foreach ($datei in $alt) {
if ($PSCmdlet.ShouldProcess($datei.FullName, 'Löschen')) {
try {
Remove-Item $datei.FullName -ErrorAction Stop
}
catch {
Write-Warning "Konnte $($datei.Name) nicht löschen: $($_.Exception.Message)"
}
}
}
```
## Prüfungsfrage
**Frage:** Ein Kollege hat ein Skript geschrieben, das auf allen Rechnern temporäre Dateien
löscht. Pfad und Rechnerliste stehen fest im Code, Fehler werden nicht behandelt. Nennen Sie
vier Verbesserungen und begründen Sie sie.
**Antwort:** (1) Pfad und Rechnerliste als Parameter übergeben – das Skript lässt sich dann
für andere Fälle nutzen, ohne den Code zu ändern. (2) `-WhatIf` unterstützen und das Skript
zuerst trocken laufen lassen – bei einem löschenden Skript lässt sich so vorab prüfen, was
betroffen wäre. (3) Fehlerbehandlung mit `try`/`catch` und `-ErrorAction Stop` – damit nicht
erreichbare Rechner oder gesperrte Dateien gemeldet und nicht stillschweigend übergangen
werden. (4) Einen Hilfeblock mit Beschreibung und Beispiel ergänzen und das Skript im
Repository der IT versionieren – so kann es auch jemand anderes betreuen. Ebenfalls richtig:
Eingaben prüfen, z. B. mit `Test-Path`, und ein Protokoll der gelöschten Dateien schreiben.
@@ -0,0 +1,63 @@
---
id: S3
titel: Datensicherung planen – voll, differenziell, inkrementell
block: system
woche: 8
worum: Eine Sicherung ist erst dann geplant, wenn feststeht, wie viel Datenverlust und wie viel Ausfallzeit der Betrieb verkraftet.
rahmenplan: [A9, A6, A8]
lernfeld: LF4
merksatz: Drei Kopien, zwei Medien, eine außer Haus – und eine, an die kein Angreifer herankommt.
karten:
- frage: Was ist der Unterschied zwischen einer inkrementellen und einer differenziellen Sicherung?
antwort: Die inkrementelle sichert alles, was sich seit der letzten Sicherung gleich welcher Art geändert hat. Die differenzielle sichert alles seit der letzten Vollsicherung – sie wächst also jeden Tag, lässt sich aber schneller zurückspielen.
- frage: Was braucht man, um am Donnerstag aus einer Vollsicherung vom Sonntag und täglichen inkrementellen Sicherungen wiederherzustellen?
antwort: Die Vollsicherung vom Sonntag und alle inkrementellen Sicherungen von Montag bis Donnerstag, in dieser Reihenfolge. Bei differenziellen Sicherungen genügen die Vollsicherung und die vom Donnerstag.
- frage: Was bedeuten RPO und RTO?
antwort: RPO (Recovery Point Objective) ist der größte hinnehmbare Datenverlust, gemessen in Zeit – er bestimmt, wie oft gesichert wird. RTO (Recovery Time Objective) ist die größte hinnehmbare Ausfallzeit bis zur Wiederherstellung.
- frage: Warum ist ein RAID keine Datensicherung?
antwort: Ein RAID schützt nur vor dem Ausfall einer Festplatte. Gelöschte, verschlüsselte oder beschädigte Dateien sind auf allen Platten gleichzeitig weg – das RAID spiegelt den Schaden mit.
---
## Das Wesentliche
- **Erst die Anforderungen, dann die Technik.** Wie viel Arbeit darf verloren gehen (RPO)?
Wie lange darf es dauern, bis wieder gearbeitet werden kann (RTO)? Die Antworten kommen
aus der Schutzbedarfsfeststellung, nicht aus dem Datenblatt der Software.
- **Vollsicherung:** alles, jedes Mal. Einfach zurückzuspielen, aber groß und langsam.
- **Inkrementell:** nur das Geänderte seit der letzten Sicherung. Klein und schnell beim
Sichern, beim Zurückspielen braucht man die ganze Kette.
- **Differenziell:** das Geänderte seit der letzten Vollsicherung. Wächst bis zur nächsten
Vollsicherung, zum Zurückspielen reichen zwei Sicherungen.
- **3-2-1-Regel:** drei Kopien der Daten, auf zwei verschiedenen Medien, eine davon außer
Haus. Gegen Ransomware kommt eine Kopie hinzu, die offline oder unveränderlich ist – sonst
verschlüsselt der Angreifer die Sicherung gleich mit.
- **Generationen:** Tägliche Sicherungen werden einige Tage aufbewahrt, wöchentliche einige
Wochen, monatliche länger (Großvater-Vater-Sohn). So findet man auch einen Fehler, der erst
nach drei Wochen auffällt.
- **Keine Sicherung:** ein RAID, eine Synchronisation in die Cloud, ein Papierkorb. Alle drei
übernehmen eine Löschung oder Verschlüsselung sofort.
- **Sicherungen verschlüsseln**, wenn sie den Betrieb verlassen – und den Schlüssel so
ablegen, dass man ihn im Notfall findet, ohne die Sicherung zu brauchen.
## Übersicht
| Art | Sichert | Zurückspielen braucht | Platzbedarf |
|---|---|---|---|
| Voll | alles | eine Sicherung | hoch |
| Differenziell | Änderungen seit der letzten Vollsicherung | Vollsicherung + letzte differenzielle | mittel, wächst täglich |
| Inkrementell | Änderungen seit der letzten Sicherung | Vollsicherung + alle inkrementellen seitdem | gering |
## Prüfungsfrage
**Frage:** Die Buchhaltung verkraftet höchstens vier Stunden Datenverlust und muss nach
einem Ausfall am selben Arbeitstag wieder arbeiten können. Entwerfen Sie ein passendes
Sicherungskonzept und begründen Sie es.
**Antwort:** Das RPO von vier Stunden verlangt mindestens alle vier Stunden während der
Arbeitszeit eine Sicherung, etwa inkrementell um 10, 14 und 18 Uhr, ergänzt durch eine
nächtliche differenzielle und eine wöchentliche Vollsicherung. Das RTO von einem halben
Arbeitstag erfordert, dass die Sicherung schnell erreichbar ist – also eine Kopie auf einem
Sicherungsspeicher im Haus. Nach der 3-2-1-Regel kommt eine Kopie außer Haus hinzu, eine
davon offline oder unveränderlich gegen Ransomware. Aufbewahrt wird nach Generationen
(z. B. 14 Tage täglich, 8 Wochen wöchentlich, 12 Monate monatlich). Das Konzept gilt erst als
umgesetzt, wenn eine Wiederherstellung getestet und die benötigte Zeit gemessen wurde.
@@ -0,0 +1,61 @@
---
id: S4
titel: Wiederherstellen und testen – die Sicherung, die zählt
block: system
woche: 8
worum: Niemand braucht eine Sicherung. Alle brauchen eine Wiederherstellung. Ob sie gelingt, weiß man nur, wenn man sie geübt hat.
rahmenplan: [A9, A5, A8]
lernfeld: LF4
merksatz: Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung – sondern eine Hoffnung.
karten:
- frage: Woran erkennt man, ob ein Wiederherstellungstest bestanden ist?
antwort: Die Daten sind vollständig, lesbar und auf dem erwarteten Stand, die Anwendung arbeitet damit, und die gemessene Dauer liegt innerhalb des vereinbarten RTO.
- frage: Warum gehört der Wiederherstellungsplan auch auf Papier?
antwort: Weil er genau dann gebraucht wird, wenn Server, Wiki und Passwortmanager vielleicht nicht erreichbar sind. Ein Plan, der nur auf dem ausgefallenen System liegt, hilft niemandem.
- frage: In welcher Reihenfolge stellt man nach einem Totalausfall die Systeme wieder her?
antwort: Nach Abhängigkeiten und Schutzbedarf – zuerst die Grundlagen wie Netz, Verzeichnisdienst und Namensauflösung, dann die Systeme mit dem höchsten Verfügbarkeitsbedarf, zuletzt der Rest.
---
## Das Wesentliche
- **Regelmäßig testen**, nicht erst im Ernstfall. Eine einzelne Datei jeden Monat, eine
ganze Anwendung oder ein ganzer Server mindestens einmal im Jahr und nach jeder größeren
Änderung an der Sicherung.
- **Auf eine Testumgebung zurückspielen**, nicht über das laufende System. Sonst wird aus
dem Test der Ernstfall.
- **Messen, nicht schätzen.** Wie lange hat das Zurückspielen gedauert – einschließlich
Suchen, Anfordern des Mediums und Prüfen? Das ist die Zahl, die man mit dem RTO vergleicht.
- **Das Ergebnis prüfen:** Öffnet sich die Datei, startet die Anwendung, stimmt der Stand?
Eine fehlerfrei abgeschlossene Wiederherstellung mit leeren Dateien ist ein
fehlgeschlagener Test.
- **Protokollieren:** Datum, was zurückgespielt wurde, von welcher Sicherung, Dauer,
Ergebnis, Auffälligkeiten. Das Protokoll ist der Nachweis gegenüber Geschäftsführung,
Prüfern und Versicherung.
- **Der Wiederherstellungsplan** nennt Reihenfolge, Zuständige, Zugangsdaten zum
Sicherungssystem und den Ort der Schlüssel. Er liegt ausgedruckt an einem bekannten Ort.
- **Reihenfolge nach Abhängigkeiten:** Ohne Netz, Verzeichnisdienst und Namensauflösung
läuft keine Fachanwendung, egal wie wichtig sie ist.
## Übersicht
| Test | Wie oft | Zeigt |
|---|---|---|
| Einzelne Datei oder E-Mail | monatlich | Die Sicherung läuft und ist lesbar |
| Ganzer Ordner mit Berechtigungen | vierteljährlich | Rechte kommen mit zurück |
| Ganze Anwendung samt Datenbank | halbjährlich | Die Anwendung arbeitet mit den gesicherten Daten |
| Ganzer Server bzw. virtuelle Maschine | jährlich | Das RTO ist einzuhalten |
## Prüfungsfrage
**Frage:** Die Sicherungssoftware meldet seit Monaten „erfolgreich". Warum genügt das nicht
als Nachweis, und wie gehen Sie bei einem Wiederherstellungstest vor?
**Antwort:** Die Meldung bestätigt nur, dass der Sicherungsvorgang ohne Fehler beendet
wurde – nicht, dass die richtigen Daten gesichert wurden, dass sie lesbar sind und dass sie
sich in der geforderten Zeit zurückspielen lassen. Vorgehen: (1) Umfang und Ziel des Tests
festlegen, etwa eine Anwendung samt Datenbank auf einen Testserver. (2) Die Wiederherstellung
durchführen und die Zeit vom Beginn bis zur Arbeitsfähigkeit messen. (3) Das Ergebnis
inhaltlich prüfen – Daten vollständig, Anwendung startet, Stand entspricht dem
Sicherungszeitpunkt. (4) Das Ergebnis mit RPO und RTO vergleichen, protokollieren und
Abweichungen als Maßnahme erfassen. (5) Die Testumgebung anschließend bereinigen, weil sie
echte Daten enthält.
+62
View File
@@ -0,0 +1,62 @@
---
id: S5
titel: RAID, NAS und SAN – Speicher, der einen Ausfall übersteht
block: system
woche: 8
worum: Festplatten fallen aus, das ist sicher. Ein RAID sorgt dafür, dass man es erst an der Warnmeldung merkt und nicht am Stillstand.
rahmenplan: [A9, B1, A3]
lernfeld: LF2
merksatz: RAID gegen Plattenausfall, Sicherung gegen alles andere. Das eine ersetzt das andere nie.
karten:
- frage: Wie viel Speicher ist nutzbar, und wie viele Platten dürfen ausfallen – bei RAID 1, RAID 5 und RAID 6?
antwort: RAID 1 – die Hälfte, eine Platte je Spiegel. RAID 5 – alle Platten bis auf eine, eine darf ausfallen. RAID 6 – alle Platten bis auf zwei, zwei dürfen ausfallen.
- frage: Ein RAID 5 aus fünf Platten mit je 4 TB – wie viel ist nutzbar?
antwort: 16 TB. Eine Platte entfällt auf die Paritätsinformation, also (5 − 1) × 4 TB.
- frage: Worin unterscheiden sich NAS und SAN?
antwort: Ein NAS stellt Dateien über das Netzwerk bereit, etwa per SMB oder NFS – die Clients sehen Freigaben. Ein SAN stellt Speicherblöcke bereit, etwa per iSCSI oder Fibre Channel – der Server sieht eine Festplatte und legt selbst ein Dateisystem an.
---
## Das Wesentliche
- **RAID 0** verteilt die Daten auf mehrere Platten (Striping). Schnell, aber ohne jede
Ausfallsicherheit: Fällt eine Platte aus, ist alles weg. Kein Einsatz für wichtige Daten.
- **RAID 1** spiegelt zwei Platten. Halbe Kapazität, eine Platte darf ausfallen. Typisch für
das Betriebssystem eines Servers.
- **RAID 5** verteilt Daten und Paritätsinformation über mindestens drei Platten. Eine Platte
darf ausfallen. Bei großen Platten ist der Wiederaufbau lang und belastet die übrigen
Platten – fällt währenddessen eine zweite aus, sind die Daten verloren.
- **RAID 6** wie RAID 5 mit doppelter Parität, mindestens vier Platten. Zwei Platten dürfen
ausfallen. Heute die bessere Wahl für große Datenspeicher.
- **RAID 10** ist ein Verbund aus Spiegeln (mindestens vier Platten). Halbe Kapazität, schnell
beim Schreiben, je Spiegelpaar darf eine Platte ausfallen. Gut für Datenbanken.
- **Hot-Spare:** eine Reserveplatte, die bei einem Ausfall sofort einspringt. Der
Wiederaufbau beginnt, bevor jemand den Ausfall bemerkt.
- **Überwachen:** Ein RAID mit ausgefallener Platte läuft weiter und sieht gesund aus.
Ohne Benachrichtigung merkt man den ersten Ausfall erst am zweiten.
- **NAS, SAN, DAS:** NAS liefert Dateien übers Netz, SAN liefert Speicherblöcke an Server, DAS
hängt direkt an einem einzigen Server.
## Übersicht
| Stufe | Mindestens | Nutzbar bei n Platten | Darf ausfallen | Typischer Einsatz |
|---|---|---|---|---|
| RAID 0 | 2 | n | keine | Zwischenspeicher, Videoschnitt |
| RAID 1 | 2 | n / 2 | eine | Systemplatten |
| RAID 5 | 3 | n − 1 | eine | kleinere Datenspeicher |
| RAID 6 | 4 | n − 2 | zwei | große Datenspeicher, NAS |
| RAID 10 | 4 | n / 2 | eine je Spiegel | Datenbanken, virtuelle Maschinen |
## Prüfungsfrage
**Frage:** Für ein NAS stehen sechs Festplatten mit je 8 TB zur Verfügung. Vergleichen Sie
RAID 5, RAID 6 und RAID 10 hinsichtlich nutzbarer Kapazität und Ausfallsicherheit und
geben Sie eine Empfehlung.
**Antwort:** RAID 5: (6 − 1) × 8 TB = 40 TB nutzbar, eine Platte darf ausfallen. RAID 6:
(6 − 2) × 8 TB = 32 TB, zwei beliebige Platten dürfen ausfallen. RAID 10: 6 / 2 × 8 TB = 24 TB,
je Spiegelpaar darf eine Platte ausfallen – fallen beide Platten eines Paares aus, sind die
Daten verloren. Empfehlung: RAID 6. Bei 8-TB-Platten dauert der Wiederaufbau viele Stunden;
in dieser Zeit verträgt RAID 5 keinen weiteren Fehler, RAID 6 schon. RAID 10 wäre schneller
beim Schreiben, kostet aber deutlich mehr Kapazität, und für ein NAS mit Dateiablagen ist
die Schreibleistung meist nicht entscheidend. Unabhängig davon ersetzt keine RAID-Stufe die
Datensicherung.
+94
View File
@@ -0,0 +1,94 @@
id: woche-05
woche: 5
titel: Lernzielkontrolle Woche 5 – Ausbildung, Betrieb und Zusammenarbeit
dauer_minuten: 40
hinweis: >-
Offene Fragen. Antworten in ganzen Sätzen; bei Rechtsfragen genügt die
sinngemäße Regel, die Paragrafennummer ist nicht gefordert.
fragen:
- id: f1
frage: >-
Ein Auszubildender ist seit sechs Wochen im Betrieb und möchte die
Ausbildung beenden, weil er doch studieren will. Was muss er beachten?
Wie wäre es nach acht Monaten?
punkte: 6
musterloesung: >-
Nach sechs Wochen liegt er in der Probezeit (ein bis vier Monate). Er kann
jederzeit ohne Einhaltung einer Frist und ohne Angabe von Gründen kündigen,
die Kündigung muss aber schriftlich erfolgen. Nach acht Monaten ist die
Probezeit vorbei: Er kann dann mit einer Frist von vier Wochen kündigen,
weil er die Berufsausbildung aufgeben will; die Kündigung muss schriftlich
und unter Angabe des Grundes erfolgen. (Probezeit 2 P., Kündigung darin 1 P.,
Frist danach 2 P., Schriftform mit Grund 1 P.)
- id: f2
frage: >-
Nennen Sie je drei Pflichten des Auszubildenden und des Ausbildungsbetriebs.
punkte: 6
musterloesung: >-
Auszubildender (drei aus): sich bemühen, die berufliche Handlungsfähigkeit
zu erwerben; übertragene Aufgaben sorgfältig ausführen; an
Ausbildungsmaßnahmen und der Berufsschule teilnehmen; Weisungen im Rahmen
der Ausbildung befolgen; die Betriebsordnung beachten; Geräte und
Werkzeuge pfleglich behandeln; über Betriebs- und Geschäftsgeheimnisse
Stillschweigen wahren; den Ausbildungsnachweis führen.
Betrieb (drei aus): die Ausbildung planmäßig durchführen; Ausbildungsmittel
kostenlos zur Verfügung stellen; zum Besuch der Berufsschule freistellen
und anhalten; Zeit zum Führen des Ausbildungsnachweises geben und ihn
regelmäßig durchsehen; nur ausbildungsbezogene Aufgaben übertragen; eine
angemessene Vergütung zahlen. (Je Pflicht 1 P.)
- id: f3
frage: >-
Warum lohnt es sich, das Berichtsheft gewissenhaft zu führen? Nennen Sie
zwei Gründe.
punkte: 4
musterloesung: >-
(1) Ein ordnungsgemäß geführter Ausbildungsnachweis ist Voraussetzung für
die Zulassung zur Abschlussprüfung. (2) Er belegt gegenüber Betrieb und
Kammer, was tatsächlich vermittelt wurde – bei Streit über ausbildungsfremde
Tätigkeiten oder Lücken ist er das wichtigste Beweismittel. Ebenfalls
richtig: Er hilft bei der Prüfungsvorbereitung, weil er zeigt, welche Themen
schon behandelt wurden. (Je Grund 2 P.)
- id: f4
frage: >-
Erklären Sie den Unterschied zwischen Aufbau- und Ablauforganisation an
einem Beispiel aus Ihrem Betrieb.
punkte: 6
musterloesung: >-
Die Aufbauorganisation beschreibt die Struktur: Abteilungen, Stellen und
Weisungsbeziehungen, dargestellt im Organigramm – etwa dass die IT der
kaufmännischen Leitung unterstellt ist. Die Ablauforganisation beschreibt,
wie Arbeit über die Stellen hinweg fließt – etwa der Weg einer
Kundenbestellung vom Vertrieb über die Arbeitsvorbereitung und Fertigung
bis zum Versand und zur Rechnung. (Definition je 2 P., stimmiges Beispiel 2 P.)
- id: f5
frage: >-
Ein Kollege schreibt Ihnen im Chat das Passwort für einen Serverzugang,
damit Sie eine Störung beheben können. Wie bewerten Sie das, und wie wäre
es richtig gewesen?
punkte: 5
musterloesung: >-
Passwörter gehören nicht in den Chat: Nachrichten werden gespeichert,
lassen sich weiterleiten, sind auf mehreren Geräten sichtbar und oft auch
für Administratoren der Plattform lesbar. Richtig wäre, den Zugang über den
Passwortmanager des Teams zu teilen oder ein persönliches Konto mit den
nötigen Rechten einzurichten. Ist das Passwort bereits im Chat gelandet,
wird es nach der Störung geändert. (Bewertung mit Begründung 3 P.,
richtiger Weg 2 P.)
- id: f6
frage: >-
Ordnen Sie jedem Anliegen den passenden Kommunikationsweg zu und begründen
Sie kurz: (a) Der Drucker im Lager druckt nicht. (b) Sie möchten wissen,
ob ein Kollege um 14 Uhr Zeit hat. (c) Sie sagen einem Lieferanten einen
Liefertermin zu.
punkte: 6
musterloesung: >-
(a) Ticket – die Störung muss erfasst, zugewiesen und nachvollziehbar
abgeschlossen werden. (b) Chat oder kurzer Anruf – eine schnelle Absprache
ohne Nachweisbedarf. (c) E-Mail – eine Zusage an Externe muss nachweisbar
sein und an einen bestimmten Empfänger gehen. (Je Zuordnung mit Begründung 2 P.)
+86
View File
@@ -0,0 +1,86 @@
id: woche-07
woche: 7
titel: Lernzielkontrolle Woche 7 – Skripting mit PowerShell
dauer_minuten: 45
hinweis: >-
Offene Fragen. Bei Code zählt, dass er funktionieren würde – Schreibweise von
Groß- und Kleinbuchstaben und kleine Tippfehler werden nicht abgezogen.
fragen:
- id: f1
frage: >-
Erklären Sie, was in der PowerShell-Pipeline von Befehl zu Befehl
weitergereicht wird, und welchen Vorteil das gegenüber einer Shell hat,
die Text weiterreicht.
punkte: 5
musterloesung: >-
Weitergereicht werden Objekte mit Eigenschaften und Methoden, keine
Textzeilen. Der nächste Befehl kann direkt auf Eigenschaften wie Status,
Name oder Größe zugreifen, nach ihnen filtern und sortieren. Bei einer
Textshell müsste man die gewünschte Angabe erst aus der Zeile
herausschneiden; ändert sich das Ausgabeformat, bricht das Skript.
(Objekte 2 P., Vorteil mit Begründung 3 P.)
- id: f2
frage: >-
Schreiben Sie eine Befehlszeile, die die zehn größten Dateien im Ordner
D:\Daten (ohne Unterordner) mit Name und Größe anzeigt, die größte zuerst.
punkte: 6
musterloesung: >-
Get-ChildItem D:\Daten -File | Sort-Object Length -Descending |
Select-Object -First 10 Name, Length. Bewertung: Dateien des Ordners
abrufen (2 P.), absteigend nach Length sortieren (2 P.), auf zehn
begrenzen und die Eigenschaften auswählen (2 P.). Der Schalter -File ist
wünschenswert, aber nicht gefordert.
- id: f3
frage: >-
Was gibt das folgende Skript aus? Begründen Sie.
$liste = @(3, 8, 12, 5);
$treffer = 0;
foreach ($zahl in $liste) { if ($zahl -gt 4) { $treffer++ } };
$treffer
punkte: 4
musterloesung: >-
Ausgabe: 3. Die Schleife geht alle vier Zahlen durch; die Bedingung ist
bei 8, 12 und 5 erfüllt, nicht bei 3. Bei jedem Treffer wird der Zähler
um eins erhöht. (Ergebnis 2 P., Begründung 2 P.)
- id: f4
frage: >-
Erklären Sie den Unterschied zwischen -eq, -like und -match an je einem
Beispiel.
punkte: 6
musterloesung: >-
-eq vergleicht auf Gleichheit: 'PC-001' -eq 'pc-001' ist wahr, weil
Groß- und Kleinschreibung keine Rolle spielt. -like vergleicht mit
Platzhaltern: 'PC-001' -like 'PC-*' ist wahr. -match prüft gegen einen
regulären Ausdruck: 'PC-001' -match '^PC-\d{3}$' ist wahr, weil auf
"PC-" genau drei Ziffern folgen. (Je Operator mit Beispiel 2 P.)
- id: f5
frage: >-
Ein Skript soll alte Benutzerprofile löschen. Was tun Sie, bevor Sie es
auf allen Rechnern ausführen? Nennen Sie drei Schritte.
punkte: 6
musterloesung: >-
(1) Mit -WhatIf laufen lassen und prüfen, welche Profile betroffen wären.
(2) Zuerst an einem Testrechner, dann an einem einzelnen echten Rechner
ausführen und das Ergebnis kontrollieren. (3) Sicherstellen, dass eine
aktuelle Sicherung der betroffenen Daten vorliegt. Ebenfalls richtig:
Änderung ankündigen bzw. als Änderungsanfrage freigeben lassen, Protokoll
der gelöschten Profile schreiben, Ausschlussliste für Dienst- und
Administratorkonten einbauen. (Je Schritt 2 P.)
- id: f6
frage: >-
Im folgenden Ausschnitt wird ein Fehler beim Kopieren nicht vom
catch-Block aufgefangen. Warum, und wie beheben Sie das?
try { Copy-Item \\server\vorlagen\*.docx C:\Vorlagen } catch { Write-Warning 'Kopieren fehlgeschlagen' }
punkte: 5
musterloesung: >-
Copy-Item meldet die meisten Fehler als nicht abbrechende Fehler: Er gibt
eine Fehlermeldung aus und läuft weiter, der catch-Block wird nicht
erreicht. Abhilfe: -ErrorAction Stop an den Befehl anhängen, dann wird der
Fehler zu einem abbrechenden Fehler und vom catch-Block aufgefangen.
(Ursache 3 P., Behebung 2 P.)
+8
View File
@@ -203,3 +203,11 @@ bloecke:
kuerzel: S
titel: Betriebssysteme und Dienste
farbe: petrol
- id: sicherheit
kuerzel: I
titel: IT-Sicherheit und Datenschutz
farbe: petrol
- id: programmierung
kuerzel: P
titel: Skripting und Automatisierung
farbe: petrol
@@ -0,0 +1,30 @@
nummer: 5
titel: Ausbildung, Betrieb und Zusammenarbeit
block: arbeitsalltag
leitfrage: Für wen arbeitet die IT – und woher weißt du, was diese Leute brauchen?
ziel: >-
Du kennst deine Rechte und Pflichten in der Ausbildung, kannst den Aufbau des
Betriebs aus Sicht der IT beschreiben und wählst für jede Absprache den
passenden Kanal.
handouts: [A4, A5, A6]
aufgaben: ["5.1"]
checklisten: []
lernzielkontrolle: woche-05
tage:
- tag: Montag
theorie: [A4]
praxis: Den eigenen Ausbildungsvertrag und den betrieblichen Ausbildungsplan durchlesen, Fragen notieren und mit dem Ausbilder klären.
- tag: Dienstag
theorie: [A5]
praxis: Praxisaufgabe 5.1 beginnen – Organigramm besorgen, Gesprächstermine mit allen Abteilungen vereinbaren, erste Gespräche führen.
- tag: Mittwoch
theorie: [A6]
praxis: Ablage der IT-Dokumentation sichten, drei veraltete oder doppelte Dokumente finden und einen Vorschlag zur Bereinigung machen.
- tag: Donnerstag
theorie: []
praxis: Praxisaufgabe 5.1 abschließen – restliche Gespräche, Abhängigkeiten einzeichnen, Übersicht ablegen, Tickets anlegen.
- tag: Freitag
theorie: []
praxis: Lernzielkontrolle, danach Berichtsheft schreiben.
@@ -0,0 +1,30 @@
nummer: 6
titel: IT-Sicherheit und Datenschutz
block: sicherheit
leitfrage: Was ist hier eigentlich wie viel wert – und wer würde es vermissen?
ziel: >-
Du kannst den Schutzbedarf eines Arbeitsbereichs feststellen und begründen,
einen Arbeitsplatz auf typische Schwachstellen prüfen und richtig reagieren,
wenn ein Konto oder Daten in falsche Hände geraten sind.
handouts: [I1, I2, I3]
aufgaben: ["6.1"]
checklisten: [05-arbeitsplatz-sicherheit-pruefen]
lernzielkontrolle: null
tage:
- tag: Montag
theorie: [I1]
praxis: Drei Anwendungen des Betriebs gemeinsam mit dem Ausbilder einstufen, je Schutzziel mit Begründung.
- tag: Dienstag
theorie: [I2]
praxis: Fünf Arbeitsplätze nach Checkliste prüfen, Abweichungen als Tickets anlegen.
- tag: Mittwoch
theorie: [I3]
praxis: Praxisaufgabe 6.1 beginnen – Bereich wählen, Informationen und Anwendungen erfassen, Gespräch mit der Leitung vereinbaren.
- tag: Donnerstag
theorie: []
praxis: Praxisaufgabe 6.1 abschließen – Systeme ableiten, Einstufung abstimmen, Maßnahmen vorschlagen.
- tag: Freitag
theorie: []
praxis: Ergebnis der Schutzbedarfsfeststellung dem Ausbilder vorstellen, Berichtsheft schreiben.
+30
View File
@@ -0,0 +1,30 @@
nummer: 7
titel: Skripting mit PowerShell
block: programmierung
leitfrage: Welche Arbeit machst du jede Woche von Hand, obwohl ein Rechner sie besser könnte?
ziel: >-
Du kannst mit der PowerShell-Pipeline Informationen abfragen und filtern,
ein Skript mit Variablen, Bedingungen und Schleifen schreiben und es so
gestalten, dass andere es sicher benutzen können.
handouts: [P1, P2, P3]
aufgaben: ["7.1"]
checklisten: []
lernzielkontrolle: woche-07
tage:
- tag: Montag
theorie: [P1]
praxis: Zehn Fragen an das eigene System mit der Pipeline beantworten – etwa welche Dienste beendet sind, die automatisch starten sollten.
- tag: Dienstag
theorie: [P2]
praxis: Ein Skript schreiben, das vor knappem Speicherplatz warnt, und an drei Rechnern testen.
- tag: Mittwoch
theorie: [P3]
praxis: Praxisaufgabe 7.1 beginnen – Angaben in den CIM-Klassen finden, Objekt bauen, erster Lauf.
- tag: Donnerstag
theorie: []
praxis: Praxisaufgabe 7.1 abschließen – Aktualisieren statt Anhängen, Fehlerbehandlung, Hilfeblock, Lauf auf drei Rechnern.
- tag: Freitag
theorie: []
praxis: Lernzielkontrolle, danach Berichtsheft schreiben.
+30
View File
@@ -0,0 +1,30 @@
nummer: 8
titel: Datensicherung und Speicher
block: system
leitfrage: Wie lange dauert es wirklich, bis nach einem Ausfall wieder gearbeitet werden kann?
ziel: >-
Du kannst ein Sicherungskonzept aus dem Schutzbedarf ableiten, eine
Wiederherstellung durchführen und messen und die passende RAID-Stufe für
einen Datenspeicher begründen.
handouts: [S3, S4, S5]
aufgaben: ["8.1"]
checklisten: [06-wiederherstellung-testen]
lernzielkontrolle: null
tage:
- tag: Montag
theorie: [S3]
praxis: Die Sicherungsaufträge des Betriebs gemeinsam mit dem Ausbilder durchgehen und je Auftrag Art, Häufigkeit und Aufbewahrung notieren.
- tag: Dienstag
theorie: [S5]
praxis: RAID-Zustand und Plattenüberwachung von Server und NAS prüfen, Kapazitäten für drei Ausbaustufen durchrechnen.
- tag: Mittwoch
theorie: [S4]
praxis: Eine einzelne Datei und einen Ordner mit Berechtigungen nach Checkliste zurückspielen.
- tag: Donnerstag
theorie: []
praxis: Praxisaufgabe 8.1 – Sicherung des Bereichs prüfen, auf die Testumgebung zurückspielen, Zeit messen, Vorschläge schreiben.
- tag: Freitag
theorie: []
praxis: Monatliches Feedbackgespräch, danach Berichtsheft schreiben.
+68
View File
@@ -0,0 +1,68 @@
# Abnahmeprotokoll
Erzeugt am 2026-10-04 16:24 Uhr
Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenbank**.
## A1 · Aus dem Nichts aufsetzbar
- ✓ Das erste Ausbilderkonto entsteht auf der Kommandozeile — ohne Fehler
- ✓ Die Datenbank wurde angelegt und die Migrationen liefen an
- ✓ Die Anwendung startet und meldet sich gesund
- ✓ Heile Inhalte werden ohne Beanstandung gelesen — Inhalte in Ordnung
- ✓ Ein Inhaltsfehler lässt die Prüfung fehlschlagen — Ende 1
- ✓ Die Meldung nennt die Datei — kaputte-inhalte/handouts/N3-subnetting-rechnen.md:7 (rahmenplan)
- ✓ Die Meldung nennt die richtige Zeile — erwartet Zeile 7
- ✓ Die Meldung nennt den fehlerhaften Wert
- ✓ Die Meldung sagt, wo die gültigen Werte stehen
## A2 · Zwei Rollen, keine Selbstregistrierung
- ✓ Die Anmeldeseite bietet keine Registrierung
- ✓ Ohne Anmeldung führt jeder Pfad zur Anmeldung — HTTP 307
- ✓ Der Ausbilder meldet sich an
## A3 · Der Ausbilder legt Konten an
- ✓ Der Azubi steht in der Verwaltung
- ✓ Das Passwort ist in der Oberfläche nirgends mehr zu sehen
- ✓ In der Datenbank steht kein Klartextpasswort
- ✓ Das Passwort liegt als Argon2id-Hash — $argon2id$v=19$m=19456,t=2,p=1
## A4 · Der Azubi arbeitet
- ✓ Der Azubi meldet sich mit dem vergebenen Passwort an
- ✓ Ein Handout lässt sich als gelesen markieren
- ✓ Eine Merkkarte lässt sich bewerten — Zuletzt: kommt morgen.
## A5 · Aufgabe abgeben und abnehmen
- ✓ Der Azubi hält seine Arbeit in Notizen fest
- ✓ Der Azubi meldet die Aufgabe zur Abnahme
- ✓ Die Aufgabe erscheint beim Ausbilder zur Abnahme
- ✓ Der Ausbilder nimmt die Aufgabe ab
- ✓ Für den Azubi ist die abgenommene Aufgabe gesperrt
- ✓ Die Abnahme schlägt in der Rahmenplanabdeckung durch
- ✓ Lücken im Lehrgang werden getrennt ausgewiesen
## A6 · Rechte serverseitig
- ✓ Der Azubi kommt nicht in die Verwaltung — HTTP 307
## A7 · Berichtsheft
- ✓ Der Azubi legt das Wochenblatt der laufenden Woche an
- ✓ Ein Wochenblatt lässt sich öffnen
- ✓ Das Wochenblatt kommt als PDF heraus — HTTP 200, 2649 Bytes
## A8 · Sicherung zurückspielen
- ✓ Die Sicherung läuft durch — lernbar-20261004-182433.tar.gz
- ✓ Zum Beweis wird der Datenbestand beschädigt — 2 → 1 Konten
- ✓ Die Rückspielung läuft durch — ohne Fehler
- ✓ Der gelöschte Azubi ist wieder da — Tim Vogt
- ✓ Auch der Lernverlauf kam mit zurück — 1 Einträge
- ✓ Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht — verdraengt-20261004-182436
**36 von 36 Prüfungen erfüllt.**
+413
View File
@@ -0,0 +1,413 @@
# Lern-Bar – Anleitung
Diese Anleitung zeigt die Anwendung so, wie sie im Betrieb benutzt wird: erst
der Ausbilder, dann der Azubi. Wer die Lern-Bar aufsetzen will, findet das im
[README](../README.md) – hier geht es um den Umgang damit.
Die Bilder stammen aus einer laufenden Anwendung mit den Beispielinhalten und
werden mit `npm run screenshots` neu erzeugt. Was hier steht, ist also nicht
gemalt, sondern gemessen.
---
## Inhalt
**Für den Ausbilder**
1. [Anmelden](#1-anmelden)
2. [Ein Azubikonto anlegen](#2-ein-azubikonto-anlegen)
3. [Den Wochenplan festlegen](#3-den-wochenplan-festlegen)
4. [Eine Praxisaufgabe abnehmen](#4-eine-praxisaufgabe-abnehmen)
5. [Das Berichtsheft abzeichnen](#5-das-berichtsheft-abzeichnen)
6. [Eine Lernzielkontrolle korrigieren](#6-eine-lernzielkontrolle-korrigieren)
7. [Die Rahmenplanabdeckung lesen](#7-die-rahmenplanabdeckung-lesen)
8. [Ein Feedbackgespräch führen](#8-ein-feedbackgespräch-führen)
**Für den Azubi**
9. [Die Woche](#9-die-woche)
10. [Handouts lesen und suchen](#10-handouts-lesen-und-suchen)
11. [Merkkarten lernen](#11-merkkarten-lernen)
12. [Eine Praxisaufgabe abgeben](#12-eine-praxisaufgabe-abgeben)
13. [Eine Checkliste abarbeiten](#13-eine-checkliste-abarbeiten)
14. [Das Berichtsheft führen](#14-das-berichtsheft-führen)
15. [Eine Lernzielkontrolle schreiben](#15-eine-lernzielkontrolle-schreiben)
**Überall**
16. [Auf dem Handy](#16-auf-dem-handy)
17. [Ohne Netz](#17-ohne-netz)
18. [Hell und dunkel](#18-hell-und-dunkel)
19. [Drucken](#19-drucken)
20. [Wenn etwas nicht geht](#20-wenn-etwas-nicht-geht)
---
## Für den Ausbilder
### 1. Anmelden
![Anmeldung](bilder/01-anmelden.png)
Es gibt **keine Selbstregistrierung**. Das erste Ausbilderkonto legst du auf der
Kommandozeile an (siehe README, Abschnitt *In fünf Minuten zum laufenden
System*), alle weiteren Konten entstehen in der Verwaltung.
Nach dem Anmelden landest du auf deinem Überblick: was in dieser Woche ansteht,
was offen ist, was auf dich wartet.
![Überblick des Ausbilders](bilder/02-ausbilder-ueberblick.png)
---
### 2. Ein Azubikonto anlegen
**Verwaltung → Neues Azubikonto**
![Verwaltung](bilder/03-verwaltung.png)
Du gibst Name, E-Mail-Adresse, ein erstes Passwort (mindestens 10 Zeichen) und
den Ausbildungsbeginn an; Probezeit und geplantes Ende sind freiwillig.
Das Passwort gibst du dem Azubi **persönlich** weiter – er kann es danach selbst
ändern. Einsehbar ist es danach nirgends mehr, auch für dich nicht: In der
Datenbank liegt nur ein Argon2id-Hash. Hat jemand sein Passwort vergessen,
setzt du in der Verwaltung ein neues; das alte lässt sich nicht anzeigen.
Der Ausbildungsbeginn ist wichtiger, als er aussieht. Aus ihm ergeben sich das
Ausbildungsjahr und damit die Wochennummern im Berichtsheft.
> **Ein Azubi hat genau einen Ausbilder.** Das ist keine Einschränkung, die sich
> später aufheben ließe, sondern die Grundlage der Rechteprüfung: Der Azubi
> sieht ausschließlich seine eigenen Daten, der Ausbilder die seiner Azubis.
---
### 3. Den Wochenplan festlegen
**Wochenplan**
![Wochenplan](bilder/04-wochenplan.png)
Die Wochen selbst stehen als Dateien unter `content/wochen/`. Hier legst du
fest, **wann** welche Woche läuft, und kannst sie verschieben – Urlaub,
Berufsschulblock, ein Projekt, das dazwischenkommt.
Verschiebst du eine Woche, wandern die folgenden mit. Was der Azubi schon
erledigt hat, bleibt erledigt.
---
### 4. Eine Praxisaufgabe abnehmen
**Aufgaben → eine abgegebene Aufgabe öffnen**
![Aufgabe abnehmen](bilder/05-aufgabe-abnahme.png)
Der Azubi gibt eine Aufgabe mit seiner Lösung und optional mit Anhängen ab –
meist Fotos vom Handy. Du siehst die Abnahmekriterien aus der Aufgabendatei und
hakst ab, was erfüllt ist.
Eine Aufgabe ist **abgenommen**, wenn du sie abgenommen hast. Nicht, wenn der
Azubi sie abgegeben hat. Genau dieser Unterschied macht sie später in der
Rahmenplanabdeckung zu einem belastbaren Nachweis.
Reicht etwas nicht, gibst du sie mit einer Bemerkung zurück. Der Azubi sieht,
was fehlt, und kann nachbessern.
---
### 5. Das Berichtsheft abzeichnen
**Berichtshefte → ein Wochenblatt öffnen**
![Wochenblatt](bilder/22-wochenblatt.png)
Der Ausbildungsnachweis ist der einzige Teil dieser Anwendung, den das Gesetz
verlangt. Deshalb ist er der sorgfältigste.
Du liest das Blatt, siehst die Warnhinweise (leere Tage, Floskeln, mögliche
Betriebs- oder Personennamen) und zeichnest ab. **Nach dem Abzeichnen ist das
Blatt unveränderlich** – auch für dich. Willst du eine Änderung, gibst du es
frei; das wird protokolliert.
Über *Als PDF* bekommst du das Blatt im IHK-Layout, mit Unterschriftenfeldern.
Ein Beispiel liegt unter [`docs/beispiel-ausbildungsnachweis.pdf`](beispiel-ausbildungsnachweis.pdf).
---
### 6. Eine Lernzielkontrolle korrigieren
**Kontrollen**
![Kontrollen](bilder/23-kontrollen.png)
Du gibst eine Kontrolle frei, der Azubi schreibt sie, du korrigierst. Punkte je
Frage, daraus Prozent, daraus die Note nach dem **IHK-Notenschlüssel**
(92/81/67/50/30 %).
![Ergebnis einer Kontrolle](bilder/24-kontrolle-ergebnis.png)
> **Die Musterlösung bekommt der Azubi erst nach dem Absenden zu sehen.** Vorher
> ist sie über keine Schnittstelle abrufbar – nicht im HTML, nicht in der
> Nachladung, nirgends. Das wird serverseitig entschieden, nicht in der
> Oberfläche, und mit einem eigenen Prüfskript nachgewiesen
> (`scripts/pruefungen/musterloesung-dichtheit.mjs`).
---
### 7. Die Rahmenplanabdeckung lesen
**Auswertung**
![Auswertung](bilder/06-auswertung.png)
Das ist die Ansicht, die gegenüber der IHK zählt. Sie beantwortet die Frage, die
im Prüfungsausschuss gestellt wird: *Ist der Ausbildungsrahmenplan abgedeckt?*
Statt „erledigt / offen" gibt es **vier Lagen**:
| Lage | Bedeutung |
|---|---|
| **vermittelt** | eine abgenommene Praxisaufgabe oder ein abgeschlossener Checklisten-Durchlauf, **oder** mindestens zwei gelesene Handouts |
| **angefangen** | etwas erledigt, aber noch nicht genug – ein gelesenes Handout allein genügt bewusst nicht |
| **geplant** | Material vorhanden, aber unbearbeitet |
| **ohne Inhalt** | zu dieser Position gibt es im ganzen Lehrgang nichts |
Die vierte Lage ist die wichtigste und wird rot ausgewiesen:
![Positionen ohne Inhalt](bilder/07-rahmenplanabdeckung.png)
**Das ist keine Lücke beim Azubi, sondern eine im Lehrgang.** Dort hilft kein
Lernen, sondern nur ein neues Handout oder eine neue Aufgabe unter `content/`.
In den Beispielinhalten betrifft das fünf Positionen: A4, A10, C1, C2 und C5.
Jede Position führt ihre einzelnen Nachweise mit Kürzel und Zustand auf und
nennt ihre amtliche Fundstelle in der Verordnung. Wenn jemand fragt, worauf sich
die Bewertung stützt, steht die Antwort da.
---
### 8. Ein Feedbackgespräch führen
**Feedback → Neues Gespräch anlegen**
![Feedbackgespräche](bilder/08-feedback-liste.png)
Monatlich, mit Leitfaden und Bewertungsbogen. Der Ablauf:
1. Du legst das Gespräch an und trägst **deine** Einschätzung ein: acht Kriterien
auf einer Skala von 1 bis 5, dazu Stärken, Entwicklungsfelder und die
vereinbarten Ziele.
2. Der Azubi trägt **seine** Selbsteinschätzung ein. Er sieht deine Freitexte,
kann sie aber nicht ändern – und du kannst seine Selbsteinschätzung nicht
eintragen. Eine Selbsteinschätzung, die der Ausbilder eintragen könnte, wäre
keine.
3. Wo beide um **zwei Stufen oder mehr** auseinanderliegen, wird das
hervorgehoben. Genau darüber lohnt das Gespräch – nicht über die Zahlen,
sondern über die Stellen, an denen sie auseinandergehen.
4. Beide bestätigen. Danach ist das Gespräch **unveränderlich**.
![Feedbackgespräch](bilder/09-feedbackgespraech.png)
*Als PDF für die Akte* gibt beide Einschätzungen nebeneinander aus, mit
Unterschriftenfeldern. Abweichungen sind auch dort markiert.
> Die Kriterien und der Leitfaden stehen in
> `content/feedbackgespraech.yaml` und lassen sich an den eigenen Betrieb
> anpassen. Ein bereits geführtes Gespräch behält seine Fassung, auch wenn du
> die Vorlage später änderst.
---
## Für den Azubi
### 9. Die Woche
![Woche des Azubis](bilder/10-azubi-woche.png)
Die Startseite zeigt genau eine Woche: das Thema, was diese Woche zu tun ist,
was schon erledigt ist, was drängt. Kein Dashboard mit zwölf Kacheln – die
Frage lautet „Was mache ich jetzt?", nicht „Wie ist meine Gesamtlage?".
---
### 10. Handouts lesen und suchen
![Handouts](bilder/11-handouts-liste.png)
Die Handouts sind der Lesestoff: kurz, mit Beispielen, jeweils einem Thema
zugeordnet. Die Suche geht über Titel und Volltext.
![Suche](bilder/12-handouts-suche.png)
![Ein Handout](bilder/13-handout-n3.png)
Unten markierst du ein Handout als gelesen. Das ist keine Spielerei: In der
Auswertung zählen gelesene Handouts als **stützende** Nachweise für die
Rahmenplanabdeckung.
Fachbegriffe, die dir unterwegs begegnen, stehen im **Glossar** – kurz erklärt,
mit Querverweisen.
![Glossar](bilder/15-glossar.png)
---
### 11. Merkkarten lernen
![Merkkarten](bilder/14-merkkarten.png)
Die Karten gehören zu den Handouts und kommen nach dem Prinzip der
**Wiederholung nach Zeitabstand** wieder: Was sitzt, kommt später wieder; was
nicht sitzt, heute noch einmal.
Im Lernmodus:
- **Antippen** oder **Leertaste** – Karte umdrehen
- **1 / 2 / 3** – nochmal / unsicher / sitzt
- **Wischen** nach rechts für „sitzt", nach links für „nochmal"
Zehn Minuten am Tag reichen. Wer das durchhält, hat vor der Prüfung nichts
nachzuholen.
---
### 12. Eine Praxisaufgabe abgeben
![Aufgaben](bilder/16-aufgaben-liste.png)
![Eine Aufgabe](bilder/17-aufgabe-azubi.png)
Du liest den Auftrag, siehst die Abnahmekriterien vorher – du weißt also, woran
du gemessen wirst –, schreibst deine Lösung und hängst an, was dazugehört.
Fotos vom Handy gehen direkt.
Dann gibst du ab. Der Ausbilder nimmt ab oder gibt zurück, mit Bemerkung.
---
### 13. Eine Checkliste abarbeiten
![Checklisten](bilder/18-checklisten.png)
Checklisten sind für Abläufe, die immer gleich gehen: einen Arbeitsplatz
übergeben, Windows installieren, einen Rechner zerlegen. Du startest einen
Durchlauf und hakst Schritt für Schritt ab.
![Ein Durchlauf](bilder/19-checklisten-lauf.png)
Ein Durchlauf **kopiert die Schritte bei seinem Start**. Ändert jemand die
Checkliste, während du sie abarbeitest, bleibt dein Durchlauf so, wie du ihn
begonnen hast. Am Ende gibt es ein PDF – ein Beispiel liegt unter
[`docs/beispiel-checkliste.pdf`](beispiel-checkliste.pdf).
---
### 14. Das Berichtsheft führen
![Berichtsheft](bilder/21-berichtsheft.png)
Pro Kalenderwoche ein Blatt, gegliedert nach Tagen, mit Stunden. Die Anwendung
hilft an drei Stellen:
- **Vorschläge** aus dem, was du in der Woche tatsächlich gemacht hast:
abgenommene Aufgaben, abgeschlossene Checklisten, gelesene Handouts.
- **Prüfung vor dem Abgeben**: leere Tage, Floskeln („diverse Tätigkeiten",
„wie immer"), verdächtige Namen von Firmen oder Personen. Der
Ausbildungsnachweis geht an die IHK – da haben Kundennamen nichts verloren.
- **Formulierungshilfe**: aus „Drucker repariert" wird „Fehlerbild aufgenommen,
Fixiereinheit getauscht, Testdruck geprüft, Ticket dokumentiert".
Nach dem Abzeichnen ist das Blatt unveränderlich.
---
### 15. Eine Lernzielkontrolle schreiben
Der Ausbilder gibt die Kontrolle frei, du siehst sie unter *Kontrollen*. Du
schreibst deine Antworten und sendest ab.
**Erst nach dem Absenden** siehst du die Musterlösung – vorher nicht, und auch
nicht über Umwege. Das ist keine Gängelei, sondern der Sinn der Sache: Eine
Kontrolle, deren Lösung vorher abrufbar wäre, misst nichts.
---
## Überall
### 16. Auf dem Handy
Die Anwendung ist für das Handy gebaut, nicht dafür angepasst. Der Azubi hat es
in der Tasche, nicht den Laptop.
| Handout | Lernmodus, Frage | Lernmodus, Antwort |
|---|---|---|
| ![Handout auf dem Handy](bilder/30-handy-handout.png) | ![Frage](bilder/31-handy-lernmodus-frage.png) | ![Antwort](bilder/32-handy-lernmodus-antwort.png) |
**Auf den Startbildschirm legen:** Die Lern-Bar ist eine installierbare
Anwendung.
- *Android (Chrome):* Menü → **App installieren**
- *iPhone (Safari):* Teilen → **Zum Home-Bildschirm**
Danach startet sie ohne Adresszeile und mit eigenem Symbol.
---
### 17. Ohne Netz
Im Serverraum, im Keller, im Zug gibt es kein Netz. Deshalb:
- **Merkkarten** und **Handouts, die du schon einmal geöffnet hattest**,
funktionieren weiter.
- Deine **Bewertungen werden gemerkt** und gehen ab, sobald du wieder verbunden
bist. Eine schmale Leiste sagt dir, wie viele warten – und meldet sich, wenn
sie angekommen sind.
- Der Tag zählt, an dem du **gelernt** hast, nicht der Tag des Absendens.
Was **nicht** offline liegt: Lernzielkontrollen, Berichtsheft, Bewertungen,
Feedbackgespräche, Anhänge und jedes PDF. Das ist Absicht. Diese Dinge stehen
hinter einer eigenen Rechteprüfung, und was im Browserspeicher liegt, ist an
dieser Prüfung vorbei.
Auf einem geteilten Gerät wird beim Abmelden alles Zwischengespeicherte
weggeräumt.
---
### 18. Hell und dunkel
![Dunkle Fassung](bilder/20-dunkel.png)
Oben rechts in der Kopfzeile schaltest du um. Die Einstellung bleibt auf dem
Gerät. Beim Drucken wird immer hell gedruckt, auch wenn du dunkel eingestellt
hast – sonst käme schwarzes Papier aus dem Drucker.
---
### 19. Drucken
Für die Dokumente, die eine Unterschrift tragen, gibt es **PDFs**:
Wochenblatt, Checklisten-Durchlauf, Feedbackgespräch.
Für alles andere gibt es einen **Druckknopf**: Handout, Glossar, Auswertung. Die
Navigation, Knöpfe und Hinweise werden nicht mitgedruckt, Karten werden nicht
mitten durchgeschnitten, aufklappbare Abschnitte drucken aufgeklappt.
---
### 20. Wenn etwas nicht geht
| Beobachtung | Woran es liegt |
|---|---|
| „Zu viele Fehlversuche" bei der Anmeldung | Nach fünf falschen Passwörtern wird die Adresse für eine Minute gesperrt, bei weiteren Fehlversuchen länger, höchstens 15 Minuten. Abwarten – oder Passwort vom Ausbilder neu setzen lassen. Hat sich das einzige Ausbilderkonto ausgesperrt: `docker compose restart lernbar` setzt die Sperre zurück. |
| „Diese Seite gibt es nicht" bei fremden Daten | Kein Fehler, sondern die Rechteprüfung. Ob es zu einer Kennung überhaupt Daten gibt, geht Unbefugte nichts an – deshalb 404 und nicht „kein Zugriff". |
| Die Anwendung startet nicht, nennt aber Datei und Zeile | Ein Inhaltsfehler unter `content/`. Die Meldung sagt genau, wo. `npm run inhalte:pruefen` prüft ohne Start. |
| Ein Anhang lässt sich nicht hochladen | Über 25 MB. Bei einem Reverse Proxy zusätzlich `client_max_body_size 30m` setzen (siehe README). |
| Ein abgezeichnetes Blatt lässt sich nicht ändern | So gewollt. Der Ausbilder kann es freigeben; das wird protokolliert. |
| Offline erscheint „Kein Netz" | Diese Seite lag nicht auf dem Gerät. Merkkarten und schon geöffnete Handouts gehen weiter. |
Bleibt etwas offen: `docker compose logs -f` zeigt, was der Server sagt, und
`/api/gesundheit` antwortet nur dann mit 200, wenn die Datenbank ansprechbar
ist.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+6
View File
@@ -7,6 +7,12 @@ const konfiguration: NextConfig = {
serverExternalPackages: ['better-sqlite3', '@node-rs/argon2', 'pdfkit'],
poweredByHeader: false,
reactStrictMode: true,
experimental: {
// Next.js deckelt Serveraktionen sonst bei 1 MB – damit wäre kein einziges
// Handyfoto durchgekommen. Etwas über der eigenen Grenze von 25 MB, damit
// eine zu große Datei die *eigene* Meldung bekommt und nicht die des Gerüsts.
serverActions: { bodySizeLimit: '30mb' },
},
eslint: { ignoreDuringBuilds: true },
async headers() {
return [
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "lernbar",
"version": "0.1.0",
"version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lernbar",
"version": "0.1.0",
"version": "1.0.3",
"dependencies": {
"@node-rs/argon2": "^2.0.2",
"better-sqlite3": "^12.11.1",
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "lernbar",
"version": "0.1.0",
"version": "1.0.3",
"private": true,
"description": "Lern-Bar – betriebliche Ausbildung Fachinformatiker Systemintegration",
"engines": {
@@ -17,9 +17,13 @@
"e2e": "playwright test",
"inhalte:pruefen": "tsx scripts/inhalte-pruefen.ts",
"konto:anlegen": "tsx scripts/konto-anlegen.ts",
"werkzeuge:bauen": "esbuild scripts/konto-anlegen.ts --bundle --platform=node --target=node22 --format=cjs --external:better-sqlite3 --external:@node-rs/argon2 --outfile=build/werkzeuge/konto.js",
"backup": "bash scripts/im-container/sicherung.sh",
"restore": "bash scripts/im-container/wiederherstellen.sh",
"screenshots": "tsx scripts/screenshots.ts"
"screenshots": "tsx scripts/screenshots.ts",
"symbole": "tsx scripts/symbole.ts",
"abnahme": "node scripts/pruefungen/abnahme.mjs",
"abnahme:container": "node scripts/pruefungen/container.mjs"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
+161
View File
@@ -0,0 +1,161 @@
/* eslint-disable */
/**
* Der Dienstarbeiter der Lern-Bar.
*
* Er hat genau eine Aufgabe: Der Azubi soll im Serverraum, im Keller oder im
* Zug weiterlernen können, wo kein Netz ist. Alles andere wäre Ballast.
*
* ─── Was offline liegen darf ──────────────────────────────────────────────
*
* Nur das Lernmaterial: Handouts, Merkkarten, Glossar und die eigene Woche.
*
* **Nicht** zwischengespeichert werden Lernzielkontrollen, Berichtsheft,
* Bewertungen, Feedbackgespräche, Anhänge und jedes PDF. Der Grund ist keine
* Sparsamkeit, sondern eine Zusage aus dem Pflichtenheft: Musterlösungen und
* Bewertungsdaten stehen hinter einer eigenen Prüfung. Was im Browsercache
* liegt, ist an dieser Prüfung vorbei – also liegt es dort nicht.
*
* ─── Wie zwischengespeichert wird ─────────────────────────────────────────
*
* Seiten: erst das Netz, dann der Speicher. So sieht man nach einer
* Aktualisierung sofort die neue Fassung und nur bei fehlendem Netz die alte.
*
* Dateien unter /_next/static/: erst der Speicher. Die Namen tragen eine
* Prüfsumme, ihr Inhalt ändert sich nie.
*
* Alles andere – Serveraktionen, Anmeldung, POST – geht immer ans Netz.
*/
const FASSUNG = 'v1';
const SCHALE = `lernbar-schale-${FASSUNG}`;
const SEITEN = `lernbar-seiten-${FASSUNG}`;
const DATEIEN = `lernbar-dateien-${FASSUNG}`;
/** Was schon beim Einrichten vorliegen muss. */
const GRUNDVORRAT = ['/offline', '/symbol-192.png', '/lernbar.svg', '/manifest.webmanifest'];
/** Höchstens so viele Seiten liegen im Speicher – ein Handy ist kein Archiv. */
const HOECHSTZAHL_SEITEN = 60;
/** Nur diese Pfade dürfen offline vorliegen. Alles andere niemals. */
function darfOfflineLiegen(pfad) {
if (pfad === '/' || pfad === '/offline' || pfad === '/glossar') return true;
if (pfad === '/handouts' || pfad.startsWith('/handouts/')) return true;
if (pfad === '/karten' || pfad.startsWith('/karten/')) return true;
if (/^\/woche\/\d+$/.test(pfad)) return true;
return false;
}
self.addEventListener('install', (ereignis) => {
ereignis.waitUntil(
caches
.open(SCHALE)
// Einzeln, damit ein fehlendes Stück nicht die ganze Einrichtung kippt.
.then((speicher) => Promise.all(GRUNDVORRAT.map((pfad) => speicher.add(pfad).catch(() => undefined))))
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (ereignis) => {
ereignis.waitUntil(
caches
.keys()
.then((namen) =>
Promise.all(
namen
.filter((name) => name.startsWith('lernbar-') && ![SCHALE, SEITEN, DATEIEN].includes(name))
.map((name) => caches.delete(name)),
),
)
.then(() => self.clients.claim()),
);
});
/**
* Beim Abmelden wird alles Persönliche weggeräumt.
*
* Ein geteiltes Werkstattgerät soll nach dem Abmelden nicht noch die Woche des
* vorigen Azubis im Speicher haben.
*/
self.addEventListener('message', (ereignis) => {
if (ereignis.data && ereignis.data.typ === 'aufraeumen') {
ereignis.waitUntil(Promise.all([caches.delete(SEITEN), caches.delete(DATEIEN)]));
}
});
async function speicherKuerzen(name, hoechstzahl) {
const speicher = await caches.open(name);
const schluessel = await speicher.keys();
// Die ältesten zuerst – `keys()` liefert sie in der Reihenfolge des Ablegens.
for (const alt of schluessel.slice(0, Math.max(0, schluessel.length - hoechstzahl))) {
await speicher.delete(alt);
}
}
/** Seiten: erst das Netz, bei Ausfall der Speicher, sonst die Offlineseite. */
async function seiteHolen(anfrage) {
const pfad = new URL(anfrage.url).pathname;
try {
const antwort = await fetch(anfrage);
if (antwort.ok && darfOfflineLiegen(pfad)) {
const speicher = await caches.open(SEITEN);
await speicher.put(anfrage, antwort.clone());
await speicherKuerzen(SEITEN, HOECHSTZAHL_SEITEN);
}
return antwort;
} catch (fehler) {
const gespeichert = await caches.match(anfrage, { ignoreSearch: false });
if (gespeichert) return gespeichert;
const offline = await caches.match('/offline');
if (offline) return offline;
return new Response('Kein Netz und nichts im Speicher.', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
}
/** Unveränderliche Dateien: erst der Speicher. */
async function dateiHolen(anfrage) {
const gespeichert = await caches.match(anfrage);
if (gespeichert) return gespeichert;
const antwort = await fetch(anfrage);
if (antwort.ok) {
const speicher = await caches.open(DATEIEN);
await speicher.put(anfrage, antwort.clone());
}
return antwort;
}
self.addEventListener('fetch', (ereignis) => {
const anfrage = ereignis.request;
// Nur eigene Adressen, nur Lesezugriffe.
if (anfrage.method !== 'GET') return;
const adresse = new URL(anfrage.url);
if (adresse.origin !== self.location.origin) return;
// Serveraktionen und Datenschnittstellen gehen immer ans Netz – eine
// zwischengespeicherte Antwort wäre hier schlicht falsch.
if (adresse.pathname.startsWith('/api/') || anfrage.headers.has('Next-Action')) return;
// Auch die Teilnachladungen von Next (RSC) nicht: Sie gehören zu einer
// Sitzung und sollen nicht als Seite verwechselt werden.
if (adresse.searchParams.has('_rsc')) return;
if (anfrage.mode === 'navigate') {
ereignis.respondWith(seiteHolen(anfrage));
return;
}
if (adresse.pathname.startsWith('/_next/static/') || adresse.pathname.endsWith('.svg') || adresse.pathname.endsWith('.png')) {
ereignis.respondWith(dateiHolen(anfrage));
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+7 -2
View File
@@ -46,7 +46,12 @@ Inhalt: lernbar.sqlite (Datenbank), anhaenge/ (hochgeladene Dateien)
Zurückspielen: wiederherstellen.sh <diese-datei.tar.gz>
ENDE
ARCHIV="$ZIEL/lernbar-$STEMPEL.tar.gz"
tar -czf "$ARCHIV" -C "$ARBEIT" lernbar.sqlite anhaenge sicherung.txt
# Bewusst erst ins Zielverzeichnis wechseln und dann einen blossen Dateinamen
# uebergeben: tar deutet einen Doppelpunkt im Dateinamen als Rechnernamen
# ("C:\..." wird zu Rechner "C"). Das faellt nur auf Windows auf, aber ein
# Pfad mit Doppelpunkt kann ueberall vorkommen.
NAME="lernbar-$STEMPEL.tar.gz"
ARCHIV="$ZIEL/$NAME"
( cd "$ZIEL" && tar -czf "$NAME" -C "$ARBEIT" lernbar.sqlite anhaenge sicherung.txt )
echo "$ARCHIV"
+4 -1
View File
@@ -36,7 +36,10 @@ fi
ARBEIT="$(mktemp -d)"
trap 'rm -rf "$ARBEIT"' EXIT
tar -xzf "$ARCHIV" -C "$ARBEIT"
# Siehe sicherung.sh: tar darf den Doppelpunkt im Pfad nicht zu sehen bekommen.
VERZEICHNIS="$(cd "$(dirname "$ARCHIV")" && pwd)"
DATEI="$(basename "$ARCHIV")"
( cd "$VERZEICHNIS" && tar -xzf "$DATEI" -C "$ARBEIT" )
if [ ! -f "$ARBEIT/lernbar.sqlite" ]; then
echo "Das Archiv enthält keine lernbar.sqlite – falsche Datei?" >&2
+1
View File
@@ -30,6 +30,7 @@ function main() {
console.log(` Glossareinträge ${alle.glossar.length}`);
console.log(` Berufsbildpositionen ${alle.positionen.length}`);
console.log(` Lernfelder ${alle.lernfelder.length}`);
console.log(` Feedbackkriterien ${alle.feedback.kriterien.length}`);
console.log('');
const ohneAbdeckung = alle.positionen.filter(
+496
View File
@@ -0,0 +1,496 @@
import { chromium } from '@playwright/test';
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
/**
* Der Abnahmelauf.
*
* Anders als die Prüfungen der einzelnen Bausteine läuft dieser hier gegen
* eine **frische, leere Datenbank** in einem Wegwerfverzeichnis. Er beweist
* damit nicht nur, dass die Anwendung funktioniert, sondern dass sie sich aus
* dem Nichts aufsetzen lässt: Migrationen laufen an, das erste Ausbilderkonto
* entsteht auf der Kommandozeile, alles Weitere über die Oberfläche.
*
* Am Ende wird gesichert, absichtlich etwas kaputtgemacht und zurückgespielt.
* Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung.
*
* node scripts/pruefungen/abnahme.mjs
*
* Voraussetzung: `npm run build` ist gelaufen.
*/
const PORT = Number(process.env.LERNBAR_ABNAHME_PORT ?? 3399);
const BASIS = `http://localhost:${PORT}`;
const AUSBILDER = { name: 'Petra Kessler', email: 'petra.kessler@betrieb.test', pw: 'abnahme-ausbilder' };
const AZUBI = { name: 'Tim Vogt', email: 'tim.vogt@betrieb.test', pw: 'abnahme-azubi-2026' };
let fehler = 0;
const protokoll = [];
function pruefe(bedingung, text, zusatz = '') {
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
protokoll.push({ erfuellt: Boolean(bedingung), text, zusatz });
if (!bedingung) fehler += 1;
}
function abschnitt(titel) {
console.log(`\n${titel}`);
protokoll.push({ abschnitt: titel });
}
// --- Wegwerfumgebung -------------------------------------------------------
const daten = fs.mkdtempSync(path.join(os.tmpdir(), 'lernbar-abnahme-'));
const umgebung = { ...process.env, LERNBAR_DATEN: daten, PORT: String(PORT), TZ: 'Europe/Berlin' };
console.log(`Wegwerfverzeichnis: ${daten}`);
let server = null;
let browser = null;
/**
* Den Server beenden – wirklich.
*
* Unter Windows beendet `kill()` auf einem über die Eingabeaufforderung
* gestarteten Prozess nur diese, nicht den Node-Prozess darunter. Deshalb wird
* hier ohne Eingabeaufforderung gestartet und zur Sicherheit der ganze Baum
* abgeräumt. Ein überlebender Server aus einem früheren Lauf ist kein
* Schönheitsfehler: Er belegt den Port, der nächste Lauf bekommt ihn nicht,
* und dann prüft man stundenlang gegen den alten Datenbestand.
*/
function serverBeenden() {
if (!server) return;
try {
if (process.platform === 'win32') {
spawnSync('taskkill', ['/PID', String(server.pid), '/T', '/F'], { stdio: 'ignore' });
} else {
server.kill('SIGTERM');
}
} catch {
/* egal */
}
server = null;
}
function aufraeumen() {
serverBeenden();
try {
fs.rmSync(daten, { recursive: true, force: true });
} catch {
// Unter Windows hält der Serverprozess die Datei manchmal noch kurz.
}
}
process.on('exit', aufraeumen);
/** Antwortet dort schon jemand? Dann ist es nicht unser Server. */
async function portBelegt() {
try {
await fetch(`${BASIS}/api/gesundheit`, { signal: AbortSignal.timeout(2000) });
return true;
} catch {
return false;
}
}
/** Wartet, bis der Server antwortet. */
async function serverBereit(sekunden = 180) {
for (let versuch = 0; versuch < sekunden * 2; versuch += 1) {
if (server?.exitCode !== null && server?.exitCode !== undefined) return false;
try {
const antwort = await fetch(`${BASIS}/api/gesundheit`);
if (antwort.ok) return true;
} catch {
/* noch nicht da */
}
await new Promise((weiter) => setTimeout(weiter, 500));
}
return false;
}
function laufen(befehl, argumente, zusatz = {}) {
return spawnSync(befehl, argumente, {
env: umgebung,
encoding: 'utf8',
shell: process.platform === 'win32',
...zusatz,
});
}
try {
// --- A1 Aufsetzen aus dem Nichts -----------------------------------------
abschnitt('A1 · Aus dem Nichts aufsetzbar');
if (await portBelegt()) {
throw new Error(
`Auf ${BASIS} antwortet bereits etwas. Dieser Lauf braucht den Port für sich – ` +
'sonst prüft er gegen einen fremden Datenbestand. Bitte beenden oder ' +
'LERNBAR_ABNAHME_PORT setzen.',
);
}
const konto = laufen('npx', [
'tsx', 'scripts/konto-anlegen.ts',
'--name', AUSBILDER.name,
'--email', AUSBILDER.email,
'--rolle', 'ausbilder',
'--passwort', AUSBILDER.pw,
]);
pruefe(konto.status === 0, 'Das erste Ausbilderkonto entsteht auf der Kommandozeile', (konto.stderr || '').trim().split('\n')[0] || 'ohne Fehler');
pruefe(
fs.existsSync(path.join(daten, 'lernbar.sqlite')),
'Die Datenbank wurde angelegt und die Migrationen liefen an',
);
// Ohne Eingabeaufforderung starten: nur dann lässt sich der Prozess später
// auch wirklich beenden.
server = spawn(process.execPath, [path.join('node_modules', 'next', 'dist', 'bin', 'next'), 'start', '-p', String(PORT)], {
env: umgebung,
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverAusgabe = '';
server.stdout.on('data', (stueck) => (serverAusgabe += stueck));
server.stderr.on('data', (stueck) => (serverAusgabe += stueck));
const laeuft = await serverBereit();
pruefe(laeuft, 'Die Anwendung startet und meldet sich gesund', laeuft ? '' : serverAusgabe.slice(-400));
if (!laeuft) throw new Error('Server nicht erreichbar.');
// Die Zusage lautet: Bei einem Inhaltsfehler startet die Anwendung nicht und
// nennt Datei und Zeile. Das prüft man, indem man einen Fehler einbaut –
// nicht, indem man in der Startausgabe nach einem Wort sucht.
const heil = laufen('npx', ['tsx', 'scripts/inhalte-pruefen.ts']);
pruefe(
heil.status === 0,
'Heile Inhalte werden ohne Beanstandung gelesen',
((heil.stdout || '').match(/Inhalte in Ordnung/) ?? ['—'])[0],
);
const kaputteInhalte = path.join(daten, 'kaputte-inhalte');
fs.cpSync(path.join(process.cwd(), 'content'), kaputteInhalte, { recursive: true });
// Ein Verweis auf eine Rahmenplanposition, die es nicht gibt. Das ist der
// Fehler, der ohne Prüfung erst Monate später auffiele – in der Auswertung.
const opfer = path.join(kaputteInhalte, 'handouts', 'N3-subnetting-rechnen.md');
const zeilen = fs.readFileSync(opfer, 'utf8').split(/\r?\n/);
const stelle = zeilen.findIndex((zeile) => zeile.startsWith('rahmenplan:'));
// Wohlgeformt, aber ohne Entsprechung – so greift die Querprüfung zwischen
// den Dateien und nicht bloß die Formatprüfung.
zeilen[stelle] = 'rahmenplan: [B2, C9]';
fs.writeFileSync(opfer, zeilen.join('\n'), 'utf8');
const erwarteteZeile = stelle + 1;
const kaputt = spawnSync('npx', ['tsx', 'scripts/inhalte-pruefen.ts'], {
env: { ...umgebung, LERNBAR_INHALTE: kaputteInhalte },
encoding: 'utf8',
shell: process.platform === 'win32',
});
const meldung = `${kaputt.stdout ?? ''}${kaputt.stderr ?? ''}`;
pruefe(kaputt.status !== 0, 'Ein Inhaltsfehler lässt die Prüfung fehlschlagen', `Ende ${kaputt.status}`);
pruefe(
/N3-subnetting-rechnen\.md/.test(meldung),
'Die Meldung nennt die Datei',
(meldung.match(/.*N3-subnetting-rechnen\.md.*/) ?? ['—'])[0].trim().slice(0, 100),
);
pruefe(
meldung.includes(`N3-subnetting-rechnen.md:${erwarteteZeile}`),
'Die Meldung nennt die richtige Zeile',
`erwartet Zeile ${erwarteteZeile}`,
);
pruefe(meldung.includes('C9'), 'Die Meldung nennt den fehlerhaften Wert');
pruefe(meldung.includes('rahmenplan.yaml'), 'Die Meldung sagt, wo die gültigen Werte stehen');
browser = await chromium.launch();
// --- A2 Zwei Rollen, keine Selbstregistrierung ---------------------------
abschnitt('A2 · Zwei Rollen, keine Selbstregistrierung');
const gast = await browser.newContext({ locale: 'de-DE' });
const gastSeite = await gast.newPage();
gastSeite.setDefaultTimeout(60000);
await gastSeite.goto(`${BASIS}/anmelden`);
const anmeldetext = await gastSeite.locator('body').innerText();
pruefe(/keine Selbstregistrierung/i.test(anmeldetext), 'Die Anmeldeseite bietet keine Registrierung');
const ungeschuetzt = await gast.request.get(`${BASIS}/berichtsheft`, { maxRedirects: 0 });
pruefe(
ungeschuetzt.status() >= 300 && ungeschuetzt.status() < 400,
'Ohne Anmeldung führt jeder Pfad zur Anmeldung',
`HTTP ${ungeschuetzt.status()}`,
);
await gast.close();
const ausbilder = await browser.newContext({ locale: 'de-DE' });
const a = await ausbilder.newPage();
a.setDefaultTimeout(60000);
await a.goto(`${BASIS}/anmelden`);
await a.fill('#email', AUSBILDER.email);
await a.fill('#passwort', AUSBILDER.pw);
await a.click('button[type=submit]');
await a.waitForURL(`${BASIS}/`);
pruefe(true, 'Der Ausbilder meldet sich an');
// --- A3 Der Ausbilder legt ein Azubikonto an -----------------------------
abschnitt('A3 · Der Ausbilder legt Konten an');
await a.goto(`${BASIS}/verwaltung`);
await a.waitForSelector('#neu-name');
await a.fill('#neu-name', AZUBI.name);
await a.fill('#neu-email', AZUBI.email);
await a.fill('#neu-passwort', AZUBI.pw);
await a.fill('#neu-beginn', '2026-08-03');
await a.click('form:has(#neu-name) button[type=submit]');
await a.waitForSelector(`text=${AZUBI.name}`, { timeout: 20000 });
const verwaltungstext = await a.locator('body').innerText();
pruefe(verwaltungstext.includes(AZUBI.name), 'Der Azubi steht in der Verwaltung');
// Das Passwort darf nirgends wieder auftauchen – weder in der Ansicht noch
// in der Datenbank.
await a.reload();
const nachNeuladen = await a.content();
pruefe(!nachNeuladen.includes(AZUBI.pw), 'Das Passwort ist in der Oberfläche nirgends mehr zu sehen');
const { default: Sqlite } = await import('better-sqlite3');
// Bewusst **nicht** readonly: Eine Lesesitzung auf einer WAL-Datenbank sieht
// ohne Schreibrecht auf die Begleitdateien unter Umständen nur den letzten
// Prüfpunkt – und damit ein frisch angelegtes Konto noch nicht.
const pruefDb = new Sqlite(path.join(daten, 'lernbar.sqlite'));
const alleKonten = pruefDb.prepare('select email, rolle from benutzer').all();
const gespeichert = pruefDb.prepare('select passwort_hash from benutzer where email = ?').get(AZUBI.email);
pruefDb.close();
if (!gespeichert) {
console.log(` (in der Datenbank stehen: ${alleKonten.map((k) => `${k.email} [${k.rolle}]`).join(', ') || 'keine'})`);
}
pruefe(
Boolean(gespeichert) && !String(gespeichert.passwort_hash).includes(AZUBI.pw),
'In der Datenbank steht kein Klartextpasswort',
);
pruefe(
String(gespeichert?.passwort_hash ?? '').startsWith('$argon2id$'),
'Das Passwort liegt als Argon2id-Hash',
String(gespeichert?.passwort_hash ?? '').slice(0, 30),
);
// --- A4 Der Azubi arbeitet ------------------------------------------------
abschnitt('A4 · Der Azubi arbeitet');
const azubi = await browser.newContext({ locale: 'de-DE' });
const z = await azubi.newPage();
z.setDefaultTimeout(60000);
await z.goto(`${BASIS}/anmelden`);
await z.fill('#email', AZUBI.email);
await z.fill('#passwort', AZUBI.pw);
await z.click('button[type=submit]');
const angemeldet = await z.waitForURL(`${BASIS}/`, { timeout: 15000 }).then(() => true, () => false);
pruefe(angemeldet, 'Der Azubi meldet sich mit dem vergebenen Passwort an');
if (!angemeldet) throw new Error('Anmeldung des Azubis fehlgeschlagen.');
await z.goto(`${BASIS}/handouts/N3`);
await z.waitForSelector('h1');
await z.click('button:has-text("Als gelesen markieren")');
await z.waitForSelector('button:has-text("Doch noch nicht gelesen")', { timeout: 15000 });
pruefe(true, 'Ein Handout lässt sich als gelesen markieren');
await z.goto(`${BASIS}/karten/lernen`);
const hatKarte = (await z.locator('.lernkarte').count()) > 0;
if (hatKarte) {
await z.click('.lernkarte');
await z.click('.bewerten-sitzt');
await z.waitForTimeout(600);
const rueck = await z.locator('.lernmodus-rueckmeldung').innerText();
pruefe(/kommt/.test(rueck), 'Eine Merkkarte lässt sich bewerten', rueck.trim());
} else {
pruefe(false, 'Eine Merkkarte lässt sich bewerten', 'kein Stapel fällig');
}
// --- A5 Aufgabe abgeben und abnehmen -------------------------------------
abschnitt('A5 · Aufgabe abgeben und abnehmen');
await z.goto(`${BASIS}/aufgaben/1.1`);
await z.waitForSelector('#aufgaben-notiz');
await z.fill(
'#aufgaben-notiz',
'Arbeitsplatz nach Checkliste übergeben: Gerät angeschlossen, Anmeldung geprüft, ' +
'Drucker verbunden, Nutzer eingewiesen, Ticket geschlossen.',
);
await z.click('button:has-text("Notizen speichern")');
await z.waitForSelector('text=/gespeichert/i', { timeout: 20000 });
pruefe(true, 'Der Azubi hält seine Arbeit in Notizen fest');
await z.click('button:has-text("Zur Abnahme")');
await z.waitForTimeout(1500);
await z.reload();
pruefe(
await z.isDisabled('button:has-text("Zur Abnahme")'),
'Der Azubi meldet die Aufgabe zur Abnahme',
);
// Der Ausbilder nimmt ab.
await a.goto(`${BASIS}/aufgaben`);
await a.waitForSelector('h1');
const zurAbnahme = await a.$('a[href*="/aufgaben/1.1?azubi="]');
pruefe(Boolean(zurAbnahme), 'Die Aufgabe erscheint beim Ausbilder zur Abnahme');
if (zurAbnahme) {
await zurAbnahme.click();
await a.waitForSelector('.kriterien', { timeout: 15000 });
for (const kasten of await a.$$('.kriterien input[type=checkbox]')) await kasten.check();
await a.fill('#bewertung', 'gut');
await a.fill('#rueckmeldung', 'Sauber dokumentiert, Einweisung nachvollziehbar.');
await a.click('button:has-text("Bewertung speichern")');
await a.waitForSelector('text=/gespeichert/i', { timeout: 20000 });
await a.click('button:has-text("Aufgabe abnehmen")');
await a.waitForSelector('button:has-text("Bereits abgenommen")', { timeout: 20000 });
pruefe(true, 'Der Ausbilder nimmt die Aufgabe ab');
// Und danach ist sie für den Azubi gesperrt.
await z.goto(`${BASIS}/aufgaben/1.1`);
await z.waitForSelector('h1');
pruefe(
await z.isDisabled('#aufgaben-notiz'),
'Für den Azubi ist die abgenommene Aufgabe gesperrt',
);
// Eine abgenommene Aufgabe ist der belastbare Nachweis in der Abdeckung.
await a.goto(`${BASIS}/auswertung`);
await a.waitForSelector('h1');
const abdeckung = await a.locator('body').innerText();
pruefe(/vermittelt/.test(abdeckung), 'Die Abnahme schlägt in der Rahmenplanabdeckung durch');
pruefe(/ohne Inhalt|kein Inhalt/i.test(abdeckung), 'Lücken im Lehrgang werden getrennt ausgewiesen');
}
// --- A6 Rechte -----------------------------------------------------------
abschnitt('A6 · Rechte serverseitig');
const fremd = await azubi.request.get(`${BASIS}/verwaltung`, { maxRedirects: 0 });
pruefe(
fremd.status() >= 300 && fremd.status() < 400,
'Der Azubi kommt nicht in die Verwaltung',
`HTTP ${fremd.status()}`,
);
// --- A7 Berichtsheft -----------------------------------------------------
abschnitt('A7 · Berichtsheft');
await z.goto(`${BASIS}/berichtsheft`);
await z.waitForSelector('h1');
// Auf einer frischen Datenbank gibt es noch kein Blatt – der Azubi legt das
// der laufenden Woche an. Genau so fängt er im Betrieb auch an.
const anlegen = await z.$('button:has-text("Blatt für diese Woche anlegen")');
if (anlegen) {
await anlegen.click();
await z.waitForSelector('.blattzeile-link, h1', { timeout: 30000 });
await z.goto(`${BASIS}/berichtsheft`);
await z.waitForSelector('h1');
}
pruefe(
(await z.locator('.blattzeile-link').count()) > 0,
'Der Azubi legt das Wochenblatt der laufenden Woche an',
);
const blatt = await z.$('.blattzeile-link');
if (blatt) {
await blatt.click();
// Auf die Adresse warten, nicht auf eine Überschrift: Die steht auch auf
// der Liste, von der wir kommen. Sonst zeigt `url()` noch dorthin – und
// die Anfrage landet auf der Sammelroute statt auf dem einzelnen Blatt.
await z.waitForURL(/\/berichtsheft\/[0-9a-f-]{36}$/, { timeout: 30000 });
pruefe(true, 'Ein Wochenblatt lässt sich öffnen');
const pdf = await azubi.request.get(`${z.url()}/pdf`);
const puffer = await pdf.body();
pruefe(
pdf.status() === 200 && puffer.subarray(0, 5).toString() === '%PDF-',
'Das Wochenblatt kommt als PDF heraus',
`HTTP ${pdf.status()}, ${puffer.byteLength} Bytes${
puffer.subarray(0, 5).toString() === '%PDF-' ? '' : `: „${puffer.toString('utf8').slice(0, 60)}"`
}`,
);
} else {
pruefe(false, 'Ein Wochenblatt lässt sich öffnen', 'keine Blattzeile gefunden');
}
// --- A8 Sicherung und Rückspielung ---------------------------------------
abschnitt('A8 · Sicherung zurückspielen');
// Pfade für bash mit Schrägstrichen: Ein Backslash ist dort ein Fluchtzeichen.
const alsPosix = (pfad) => pfad.split(path.sep).join('/');
const sicherung = laufen('bash', ['scripts/im-container/sicherung.sh', alsPosix(path.join(daten, 'sicherungen'))]);
const archiv = (sicherung.stdout || '').trim().split('\n').pop();
pruefe(
sicherung.status === 0 && archiv && fs.existsSync(archiv),
'Die Sicherung läuft durch',
archiv ? path.basename(archiv) : (sicherung.stderr || '').trim(),
);
// Jetzt absichtlich kaputtmachen: Der Azubi verschwindet. Vorher den Server
// beenden – zurückgespielt wird bei gestoppter Anwendung, so steht es auch
// in der Anleitung.
serverBeenden();
await new Promise((weiter) => setTimeout(weiter, 2500));
const { default: Datenbank } = await import('better-sqlite3');
const dbPfad = path.join(daten, 'lernbar.sqlite');
const vorher = (() => {
const db = new Datenbank(dbPfad);
const zahl = db.prepare('select count(*) n from benutzer').get().n;
db.prepare('delete from benutzer where email = ?').run(AZUBI.email);
const nachher = db.prepare('select count(*) n from benutzer').get().n;
db.close();
return { zahl, nachher };
})();
pruefe(vorher.nachher < vorher.zahl, 'Zum Beweis wird der Datenbestand beschädigt', `${vorher.zahl} → ${vorher.nachher} Konten`);
const zurueck = laufen('bash', ['scripts/im-container/wiederherstellen.sh', alsPosix(archiv ?? ''), '--ohne-rueckfrage']);
pruefe(zurueck.status === 0, 'Die Rückspielung läuft durch', (zurueck.stderr || '').trim() || 'ohne Fehler');
const wiederDa = (() => {
const db = new Datenbank(dbPfad);
const treffer = db.prepare('select name from benutzer where email = ?').get(AZUBI.email);
const karten = db.prepare('select count(*) n from karten_verlauf').get().n;
db.close();
return { treffer, karten };
})();
pruefe(wiederDa.treffer?.name === AZUBI.name, 'Der gelöschte Azubi ist wieder da', wiederDa.treffer?.name ?? 'fehlt');
pruefe(wiederDa.karten >= 0, 'Auch der Lernverlauf kam mit zurück', `${wiederDa.karten} Einträge`);
const verdraengt = fs.readdirSync(daten).filter((name) => name.startsWith('verdraengt-'));
pruefe(verdraengt.length === 1, 'Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht', verdraengt[0] ?? '—');
} catch (ausnahme) {
console.error('\nAbbruch:', ausnahme instanceof Error ? ausnahme.message : ausnahme);
fehler += 1;
} finally {
await browser?.close();
}
// --- Protokoll -------------------------------------------------------------
const zeilen = ['# Abnahmeprotokoll', '', `Erzeugt am ${new Date().toISOString().slice(0, 16).replace('T', ' ')} Uhr`, '', 'Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenbank**.', ''];
for (const eintrag of protokoll) {
if (eintrag.abschnitt) zeilen.push('', `## ${eintrag.abschnitt}`, '');
else zeilen.push(`- ${eintrag.erfuellt ? '✓' : '✗'} ${eintrag.text}${eintrag.zusatz ? ` — ${eintrag.zusatz}` : ''}`);
}
zeilen.push('', `**${protokoll.filter((e) => e.erfuellt).length} von ${protokoll.filter((e) => 'erfuellt' in e).length} Prüfungen erfüllt.**`, '');
fs.writeFileSync(path.join(process.cwd(), 'docs', 'ABNAHME.md'), zeilen.join('\n'), 'utf8');
console.log('\nProtokoll: docs/ABNAHME.md');
console.log(`${fehler === 0 ? '✓ Abnahme bestanden.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
process.exit(fehler === 0 ? 0 : 1);
+292
View File
@@ -0,0 +1,292 @@
import { chromium } from '@playwright/test';
/**
* Durchstich für Baustein 10 – PWA, Offlinebetrieb, Druck.
*
* Die Zusage dieses Bausteins ist schmal und genau: Der Azubi soll ohne Netz
* weiterlernen können, und was er dabei bewertet, darf nicht verloren gehen.
* Gleichzeitig darf **nichts** offline liegen, was hinter einer eigenen
* Rechteprüfung steht – Musterlösungen und Bewertungen gehören nicht in den
* Browserspeicher.
*
* Geprüft wird gegen den laufenden Server, mit echtem Abschalten des Netzes.
*/
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
let fehler = 0;
function pruefe(bedingung, text, zusatz = '') {
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
if (!bedingung) fehler += 1;
}
const browser = await chromium.launch();
const kontext = await browser.newContext({ locale: 'de-DE' });
const seite = await kontext.newPage();
async function anmelden() {
await seite.goto(`${BASIS}/anmelden`);
await seite.fill('#email', AZUBI.email);
await seite.fill('#passwort', AZUBI.pw);
await seite.click('button[type=submit]');
await seite.waitForURL(`${BASIS}/`);
}
/** Wartet, bis der Dienstarbeiter das Ruder übernommen hat. */
async function dienstarbeiterBereit() {
return seite.evaluate(async () => {
if (!('serviceWorker' in navigator)) return false;
const registrierung = await navigator.serviceWorker.ready;
// `ready` sagt nur „installiert". Erst ein Controller fängt Anfragen ab.
if (navigator.serviceWorker.controller) return true;
await new Promise((fertig) => {
navigator.serviceWorker.addEventListener('controllerchange', fertig, { once: true });
window.setTimeout(fertig, 5000);
});
return Boolean(navigator.serviceWorker.controller || registrierung.active);
});
}
// --- 1. Die Anwendung lässt sich installieren ------------------------------
console.log('\n1. Installierbar');
const manifest = await kontext.request.get(`${BASIS}/manifest.webmanifest`);
pruefe(manifest.status() === 200, 'Das Manifest wird ausgeliefert', `HTTP ${manifest.status()}`);
const manifestInhalt = await manifest.json().catch(() => null);
pruefe(manifestInhalt?.name?.includes('Lern-Bar'), 'Es trägt den Namen der Anwendung');
pruefe(manifestInhalt?.display === 'standalone', 'Es startet als eigenständige Anwendung');
pruefe(
Array.isArray(manifestInhalt?.icons) && manifestInhalt.icons.some((s) => s.purpose === 'maskable'),
'Es enthält ein Symbol für den Beschnitt unter Android',
);
for (const datei of ['/symbol-192.png', '/symbol-512.png', '/symbol-maskabel-512.png', '/sw.js']) {
const antwort = await kontext.request.get(`${BASIS}${datei}`);
pruefe(antwort.status() === 200, `${datei} liegt bereit`, `HTTP ${antwort.status()}`);
}
// Nichts davon darf von außen kommen – die Anwendung läuft in einem Netz
// ohne Internet.
await anmelden();
const fremdeQuellen = await seite.evaluate(() =>
[...document.querySelectorAll('script[src], link[href], img[src]')]
.map((element) => element.getAttribute('src') ?? element.getAttribute('href') ?? '')
.filter((adresse) => /^https?:\/\//.test(adresse) && !adresse.startsWith(location.origin)),
);
pruefe(fremdeQuellen.length === 0, 'Keine Quelle von außerhalb', fremdeQuellen.join(', ') || 'keine');
// --- 2. Der Dienstarbeiter übernimmt ---------------------------------------
console.log('\n2. Dienstarbeiter');
pruefe(await dienstarbeiterBereit(), 'Der Dienstarbeiter ist eingerichtet und steuert die Seite');
// Lernmaterial einmal ansehen, damit es im Speicher landet.
await seite.goto(`${BASIS}/handouts/N3`);
await seite.waitForSelector('h1');
await seite.goto(`${BASIS}/karten/lernen`);
await seite.waitForSelector('h1, .lernkarte');
// Und etwas, das **nicht** in den Speicher darf.
await seite.goto(`${BASIS}/kontrollen`);
await seite.waitForSelector('h1');
const imSpeicher = await seite.evaluate(async () => {
const namen = await caches.keys();
const pfade = [];
for (const name of namen) {
const speicher = await caches.open(name);
for (const anfrage of await speicher.keys()) pfade.push(new URL(anfrage.url).pathname);
}
return pfade;
});
pruefe(imSpeicher.includes('/handouts/N3'), 'Das gelesene Handout liegt im Speicher');
pruefe(imSpeicher.includes('/karten/lernen'), 'Der Lernmodus liegt im Speicher');
// Das ist die eigentliche Zusage – hier wird eine Regel geprüft, keine Laune.
const verboten = imSpeicher.filter((pfad) =>
/^\/(kontrollen|berichtsheft|feedback|auswertung|aufgaben|checklisten|verwaltung|anhaenge)/.test(pfad),
);
pruefe(
verboten.length === 0,
'Nichts Bewertungsrelevantes liegt im Browserspeicher',
verboten.join(', ') || 'nichts',
);
pruefe(
!imSpeicher.some((pfad) => pfad.endsWith('/pdf')),
'Kein PDF liegt im Browserspeicher',
);
// --- 3. Ohne Netz --------------------------------------------------------
console.log('\n3. Ohne Netz');
await kontext.setOffline(true);
const handoutOffline = await seite.goto(`${BASIS}/handouts/N3`).catch(() => null);
pruefe(handoutOffline !== null && handoutOffline.ok(), 'Das gelesene Handout öffnet sich ohne Netz');
pruefe(
(await seite.locator('h1').innerText().catch(() => '')).length > 0,
'Es ist wirklich der Inhalt und keine Fehlerseite',
await seite.locator('h1').innerText().catch(() => '—'),
);
const lernenOffline = await seite.goto(`${BASIS}/karten/lernen`).catch(() => null);
pruefe(lernenOffline !== null && lernenOffline.ok(), 'Der Lernmodus öffnet sich ohne Netz');
// Die Netzleiste sagt Bescheid.
await seite.waitForTimeout(500);
const leiste = await seite.locator('.netzleiste').count();
pruefe(leiste > 0, 'Die Leiste „Ohne Netz" erscheint');
// Eine Seite, die nie im Speicher war, landet auf der Offlineseite.
const unbekannt = await seite.goto(`${BASIS}/kontrollen`).catch(() => null);
const unbekannterText = await seite.locator('body').innerText().catch(() => '');
pruefe(
unbekannterText.includes('Kein Netz'),
'Eine nicht gespeicherte Seite zeigt die Offlineseite',
`HTTP ${unbekannt?.status() ?? '—'}`,
);
// --- 4. Bewertungen gehen nicht verloren -----------------------------------
console.log('\n4. Bewertungen ohne Netz');
await seite.goto(`${BASIS}/karten/lernen`);
const hatKarte = (await seite.locator('.lernkarte').count()) > 0;
if (!hatKarte) {
console.log(' – heute ist keine Karte fällig, Warteschlange übersprungen');
} else {
await seite.click('.lernkarte');
await seite.waitForSelector('.bewerten-knopf');
await seite.click('.bewerten-sitzt');
await seite.waitForTimeout(800);
const schlange = await seite.evaluate(() =>
Object.keys(localStorage)
.filter((s) => s.startsWith('lernbar-warteschlange-'))
.flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')),
);
pruefe(schlange.length >= 1, 'Die Bewertung liegt in der Warteschlange', `${schlange.length} Eintrag/Einträge`);
pruefe(
schlange.every((e) => e.karteId && e.bewertung && /^\d{4}-\d{2}-\d{2}$/.test(e.erfasstAm)),
'Jeder Eintrag trägt Karte, Bewertung und den Tag der Bewertung',
);
const gemeldet = await seite.locator('.lernmodus-rueckmeldung').innerText();
pruefe(/gemerkt/.test(gemeldet), 'Der Azubi sieht, dass die Bewertung gemerkt wurde', gemeldet.trim());
// --- Und wieder im Netz ---
console.log('\n5. Wieder im Netz');
// Wichtig: auf **derselben** Seite bleiben. Die Verbindung kehrt zurück,
// während der Azubi noch im Lernmodus steht – genau dort muss die
// Bestätigung erscheinen. Wer vorher die Seite wechselt, sieht sie nie,
// weil die Schlange dann schon leer ist.
await kontext.setOffline(false);
const bestaetigung = await seite
.locator('.netzleiste')
.filter({ hasText: 'nachgereicht' })
.first()
.innerText({ timeout: 15000 })
.catch(() => '');
pruefe(/nachgereicht/.test(bestaetigung), 'Der Azubi bekommt die Bestätigung zu sehen', bestaetigung.trim() || '—');
const nachher = await seite.evaluate(() =>
Object.keys(localStorage)
.filter((s) => s.startsWith('lernbar-warteschlange-'))
.flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')),
);
pruefe(nachher.length === 0, 'Die Warteschlange ist abgearbeitet', `${nachher.length} übrig`);
// Und die Bewertung ist wirklich angekommen, nicht nur verschwunden.
await seite.goto(`${BASIS}/karten`);
await seite.waitForSelector('h1');
const uebersicht = await seite.locator('body').innerText();
pruefe(
!/0 von \d+ Karten/.test(uebersicht) || /sitzt/i.test(uebersicht),
'Die Übersicht kennt den neuen Lernstand',
);
}
await kontext.setOffline(false);
// --- 6. Die Schnittstelle prüft die Rechte ---------------------------------
console.log('\n6. Rechte an der Schnittstelle');
const ohneAnmeldung = await browser.newContext();
const abgewiesen = await ohneAnmeldung.request.post(`${BASIS}/api/lernstand`, {
data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] },
});
pruefe(abgewiesen.status() === 401, 'Ohne Anmeldung: abgewiesen', `HTTP ${abgewiesen.status()}`);
await ohneAnmeldung.close();
const alsAusbilder = await browser.newContext({ locale: 'de-DE' });
const ausbilderSeite = await alsAusbilder.newPage();
await ausbilderSeite.goto(`${BASIS}/anmelden`);
await ausbilderSeite.fill('#email', 'fw@wildeflorian.de');
await ausbilderSeite.fill('#passwort', 'ausbilder-start');
await ausbilderSeite.click('button[type=submit]');
await ausbilderSeite.waitForURL(`${BASIS}/`);
const alsAusbilderAbgewiesen = await alsAusbilder.request.post(`${BASIS}/api/lernstand`, {
data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] },
});
pruefe(
alsAusbilderAbgewiesen.status() === 403,
'Der Ausbilder darf den Lernstand nicht führen',
`HTTP ${alsAusbilderAbgewiesen.status()}`,
);
await alsAusbilder.close();
// --- 7. Druck --------------------------------------------------------------
console.log('\n7. Druck');
await seite.goto(`${BASIS}/handouts/N3`);
await seite.waitForSelector('h1');
// Die dunkle Fassung einstellen – und dann drucken. Das ist der Fall, in dem
// früher schwarze Flächen aufs Papier gekommen wären.
await seite.evaluate(() => {
document.documentElement.setAttribute('data-thema', 'dunkel');
localStorage.setItem('lernbar-thema', 'dunkel');
});
await seite.emulateMedia({ media: 'print' });
await seite.waitForTimeout(200);
const druckfarben = await seite.evaluate(() => {
const stil = getComputedStyle(document.body);
const wurzel = getComputedStyle(document.documentElement);
return { grund: stil.backgroundColor, text: stil.color, flaeche: wurzel.getPropertyValue('--flaeche').trim() };
});
pruefe(
druckfarben.grund === 'rgb(255, 255, 255)',
'Im Druck ist der Grund weiß, auch bei eingestellter dunkler Fassung',
druckfarben.grund,
);
pruefe(druckfarben.text === 'rgb(0, 0, 0)', 'Der Text ist schwarz', druckfarben.text);
pruefe(
druckfarben.flaeche.toLowerCase() === '#f2f2f2',
'Auch die Flächen sind hell',
druckfarben.flaeche,
);
const versteckt = await seite.evaluate(() => {
const kopf = document.querySelector('.kopf');
return kopf ? getComputedStyle(kopf).display : 'keine';
});
pruefe(versteckt === 'none', 'Die Navigation wird nicht mitgedruckt', versteckt);
await seite.emulateMedia({ media: null });
await browser.close();
console.log(`\n${fehler === 0 ? '✓ Baustein 10 steht.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
process.exit(fehler === 0 ? 0 : 1);
+237
View File
@@ -0,0 +1,237 @@
import { chromium } from '@playwright/test';
/**
* Durchstich für Baustein 9.
*
* Geprüft werden die beiden Zusagen, die in diesem Baustein zählen:
*
* 1. Die Rahmenplanabdeckung unterscheidet vier Lagen – und weist Positionen
* ohne Material im Lehrgang getrennt aus (A4, A10, C1, C2, C5).
* 2. Das Feedbackgespräch bewertet beide Seiten getrennt, hebt Abweichungen
* hervor, ist nach beidseitiger Bestätigung unveränderlich und liefert
* ein PDF.
*/
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
const AUSBILDER = { email: 'fw@wildeflorian.de', pw: 'ausbilder-start' };
let fehler = 0;
function pruefe(bedingung, text, zusatz = '') {
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
if (!bedingung) fehler += 1;
}
const browser = await chromium.launch();
const UEBRIGE = [
'sorgfalt', 'dokumentation', 'fachwissen', 'fehlersuche',
'kundenkontakt', 'zuverlaessigkeit', 'lernbereitschaft',
];
/**
* Die Radios selbst sind für das Auge weggeblendet; angeklickt wird die
* Beschriftung – genau wie ein Mensch es täte.
*/
async function waehle(seite, kriterium, wert) {
await seite.click(`label.stufe:has(input[name="stufe-${kriterium}"][value="${wert}"])`);
}
async function neueSitzung(konto) {
const kontext = await browser.newContext({ locale: 'de-DE' });
const seite = await kontext.newPage();
await seite.goto(`${BASIS}/anmelden`);
await seite.fill('#email', konto.email);
await seite.fill('#passwort', konto.pw);
await seite.click('button[type=submit]');
await seite.waitForURL(`${BASIS}/`);
return { kontext, seite };
}
// --- 1. Auswertung ---------------------------------------------------------
console.log('\n1. Auswertung und Rahmenplanabdeckung (Ausbilder)');
const ausbilder = await neueSitzung(AUSBILDER);
await ausbilder.seite.goto(`${BASIS}/auswertung`);
await ausbilder.seite.waitForLoadState('networkidle');
const text = await ausbilder.seite.locator('body').innerText();
pruefe(text.includes('Abdeckung'), 'Die Auswertung zeigt die Rahmenplanabdeckung');
pruefe(/ohne Inhalt/i.test(text), 'Die vierte Lage „ohne Inhalt" kommt vor');
// Die fünf Lücken im Beispielinhalt müssen als eigene Gruppe auftauchen.
const luecken = ['A4', 'A10', 'C1', 'C2', 'C5'];
const gefunden = [];
for (const kuerzel of luecken) {
const treffer = await ausbilder.seite
.locator(`[data-stufe="ohne_inhalt"]:has-text("${kuerzel}")`)
.count()
.catch(() => 0);
if (treffer > 0) gefunden.push(kuerzel);
}
pruefe(
gefunden.length === luecken.length,
'Alle fünf Positionen ohne Material sind als Lücke im Lehrgang gekennzeichnet',
`gefunden: ${gefunden.join(', ') || 'keine'}`,
);
pruefe(/Lernfeld/i.test(text), 'Die Lernfeldabdeckung ist vorhanden');
pruefe(/§|Verordnung|Abschnitt/i.test(text), 'Die Fundstelle in der Verordnung wird genannt');
// --- 2. Feedbackgespräch anlegen ------------------------------------------
console.log('\n2. Feedbackgespräch – Ausbilder legt an und bewertet');
await ausbilder.seite.goto(`${BASIS}/feedback`);
await ausbilder.seite.click('button:has-text("Neues Gespräch anlegen")');
await ausbilder.seite.waitForURL(/\/feedback\/[0-9a-f-]{36}$/);
const gespraechUrl = ausbilder.seite.url();
const gespraechId = gespraechUrl.split('/').pop();
pruefe(Boolean(gespraechId), 'Gespräch angelegt', gespraechId);
// Ausbilder bewertet: Selbstständigkeit 2, alle anderen 4.
await waehle(ausbilder.seite, 'selbststaendigkeit', 2);
for (const k of UEBRIGE) await waehle(ausbilder.seite, k, 4);
await ausbilder.seite.fill('#staerken', 'Arbeitet ruhig und fragt nach, bevor etwas kaputtgeht.');
await ausbilder.seite.fill('#entwicklungsfelder', 'Bei unklarer Lage zu lange allein – früher melden.');
await ausbilder.seite.fill('#ziele', 'Bis Ende November eine Rücksicherung eigenständig durchführen.');
await ausbilder.seite.click('button:has-text("Speichern")');
await ausbilder.seite.waitForSelector('text=Gespeichert', { timeout: 15000 });
pruefe(true, 'Einschätzung des Ausbilders gespeichert');
// --- 3. Azubi sieht das Gespräch und schätzt sich selbst ein ---------------
console.log('\n3. Feedbackgespräch – Azubi schätzt sich selbst ein');
const azubi = await neueSitzung(AZUBI);
await azubi.seite.goto(gespraechUrl);
await azubi.seite.waitForLoadState('networkidle');
const azubiText = await azubi.seite.locator('body').innerText();
pruefe(azubiText.includes('Feedbackgespräch'), 'Der Azubi sieht sein Gespräch');
pruefe(
azubiText.includes('Arbeitet ruhig und fragt nach'),
'Der Azubi liest die Freitexte des Ausbilders',
);
pruefe(
(await azubi.seite.locator('#staerken').count()) === 0,
'Der Azubi kann die Freitexte nicht bearbeiten',
);
// Azubi schätzt sich bei Selbstständigkeit deutlich höher ein: 5 gegen 2.
await waehle(azubi.seite, 'selbststaendigkeit', 5);
for (const k of UEBRIGE) await waehle(azubi.seite, k, 4);
await azubi.seite.click('button:has-text("Speichern")');
await azubi.seite.waitForSelector('text=Selbsteinschätzung ist gespeichert', { timeout: 15000 });
await azubi.seite.reload();
const nachSpeichern = await azubi.seite.locator('body').innerText();
pruefe(
/Unterschiedliche Sicht/i.test(nachSpeichern),
'Die Abweichung von drei Stufen wird hervorgehoben',
);
// Gegenprobe: Die Spalte des Ausbilders blieb unangetastet.
await ausbilder.seite.reload();
pruefe(
await ausbilder.seite.isChecked('input[name="stufe-selbststaendigkeit"][value="2"]'),
'Der Azubi hat die Einschätzung des Ausbilders nicht überschrieben',
);
// --- 4. Beidseitige Bestätigung sperrt -------------------------------------
console.log('\n4. Bestätigung durch beide Seiten');
await azubi.seite.click('button:has-text("Gespräch bestätigen")');
await azubi.seite.waitForSelector('text=Bestätigt', { timeout: 15000 });
pruefe(true, 'Azubi hat bestätigt');
// Erst ohne, dann mit Neuladen – so ist ein Rennen von einem echten Fehler
// zu unterscheiden.
const sofort = await azubi.seite
.locator('button:has-text("Bestätigung zurücknehmen")')
.waitFor({ timeout: 5000 })
.then(() => true, () => false);
await azubi.seite.reload();
const nachLaden = (await azubi.seite.locator('button:has-text("Bestätigung zurücknehmen")').count()) > 0;
pruefe(
nachLaden,
'Solange nur eine Seite bestätigt hat, lässt sich das zurücknehmen',
sofort ? 'sofort sichtbar' : 'erst nach dem Neuladen sichtbar',
);
await ausbilder.seite.reload();
await ausbilder.seite.click('button:has-text("Als Ausbilder bestätigen")');
await ausbilder.seite.waitForSelector('text=unveränderlich', { timeout: 15000 });
await ausbilder.seite.reload();
const gesperrt = await ausbilder.seite.locator('body').innerText();
pruefe(/abgeschlossen und unveränderlich/.test(gesperrt), 'Das Gespräch ist jetzt abgeschlossen');
pruefe(
(await ausbilder.seite.locator('button:has-text("Speichern")').count()) === 0,
'Es gibt keinen Speichern-Knopf mehr',
);
pruefe(
(await ausbilder.seite.locator('button:has-text("Bestätigung zurücknehmen")').count()) === 0,
'Die Bestätigung lässt sich nicht mehr zurücknehmen',
);
// Serverseitige Gegenprobe: Schreiben trotz Sperre muss abgewiesen werden.
await azubi.seite.reload();
pruefe(
await azubi.seite.isDisabled('input[name="stufe-selbststaendigkeit"][value="5"]'),
'Auch beim Azubi sind die Felder gesperrt',
);
// --- 5. PDF ----------------------------------------------------------------
console.log('\n5. PDF für die Akte');
const pdfAntwort = await ausbilder.kontext.request.get(`${gespraechUrl}/pdf`);
const pdfKoerper = await pdfAntwort.body();
pruefe(pdfAntwort.status() === 200, 'PDF wird ausgeliefert', `HTTP ${pdfAntwort.status()}`);
pruefe(pdfKoerper.subarray(0, 5).toString() === '%PDF-', 'Die Antwort ist wirklich ein PDF');
pruefe(pdfKoerper.byteLength > 3000, 'Das PDF hat Inhalt', `${pdfKoerper.byteLength} Bytes`);
// Und ohne Anmeldung? Ein PDF aus der Ausbildungsakte darf nicht herausfallen.
const fremd = await browser.newContext();
const ohne = await fremd.request.get(`${gespraechUrl}/pdf`, { maxRedirects: 0 });
pruefe(
ohne.status() >= 300 && ohne.status() < 400,
'Ohne Anmeldung kommt kein PDF heraus',
`HTTP ${ohne.status()} → ${ohne.headers()['location'] ?? '—'}`,
);
await fremd.close();
// --- 6. Fremdzugriff -------------------------------------------------------
console.log('\n6. Rechte');
const fremderAzubi = await browser.newContext({ locale: 'de-DE' });
const fremdSeite = await fremderAzubi.newPage();
await fremdSeite.goto(`${BASIS}/anmelden`);
await fremdSeite.fill('#email', 'lea.hoffmann@betrieb.de');
await fremdSeite.fill('#passwort', 'azubi-start-2026');
await fremdSeite.click('button[type=submit]');
const angemeldet = await fremdSeite.waitForURL(`${BASIS}/`, { timeout: 8000 }).then(
() => true,
() => false,
);
if (angemeldet) {
const antwort = await fremdSeite.goto(gespraechUrl);
pruefe(antwort?.status() === 404, 'Ein anderer Azubi bekommt 404', `HTTP ${antwort?.status()}`);
const fremdPdf = await fremderAzubi.request.get(`${gespraechUrl}/pdf`);
pruefe(fremdPdf.status() === 404, 'Auch das PDF bleibt verschlossen', `HTTP ${fremdPdf.status()}`);
} else {
console.log(' – zweites Azubikonto nicht vorhanden, Fremdzugriff übersprungen');
}
await fremderAzubi.close();
await browser.close();
console.log(`\n${fehler === 0 ? '✓ Baustein 9 steht.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
process.exit(fehler === 0 ? 0 : 1);
+199
View File
@@ -0,0 +1,199 @@
import { chromium } from '@playwright/test';
import { spawnSync } from 'node:child_process';
/**
* Der Abnahmelauf gegen das Docker-Abbild.
*
* `abnahme.mjs` prüft die Anwendung über `next start`. Betrieben wird sie aber
* als Container – und was dort fehlt, fällt dabei nicht auf. So war es mit dem
* Kontowerkzeug: Die README beschrieb `node werkzeuge/konto.js`, im Abbild lag
* es nie, und ein frisch aufgesetzter Server stand ohne jedes Konto da.
*
* Dieser Lauf baut das Abbild, startet es mit leerem Volume und geht den Weg
* aus der README: Lebenszeichen, erstes Konto, Anmeldung, Sperre bei
* Fehlversuchen, Sicherung.
*
* node scripts/pruefungen/container.mjs
*
* Voraussetzung: Docker läuft, Port 3398 ist frei (LERNBAR_CONTAINER_PORT).
*/
const PORT = Number(process.env.LERNBAR_CONTAINER_PORT ?? 3398);
const BASIS = `http://localhost:${PORT}`;
const ABBILD = 'lernbar:abnahme';
const NAME = `lernbar-abnahme-${Date.now()}`;
const AUSBILDER = { name: 'Petra Kessler', email: 'petra.kessler@betrieb.test', pw: 'container-ausbilder' };
let fehler = 0;
function pruefe(bedingung, text, zusatz = '') {
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
if (!bedingung) fehler += 1;
}
function abschnitt(titel) {
console.log(`\n${titel}`);
}
function docker(argumente, zusatz = {}) {
return spawnSync('docker', argumente, { encoding: 'utf8', ...zusatz });
}
function imContainer(...argumente) {
const ergebnis = docker(['exec', NAME, ...argumente]);
return { ende: ergebnis.status, ausgabe: `${ergebnis.stdout}${ergebnis.stderr}` };
}
let browser = null;
function aufraeumen() {
// -v nimmt das namenlose Volume mit – sonst bleibt bei jedem Lauf eins liegen.
docker(['rm', '-fv', NAME], { stdio: 'ignore' });
docker(['rmi', ABBILD], { stdio: 'ignore' });
}
async function portBelegt() {
try {
await fetch(`${BASIS}/api/gesundheit`, { signal: AbortSignal.timeout(1000) });
return true;
} catch {
return false;
}
}
async function gesund(sekunden = 60) {
for (let i = 0; i < sekunden; i += 1) {
try {
const antwort = await fetch(`${BASIS}/api/gesundheit`);
if (antwort.ok) return true;
} catch {
/* noch nicht da */
}
await new Promise((r) => setTimeout(r, 1000));
}
return false;
}
async function anmelden(email, passwort) {
const kontext = await browser.newContext({ locale: 'de-DE' });
const seite = await kontext.newPage();
seite.setDefaultTimeout(30000);
await seite.goto(`${BASIS}/anmelden`);
await seite.fill('#email', email);
await seite.fill('#passwort', passwort);
await seite.click('button[type=submit]');
const angekommen = await seite
.waitForURL(`${BASIS}/`, { timeout: 15000 })
.then(() => true)
.catch(() => false);
await kontext.close();
return angekommen;
}
/** Ein Anmeldeversuch, der die angezeigte Meldung zurückgibt. */
async function meldungNachAnmeldung(email, passwort) {
const kontext = await browser.newContext({ locale: 'de-DE' });
const seite = await kontext.newPage();
seite.setDefaultTimeout(30000);
await seite.goto(`${BASIS}/anmelden`);
await seite.fill('#email', email);
await seite.fill('#passwort', passwort);
await seite.click('button[type=submit]');
const text = await seite
.locator('.hinweis-fehler')
.innerText({ timeout: 15000 })
.catch(() => '');
await kontext.close();
return text;
}
async function main() {
if (docker(['info'], { stdio: 'ignore' }).status !== 0) {
console.error('Docker ist nicht erreichbar.');
process.exit(2);
}
if (await portBelegt()) {
console.error(`Auf Port ${PORT} antwortet schon etwas. Anderen Port über LERNBAR_CONTAINER_PORT setzen.`);
process.exit(2);
}
// --- C1 Das Abbild entsteht ----------------------------------------------
abschnitt('C1 · Das Abbild entsteht und startet');
console.log(' … baue das Abbild, das dauert beim ersten Mal einige Minuten');
const bau = docker(['build', '-t', ABBILD, '.'], { stdio: ['ignore', 'ignore', 'pipe'] });
pruefe(bau.status === 0, 'Das Abbild lässt sich bauen', bau.status === 0 ? '' : bau.stderr.split('\n').slice(-5).join(' '));
if (bau.status !== 0) return;
const start = docker(['run', '-d', '--name', NAME, '-p', `127.0.0.1:${PORT}:3000`, ABBILD]);
pruefe(start.status === 0, 'Der Container startet');
if (start.status !== 0) return;
pruefe(await gesund(), 'Die Anwendung meldet sich gesund');
pruefe(imContainer('id', '-un').ausgabe.trim() === 'node', 'Sie läuft nicht als root');
// --- C2 Das erste Konto ---------------------------------------------------
abschnitt('C2 · Das erste Konto entsteht im Container');
const werkzeuge = imContainer('ls', '/app/werkzeuge').ausgabe;
pruefe(werkzeuge.includes('konto.js'), 'Das Kontowerkzeug liegt im Abbild');
const anlegen = imContainer(
'node', 'werkzeuge/konto.js',
'--name', AUSBILDER.name, '--email', AUSBILDER.email, '--rolle', 'ausbilder', '--passwort', AUSBILDER.pw,
);
pruefe(anlegen.ende === 0 && anlegen.ausgabe.includes('Konto angelegt'), 'Der Aufruf aus der README legt das Konto an', anlegen.ausgabe.split('\n')[0]);
browser = await chromium.launch();
pruefe(await anmelden(AUSBILDER.email, AUSBILDER.pw), 'Der Ausbilder meldet sich mit diesem Konto an');
pruefe(!(await anmelden(AUSBILDER.email, 'falsch-falsch')), 'Ein falsches Passwort wird abgewiesen');
// Derselbe Aufruf ist der Rettungsweg, wenn das einzige Ausbilderkonto sein
// Passwort vergessen hat.
const neu = imContainer('node', 'werkzeuge/konto.js', '--name', AUSBILDER.name, '--email', AUSBILDER.email);
const neuesPw = neu.ausgabe.match(/Passwort: (\S+)/)?.[1];
pruefe(neu.ende === 0 && neu.ausgabe.includes('gab es schon') && neuesPw, 'Ein zweiter Aufruf setzt das Passwort neu');
pruefe(neuesPw && (await anmelden(AUSBILDER.email, neuesPw)), 'Mit dem neuen Passwort geht die Anmeldung');
pruefe(!(await anmelden(AUSBILDER.email, AUSBILDER.pw)), 'Das alte Passwort gilt nicht mehr');
// --- C3 Bremse gegen Passwortraten ----------------------------------------
abschnitt('C3 · Bremse gegen Passwortraten');
// Eine Adresse, die es nicht gibt: Die Sperre muss genauso greifen, sonst
// verriete sie, welche Konten existieren.
const unbekannt = 'niemand@betrieb.test';
for (let i = 0; i < 5; i += 1) await meldungNachAnmeldung(unbekannt, `geraten-${i}`);
const gesperrt = await meldungNachAnmeldung(unbekannt, 'geraten-6');
pruefe(gesperrt.includes('Zu viele Fehlversuche'), 'Nach fünf Fehlversuchen wird gesperrt – auch für eine unbekannte Adresse', gesperrt);
pruefe(neuesPw && (await anmelden(AUSBILDER.email, neuesPw)), 'Ein anderes Konto ist davon nicht betroffen');
for (let i = 0; i < 5; i += 1) await meldungNachAnmeldung(AUSBILDER.email, `geraten-${i}`);
pruefe(!(await anmelden(AUSBILDER.email, neuesPw)), 'Während der Sperre kommt auch das richtige Passwort nicht durch');
// --- C4 Sicherung im Container -------------------------------------------
abschnitt('C4 · Sicherung im Container');
const sicherung = imContainer('/app/werkzeuge/sicherung.sh', '/daten/sicherungen');
pruefe(sicherung.ende === 0, 'Die Sicherung läuft im Container durch', sicherung.ausgabe.trim().split('\n').at(-1));
const archive = imContainer('sh', '-c', 'ls /daten/sicherungen/lernbar-*.tar.gz').ausgabe;
pruefe(/lernbar-\d{8}-\d{6}\.tar\.gz/.test(archive), 'Das Archiv liegt im Volume');
}
try {
await main();
} catch (e) {
console.error(e);
fehler += 1;
} finally {
if (browser) await browser.close();
aufraeumen();
}
console.log(fehler === 0 ? '\n✓ Containerabnahme bestanden.' : `\n✗ ${fehler} Prüfung(en) nicht erfüllt.`);
process.exit(fehler === 0 ? 0 : 1);
@@ -0,0 +1,142 @@
import { chromium } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
/**
* Gezielte Prüfung der einen Zusage, auf die es bei den Lernzielkontrollen
* ankommt: Die Musterlösung darf vor dem Absenden nirgends herauskommen.
*
* Geprüft wird gegen den **echten** Wortlaut aus der Inhaltsdatei, nicht gegen
* ausgedachte Zeichenketten – und im rohen Quelltext samt RSC-Nutzlast, nicht
* im sichtbaren Text.
*/
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' };
const AUSBILDER = { email: 'fw@wildeflorian.de', pw: 'ausbilder-start' };
// Musterlösungen direkt aus der Datei lesen – so kann der Test nicht veralten.
const yaml = fs.readFileSync(path.join(process.cwd(), 'content/lernzielkontrollen/woche-01.yaml'), 'utf8');
const loesungen = [...yaml.matchAll(/musterloesung:\s*>-\s*\n((?:\s{6}.+\n)+)/g)].map((t) =>
t[1].replace(/\s+/g, ' ').trim(),
);
// Aussagekräftige Bruchstücke: lang genug, um nicht zufällig vorzukommen.
const bruchstuecke = loesungen.map((l) => l.slice(40, 110)).filter((b) => b.length > 40);
console.log(`${loesungen.length} Musterlösungen aus woche-01.yaml gelesen.`);
console.log('Geprüft wird auf Bruchstücke wie:', JSON.stringify(bruchstuecke[0]?.slice(0, 60)));
// --- Ausgangslage herstellen ----------------------------------------------
//
// Der entscheidende Fall ist „freigegeben, aber noch nicht abgegeben". Den gibt
// es nur einmal je Kontrolle. Damit die Prüfung wiederholbar bleibt, wird die
// Kontrolle „woche-01" vorher zurückgesetzt – das ist ein Testaufbau, kein Teil
// der Anwendung, und greift deshalb ausdrücklich nur auf die lokale
// Beispieldatenbank zu.
const { default: Datenbank } = await import('better-sqlite3');
const dbPfad = process.env.LERNBAR_DB ?? path.join(process.cwd(), 'daten', 'lernbar.sqlite');
if (fs.existsSync(dbPfad)) {
const db = new Datenbank(dbPfad);
const betroffen = db.prepare("select id from kontrolle where kontrolle_id = 'woche-01'").all();
for (const eintrag of betroffen) {
db.prepare('delete from kontroll_antwort where kontrolle_id = ?').run(eintrag.id);
db.prepare('delete from kontrolle where id = ?').run(eintrag.id);
}
db.close();
console.log(`Ausgangslage hergestellt: ${betroffen.length} frühere Bearbeitung(en) von woche-01 entfernt.`);
} else {
console.log(`Keine Datenbank unter ${dbPfad} – die Ausgangslage wird nicht zurückgesetzt.`);
}
const browser = await chromium.launch();
const seite = await (await browser.newContext({ locale: 'de-DE' })).newPage();
async function anmelden(k) {
await seite.goto(`${BASIS}/anmelden`);
await seite.fill('#email', k.email);
await seite.fill('#passwort', k.pw);
await seite.click('button[type=submit]');
await seite.waitForURL(`${BASIS}/`);
}
async function abmelden() {
await seite.click('button:has-text("Abmelden")');
await seite.waitForURL(/anmelden/);
}
/** Sucht im rohen HTML **und** in der Server-Nutzlast. */
async function lecksuche(beschriftung) {
const html = await seite.content();
// Die Nutzlast steht in eingebetteten Skripten, die `self.__next_f` füllen.
// Dort **nachträglich** nachzusehen bringt nichts: Next leert das Feld, sobald
// es verarbeitet ist – es wäre immer leer, und die Prüfung liefe ins Leere,
// ohne dass es auffällt. Deshalb wird der Quelltext der Skripte gelesen.
const rsc = await seite.evaluate(() =>
[...document.querySelectorAll('script:not([src])')]
.map((element) => element.textContent ?? '')
.filter((text) => text.includes('__next_f'))
.join(''),
);
const treffer = bruchstuecke.filter((b) => html.includes(b) || rsc.includes(b));
const nullFelder = (rsc.match(/musterloesung.{0,12}null/g) ?? []).length;
console.log(
` ${beschriftung.padEnd(34)} Leck: ${treffer.length === 0 ? 'nein' : `JA (${treffer.length})`}` +
` | Nutzlast ${rsc.length} Zeichen | "musterloesung":null ${nullFelder}x`,
);
return treffer;
}
console.log('\n1) Vor der Freigabe');
await anmelden(AZUBI);
await seite.goto(`${BASIS}/kontrollen/woche-01`);
await seite.waitForSelector('h1');
await lecksuche('gesperrt');
console.log('\n2) Ausbilder gibt frei');
await abmelden();
await anmelden(AUSBILDER);
await seite.goto(`${BASIS}/kontrollen`);
await seite.waitForSelector('.kontrollzeile');
const freigeben = seite
.locator('.kontrollzeile', { hasText: 'Woche 1' })
.locator('button:has-text("Freigeben")');
if ((await freigeben.count()) > 0) {
await freigeben.click();
await seite.waitForTimeout(1500);
} else {
// Bei einem wiederholten Lauf ist sie schon freigegeben. Das ist kein
// Grund abzubrechen – der entscheidende Fall kommt erst danach.
console.log(' (war bereits freigegeben)');
}
console.log('\n3) Nach der Freigabe, vor der Abgabe – der entscheidende Fall');
await abmelden();
await anmelden(AZUBI);
await seite.goto(`${BASIS}/kontrollen/woche-01`);
await seite.waitForSelector('.frage-text');
console.log(` Fragen sichtbar: ${(await seite.$$('.frage-text')).length}`);
const leck = await lecksuche('freigegeben, nicht abgegeben');
console.log('\n4) Nach dem Zwischenspeichern einer Antwort');
await seite.locator('textarea[id^=antwort-]').first().fill('Person, Gerät, Fehlerbild, Zeitpunkt.');
await seite.waitForTimeout(1600);
await seite.reload();
await seite.waitForSelector('.frage-text');
const leck2 = await lecksuche('nach Zwischenspeichern');
console.log('\n5) Nach der Abgabe muss sie da sein');
await seite.click('button:has-text("Kontrolle abgeben")');
await seite.waitForTimeout(2500);
await seite.reload();
await seite.waitForSelector('h1');
const nachher = await lecksuche('abgegeben');
console.log(` Musterlösungen jetzt sichtbar: ${nachher.length} von ${bruchstuecke.length}`);
await browser.close();
const gescheitert = leck.length + leck2.length;
console.log(`\n${gescheitert === 0 ? 'BESTANDEN' : 'DURCHGEFALLEN'}: vor der Abgabe ${gescheitert} Leck(s).`);
process.exit(gescheitert === 0 && nachher.length === bruchstuecke.length ? 0 : 1);
+94 -2
View File
@@ -74,11 +74,46 @@ async function main() {
await seite.waitForSelector('#neu-name');
await bild(seite, '03-verwaltung');
await seite.goto(`${BASIS}/wochenplan`);
await seite.waitForSelector('h1');
await bild(seite, '04-wochenplan');
await seite.goto(`${BASIS}/aufgaben`);
await seite.waitForSelector('h1');
const abnahme = await seite.$('a[href*="/aufgaben/1.1?azubi="]');
if (abnahme) {
await abnahme.click();
await seite.waitForSelector('#abnahme');
await bild(seite, '05-aufgabe-abnahme');
}
// --- Auswertung und Feedbackgespräch (Ausbildersicht) ---
await seite.goto(`${BASIS}/auswertung`);
await seite.waitForSelector('h1');
await bild(seite, '06-auswertung');
const luecke = await seite.$('[data-stufe="ohne_inhalt"]');
if (luecke) {
await luecke.scrollIntoViewIfNeeded();
await bild(seite, '07-rahmenplanabdeckung', false);
}
await seite.goto(`${BASIS}/feedback`);
await seite.waitForSelector('h1');
await bild(seite, '08-feedback-liste');
const gespraech = await seite.$('.gespraechszeile');
if (gespraech) {
await gespraech.click();
await seite.waitForSelector('#bogen');
await bild(seite, '09-feedbackgespraech');
}
await abmelden(seite);
// --- Azubi ---
await anmelden(seite, AZUBI);
await bild(seite, '10-azubi-start');
await bild(seite, '10-azubi-woche');
await seite.goto(`${BASIS}/handouts`);
await seite.waitForSelector('h1');
@@ -93,9 +128,57 @@ async function main() {
await seite.click('.pruefungsfrage > summary');
await bild(seite, '13-handout-n3');
await seite.goto(`${BASIS}/karten`);
await seite.waitForSelector('h1');
await bild(seite, '14-merkkarten');
await seite.goto(`${BASIS}/aufgaben`);
await seite.waitForSelector('h1');
await bild(seite, '16-aufgaben-liste');
await seite.goto(`${BASIS}/aufgaben/1.1`);
await seite.waitForSelector('h1');
await bild(seite, '17-aufgabe-azubi');
await seite.goto(`${BASIS}/checklisten`);
await seite.waitForSelector('h1');
await bild(seite, '18-checklisten');
await seite.goto(`${BASIS}/berichtsheft`);
await seite.waitForSelector('h1');
await bild(seite, '21-berichtsheft');
await seite.goto(`${BASIS}/kontrollen`);
await seite.waitForSelector('h1');
await bild(seite, '23-kontrollen');
await seite.goto(`${BASIS}/kontrollen/woche-03`);
await seite.waitForSelector('h1');
await bild(seite, '24-kontrolle-ergebnis');
// Einzelblatt und Checklisten-Durchlauf jeweils von ihrer eigenen Liste aus –
// vorher wurde hier auf der Kontrollseite gesucht, wo es sie gar nicht gibt.
await seite.goto(`${BASIS}/berichtsheft`);
await seite.waitForSelector('h1');
const blatt = await seite.$('.blattzeile-link');
if (blatt) {
await blatt.click();
await seite.waitForSelector('h1');
await bild(seite, '22-wochenblatt');
}
await seite.goto(`${BASIS}/checklisten`);
await seite.waitForSelector('h1');
const lauf = await seite.$('.laufzeile-haupt');
if (lauf) {
await lauf.click();
await seite.waitForSelector('.schrittliste');
await bild(seite, '19-checklisten-lauf');
}
await seite.goto(`${BASIS}/glossar`);
await seite.waitForSelector('h1');
await bild(seite, '14-glossar');
await bild(seite, '15-glossar');
// --- Dunkle Darstellung ---
await seite.goto(`${BASIS}/handouts/N3`);
@@ -119,6 +202,15 @@ async function main() {
await handySeite.waitForSelector('h1');
await bild(handySeite, '30-handy-handout');
await handySeite.goto(`${BASIS}/karten/lernen`);
await handySeite.waitForSelector('.lernkarte, h1');
if (await handySeite.$('.lernkarte')) {
await bild(handySeite, '31-handy-lernmodus-frage', false);
await handySeite.tap('.lernkarte');
await handySeite.waitForSelector('.lernkarte-antwort');
await bild(handySeite, '32-handy-lernmodus-antwort', false);
}
await browser.close();
console.log('Fertig.');
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Erzeugt die Symbole für die installierte Anwendung aus dem Zeichen selbst –
* ohne Bilddienst, ohne Netz.
*
* npm run symbole
*
* Zwei Fassungen, weil Android sie unterschiedlich behandelt:
*
* symbol-192.png / symbol-512.png das Zeichen, wie es ist
* symbol-maskabel-512.png derselbe Inhalt, aber randvoll auf
* Petrol und nach innen gerückt. Android
* schneidet das Symbol in eine eigene
* Form – ein Kreis, ein Tropfen, was das
* Gerät gerade mag. Was zu weit außen
* liegt, ist dann weg.
*/
import fs from 'node:fs';
import path from 'node:path';
import { chromium } from '@playwright/test';
const ZIEL = path.join(process.cwd(), 'public');
/** Das Zeichen. Gleicher Inhalt wie `public/lernbar.svg`. */
function zeichen(randAnteil: number, eckenRadius: number): string {
// Die drei Balken liegen im Original in einem Feld von 64 × 64 Punkten.
// `randAnteil` rückt sie nach innen, ohne den Grund zu verkleinern.
const innen = 64 * (1 - 2 * randAnteil);
const versatz = 64 * randAnteil;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="${eckenRadius}" fill="#1F4E5F"/>
<g transform="translate(${versatz} ${versatz}) scale(${innen / 64})">
<rect x="14" y="16" width="10" height="32" rx="2" fill="#EDF3F5"/>
<rect x="27" y="16" width="10" height="32" rx="2" fill="#EDF3F5"/>
<rect x="40" y="16" width="10" height="32" rx="2" fill="#D98A2B"/>
</g>
</svg>`;
}
type Auftrag = { datei: string; groesse: number; svg: string };
const auftraege: Auftrag[] = [
{ datei: 'symbol-192.png', groesse: 192, svg: zeichen(0, 12) },
{ datei: 'symbol-512.png', groesse: 512, svg: zeichen(0, 12) },
// Randvoll und nach innen gerückt: der Beschnitt darf nichts abschneiden.
{ datei: 'symbol-maskabel-512.png', groesse: 512, svg: zeichen(0.14, 0) },
];
async function main() {
const browser = await chromium.launch();
for (const auftrag of auftraege) {
const seite = await browser.newPage({
viewport: { width: auftrag.groesse, height: auftrag.groesse },
deviceScaleFactor: 1,
});
await seite.setContent(
`<!doctype html><style>html,body{margin:0;padding:0}svg{display:block;width:100vw;height:100vh}</style>${auftrag.svg}`,
);
const datei = path.join(ZIEL, auftrag.datei);
await seite.screenshot({ path: datei, omitBackground: false });
await seite.close();
console.log(` public/${auftrag.datei} (${auftrag.groesse}×${auftrag.groesse}, ${fs.statSync(datei).size} Bytes)`);
}
await browser.close();
console.log('Symbole erzeugt.');
}
main().catch((fehler) => {
console.error('Die Symbole konnten nicht erzeugt werden.');
console.error(fehler instanceof Error ? fehler.message : fehler);
process.exit(1);
});
+230
View File
@@ -0,0 +1,230 @@
import Link from 'next/link';
import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
import type { Wochensicht } from '@/lib/planung/sicht';
import { alsDatumText, kalenderwocheText } from '@/lib/zeit';
const statusText: Record<string, { text: string; ton: string; zeichen: string }> = {
geplant: { text: 'geplant', ton: 'neutral', zeichen: '○' },
laufend: { text: 'läuft', ton: 'warnung', zeichen: '▶' },
abgeschlossen: { text: 'abgeschlossen', ton: 'gut', zeichen: '✓' },
};
/**
* Die Wochenansicht – für den Azubi die Startseite, für den Ausbilder dieselbe
* Woche in der Ansicht seines Azubis.
*/
export function WochenAnsicht({
sicht,
istAzubi,
wochenLink,
}: {
sicht: Wochensicht;
istAzubi: boolean;
/** Vorlage für den Verweis auf eine andere Woche, `{nr}` wird ersetzt. */
wochenLink: string;
}) {
const zurWoche = (nummer: number) => wochenLink.replace('{nr}', String(nummer));
const { woche, planung, fortschritt, tage } = sicht;
const status = statusText[planung.status] ?? statusText.geplant!;
return (
<div className="stapel">
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="wochen-nummer">Woche {woche.nummer}</span>
<span className="marke-status" data-ton={status.ton}>
{status.zeichen} {status.text}
</span>
<span className="leise">
{alsDatumText(planung.geplantVon)} – {alsDatumText(planung.geplantBis)} · {kalenderwocheText(planung.geplantVon)}
</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{woche.titel}</h1>
<p className="leitfrage">{woche.leitfrage}</p>
<p className="leise" style={{ marginBottom: 0 }}>{woche.ziel}</p>
</header>
{planung.tatsaechlichVon && planung.tatsaechlichVon !== planung.geplantVon ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
Tatsächlich begonnen am {alsDatumText(planung.tatsaechlichVon)}, geplant war der{' '}
{alsDatumText(planung.geplantVon)}.
</p>
) : null}
{planung.notiz ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
<strong>Notiz des Ausbilders:</strong> {planung.notiz}
</p>
) : null}
<section className="karte stapel">
<Fortschrittsbalken fortschritt={fortschritt} />
{fortschritt.offen.length > 0 ? (
<div>
<h2 style={{ fontSize: '1rem', marginBottom: '0.4rem' }}>
{istAzubi ? 'Das ist diese Woche noch offen' : 'Offen bei deinem Azubi'}
</h2>
<ul className="offen-liste">
{fortschritt.offen.map((bereich) => (
<li key={bereich.schluessel}>
<span>{bereich.titel}</span>
<span className="leise">
{bereich.zaehlwerk.erledigt} von {bereich.zaehlwerk.gesamt}
</span>
</li>
))}
</ul>
</div>
) : fortschritt.gesamt > 0 ? (
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
✓ Diese Woche ist vollständig abgearbeitet.
</p>
) : null}
</section>
<section aria-labelledby="tage" className="stapel">
<h2 id="tage">Die Woche</h2>
<ol className="wochentage">
{tage.map((tag) => (
<li key={tag.tag} className={`wochentag${tag.istHeute ? ' wochentag-heute' : ''}`}>
<div className="wochentag-kopf">
<strong>{tag.tag}</strong>
<span className="leise">{alsDatumText(tag.datum)}</span>
{tag.istHeute ? <span className="marke-status" data-ton="warnung">● heute</span> : null}
</div>
<div className="wochentag-inhalt">
<div>
<span className="wochentag-marke">Theorie</span>
{tag.theorie.length === 0 ? (
<span className="leise">–</span>
) : (
<ul className="theorie-liste">
{tag.theorie.map((handout) => {
const gelesen = sicht.handouts.find((h) => h.handout.id === handout.id)?.gelesen ?? false;
return (
<li key={handout.id}>
<Link href={`/handouts/${handout.id}`}>
<span className="handout-kuerzel">{handout.id}</span> {handout.titel}
</Link>
{gelesen ? (
<span className="marke-status" data-ton="gut">
✓ gelesen
</span>
) : null}
</li>
);
})}
</ul>
)}
</div>
<div>
<span className="wochentag-marke">Praxis</span>
<span>{tag.praxis || '–'}</span>
</div>
</div>
</li>
))}
</ol>
</section>
<div className="raster">
<section className="karte" aria-labelledby="woche-handouts">
<h2 id="woche-handouts" style={{ fontSize: '1rem' }}>
Handouts dieser Woche
</h2>
{sicht.handouts.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{sicht.handouts.map(({ handout, gelesen }) => (
<li key={handout.id} className="reihe" style={{ gap: '0.5rem', flexWrap: 'nowrap' }}>
<span className="handout-kuerzel">{handout.id}</span>
<Link href={`/handouts/${handout.id}`} style={{ flex: 1 }}>
{handout.titel}
</Link>
<span aria-label={gelesen ? 'gelesen' : 'noch nicht gelesen'} title={gelesen ? 'gelesen' : 'offen'}>
{gelesen ? '✓' : '○'}
</span>
</li>
))}
</ul>
)}
</section>
<section className="karte" aria-labelledby="woche-aufgaben">
<h2 id="woche-aufgaben" style={{ fontSize: '1rem' }}>
Praxisaufgaben
</h2>
{sicht.aufgaben.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{sicht.aufgaben.map((aufgabe) => (
<li key={aufgabe.id}>
<strong>{aufgabe.id}</strong> {aufgabe.titel}
<br />
<span className="leise">{aufgabe.zeitrahmen}</span>
</li>
))}
</ul>
)}
</section>
<section className="karte" aria-labelledby="woche-checklisten">
<h2 id="woche-checklisten" style={{ fontSize: '1rem' }}>
Checklisten
</h2>
{sicht.checklisten.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{sicht.checklisten.map((liste) => (
<li key={liste.id}>{liste.titel}</li>
))}
</ul>
)}
</section>
<section className="karte" aria-labelledby="woche-kontrolle">
<h2 id="woche-kontrolle" style={{ fontSize: '1rem' }}>
Lernzielkontrolle
</h2>
{sicht.kontrolle ? (
<p style={{ marginBottom: 0 }}>
{sicht.kontrolle.titel}
<br />
<span className="leise">
{sicht.kontrolle.fragen.length} Fragen ·{' '}
{sicht.kontrolle.fragen.reduce((summe, f) => summe + f.punkte, 0)} Punkte
{sicht.kontrolle.dauer_minuten ? ` · ${sicht.kontrolle.dauer_minuten} Minuten` : ''}
</span>
</p>
) : (
<p className="leise" style={{ marginBottom: 0 }}>Diese Woche keine.</p>
)}
</section>
</div>
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Andere Wochen">
{sicht.vorherige ? (
<Link href={zurWoche(sicht.vorherige)} className="knopf knopf-still">
← Woche {sicht.vorherige}
</Link>
) : (
<span />
)}
{sicht.naechste ? (
<Link href={zurWoche(sicht.naechste)} className="knopf knopf-still">
Woche {sicht.naechste} →
</Link>
) : (
<span />
)}
</nav>
</div>
);
}
@@ -0,0 +1,21 @@
import type { AufgabenStatus } from '@/lib/db/schema';
/**
* Status einer Praxisaufgabe. Nie nur über die Farbe – jede Stufe trägt ein
* eigenes Zeichen und ihren Namen.
*/
export const statusText: Record<AufgabenStatus, { text: string; ton: string; zeichen: string }> = {
offen: { text: 'offen', ton: 'neutral', zeichen: '○' },
in_arbeit: { text: 'in Arbeit', ton: 'warnung', zeichen: '◐' },
zur_abnahme: { text: 'zur Abnahme', ton: 'warnung', zeichen: '▲' },
abgenommen: { text: 'abgenommen', ton: 'gut', zeichen: '✓' },
};
export function StatusMarke({ status }: { status: AufgabenStatus }) {
const marke = statusText[status];
return (
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
);
}
@@ -0,0 +1,225 @@
'use client';
import { useActionState, useRef, useState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import {
anhangHochladen,
bewerten,
eigeneNotizSpeichern,
eigenenStatusSetzen,
type Rueckmeldung,
} from '../aktionen';
import type { AufgabenStatus } from '@/lib/db/schema';
export function StatusWaehlen({ aufgabeId, status }: { aufgabeId: string; status: AufgabenStatus }) {
const stufen: { wert: AufgabenStatus; text: string }[] = [
{ wert: 'offen', text: 'Offen' },
{ wert: 'in_arbeit', text: 'In Arbeit' },
{ wert: 'zur_abnahme', text: 'Zur Abnahme' },
];
return (
<form action={eigenenStatusSetzen} className="statuswahl">
<input type="hidden" name="aufgabeId" value={aufgabeId} />
{stufen.map((stufe) => (
<button
key={stufe.wert}
type="submit"
name="status"
value={stufe.wert}
className={`knopf ${status === stufe.wert ? '' : 'knopf-zweit'}`}
disabled={status === stufe.wert || status === 'abgenommen'}
aria-pressed={status === stufe.wert}
>
{stufe.text}
</button>
))}
</form>
);
}
export function NotizFormular({
aufgabeId,
notiz,
gesperrt,
}: {
aufgabeId: string;
notiz: string;
gesperrt: boolean;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(eigeneNotizSpeichern, {});
return (
<form action={aktion} className="stapel-eng">
<Meldung zustand={zustand} />
<input type="hidden" name="aufgabeId" value={aufgabeId} />
<label htmlFor="aufgaben-notiz">Deine Notizen</label>
<textarea
id="aufgaben-notiz"
name="notiz"
defaultValue={notiz}
rows={8}
maxLength={8000}
disabled={gesperrt}
placeholder="Was hast du gemacht, was ist aufgefallen, wo hakte es? Diese Notizen helfen dir später beim Berichtsheft."
/>
{gesperrt ? (
<p className="leise" style={{ margin: 0 }}>
Die Aufgabe ist abgenommen und damit gesperrt.
</p>
) : (
<div>
<Absenden className="knopf knopf-zweit">Notizen speichern</Absenden>
</div>
)}
</form>
);
}
/** Muss zur Prüfung auf dem Server passen – siehe lib/anhaenge/dateien.ts. */
const HOECHSTGROESSE = 25 * 1024 * 1024;
export function AnhangFormular({ aufgabeId, gesperrt }: { aufgabeId: string; gesperrt: boolean }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(anhangHochladen, {});
const [namen, setzeNamen] = useState<string[]>([]);
const [zuGross, setzeZuGross] = useState<string[]>([]);
const feld = useRef<HTMLInputElement>(null);
if (gesperrt) return null;
function beiAuswahl(dateien: FileList | null) {
const liste = Array.from(dateien ?? []);
setzeNamen(liste.map((d) => d.name));
// Vorab im Browser prüfen: Eine zu große Datei erst hochzuladen und dann
// abzulehnen kostet den Azubi im Zweifel Minuten am Mobilfunk.
setzeZuGross(liste.filter((d) => d.size > HOECHSTGROESSE).map((d) => d.name));
}
return (
<form
action={(daten) => {
setzeNamen([]);
setzeZuGross([]);
if (feld.current) feld.current.value = '';
return aktion(daten);
}}
className="stapel-eng"
>
<Meldung zustand={zustand} />
<input type="hidden" name="aufgabeId" value={aufgabeId} />
<label htmlFor="anhang-datei">Dateien anhängen</label>
<input
id="anhang-datei"
ref={feld}
name="datei"
type="file"
multiple
accept="image/jpeg,image/png,image/webp,image/gif,image/heic,application/pdf,text/plain,text/csv"
onChange={(e) => beiAuswahl(e.target.files)}
/>
<p className="leise" style={{ margin: 0 }}>
Fotos vom Aufbau, Bildschirmfotos, ein Netzplan als PDF. Höchstens 25 MB je Datei.
</p>
{zuGross.length > 0 ? (
<p className="hinweis hinweis-fehler" role="alert" style={{ margin: 0 }}>
Zu groß (mehr als 25 MB): {zuGross.join(', ')}. Bitte die Datei verkleinern.
</p>
) : namen.length > 0 ? (
<p className="leise" style={{ margin: 0 }}>
Ausgewählt: {namen.join(', ')}
</p>
) : null}
<div>
<Absenden className="knopf knopf-zweit" wartetext="Wird übertragen …">
Hochladen
</Absenden>
</div>
</form>
);
}
export function BewertungsFormular({
aufgabeId,
azubiId,
kriterien,
erfuellt,
bewertung,
rueckmeldung,
}: {
aufgabeId: string;
azubiId: string;
kriterien: string[];
erfuellt: number[];
bewertung: string;
rueckmeldung: string;
}) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(bewerten, {});
const [angehakt, setzeAngehakt] = useState<number[]>(erfuellt);
function umschalten(stelle: number) {
setzeAngehakt((vorher) =>
vorher.includes(stelle) ? vorher.filter((n) => n !== stelle) : [...vorher, stelle],
);
}
return (
<form action={aktion} className="stapel">
<Meldung zustand={zustand} />
<input type="hidden" name="aufgabeId" value={aufgabeId} />
<input type="hidden" name="azubiId" value={azubiId} />
<fieldset className="kriterien">
<legend>
Bewertungskriterien{' '}
<span className="leise">
({angehakt.length} von {kriterien.length} erfüllt)
</span>
</legend>
{kriterien.map((text, stelle) => (
<label key={stelle} className="ankreuzfeld kriterium">
<input
type="checkbox"
name="kriterium"
value={stelle}
checked={angehakt.includes(stelle)}
onChange={() => umschalten(stelle)}
/>
<span style={{ fontWeight: 400 }}>{text}</span>
</label>
))}
</fieldset>
<div className="feld">
<label htmlFor="bewertung">Note oder kurze Bewertung</label>
<input
id="bewertung"
name="bewertung"
type="text"
maxLength={60}
defaultValue={bewertung}
placeholder={'z. B. "gut" oder die Note 2'}
/>
</div>
<div className="feld">
<label htmlFor="rueckmeldung">Rückmeldung an den Azubi</label>
<textarea
id="rueckmeldung"
name="rueckmeldung"
rows={5}
maxLength={4000}
defaultValue={rueckmeldung}
placeholder="Was war gut, was fehlt noch? Konkret genug, dass er damit weiterarbeiten kann."
/>
</div>
<div>
<Absenden className="knopf">Bewertung speichern</Absenden>
</div>
</form>
);
}
+310
View File
@@ -0,0 +1,310 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { inhalte } from '@/lib/inhalte';
import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
import { anhaengeVon } from '@/lib/repos/anhaenge';
import { erfuellteKriterien, standVon } from '@/lib/repos/aufgaben';
import { istBild, megabyte } from '@/lib/anhaenge/dateien';
import { alsZeitpunktText } from '@/lib/zeit';
import { StatusMarke } from '../StatusMarke';
import { AnhangFormular, BewertungsFormular, NotizFormular, StatusWaehlen } from './Formulare';
import { anhangEntfernen, aufgabeAbnehmen, aufgabeZurueckgeben } from '../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const aufgabe = inhalte().aufgabeNach.get(decodeURIComponent(id));
return { title: aufgabe ? `${aufgabe.id} – ${aufgabe.titel}` : 'Praxisaufgabe' };
}
export default async function AufgabenSeite({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ azubi?: string }>;
}) {
const benutzer = await erfordereBenutzer();
const { id } = await params;
const { azubi: azubiParameter } = await searchParams;
const alle = inhalte();
const aufgabe = alle.aufgabeNach.get(decodeURIComponent(id));
if (!aufgabe) notFound();
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (azubiParameter ?? '');
if (!azubiId) notFound();
// Serverseitige Rechteprüfung – die Kennung aus der Adresszeile wird nie geglaubt.
erfordereSichtAufAzubi(benutzer, azubiId);
const istAzubi = benutzer.rolle === 'azubi';
const azubi = istAzubi ? benutzer : benutzerNachId(azubiId);
if (!azubi) notFound();
const stand = standVon(azubiId, aufgabe.id);
const status = stand?.status ?? 'offen';
const gesperrt = status === 'abgenommen';
const erfuellt = erfuellteKriterien(stand);
const anhaenge = anhaengeVon('aufgabe', `${aufgabe.id}::${azubiId}`);
const lernfeld = alle.lernfeldNach.get(aufgabe.lernfeld);
const positionen = aufgabe.rahmenplan.map((p) => alle.positionNach.get(p)).filter((p) => p !== undefined);
const abnehmer = stand?.abgenommenVon ? benutzerNachId(stand.abgenommenVon) : null;
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/aufgaben">Praxisaufgaben</Link>
{!istAzubi ? ` › ${azubi.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="aufgaben-nummer aufgaben-nummer-gross">{aufgabe.id}</span>
<StatusMarke status={status} />
<span className="leise">
Woche {aufgabe.woche} · {aufgabe.zeitrahmen}
</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{aufgabe.titel}</h1>
<p className="handout-worum">{aufgabe.ziel}</p>
</header>
<div className="handout-zuordnung karte">
<dl>
<div>
<dt>Ausbildungsrahmenplan</dt>
<dd>
{positionen.map((p) => (
<span key={p.id} className="zuordnung-marke" title={`${p.titel} (${p.verordnung})`}>
<strong>{p.id}</strong> {p.kurz}
</span>
))}
</dd>
</div>
<div>
<dt>Lernfeld</dt>
<dd>
{lernfeld ? (
<span className="zuordnung-marke">
<strong>{lernfeld.id}</strong> {lernfeld.titel}
</span>
) : (
'–'
)}
</dd>
</div>
</dl>
</div>
{aufgabe.material.length > 0 ? (
<section className="karte" aria-labelledby="material">
<h2 id="material" style={{ fontSize: '1rem' }}>
Benötigtes Material
</h2>
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
{aufgabe.material.map((posten, stelle) => (
<li key={stelle}>{posten}</li>
))}
</ul>
</section>
) : null}
{aufgabe.abschnitte.map((abschnitt, stelle) =>
abschnitt.titel ? (
<section key={stelle} aria-label={abschnitt.titel}>
<h2>{abschnitt.titel}</h2>
<div
className="fliesstext"
dangerouslySetInnerHTML={{ __html: verlinkeGlossar(abschnitt.html, alle.glossar) }}
/>
</section>
) : null,
)}
<section className="karte stapel" aria-labelledby="kriterien-liste">
<h2 id="kriterien-liste" style={{ marginBottom: 0 }}>
Bewertungskriterien
</h2>
<p className="leise" style={{ margin: 0 }}>
{erfuellt.length} von {aufgabe.kriterien.length} erfüllt · abgehakt wird vom Ausbilder
</p>
<ul className="kriterienliste">
{aufgabe.kriterien.map((text, stelle) => (
<li key={stelle} data-erfuellt={erfuellt.includes(stelle) ? 'ja' : 'nein'}>
<span aria-hidden="true">{erfuellt.includes(stelle) ? '✓' : '○'}</span>
<span>{text}</span>
<span className="nur-lesehilfe">{erfuellt.includes(stelle) ? ' – erfüllt' : ' – noch offen'}</span>
</li>
))}
</ul>
</section>
{/* --- Bearbeitung durch den Azubi --- */}
{istAzubi ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="bearbeitung">
<h2 id="bearbeitung" style={{ marginBottom: 0 }}>
Deine Bearbeitung
</h2>
<div>
<p className="leise" style={{ margin: '0 0 0.35rem' }}>
Stand setzen
</p>
<StatusWaehlen aufgabeId={aufgabe.id} status={status} />
{status === 'zur_abnahme' ? (
<p className="hinweis hinweis-warnung" style={{ marginTop: '0.6rem', marginBottom: 0 }}>
Abgegeben am {alsZeitpunktText(stand?.abgegebenAm)} – wartet auf die Abnahme.
</p>
) : null}
{gesperrt ? (
<p className="hinweis hinweis-gut" style={{ marginTop: '0.6rem', marginBottom: 0 }}>
✓ Abgenommen am {alsZeitpunktText(stand?.abgenommenAm)}
{abnehmer ? ` von ${abnehmer.name}` : ''}.
</p>
) : null}
</div>
<NotizFormular aufgabeId={aufgabe.id} notiz={stand?.notiz ?? ''} gesperrt={gesperrt} />
<AnhangFormular aufgabeId={aufgabe.id} gesperrt={gesperrt} />
</section>
) : (
<section className="karte stapel" aria-labelledby="azubi-stand">
<h2 id="azubi-stand" style={{ marginBottom: 0 }}>
Bearbeitung von {azubi.name}
</h2>
<p className="leise" style={{ margin: 0 }}>
{stand?.begonnenAm ? `Begonnen ${alsZeitpunktText(stand.begonnenAm)}` : 'Noch nicht begonnen'}
{stand?.abgegebenAm ? ` · abgegeben ${alsZeitpunktText(stand.abgegebenAm)}` : ''}
{stand?.abgenommenAm ? ` · abgenommen ${alsZeitpunktText(stand.abgenommenAm)}` : ''}
</p>
<div>
<h3 style={{ fontSize: '0.95rem', marginBottom: '0.3rem' }}>Notizen</h3>
{stand?.notiz ? (
<p className="notiz-anzeige">{stand.notiz}</p>
) : (
<p className="leise" style={{ margin: 0 }}>
Noch keine Notizen.
</p>
)}
</div>
</section>
)}
{/* --- Anhänge --- */}
<section className="karte stapel" aria-labelledby="anhaenge">
<h2 id="anhaenge" style={{ marginBottom: 0 }}>
Anhänge{anhaenge.length > 0 ? ` (${anhaenge.length})` : ''}
</h2>
{anhaenge.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Noch nichts angehängt.
</p>
) : (
<ul className="anhangliste">
{anhaenge.map((eintrag) => (
<li key={eintrag.id}>
<a href={`/anhaenge/${eintrag.id}`} target="_blank" rel="noreferrer" className="anhang-kachel">
{istBild(eintrag.mimeTyp) ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={`/anhaenge/${eintrag.id}`} alt={eintrag.beschriftung ?? eintrag.dateiname} />
) : (
<span className="anhang-symbol" aria-hidden="true">
{eintrag.mimeTyp === 'application/pdf' ? 'PDF' : 'TXT'}
</span>
)}
<span className="anhang-name">{eintrag.beschriftung ?? eintrag.dateiname}</span>
<span className="leise">{megabyte(eintrag.groesse)}</span>
</a>
{istAzubi && !gesperrt ? (
<form action={anhangEntfernen} className="nicht-drucken">
<input type="hidden" name="anhangId" value={eintrag.id} />
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<button type="submit" className="knopf knopf-still">
Entfernen
</button>
</form>
) : null}
</li>
))}
</ul>
)}
</section>
{/* --- Bewertung und Abnahme --- */}
{!istAzubi ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="abnahme">
<h2 id="abnahme" style={{ marginBottom: 0 }}>
Bewerten und abnehmen
</h2>
<BewertungsFormular
aufgabeId={aufgabe.id}
azubiId={azubiId}
kriterien={aufgabe.kriterien}
erfuellt={erfuellt}
bewertung={stand?.bewertung ?? ''}
rueckmeldung={stand?.rueckmeldung ?? ''}
/>
<hr />
<div className="reihe">
<form action={aufgabeAbnehmen}>
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<input type="hidden" name="azubiId" value={azubiId} />
<button type="submit" className="knopf knopf-akzent" disabled={status === 'abgenommen'}>
{status === 'abgenommen' ? 'Bereits abgenommen' : 'Aufgabe abnehmen'}
</button>
</form>
<form action={aufgabeZurueckgeben}>
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<input type="hidden" name="azubiId" value={azubiId} />
<button type="submit" className="knopf knopf-zweit" disabled={status === 'offen'}>
Zur Nacharbeit zurückgeben
</button>
</form>
</div>
<p className="leise" style={{ marginBottom: 0 }}>
Abnehmen sperrt die Aufgabe für den Azubi. Zurückgeben setzt sie auf &bdquo;in Arbeit&ldquo; – schreib in dem
Fall vorher eine Rückmeldung, sonst weiß er nicht, was fehlt.
</p>
</section>
) : null}
{/* --- Ergebnis, für beide sichtbar --- */}
{stand?.bewertung || stand?.rueckmeldung ? (
<section className="karte stapel-eng" aria-labelledby="ergebnis">
<h2 id="ergebnis" style={{ marginBottom: 0 }}>
Rückmeldung des Ausbilders
</h2>
{stand.bewertung ? (
<p style={{ margin: 0 }}>
<strong>Bewertung: {stand.bewertung}</strong>
</p>
) : null}
{stand.rueckmeldung ? <p className="notiz-anzeige">{stand.rueckmeldung}</p> : null}
</section>
) : null}
<p className="leise nicht-drucken" style={{ marginBottom: 0 }}>
Quelle: <code>{aufgabe.datei}</code>
</p>
</article>
);
}
+186
View File
@@ -0,0 +1,186 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereBenutzer, erfordereBewertungsrecht, erfordereZugriffAufAzubi } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { anhangAblegen, anhangLoeschen, anhangNachId } from '@/lib/repos/anhaenge';
import {
abnehmen,
bewertungSetzen,
notizSetzen,
statusSetzen,
standVon,
zurueckgeben,
} from '@/lib/repos/aufgaben';
import { aufgabenStatus, type AufgabenStatus } from '@/lib/db/schema';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
function aktualisiere(aufgabeId: string) {
revalidatePath('/aufgaben');
revalidatePath(`/aufgaben/${aufgabeId}`);
revalidatePath('/');
}
function aufgabeGibtEs(aufgabeId: string): boolean {
return inhalte().aufgabeNach.has(aufgabeId);
}
// --- Der Azubi an seiner eigenen Aufgabe -----------------------------------
export async function eigenenStatusSetzen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return;
const aufgabeId = String(formular.get('aufgabeId') ?? '');
const status = String(formular.get('status') ?? '') as AufgabenStatus;
if (!aufgabeGibtEs(aufgabeId)) return;
if (!aufgabenStatus.includes(status)) return;
// Abnehmen darf nur der Ausbilder.
if (status === 'abgenommen') return;
statusSetzen(benutzer.id, aufgabeId, status);
aktualisiere(aufgabeId);
}
export async function eigeneNotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return { fehler: 'Nur der Azubi schreibt seine eigenen Notizen.' };
const aufgabeId = String(formular.get('aufgabeId') ?? '');
const notiz = String(formular.get('notiz') ?? '');
if (!aufgabeGibtEs(aufgabeId)) return { fehler: 'Diese Aufgabe gibt es nicht.' };
if (notiz.length > 8000) return { fehler: 'Die Notiz ist zu lang – höchstens 8000 Zeichen.' };
notizSetzen(benutzer.id, aufgabeId, notiz);
aktualisiere(aufgabeId);
return { erfolg: 'Notiz gespeichert.' };
}
export async function anhangHochladen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return { fehler: 'Anhänge lädt der Azubi hoch.' };
const aufgabeId = String(formular.get('aufgabeId') ?? '');
if (!aufgabeGibtEs(aufgabeId)) return { fehler: 'Diese Aufgabe gibt es nicht.' };
const stand = standVon(benutzer.id, aufgabeId);
if (stand?.status === 'abgenommen') {
return { fehler: 'Die Aufgabe ist abgenommen – sie lässt sich nicht mehr ändern.' };
}
const dateien = formular.getAll('datei').filter((d): d is File => d instanceof File && d.size > 0);
if (dateien.length === 0) return { fehler: 'Keine Datei ausgewählt.' };
const beschriftung = String(formular.get('beschriftung') ?? '');
const fehler: string[] = [];
let angelegt = 0;
for (const datei of dateien) {
const ergebnis = await anhangAblegen({
bezugTyp: 'aufgabe',
// Der Bezug ist Aufgabe **und** Azubi – sonst sähe ein zweiter Azubi
// die Anhänge des ersten an derselben Aufgabe.
bezugId: `${aufgabeId}::${benutzer.id}`,
benutzerId: benutzer.id,
datei,
beschriftung: dateien.length === 1 ? beschriftung : undefined,
});
if ('fehler' in ergebnis) {
fehler.push(`${datei.name}: ${ergebnis.fehler}`);
} else {
angelegt += 1;
}
}
aktualisiere(aufgabeId);
if (angelegt === 0) return { fehler: fehler.join(' · ') };
if (fehler.length > 0) {
return { fehler: `${angelegt} übernommen, aber: ${fehler.join(' · ')}` };
}
return { erfolg: `${angelegt} ${angelegt === 1 ? 'Anhang' : 'Anhänge'} hinzugefügt.` };
}
export async function anhangEntfernen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
const anhangId = String(formular.get('anhangId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
const eintrag = anhangNachId(anhangId);
if (!eintrag) return;
// Nur der eigene Anhang, und nur solange die Aufgabe nicht abgenommen ist.
if (eintrag.benutzerId !== benutzer.id) return;
const stand = standVon(benutzer.id, aufgabeId);
if (stand?.status === 'abgenommen') return;
await anhangLoeschen(anhangId);
aktualisiere(aufgabeId);
}
// --- Der Ausbilder an der Aufgabe seines Azubis ----------------------------
export async function bewerten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
const aufgabe = inhalte().aufgabeNach.get(aufgabeId);
if (!aufgabe) return { fehler: 'Diese Aufgabe gibt es nicht.' };
const kriterien = formular
.getAll('kriterium')
.map((wert) => Number(wert))
.filter((n) => Number.isInteger(n) && n >= 0 && n < aufgabe.kriterien.length);
const bewertung = String(formular.get('bewertung') ?? '').trim();
const rueckmeldung = String(formular.get('rueckmeldung') ?? '').trim();
if (bewertung.length > 60) return { fehler: 'Die Bewertung ist zu lang – höchstens 60 Zeichen.' };
if (rueckmeldung.length > 4000) return { fehler: 'Die Rückmeldung ist zu lang – höchstens 4000 Zeichen.' };
bewertungSetzen(azubiId, aufgabeId, {
kriterien,
bewertung: bewertung || null,
rueckmeldung: rueckmeldung || null,
});
aktualisiere(aufgabeId);
return { erfolg: `Bewertung gespeichert – ${kriterien.length} von ${aufgabe.kriterien.length} Kriterien erfüllt.` };
}
export async function aufgabeAbnehmen(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
if (!aufgabeGibtEs(aufgabeId)) return;
abnehmen(azubiId, aufgabeId, ausbilder.id);
aktualisiere(aufgabeId);
}
export async function aufgabeZurueckgeben(formular: FormData): Promise<void> {
const ausbilder = await erfordereBenutzer();
const azubiId = String(formular.get('azubiId') ?? '');
const aufgabeId = String(formular.get('aufgabeId') ?? '');
erfordereBewertungsrecht(ausbilder, azubiId);
if (!aufgabeGibtEs(aufgabeId)) return;
zurueckgeben(azubiId, aufgabeId);
aktualisiere(aufgabeId);
}
/** Wird von der Aufgabenseite gebraucht, um fremde Daten überhaupt lesen zu dürfen. */
export async function pruefeLesezugriff(azubiId: string): Promise<void> {
const benutzer = await erfordereBenutzer();
erfordereZugriffAufAzubi(benutzer, azubiId);
}
+115
View File
@@ -0,0 +1,115 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { staendeVon } from '@/lib/repos/aufgaben';
import { alsZeitpunktText } from '@/lib/zeit';
import { StatusMarke } from './StatusMarke';
export const metadata: Metadata = { title: 'Praxisaufgaben' };
export const dynamic = 'force-dynamic';
export default async function AufgabenListe() {
const benutzer = await erfordereBenutzer();
const alle = inhalte();
if (benutzer.rolle === 'ausbilder') {
const meine = ausbildungenVonAusbilder(benutzer.id);
return (
<div className="stapel">
<div>
<h1>Praxisaufgaben</h1>
<p className="leise">
{alle.aufgaben.length} Aufgaben · Stand deiner {meine.length === 1 ? 'Auszubildenden' : 'Auszubildenden'}
</p>
</div>
{meine.length === 0 ? (
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
meine.map(({ azubi }) => {
const staende = staendeVon(azubi.id);
const wartend = alle.aufgaben.filter((a) => staende.get(a.id)?.status === 'zur_abnahme');
return (
<section key={azubi.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
{azubi.name}
</h2>
{wartend.length > 0 ? (
<span className="marke-status" data-ton="warnung">
● {wartend.length} {wartend.length === 1 ? 'Abnahme' : 'Abnahmen'} offen
</span>
) : (
<span className="leise">nichts zur Abnahme</span>
)}
</div>
<ul className="aufgabenliste">
{alle.aufgaben.map((aufgabe) => {
const stand = staende.get(aufgabe.id);
return (
<li key={aufgabe.id}>
<Link href={`/aufgaben/${aufgabe.id}?azubi=${azubi.id}`} className="aufgabenzeile">
<span className="aufgaben-nummer">{aufgabe.id}</span>
<span className="aufgaben-titel">
{aufgabe.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
Woche {aufgabe.woche} · {aufgabe.zeitrahmen}
{stand?.abgegebenAm ? ` · abgegeben ${alsZeitpunktText(stand.abgegebenAm)}` : ''}
</span>
</span>
<StatusMarke status={stand?.status ?? 'offen'} />
</Link>
</li>
);
})}
</ul>
</section>
);
})
)}
</div>
);
}
// --- Sicht des Azubis ---
const staende = staendeVon(benutzer.id);
const abgenommen = alle.aufgaben.filter((a) => staende.get(a.id)?.status === 'abgenommen').length;
return (
<div className="stapel">
<div>
<h1>Praxisaufgaben</h1>
<p className="leise">
{abgenommen} von {alle.aufgaben.length} abgenommen
</p>
</div>
<ul className="aufgabenliste">
{alle.aufgaben.map((aufgabe) => {
const stand = staende.get(aufgabe.id);
return (
<li key={aufgabe.id}>
<Link href={`/aufgaben/${aufgabe.id}`} className="aufgabenzeile">
<span className="aufgaben-nummer">{aufgabe.id}</span>
<span className="aufgaben-titel">
{aufgabe.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
Woche {aufgabe.woche} · {aufgabe.zeitrahmen} · {aufgabe.kriterien.length} Kriterien
</span>
</span>
<StatusMarke status={stand?.status ?? 'offen'} />
</Link>
</li>
);
})}
</ul>
</div>
);
}
+416
View File
@@ -0,0 +1,416 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { eigenerStand, rahmenplanabdeckung } from '@/lib/auswertung/sammeln';
import { bilanz, nachGruppen, stufenText, type Abdeckungsstufe } from '@/lib/auswertung/abdeckung';
import { verlauf } from '@/lib/kontrollen/bewertung';
import { Druckknopf } from '@/komponenten/Druckknopf';
export const metadata: Metadata = { title: 'Auswertung' };
export const dynamic = 'force-dynamic';
const stufenTon: Record<Abdeckungsstufe, string> = {
abgedeckt: 'gut',
angefangen: 'warnung',
offen: 'neutral',
ohne_inhalt: 'fehler',
};
const stufenZeichen: Record<Abdeckungsstufe, string> = {
abgedeckt: '✓',
angefangen: '◐',
offen: '○',
ohne_inhalt: '!',
};
export default async function AuswertungsSeite({ searchParams }: { searchParams: Promise<{ azubi?: string }> }) {
const benutzer = await erfordereBenutzer();
const { azubi: azubiParameter } = await searchParams;
if (benutzer.rolle === 'azubi') {
return <AzubiAuswertung azubiId={benutzer.id} name={benutzer.name} />;
}
const meine = ausbildungenVonAusbilder(benutzer.id);
if (meine.length === 0) {
return (
<div className="stapel">
<h1>Auswertung</h1>
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
</div>
);
}
const azubiId = azubiParameter ?? meine[0]!.azubi.id;
erfordereSichtAufAzubi(benutzer, azubiId);
const azubi = benutzerNachId(azubiId)!;
return (
<div className="stapel">
<div>
<h1>Auswertung</h1>
<p className="leise">{azubi.name}</p>
</div>
{meine.length > 1 ? (
<nav className="reihe nicht-drucken" aria-label="Azubi wählen">
{meine.map(({ azubi: person }) => (
<Link
key={person.id}
href={`/auswertung?azubi=${person.id}`}
className={`knopf ${person.id === azubiId ? '' : 'knopf-zweit'}`}
>
{person.name}
</Link>
))}
</nav>
) : null}
<Rahmenplan azubiId={azubiId} />
<Kontrollverlauf azubiId={azubiId} />
<Lernfelder azubiId={azubiId} />
{/* Die Abdeckung ist die Ansicht, die man zum Gespräch mitnimmt – und zwar
auf Papier, weil dort niemand scrollt. */}
<div className="reihe nicht-drucken">
<Druckknopf beschriftung="Auswertung drucken" />
</div>
</div>
);
}
// --- Sicht des Azubis ------------------------------------------------------
function AzubiAuswertung({ azubiId, name }: { azubiId: string; name: string }) {
const ausbildung = ausbildungNachAzubi(azubiId);
const stand = eigenerStand(azubiId, ausbildung?.beginn ?? null);
return (
<div className="stapel">
<div>
<h1>Dein Stand</h1>
<p className="leise">{name}</p>
</div>
<div className="raster">
<Kachel
titel="Handouts gelesen"
wert={`${stand.handouts.erledigt} von ${stand.handouts.gesamt}`}
anteil={anteil(stand.handouts.erledigt, stand.handouts.gesamt)}
fuss={`${stand.handouts.gesamt - stand.handouts.erledigt} noch offen`}
/>
<Kachel
titel="Merkkarten, die sitzen"
wert={`${stand.karten.erledigt} von ${stand.karten.gesamt}`}
anteil={anteil(stand.karten.erledigt, stand.karten.gesamt)}
fuss={stand.karten.faellig > 0 ? `${stand.karten.faellig} heute fällig` : 'heute nichts fällig'}
/>
<Kachel
titel="Praxisaufgaben abgenommen"
wert={`${stand.aufgaben.erledigt} von ${stand.aufgaben.gesamt}`}
anteil={anteil(stand.aufgaben.erledigt, stand.aufgaben.gesamt)}
fuss={
stand.aufgaben.zurAbnahme > 0
? `${stand.aufgaben.zurAbnahme} wartet auf Abnahme`
: `${stand.aufgaben.offen} noch offen`
}
/>
<Kachel
titel="Berichtsheft abgezeichnet"
wert={`${stand.berichtsheft.abgezeichnet} von ${stand.berichtsheft.gesamt}`}
anteil={anteil(stand.berichtsheft.abgezeichnet, stand.berichtsheft.gesamt)}
fuss={
stand.berichtsheft.fehlendeWochen > 0
? `${stand.berichtsheft.fehlendeWochen} Wochen ohne Blatt`
: stand.berichtsheft.eingereicht > 0
? `${stand.berichtsheft.eingereicht} beim Ausbilder`
: 'nichts offen'
}
ton={stand.berichtsheft.fehlendeWochen > 0 ? 'fehler' : undefined}
/>
</div>
{stand.berichtsheft.fehlendeWochen > 0 ? (
<p className="hinweis hinweis-warnung">
<strong>
{stand.berichtsheft.fehlendeWochen}{' '}
{stand.berichtsheft.fehlendeWochen === 1 ? 'Woche' : 'Wochen'} ohne Berichtsheftblatt.
</strong>{' '}
Ohne geführten Nachweis gibt es keine Zulassung zur Abschlussprüfung.{' '}
<Link href="/berichtsheft">Jetzt nachtragen</Link>.
</p>
) : null}
<ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} />
</div>
);
}
function Kachel({
titel,
wert,
anteil,
fuss,
ton,
}: {
titel: string;
wert: string;
anteil: number;
fuss: string;
ton?: string;
}) {
return (
<section className="karte stapel-eng">
<h2 style={{ fontSize: '0.9rem', marginBottom: 0, color: 'var(--text-leise)' }}>{titel}</h2>
<strong style={{ fontSize: '1.5rem' }}>{wert}</strong>
<div className="balken">
<span style={{ width: `${anteil}%` }} />
</div>
<span className="leise" style={{ color: ton === 'fehler' ? 'var(--fehler)' : undefined }}>
{fuss}
</span>
</section>
);
}
// --- Rahmenplanabdeckung ---------------------------------------------------
function Rahmenplan({ azubiId }: { azubiId: string }) {
const { positionen, gruppennamen } = rahmenplanabdeckung(azubiId);
const gruppen = nachGruppen(positionen);
const gesamt = bilanz(positionen);
return (
<section className="stapel" aria-labelledby="rahmenplan">
<div>
<h2 id="rahmenplan" style={{ marginBottom: '0.2rem' }}>
Abdeckung des Ausbildungsrahmenplans
</h2>
<p className="leise" style={{ margin: 0 }}>
Die Ansicht, die im Zweifel gegenüber der IHK zählt. Als <strong>vermittelt</strong> gilt eine
Position, wenn es dazu eine abgenommene Praxisaufgabe oder einen abgeschlossenen
Checklisten-Durchlauf gibt – oder mindestens zwei gelesene Handouts.
</p>
</div>
<div className="karte-grund stapel-eng">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<strong>
{gesamt.abgedeckt} von {gesamt.gesamt} Positionen vermittelt
</strong>
<span className="leise">{gesamt.prozent} %</span>
</div>
<div className="balken" style={{ height: '14px' }}>
<span style={{ width: `${gesamt.prozent}%` }} />
</div>
<div className="reihe" style={{ gap: '0.5rem' }}>
<span className="marke-status" data-ton="gut">
✓ {gesamt.abgedeckt} vermittelt
</span>
<span className="marke-status" data-ton="warnung">
◐ {gesamt.angefangen} angefangen
</span>
<span className="marke-status">○ {gesamt.offen} geplant</span>
{gesamt.ohneInhalt > 0 ? (
<span className="marke-status" data-ton="fehler">
! {gesamt.ohneInhalt} ohne Inhalt
</span>
) : null}
</div>
</div>
{gesamt.ohneInhalt > 0 ? (
<p className="hinweis hinweis-warnung">
<strong>
Zu {gesamt.ohneInhalt} {gesamt.ohneInhalt === 1 ? 'Position' : 'Positionen'} gibt es im Lehrgang
überhaupt keinen Inhalt.
</strong>{' '}
Das ist keine Lücke beim Azubi, sondern eine im Lehrgang – dort hilft kein Lernen, sondern nur ein
neues Handout oder eine neue Aufgabe unter <code>content/</code>.
</p>
) : null}
{gruppen.map((gruppe) => (
<details key={gruppe.gruppe} className="abdeckungsgruppe karte" open={gruppe.bilanz.ohneInhalt > 0}>
<summary>
<strong>{gruppennamen[gruppe.gruppe] ?? gruppe.gruppe}</strong>
<span className="leise">
{' '}
– {gruppe.bilanz.abgedeckt} von {gruppe.bilanz.gesamt} vermittelt
</span>
</summary>
<ul className="abdeckungsliste">
{gruppe.positionen.map((position) => (
<li key={position.id} data-stufe={position.stufe}>
<span className="abdeckung-kuerzel">{position.id}</span>
<span className="abdeckung-titel">
{position.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{position.verordnung}
{position.geplant > 0
? ` · ${position.nachweise.filter((n) => n.erledigt).length} von ${position.geplant} Nachweisen`
: ''}
</span>
{position.nachweise.length > 0 ? (
<span className="nachweisliste">
{position.nachweise.map((nachweis) => (
<span
key={`${nachweis.art}-${nachweis.id}`}
className="nachweis"
data-erledigt={nachweis.erledigt ? 'ja' : 'nein'}
title={`${nachweis.titel}${nachweis.erledigtAm ? ` – ${nachweis.erledigtAm}` : ''}`}
>
{nachweis.erledigt ? '✓' : '○'} {nachweis.id}
</span>
))}
</span>
) : null}
</span>
<span className="marke-status" data-ton={stufenTon[position.stufe]}>
{stufenZeichen[position.stufe]} {stufenText[position.stufe]}
</span>
</li>
))}
</ul>
</details>
))}
</section>
);
}
// --- Lernfelder ------------------------------------------------------------
function Lernfelder({ azubiId }: { azubiId: string }) {
const { lernfelder } = rahmenplanabdeckung(azubiId);
const jahre = [1, 2, 3];
return (
<section className="stapel" aria-labelledby="lernfelder">
<div>
<h2 id="lernfelder" style={{ marginBottom: '0.2rem' }}>
Lernfelder der Berufsschule
</h2>
<p className="leise" style={{ margin: 0 }}>
Nach derselben Regel bewertet. Der Unterricht selbst findet in der Schule statt – hier steht, was im
Betrieb dazu angekommen ist.
</p>
</div>
{jahre.map((jahr) => {
const eigene = lernfelder.filter((l) => l.jahr === jahr);
if (eigene.length === 0) return null;
return (
<div key={jahr} className="karte stapel-eng">
<h3 style={{ fontSize: '0.95rem', marginBottom: 0 }}>{jahr}. Ausbildungsjahr</h3>
<ul className="abdeckungsliste">
{eigene.map((lernfeld) => (
<li key={lernfeld.id} data-stufe={lernfeld.stufe}>
<span className="abdeckung-kuerzel">{lernfeld.id}</span>
<span className="abdeckung-titel">
{lernfeld.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{lernfeld.stunden} Stunden
</span>
</span>
<span className="marke-status" data-ton={stufenTon[lernfeld.stufe]}>
{stufenZeichen[lernfeld.stufe]} {stufenText[lernfeld.stufe]}
</span>
</li>
))}
</ul>
</div>
);
})}
</section>
);
}
// --- Ergebnisverlauf -------------------------------------------------------
function Kontrollverlauf({ azubiId }: { azubiId: string }) {
const ausbildung = ausbildungNachAzubi(azubiId);
const stand = eigenerStand(azubiId, ausbildung?.beginn ?? null);
return <ErgebnisTabelle ergebnisse={stand.kontrollen.ergebnisse} />;
}
function ErgebnisTabelle({
ergebnisse,
}: {
ergebnisse: {
id: string;
titel: string;
woche: number;
prozent: number;
note: number;
notentext: string;
erreicht: number;
moeglich: number;
korrigiertAm: string;
}[];
}) {
const stand = verlauf(ergebnisse);
return (
<section className="stapel" aria-labelledby="verlauf">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="verlauf" style={{ margin: 0 }}>
Lernzielkontrollen
</h2>
{stand.anzahl > 0 ? (
<span className="leise">
Schnitt {stand.schnittProzent} % · Note {stand.schnittNote.toString().replace('.', ',')} · beste{' '}
{stand.beste} %
</span>
) : null}
</div>
{ergebnisse.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch keine korrigierte Lernzielkontrolle.
</p>
) : (
<ol className="verlaufsliste">
{ergebnisse.map((ergebnis) => (
<li key={ergebnis.id}>
<span className="kontroll-woche">Woche {ergebnis.woche}</span>
<span className="verlauf-titel">
{ergebnis.titel}
<span className="leise" style={{ display: 'block', fontWeight: 400 }}>
{zahl(ergebnis.erreicht)} von {zahl(ergebnis.moeglich)} Punkten · korrigiert{' '}
{ergebnis.korrigiertAm}
</span>
</span>
<span className="verlauf-balken">
<span className="balken">
<span style={{ width: `${ergebnis.prozent}%` }} />
</span>
<span className="leise">{ergebnis.prozent} %</span>
</span>
<span className={`note-anzeige note-klein note-${ergebnis.note}`} title={ergebnis.notentext}>
{ergebnis.note}
</span>
</li>
))}
</ol>
)}
</section>
);
}
function anteil(erledigt: number, gesamt: number): number {
if (gesamt <= 0) return 0;
return Math.round((erledigt / gesamt) * 100);
}
function zahl(wert: number): string {
return wert.toString().replace('.', ',');
}
@@ -0,0 +1,18 @@
import type { BerichtStatus } from '@/lib/db/schema';
/** Zustand eines Wochenblatts – nie nur über die Farbe, immer mit Zeichen. */
export const berichtsStatusText: Record<BerichtStatus, { text: string; ton: string; zeichen: string }> = {
entwurf: { text: 'Entwurf', ton: 'neutral', zeichen: '○' },
eingereicht: { text: 'eingereicht', ton: 'warnung', zeichen: '▲' },
zurueckgegeben: { text: 'zurückgegeben', ton: 'fehler', zeichen: '↩' },
abgezeichnet: { text: 'abgezeichnet', ton: 'gut', zeichen: '✓' },
};
export function BerichtsMarke({ status }: { status: BerichtStatus }) {
const marke = berichtsStatusText[status];
return (
<span className="marke-status" data-ton={marke.ton}>
{marke.zeichen} {marke.text}
</span>
);
}
@@ -0,0 +1,380 @@
'use client';
import { useActionState, useState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { blattEinreichen, blattSpeichern, type Rueckmeldung } from './aktionen';
import { pruefeWochenblatt, summeStunden, type Wochenblatt } from '@/lib/berichtsheft/pruefung';
import type { Vorschlag } from '@/lib/berichtsheft/vorschlaege';
/**
* Das Wochenblatt zum Ausfüllen.
*
* Die Prüfung läuft im Browser mit, während getippt wird – dieselbe Funktion,
* die auch serverseitig verwendet wird. Sie warnt, sie sperrt nicht: Der Knopf
* zum Einreichen bleibt immer bedienbar.
*/
export function Blattformular({
berichtId,
anfangsblatt,
vorschlaege,
status,
}: {
berichtId: string;
anfangsblatt: Wochenblatt;
vorschlaege: Vorschlag[];
status: string;
}) {
const [blatt, setzeBlatt] = useState<Wochenblatt>(() => ({
betrieb: anfangsblatt.betrieb.length > 0 ? anfangsblatt.betrieb : [{ text: '', stunden: 0 }],
schule: anfangsblatt.schule.length > 0 ? anfangsblatt.schule : [{ lernfeld: '', themen: '', stunden: 0 }],
sonstiges: anfangsblatt.sonstiges,
}));
const [uebernommen, setzeUebernommen] = useState<string[]>([]);
const [speicherZustand, speichern] = useActionState<Rueckmeldung, FormData>(blattSpeichern, {});
const [einreichZustand, einreichen] = useActionState<Rueckmeldung, FormData>(blattEinreichen, {});
const gesperrt = status === 'abgezeichnet';
const warnungen = pruefeWochenblatt(blatt);
const ernste = warnungen.filter((w) => w.schwere === 'warnung');
function betriebAendern(stelle: number, teil: Partial<{ text: string; stunden: number }>) {
setzeBlatt((v) => ({
...v,
betrieb: v.betrieb.map((e, i) => (i === stelle ? { ...e, ...teil } : e)),
}));
}
function schuleAendern(stelle: number, teil: Partial<{ lernfeld: string; themen: string; stunden: number }>) {
setzeBlatt((v) => ({
...v,
schule: v.schule.map((e, i) => (i === stelle ? { ...e, ...teil } : e)),
}));
}
function uebernehmen(vorschlag: Vorschlag) {
setzeUebernommen((v) => [...v, vorschlag.id]);
if (vorschlag.ziel === 'schule') {
setzeBlatt((v) => {
const leer = v.schule.findIndex((e) => e.lernfeld === '' && e.themen === '');
const neu = { lernfeld: vorschlag.herkunft, themen: vorschlag.text, stunden: vorschlag.stunden };
return {
...v,
schule: leer >= 0 ? v.schule.map((e, i) => (i === leer ? neu : e)) : [...v.schule, neu],
};
});
return;
}
setzeBlatt((v) => {
const leer = v.betrieb.findIndex((e) => e.text.trim() === '');
const neu = { text: vorschlag.text, stunden: vorschlag.stunden };
return {
...v,
betrieb: leer >= 0 ? v.betrieb.map((e, i) => (i === leer ? neu : e)) : [...v.betrieb, neu],
};
});
}
const offeneVorschlaege = vorschlaege.filter((v) => !uebernommen.includes(v.id));
return (
<div className="stapel">
{!gesperrt && offeneVorschlaege.length > 0 ? (
<section className="karte stapel-eng nicht-drucken" aria-labelledby="vorschlaege">
<div>
<h2 id="vorschlaege" style={{ marginBottom: '0.15rem', fontSize: '1rem' }}>
Vorschläge aus dieser Woche
</h2>
<p className="leise" style={{ margin: 0 }}>
Übernehmen und dann <strong>in eigene Worte bringen</strong>. Ein wörtlich übernommener Satz ist kein
Ausbildungsnachweis, und die Stundenangabe ist nur ein Anhaltspunkt.
</p>
</div>
<ul className="vorschlagsliste">
{offeneVorschlaege.map((vorschlag) => (
<li key={vorschlag.id}>
<button type="button" className="vorschlag" onClick={() => uebernehmen(vorschlag)}>
<span className="vorschlag-quelle" data-quelle={vorschlag.quelle}>
{beschriftung[vorschlag.quelle]}
</span>
<span className="vorschlag-text">{vorschlag.text}</span>
<span className="leise">
{vorschlag.herkunft}
{vorschlag.stunden > 0 ? ` · ${zahlText(vorschlag.stunden)} h` : ''}
</span>
<span className="vorschlag-plus" aria-hidden="true">
+
</span>
</button>
</li>
))}
</ul>
</section>
) : null}
<form action={speichern} className="stapel" id="blattformular">
<input type="hidden" name="berichtId" value={berichtId} />
{/* --- Betriebliche Tätigkeiten --- */}
<section className="stapel-eng" aria-labelledby="betrieb">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="betrieb" style={{ margin: 0 }}>
Betriebliche Tätigkeiten und Unterweisungen
</h2>
<span className="leise">{zahlText(summeStunden({ ...blatt, schule: [] }))} h</span>
</div>
{blatt.betrieb.map((eintrag, stelle) => (
<div key={stelle} className="blattzeile">
<div className="blattzeile-text">
<label htmlFor={`betrieb-${stelle}`} className="nur-lesehilfe">
Tätigkeit {stelle + 1}
</label>
<textarea
id={`betrieb-${stelle}`}
name="betriebText"
rows={2}
maxLength={600}
value={eintrag.text}
disabled={gesperrt}
onChange={(e) => betriebAendern(stelle, { text: e.target.value })}
placeholder="Was hast du gemacht? Gerät oder Dienst nennen, Tätigkeit, Ergebnis."
aria-invalid={warnungen.some((w) => w.feld === `betrieb.${stelle}` && w.schwere === 'warnung')}
/>
<Feldwarnungen warnungen={warnungen} feld={`betrieb.${stelle}`} />
</div>
<div className="blattzeile-stunden">
<label htmlFor={`betrieb-std-${stelle}`}>Std.</label>
<input
id={`betrieb-std-${stelle}`}
name="betriebStunden"
type="number"
min={0}
max={60}
step={0.25}
value={eintrag.stunden || ''}
disabled={gesperrt}
onChange={(e) => betriebAendern(stelle, { stunden: Number(e.target.value) || 0 })}
/>
</div>
{!gesperrt ? (
<button
type="button"
className="knopf knopf-still zeile-weg"
onClick={() => setzeBlatt((v) => ({ ...v, betrieb: v.betrieb.filter((_, i) => i !== stelle) }))}
title="Zeile entfernen"
>
<span aria-hidden="true">×</span>
<span className="nur-lesehilfe">Tätigkeit {stelle + 1} entfernen</span>
</button>
) : null}
</div>
))}
{!gesperrt ? (
<div>
<button
type="button"
className="knopf knopf-zweit"
onClick={() => setzeBlatt((v) => ({ ...v, betrieb: [...v.betrieb, { text: '', stunden: 0 }] }))}
>
Zeile hinzufügen
</button>
</div>
) : null}
</section>
{/* --- Berufsschule --- */}
<section className="stapel-eng" aria-labelledby="schule">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="schule" style={{ margin: 0 }}>
Berufsschule
</h2>
<span className="leise">{zahlText(summeStunden({ ...blatt, betrieb: [] }))} h</span>
</div>
{blatt.schule.map((eintrag, stelle) => (
<div key={stelle} className="blattzeile">
<div className="blattzeile-lernfeld">
<label htmlFor={`lf-${stelle}`}>Lernfeld</label>
<input
id={`lf-${stelle}`}
name="schuleLernfeld"
type="text"
maxLength={10}
value={eintrag.lernfeld}
disabled={gesperrt}
onChange={(e) => schuleAendern(stelle, { lernfeld: e.target.value })}
placeholder="LF3"
/>
</div>
<div className="blattzeile-text">
<label htmlFor={`themen-${stelle}`} className="nur-lesehilfe">
Themen
</label>
<textarea
id={`themen-${stelle}`}
name="schuleThemen"
rows={2}
maxLength={600}
value={eintrag.themen}
disabled={gesperrt}
onChange={(e) => schuleAendern(stelle, { themen: e.target.value })}
placeholder="Themen des Unterrichts"
/>
<Feldwarnungen warnungen={warnungen} feld={`schule.${stelle}`} />
</div>
<div className="blattzeile-stunden">
<label htmlFor={`schule-std-${stelle}`}>Std.</label>
<input
id={`schule-std-${stelle}`}
name="schuleStunden"
type="number"
min={0}
max={60}
step={0.25}
value={eintrag.stunden || ''}
disabled={gesperrt}
onChange={(e) => schuleAendern(stelle, { stunden: Number(e.target.value) || 0 })}
/>
</div>
{!gesperrt ? (
<button
type="button"
className="knopf knopf-still zeile-weg"
onClick={() => setzeBlatt((v) => ({ ...v, schule: v.schule.filter((_, i) => i !== stelle) }))}
title="Zeile entfernen"
>
<span aria-hidden="true">×</span>
<span className="nur-lesehilfe">Schulzeile {stelle + 1} entfernen</span>
</button>
) : null}
</div>
))}
{!gesperrt ? (
<div>
<button
type="button"
className="knopf knopf-zweit"
onClick={() =>
setzeBlatt((v) => ({ ...v, schule: [...v.schule, { lernfeld: '', themen: '', stunden: 0 }] }))
}
>
Zeile hinzufügen
</button>
</div>
) : null}
</section>
{/* --- Sonstiges --- */}
<section className="stapel-eng" aria-labelledby="sonstiges">
<h2 id="sonstiges" style={{ margin: 0 }}>
Sonstiges
</h2>
<label htmlFor="sonstiges-feld" className="nur-lesehilfe">
Sonstiges
</label>
<textarea
id="sonstiges-feld"
name="sonstiges"
rows={3}
maxLength={2000}
value={blatt.sonstiges}
disabled={gesperrt}
onChange={(e) => setzeBlatt((v) => ({ ...v, sonstiges: e.target.value }))}
placeholder="Urlaub, Krankheit, Feiertage, Betriebsversammlung – alles, was keine Tätigkeit ist."
/>
<Feldwarnungen warnungen={warnungen} feld="sonstiges" />
</section>
{/* --- Summe und Prüfung --- */}
<section className="karte stapel-eng" aria-labelledby="pruefung">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="pruefung" style={{ margin: 0, fontSize: '1rem' }}>
Prüfung
</h2>
<strong>{zahlText(summeStunden(blatt))} Stunden gesamt</strong>
</div>
{warnungen.length === 0 ? (
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
✓ Nichts zu beanstanden.
</p>
) : (
<ul className="warnliste">
{warnungen.map((warnung, stelle) => (
<li key={stelle} data-schwere={warnung.schwere}>
<span className="warnzeichen" aria-hidden="true">
{warnung.schwere === 'warnung' ? '!' : 'i'}
</span>
<span>
{warnung.text}
{warnung.auszug ? <em> &ndash; &bdquo;{warnung.auszug}&ldquo;</em> : null}
</span>
</li>
))}
</ul>
)}
<p className="leise" style={{ marginBottom: 0 }}>
Das sind Hinweise, keine Sperre. Du kannst jederzeit einreichen.
</p>
</section>
<Meldung zustand={speicherZustand} />
{!gesperrt ? (
<div className="reihe nicht-drucken">
<Absenden className="knopf knopf-zweit">Speichern</Absenden>
<button type="submit" className="knopf knopf-akzent" formAction={einreichen}>
{ernste.length > 0 ? `Trotz ${ernste.length} Warnungen einreichen` : 'Einreichen'}
</button>
</div>
) : null}
<Meldung zustand={einreichZustand} />
</form>
</div>
);
}
function Feldwarnungen({
warnungen,
feld,
}: {
warnungen: ReturnType<typeof pruefeWochenblatt>;
feld: string;
}) {
const eigene = warnungen.filter((w) => w.feld === feld);
if (eigene.length === 0) return null;
return (
<ul className="feldwarnung">
{eigene.map((warnung, stelle) => (
<li key={stelle} data-schwere={warnung.schwere}>
{warnung.text}
</li>
))}
</ul>
);
}
const beschriftung: Record<string, string> = {
tagesnotiz: 'Tagesnotiz',
aufgabe: 'Aufgabe',
checkliste: 'Checkliste',
handout: 'Lerninhalt',
};
function zahlText(wert: number): string {
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
}
@@ -0,0 +1,74 @@
import { gegenueberstellungen, regeln, verbgruppen } from '@/lib/berichtsheft/formulierung';
/**
* Formulierungshilfe: Tätigkeitsverben und die Gegenüberstellung
* „so besser nicht / so ist es richtig".
*
* Eingeklappt, weil sie beim zweiten Wochenblatt nur noch stört – aber sie
* bleibt griffbereit, statt in einer Anleitung zu verschwinden.
*/
export function Formulierungshilfe() {
return (
<details className="karte formulierungshilfe nicht-drucken">
<summary>
<strong>Formulierungshilfe</strong>
<span className="leise"> – Tätigkeitsverben und Beispiele</span>
</summary>
<div className="stapel" style={{ marginTop: '0.9rem' }}>
<section aria-labelledby="regeln">
<h3 id="regeln" style={{ fontSize: '0.95rem' }}>
Sechs Regeln
</h3>
<ol className="stapel-eng" style={{ margin: 0, paddingLeft: '1.2rem' }}>
{regeln.map((regel, stelle) => (
<li key={stelle}>{regel}</li>
))}
</ol>
</section>
<section aria-labelledby="verben">
<h3 id="verben" style={{ fontSize: '0.95rem' }}>
Tätigkeitsverben
</h3>
<div className="verbgruppen">
{verbgruppen.map((gruppe) => (
<div key={gruppe.titel}>
<span className="wochentag-marke">{gruppe.titel}</span>
<p style={{ margin: 0 }}>{gruppe.verben.join(' · ')}</p>
</div>
))}
</div>
</section>
<section aria-labelledby="beispiele">
<h3 id="beispiele" style={{ fontSize: '0.95rem' }}>
So besser nicht – so ist es richtig
</h3>
<ul className="gegenueberstellung">
{gegenueberstellungen.map((beispiel, stelle) => (
<li key={stelle}>
<p className="schlecht">
<span className="marke-status" data-ton="fehler">
✗ so nicht
</span>{' '}
{beispiel.schlecht}
</p>
<p className="gut">
<span className="marke-status" data-ton="gut">
✓ besser
</span>{' '}
{beispiel.gut}
</p>
<p className="leise" style={{ margin: 0 }}>
{beispiel.warum}
</p>
</li>
))}
</ul>
</section>
</div>
</details>
);
}
@@ -0,0 +1,80 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { tagesnotizSpeichern, type Rueckmeldung } from './aktionen';
export type Tagesfeld = {
datum: string;
wochentag: string;
datumText: string;
text: string;
stunden: number;
istHeute: boolean;
};
/**
* Die täglichen Stichworte.
*
* Der Zweck: zwei Minuten am Tag statt einer Stunde Rekonstruktion am Freitag.
* Deshalb je Tag ein eigenes kleines Formular, das für sich allein absendet –
* ein großes Formular für die ganze Woche würde bei jedem Speichern alles
* neu schreiben und wäre am Handy unbrauchbar.
*/
export function Tagesnotizen({ tage }: { tage: Tagesfeld[] }) {
return (
<div className="tagesnotizen">
{tage.map((tag) => (
<Tagesnotiz key={tag.datum} tag={tag} />
))}
</div>
);
}
function Tagesnotiz({ tag }: { tag: Tagesfeld }) {
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(tagesnotizSpeichern, {});
return (
<form action={aktion} className={`tagesnotiz${tag.istHeute ? ' tagesnotiz-heute' : ''}`}>
<input type="hidden" name="datum" value={tag.datum} />
<div className="tagesnotiz-kopf">
<strong>{tag.wochentag}</strong>
<span className="leise">{tag.datumText}</span>
{tag.istHeute ? (
<span className="marke-status" data-ton="warnung">
● heute
</span>
) : null}
</div>
<label htmlFor={`notiz-${tag.datum}`} className="nur-lesehilfe">
Stichworte für {tag.wochentag}
</label>
<textarea
id={`notiz-${tag.datum}`}
name="text"
rows={2}
maxLength={2000}
defaultValue={tag.text}
placeholder="Stichworte genügen – woran hast du heute gearbeitet?"
/>
<div className="tagesnotiz-fuss">
<label htmlFor={`std-${tag.datum}`}>Stunden</label>
<input
id={`std-${tag.datum}`}
name="stunden"
type="number"
min={0}
max={24}
step={0.25}
defaultValue={tag.stunden || ''}
/>
<Absenden className="knopf knopf-still">Merken</Absenden>
</div>
<Meldung zustand={zustand} />
</form>
);
}
@@ -0,0 +1,56 @@
'use client';
import { useActionState } from 'react';
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
import { blattAbzeichnen, blattZurueckgeben, type Rueckmeldung } from '../aktionen';
/**
* Abzeichnen oder zurückgeben.
*
* Zurückgeben verlangt eine Anmerkung – ohne sie weiß der Azubi nicht, was er
* ändern soll, und das Blatt pendelt hin und her.
*/
export function Abzeichnen({
berichtId,
anmerkung,
status,
}: {
berichtId: string;
anmerkung: string;
status: string;
}) {
const [abzeichenZustand, abzeichnen] = useActionState<Rueckmeldung, FormData>(blattAbzeichnen, {});
const [rueckgabeZustand, zurueckgeben] = useActionState<Rueckmeldung, FormData>(blattZurueckgeben, {});
if (status === 'abgezeichnet') return null;
return (
<form action={abzeichnen} className="stapel-eng">
<input type="hidden" name="berichtId" value={berichtId} />
<Meldung zustand={abzeichenZustand} />
<Meldung zustand={rueckgabeZustand} />
<label htmlFor="anmerkung">Anmerkung an den Azubi</label>
<textarea
id="anmerkung"
name="anmerkung"
rows={3}
maxLength={2000}
defaultValue={anmerkung}
placeholder="Beim Abzeichnen freiwillig, beim Zurückgeben nötig: Was soll er ändern?"
/>
<div className="reihe">
<Absenden className="knopf knopf-akzent">Abzeichnen</Absenden>
<button type="submit" className="knopf knopf-zweit" formAction={zurueckgeben}>
Zur Nacharbeit zurückgeben
</button>
</div>
<p className="leise" style={{ marginBottom: 0 }}>
Abzeichnen sperrt das Blatt endgültig – der Azubi kann es danach nicht mehr ändern.
</p>
</form>
);
}
@@ -0,0 +1,296 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { inhalte } from '@/lib/inhalte';
import { alsWochenblatt, notizenIm } from '@/lib/repos/berichtsheft';
import { staendeVon as aufgabenStaende } from '@/lib/repos/aufgaben';
import { laeufeVon } from '@/lib/repos/checklisten';
import { gelesenSet } from '@/lib/repos/fortschritt';
import { planVon } from '@/lib/repos/wochenplanung';
import { baueVorschlaege, type Rohdaten } from '@/lib/berichtsheft/vorschlaege';
import { pruefeWochenblatt, summeBetrieb, summeSchule, summeStunden } from '@/lib/berichtsheft/pruefung';
import { alsDatumText, alsZeitpunktText, kalenderwocheText } from '@/lib/zeit';
import { Blattformular } from '../Blattformular';
import { Formulierungshilfe } from '../Formulierungshilfe';
import { BerichtsMarke } from '../BerichtsMarke';
import { Abzeichnen } from './Abzeichnen';
import { blattZurueckziehen, blattZumLesen } from '../aktionen';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ berichtId: string }> }): Promise<Metadata> {
const { berichtId } = await params;
const gelesen = await blattZumLesen(berichtId);
return { title: gelesen ? `Wochenblatt Nr. ${gelesen.bericht.nr}` : 'Wochenblatt' };
}
export default async function BlattSeite({ params }: { params: Promise<{ berichtId: string }> }) {
const { berichtId } = await params;
// Rechteprüfung steckt hier drin: kein Zugriff bedeutet null bedeutet 404.
const gelesen = await blattZumLesen(berichtId);
if (!gelesen) notFound();
const { benutzer, bericht } = gelesen;
const eigenes = bericht.benutzerId === benutzer.id;
const azubi = eigenes ? benutzer : benutzerNachId(bericht.benutzerId);
if (!azubi) notFound();
const blatt = alsWochenblatt(bericht);
const warnungen = pruefeWochenblatt(blatt);
const vorschlaege = eigenes ? sammleVorschlaege(azubi.id, bericht.von, bericht.bis) : [];
return (
<article className="stapel">
<nav className="leise nicht-drucken" aria-label="Wo du bist">
<Link href="/berichtsheft">Berichtsheft</Link>
{!eigenes ? ` › ${azubi.name}` : ''}
</nav>
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="blatt-nr blatt-nr-gross">Nr. {bericht.nr}</span>
<BerichtsMarke status={bericht.status} />
<span className="leise">{kalenderwocheText(bericht.von)}</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
</h1>
<p className="leise">
{azubi.name}
{bericht.eingereichtAm ? ` · eingereicht ${alsZeitpunktText(bericht.eingereichtAm)}` : ''}
</p>
</header>
{bericht.status === 'abgezeichnet' ? (
<p className="hinweis hinweis-gut">
✓ Abgezeichnet am {alsZeitpunktText(bericht.abgezeichnetAm)}
{bericht.abgezeichnetVon ? ` von ${benutzerNachId(bericht.abgezeichnetVon)?.name ?? 'dem Ausbilder'}` : ''}.
Das Blatt ist gesperrt.
</p>
) : null}
{bericht.status === 'zurueckgegeben' && bericht.anmerkung ? (
<div className="hinweis hinweis-warnung">
<strong>Zur Nacharbeit zurückgegeben:</strong>
<p className="notiz-anzeige" style={{ marginTop: '0.4rem' }}>
{bericht.anmerkung}
</p>
</div>
) : null}
{bericht.status === 'abgezeichnet' && bericht.anmerkung ? (
<div className="karte">
<strong>Anmerkung des Ausbilders</strong>
<p className="notiz-anzeige" style={{ marginTop: '0.4rem' }}>
{bericht.anmerkung}
</p>
</div>
) : null}
{eigenes ? (
<Blattformular
berichtId={bericht.id}
anfangsblatt={blatt}
vorschlaege={vorschlaege}
status={bericht.status}
/>
) : (
<AnsichtFuerAusbilder blatt={blatt} warnungen={warnungen} />
)}
{/* --- Werkzeuge --- */}
<div className="reihe nicht-drucken">
<a href={`/berichtsheft/${bericht.id}/pdf`} className="knopf knopf-zweit" target="_blank" rel="noreferrer">
Dieses Blatt als PDF
</a>
{eigenes && bericht.status === 'eingereicht' ? (
<form action={blattZurueckziehen}>
<input type="hidden" name="berichtId" value={bericht.id} />
<button type="submit" className="knopf knopf-still">
Einreichung zurückziehen
</button>
</form>
) : null}
</div>
{!eigenes ? (
<section className="karte-grund stapel nicht-drucken" aria-labelledby="abzeichnen">
<h2 id="abzeichnen" style={{ marginBottom: 0 }}>
Abzeichnen
</h2>
<Abzeichnen berichtId={bericht.id} anmerkung={bericht.anmerkung ?? ''} status={bericht.status} />
</section>
) : null}
{eigenes ? <Formulierungshilfe /> : null}
</article>
);
}
/** Die Nur-Lese-Ansicht für den Ausbilder – dieselben Daten, ohne Formular. */
function AnsichtFuerAusbilder({
blatt,
warnungen,
}: {
blatt: ReturnType<typeof alsWochenblatt>;
warnungen: ReturnType<typeof pruefeWochenblatt>;
}) {
return (
<div className="stapel">
<section className="karte stapel-eng" aria-labelledby="betrieb-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="betrieb-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Betriebliche Tätigkeiten und Unterweisungen
</h2>
<span className="leise">{zahlText(summeBetrieb(blatt))} h</span>
</div>
{blatt.betrieb.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Keine Eintragung.
</p>
) : (
<ul className="leseliste">
{blatt.betrieb.map((eintrag, stelle) => (
<li key={stelle}>
<span>{eintrag.text}</span>
<span className="leise">{eintrag.stunden > 0 ? `${zahlText(eintrag.stunden)} h` : '–'}</span>
</li>
))}
</ul>
)}
</section>
<section className="karte stapel-eng" aria-labelledby="schule-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="schule-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Berufsschule
</h2>
<span className="leise">{zahlText(summeSchule(blatt))} h</span>
</div>
{blatt.schule.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>
Keine Eintragung.
</p>
) : (
<ul className="leseliste">
{blatt.schule.map((eintrag, stelle) => (
<li key={stelle}>
<span>
{eintrag.lernfeld ? <strong>{eintrag.lernfeld} </strong> : null}
{eintrag.themen}
</span>
<span className="leise">{eintrag.stunden > 0 ? `${zahlText(eintrag.stunden)} h` : '–'}</span>
</li>
))}
</ul>
)}
</section>
{blatt.sonstiges ? (
<section className="karte stapel-eng" aria-labelledby="sonstiges-lesen">
<h2 id="sonstiges-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Sonstiges
</h2>
<p className="notiz-anzeige">{blatt.sonstiges}</p>
</section>
) : null}
<section className="karte stapel-eng" aria-labelledby="pruefung-lesen">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="pruefung-lesen" style={{ margin: 0, fontSize: '1rem' }}>
Prüfung
</h2>
<strong>{zahlText(summeStunden(blatt))} Stunden gesamt</strong>
</div>
{warnungen.length === 0 ? (
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
✓ Nichts zu beanstanden.
</p>
) : (
<ul className="warnliste">
{warnungen.map((warnung, stelle) => (
<li key={stelle} data-schwere={warnung.schwere}>
<span className="warnzeichen" aria-hidden="true">
{warnung.schwere === 'warnung' ? '!' : 'i'}
</span>
<span>
{warnung.text}
{warnung.auszug ? <em> &ndash; &bdquo;{warnung.auszug}&ldquo;</em> : null}
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}
/** Stellt zusammen, was die Anwendung über diese Woche weiß. */
function sammleVorschlaege(azubiId: string, von: string, bis: string) {
const alle = inhalte();
const notizen = notizenIm(azubiId, von, bis).map((n) => ({
datum: n.datum,
text: n.text,
stunden: n.stunden,
}));
// Praxisaufgaben, die in dieser Woche abgegeben oder abgenommen wurden.
const staende = aufgabenStaende(azubiId);
const aufgaben = [...staende.values()]
.filter((s) => {
const zeitpunkt = (s.abgenommenAm ?? s.abgegebenAm ?? '').slice(0, 10);
return zeitpunkt >= von && zeitpunkt <= bis;
})
.map((s) => {
const aufgabe = alle.aufgabeNach.get(s.aufgabeId);
return {
id: s.aufgabeId,
titel: aufgabe?.titel ?? s.aufgabeId,
zeitrahmen: aufgabe?.zeitrahmen ?? '',
status: s.status,
};
});
const checklisten = laeufeVon(azubiId)
.filter((l) => l.abgeschlossenAm !== null && l.datum >= von && l.datum <= bis)
.map((l) => ({ id: l.id, titel: l.titel, ticket: l.ticket, kunde: l.kunde }));
// Handouts der Wochen, die in diesem Zeitraum lagen – nur die gelesenen.
const ausbildung = ausbildungNachAzubi(azubiId);
const gelesen = gelesenSet(azubiId);
const wochenImZeitraum = ausbildung
? planVon(ausbildung.id).filter((z) => z.geplantVon <= bis && z.geplantBis >= von)
: [];
const handouts = wochenImZeitraum
.flatMap((z) => alle.wocheNach.get(z.wochenNummer)?.handouts ?? [])
.filter((id) => gelesen.has(id))
.map((id) => ({ id, titel: alle.handoutNach.get(id)?.titel ?? id }));
const lernfelder = [
...new Set(
wochenImZeitraum
.flatMap((z) => alle.wocheNach.get(z.wochenNummer)?.handouts ?? [])
.map((id) => alle.handoutNach.get(id)?.lernfeld)
.filter((lf): lf is string => Boolean(lf)),
),
].map((id) => ({ id, titel: alle.lernfeldNach.get(id)?.titel ?? id }));
const rohdaten: Rohdaten = { tagesnotizen: notizen, aufgaben, checklisten, handouts, lernfelder };
return baueVorschlaege(rohdaten);
}
function zahlText(wert: number): string {
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
}
@@ -0,0 +1,52 @@
import { benutzerNachId } from '@/lib/repos/benutzer';
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { alsWochenblatt } from '@/lib/repos/berichtsheft';
import { berichtsheftPdf } from '@/lib/pdf/berichtsheft';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
import { blattZumLesen } from '../../aktionen';
export const dynamic = 'force-dynamic';
/** Ein einzelnes Wochenblatt als PDF. Rechteprüfung über `blattZumLesen`. */
export async function GET(_anfrage: Request, kontext: { params: Promise<{ berichtId: string }> }) {
const { berichtId } = await kontext.params;
const gelesen = await blattZumLesen(berichtId);
if (!gelesen) return new Response('Nicht gefunden.', { status: 404 });
const { bericht } = gelesen;
const azubi = benutzerNachId(bericht.benutzerId);
const ausbildung = ausbildungNachAzubi(bericht.benutzerId);
const ausbilder = ausbildung ? benutzerNachId(ausbildung.ausbilderId) : null;
const abzeichner = bericht.abgezeichnetVon ? benutzerNachId(bericht.abgezeichnetVon) : null;
const pdf = await berichtsheftPdf([
{
nr: bericht.nr,
von: alsDatumText(bericht.von),
bis: alsDatumText(bericht.bis),
azubi: azubi?.name ?? 'unbekannt',
ausbilder: ausbilder?.name ?? '',
beruf: ausbildung?.beruf ?? 'Fachinformatiker/-in für Systemintegration',
ausbildungsjahr: ausbildung ? ausbildungsjahr(ausbildung.beginn, bericht.von) : null,
status: bericht.status,
anmerkung: bericht.anmerkung,
abgezeichnetVon: abzeichner?.name ?? null,
abgezeichnetAm: bericht.abgezeichnetAm ? alsZeitpunktText(bericht.abgezeichnetAm) : null,
blatt: alsWochenblatt(bericht),
},
]);
const dateiname = `Ausbildungsnachweis_${String(bericht.nr).padStart(3, '0')}_${bericht.von}.pdf`;
return new Response(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
'Content-Length': String(pdf.byteLength),
'Content-Disposition': `inline; filename="${dateiname}"`,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
});
}
@@ -0,0 +1,197 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { erfordereBenutzer, erfordereBewertungsrecht, ausbildungVonAzubi } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import {
abzeichnen,
berichtNachId,
einreichen,
inhaltSpeichern,
notizSetzen,
sorgeFuerBlatt,
zurueckgeben,
} from '@/lib/repos/berichtsheft';
import type { Wochenblatt } from '@/lib/berichtsheft/pruefung';
export type Rueckmeldung = { fehler?: string; erfolg?: string };
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/** Zum Bearbeiten: nur das eigene Blatt, und nur solange es nicht abgezeichnet ist. */
async function blattZumBearbeiten(berichtId: string) {
const benutzer = await erfordereBenutzer();
const bericht = berichtNachId(berichtId);
if (!bericht) throw new Error('Dieses Wochenblatt gibt es nicht.');
if (bericht.benutzerId !== benutzer.id) throw new Error('Kein Zugriff auf dieses Wochenblatt.');
return { benutzer, bericht };
}
/** Zum Lesen: das eigene Blatt oder das eines betreuten Azubis. */
export async function blattZumLesen(berichtId: string) {
const benutzer = await erfordereBenutzer();
const bericht = berichtNachId(berichtId);
if (!bericht) return null;
const bezug = ausbildungVonAzubi(bericht.benutzerId);
if (!darfAzubiDatenSehen(benutzer, bericht.benutzerId, bezug)) return null;
return { benutzer, bericht };
}
function aktualisiere(berichtId?: string) {
revalidatePath('/berichtsheft');
if (berichtId) revalidatePath(`/berichtsheft/${berichtId}`);
revalidatePath('/');
}
// --- Tagesnotizen ----------------------------------------------------------
export async function tagesnotizSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return { fehler: 'Tagesnotizen schreibt der Azubi.' };
const datum = String(formular.get('datum') ?? '').trim();
if (!datumMuster.test(datum)) return { fehler: 'Ungültiges Datum.' };
const text = String(formular.get('text') ?? '');
if (text.length > 2000) return { fehler: 'Die Notiz ist zu lang – höchstens 2000 Zeichen.' };
const stunden = zahl(formular.get('stunden'));
if (stunden > 24) return { fehler: 'Mehr als 24 Stunden an einem Tag sind nicht plausibel.' };
notizSetzen(benutzer.id, datum, text, stunden);
aktualisiere();
return { erfolg: text.trim() ? 'Notiz gespeichert.' : 'Notiz entfernt.' };
}
// --- Wochenblatt -----------------------------------------------------------
export async function blattAnlegen(formular: FormData): Promise<void> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') return;
const datum = String(formular.get('datum') ?? '').trim();
if (!datumMuster.test(datum)) return;
const bericht = sorgeFuerBlatt(benutzer.id, datum);
aktualisiere(bericht.id);
redirect(`/berichtsheft/${bericht.id}`);
}
/** Liest das Blatt aus dem Formular. Leere Zeilen fliegen raus. */
function leseBlatt(formular: FormData): Wochenblatt {
const betriebTexte = formular.getAll('betriebText').map(String);
const betriebStunden = formular.getAll('betriebStunden');
const schuleLernfelder = formular.getAll('schuleLernfeld').map(String);
const schuleThemen = formular.getAll('schuleThemen').map(String);
const schuleStunden = formular.getAll('schuleStunden');
return {
betrieb: betriebTexte
.map((text, stelle) => ({ text: text.trim(), stunden: zahl(betriebStunden[stelle]) }))
.filter((e) => e.text !== '' || e.stunden > 0),
schule: schuleLernfelder
.map((lernfeld, stelle) => ({
lernfeld: lernfeld.trim(),
themen: (schuleThemen[stelle] ?? '').trim(),
stunden: zahl(schuleStunden[stelle]),
}))
.filter((e) => e.lernfeld !== '' || e.themen !== '' || e.stunden > 0),
sonstiges: String(formular.get('sonstiges') ?? '').trim(),
};
}
export async function blattSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId);
if (bericht.status === 'abgezeichnet') {
return { fehler: 'Das Blatt ist abgezeichnet und damit gesperrt.' };
}
const blatt = leseBlatt(formular);
if (blatt.betrieb.length > 30) return { fehler: 'Mehr als 30 Tätigkeiten in einer Woche sind zu viel.' };
if (blatt.sonstiges.length > 2000) return { fehler: 'Der Text unter „Sonstiges" ist zu lang.' };
inhaltSpeichern(berichtId, blatt);
aktualisiere(berichtId);
return { erfolg: 'Gespeichert.' };
}
/**
* Einreichen.
*
* Die Prüfung ist eine **Warnung, keine Sperre** – deshalb wird hier nichts
* geprüft, was das Einreichen verhindern könnte. Der Azubi hat die Warnungen
* auf der Seite gesehen; ob er sie beherzigt, entscheidet er.
*/
export async function blattEinreichen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId);
if (bericht.status === 'abgezeichnet') return { fehler: 'Das Blatt ist schon abgezeichnet.' };
if (bericht.status === 'eingereicht') return { fehler: 'Das Blatt liegt schon beim Ausbilder.' };
// Vor dem Einreichen den letzten Stand übernehmen, damit nichts verlorengeht.
inhaltSpeichern(berichtId, leseBlatt(formular));
einreichen(berichtId);
aktualisiere(berichtId);
return { erfolg: 'Eingereicht. Dein Ausbilder sieht das Blatt jetzt unter „Was auf dich wartet".' };
}
export async function blattZurueckziehen(formular: FormData): Promise<void> {
const berichtId = String(formular.get('berichtId') ?? '');
const { bericht } = await blattZumBearbeiten(berichtId);
if (bericht.status !== 'eingereicht') return;
zurueckgeben(berichtId, bericht.anmerkung ?? '');
aktualisiere(berichtId);
}
// --- Abzeichnen durch den Ausbilder ----------------------------------------
export async function blattAbzeichnen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const berichtId = String(formular.get('berichtId') ?? '');
const bericht = berichtNachId(berichtId);
if (!bericht) return { fehler: 'Dieses Wochenblatt gibt es nicht.' };
erfordereBewertungsrecht(ausbilder, bericht.benutzerId);
if (bericht.status === 'abgezeichnet') return { fehler: 'Das Blatt ist schon abgezeichnet.' };
const anmerkung = String(formular.get('anmerkung') ?? '').trim();
if (anmerkung.length > 2000) return { fehler: 'Die Anmerkung ist zu lang.' };
abzeichnen(berichtId, ausbilder.id, anmerkung || null);
aktualisiere(berichtId);
return { erfolg: 'Abgezeichnet. Das Blatt ist damit gesperrt.' };
}
export async function blattZurueckgeben(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
const ausbilder = await erfordereBenutzer();
const berichtId = String(formular.get('berichtId') ?? '');
const bericht = berichtNachId(berichtId);
if (!bericht) return { fehler: 'Dieses Wochenblatt gibt es nicht.' };
erfordereBewertungsrecht(ausbilder, bericht.benutzerId);
const anmerkung = String(formular.get('anmerkung') ?? '').trim();
if (!anmerkung) {
return { fehler: 'Ohne Anmerkung weiß der Azubi nicht, was er ändern soll. Schreib bitte einen Satz dazu.' };
}
if (anmerkung.length > 2000) return { fehler: 'Die Anmerkung ist zu lang.' };
zurueckgeben(berichtId, anmerkung);
aktualisiere(berichtId);
return { erfolg: 'Zurückgegeben. Der Azubi kann das Blatt jetzt wieder bearbeiten.' };
}
function zahl(wert: FormDataEntryValue | null | undefined): number {
const roh = String(wert ?? '').replace(',', '.').trim();
const gelesen = Number(roh);
return Number.isFinite(gelesen) && gelesen > 0 ? Math.round(gelesen * 100) / 100 : 0;
}
+256
View File
@@ -0,0 +1,256 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
import { alsWochenblatt, berichteVon, fehlendeWochen, notizenIm } from '@/lib/repos/berichtsheft';
import { summeStunden } from '@/lib/berichtsheft/pruefung';
import { alsDatumText, heute, kalenderwocheText, montagDerWoche, werktage, wochentagText } from '@/lib/zeit';
import { Tagesnotizen, type Tagesfeld } from './Tagesnotizen';
import { Formulierungshilfe } from './Formulierungshilfe';
import { blattAnlegen } from './aktionen';
import { BerichtsMarke } from './BerichtsMarke';
export const metadata: Metadata = { title: 'Berichtsheft' };
export const dynamic = 'force-dynamic';
export default async function BerichtsheftSeite() {
const benutzer = await erfordereBenutzer();
return benutzer.rolle === 'ausbilder' ? (
<AusbilderSicht ausbilderId={benutzer.id} />
) : (
<AzubiSicht azubiId={benutzer.id} />
);
}
// --- Azubi -----------------------------------------------------------------
function AzubiSicht({ azubiId }: { azubiId: string }) {
const ausbildung = ausbildungNachAzubi(azubiId);
const berichte = berichteVon(azubiId);
const heuteDatum = heute();
const tage = werktage(heuteDatum);
const notizen = notizenIm(azubiId, tage[0]!, tage[4]!);
const notizNach = new Map(notizen.map((n) => [n.datum, n]));
const felder: Tagesfeld[] = tage.map((datum) => ({
datum,
wochentag: wochentagText(datum),
datumText: alsDatumText(datum),
text: notizNach.get(datum)?.text ?? '',
stunden: notizNach.get(datum)?.stunden ?? 0,
istHeute: datum === heuteDatum,
}));
const dieseWoche = berichte.find((b) => b.von === montagDerWoche(heuteDatum));
const fehlend = ausbildung ? fehlendeWochen(azubiId, ausbildung.beginn, heuteDatum) : [];
const abgezeichnet = berichte.filter((b) => b.status === 'abgezeichnet').length;
return (
<div className="stapel">
<div>
<h1>Berichtsheft</h1>
<p className="leise">
{berichte.length} {berichte.length === 1 ? 'Wochenblatt' : 'Wochenblätter'} · {abgezeichnet} abgezeichnet
{fehlend.length > 0 ? ` · ${fehlend.length} Wochen ohne Blatt` : ''}
</p>
</div>
{fehlend.length > 0 ? (
<div className="hinweis hinweis-warnung">
<strong>
Für {fehlend.length} vergangene {fehlend.length === 1 ? 'Woche' : 'Wochen'} gibt es noch kein Blatt.
</strong>{' '}
Ohne geführten Nachweis gibt es keine Zulassung zur Abschlussprüfung – das ist der Teil, der in der
Praxis am häufigsten schiefgeht.
<div className="reihe" style={{ marginTop: '0.6rem' }}>
{fehlend.slice(0, 6).map((montag) => (
<form key={montag} action={blattAnlegen}>
<input type="hidden" name="datum" value={montag} />
<button type="submit" className="knopf knopf-zweit">
{kalenderwocheText(montag)} nachtragen
</button>
</form>
))}
{fehlend.length > 6 ? <span className="leise">… und {fehlend.length - 6} weitere</span> : null}
</div>
</div>
) : null}
<section className="karte-grund stapel" aria-labelledby="notizen">
<div>
<h2 id="notizen" style={{ marginBottom: '0.15rem' }}>
Diese Woche in Stichworten
</h2>
<p className="leise" style={{ margin: 0 }}>
Zwei Minuten am Tag. Freitags baust du daraus mit einem Klick das Wochenblatt – das ist deutlich
weniger Arbeit, als sich am Freitag an den Dienstag zu erinnern.
</p>
</div>
<Tagesnotizen tage={felder} />
</section>
<section className="stapel" aria-labelledby="blaetter">
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id="blaetter" style={{ margin: 0 }}>
Wochenblätter
</h2>
{dieseWoche ? (
<Link href={`/berichtsheft/${dieseWoche.id}`} className="knopf">
Blatt dieser Woche öffnen
</Link>
) : (
<form action={blattAnlegen}>
<input type="hidden" name="datum" value={heuteDatum} />
<button type="submit" className="knopf knopf-akzent">
Blatt für diese Woche anlegen
</button>
</form>
)}
</div>
{berichte.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch kein Wochenblatt. Leg das erste an, sobald die Woche läuft.
</p>
) : (
<ul className="blattliste">
{berichte.map((bericht) => {
const blatt = alsWochenblatt(bericht);
return (
<li key={bericht.id}>
<Link href={`/berichtsheft/${bericht.id}`} className="blattzeile-link">
<span className="blatt-nr">Nr. {bericht.nr}</span>
<span className="blatt-zeitraum">
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
<span className="leise" style={{ display: 'block' }}>
{kalenderwocheText(bericht.von)} · {blatt.betrieb.length} Tätigkeiten ·{' '}
{zahlText(summeStunden(blatt))} h
</span>
</span>
<BerichtsMarke status={bericht.status} />
</Link>
</li>
);
})}
</ul>
)}
</section>
{berichte.length > 0 ? (
<section className="karte stapel-eng nicht-drucken" aria-labelledby="export">
<h2 id="export" style={{ marginBottom: 0, fontSize: '1rem' }}>
Ganzen Zeitraum ausgeben
</h2>
<p className="leise" style={{ margin: 0 }}>
Alle Blätter als ein PDF, ein Blatt je Seite – zum Ausdrucken und Abheften.
</p>
<div>
<a
href={`/berichtsheft/pdf?von=${berichte.at(-1)!.von}&bis=${berichte[0]!.bis}`}
className="knopf knopf-zweit"
target="_blank"
rel="noreferrer"
>
Alle {berichte.length} Blätter als PDF
</a>
</div>
</section>
) : null}
<Formulierungshilfe />
</div>
);
}
// --- Ausbilder -------------------------------------------------------------
function AusbilderSicht({ ausbilderId }: { ausbilderId: string }) {
const meine = ausbildungenVonAusbilder(ausbilderId);
return (
<div className="stapel">
<div>
<h1>Berichtshefte</h1>
<p className="leise">Eingereichte Blätter abzeichnen oder zur Nacharbeit zurückgeben.</p>
</div>
{meine.length === 0 ? (
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
meine.map(({ azubi, ausbildung }) => {
const berichte = berichteVon(azubi.id);
const offene = berichte.filter((b) => b.status === 'eingereicht');
const fehlend = fehlendeWochen(azubi.id, ausbildung.beginn);
return (
<section key={azubi.id} className="stapel" aria-labelledby={`azubi-${azubi.id}`}>
<div className="reihe" style={{ justifyContent: 'space-between' }}>
<h2 id={`azubi-${azubi.id}`} style={{ margin: 0 }}>
{azubi.name}
</h2>
<div className="reihe">
{offene.length > 0 ? (
<span className="marke-status" data-ton="warnung">
● {offene.length} zum Abzeichnen
</span>
) : null}
{fehlend.length > 0 ? (
<span className="marke-status" data-ton="fehler">
! {fehlend.length} Wochen ohne Blatt
</span>
) : null}
{berichte.length > 0 ? (
<a
href={`/berichtsheft/pdf?azubi=${azubi.id}&von=${berichte.at(-1)!.von}&bis=${berichte[0]!.bis}`}
className="knopf knopf-zweit"
target="_blank"
rel="noreferrer"
>
Alles als PDF
</a>
) : null}
</div>
</div>
{berichte.length === 0 ? (
<p className="karte" style={{ marginBottom: 0 }}>
Noch kein Wochenblatt.
</p>
) : (
<ul className="blattliste">
{berichte.map((bericht) => {
const blatt = alsWochenblatt(bericht);
return (
<li key={bericht.id}>
<Link href={`/berichtsheft/${bericht.id}`} className="blattzeile-link">
<span className="blatt-nr">Nr. {bericht.nr}</span>
<span className="blatt-zeitraum">
{alsDatumText(bericht.von)} – {alsDatumText(bericht.bis)}
<span className="leise" style={{ display: 'block' }}>
{blatt.betrieb.length} Tätigkeiten · {zahlText(summeStunden(blatt))} h
</span>
</span>
<BerichtsMarke status={bericht.status} />
</Link>
</li>
);
})}
</ul>
)}
</section>
);
})
)}
</div>
);
}
function zahlText(wert: number): string {
return (Math.round(wert * 100) / 100).toString().replace('.', ',');
}
@@ -0,0 +1,77 @@
import { erfordereBenutzer, ausbildungVonAzubi } from '@/lib/auth/rechte';
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
import { benutzerNachId } from '@/lib/repos/benutzer';
import { alsWochenblatt, berichteVon } from '@/lib/repos/berichtsheft';
import { berichtsheftPdf, type Blattangaben } from '@/lib/pdf/berichtsheft';
import { ausbildungsjahr } from '@/lib/berichtsheft/jahr';
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
export const dynamic = 'force-dynamic';
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
/**
* Ein ganzer Zeitraum als PDF, ein Blatt je Seite.
*
* /berichtsheft/pdf?von=2026-08-01&bis=2026-12-31[&azubi=…]
*
* Ohne `azubi` das eigene Berichtsheft. Der Ausbilder gibt die Kennung an –
* geprüft wird sie serverseitig gegen das Ausbildungsverhältnis.
*/
export async function GET(anfrage: Request) {
const benutzer = await erfordereBenutzer();
const adresse = new URL(anfrage.url);
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (adresse.searchParams.get('azubi') ?? '');
if (!azubiId) return new Response('Nicht gefunden.', { status: 404 });
const bezug = ausbildungVonAzubi(azubiId);
if (!darfAzubiDatenSehen(benutzer, azubiId, bezug)) {
return new Response('Nicht gefunden.', { status: 404 });
}
const von = adresse.searchParams.get('von') ?? '';
const bis = adresse.searchParams.get('bis') ?? '';
if (!datumMuster.test(von) || !datumMuster.test(bis) || von > bis) {
return new Response('Ungültiger Zeitraum.', { status: 400 });
}
const azubi = benutzerNachId(azubiId);
const ausbilder = bezug ? benutzerNachId(bezug.ausbilderId) : null;
// Aufsteigend, damit das Heft in der richtigen Reihenfolge ausgedruckt wird.
const berichte = berichteVon(azubiId)
.filter((b) => b.bis >= von && b.von <= bis)
.sort((a, b) => a.nr - b.nr);
const blaetter: Blattangaben[] = berichte.map((bericht) => {
const abzeichner = bericht.abgezeichnetVon ? benutzerNachId(bericht.abgezeichnetVon) : null;
return {
nr: bericht.nr,
von: alsDatumText(bericht.von),
bis: alsDatumText(bericht.bis),
azubi: azubi?.name ?? 'unbekannt',
ausbilder: ausbilder?.name ?? '',
beruf: bezug?.beruf ?? 'Fachinformatiker/-in für Systemintegration',
ausbildungsjahr: bezug ? ausbildungsjahr(bezug.beginn, bericht.von) : null,
status: bericht.status,
anmerkung: bericht.anmerkung,
abgezeichnetVon: abzeichner?.name ?? null,
abgezeichnetAm: bericht.abgezeichnetAm ? alsZeitpunktText(bericht.abgezeichnetAm) : null,
blatt: alsWochenblatt(bericht),
};
});
const pdf = await berichtsheftPdf(blaetter, `${alsDatumText(von)} bis ${alsDatumText(bis)}`);
const dateiname = `Ausbildungsnachweis_${von}_bis_${bis}.pdf`;
return new Response(new Uint8Array(pdf), {
headers: {
'Content-Type': 'application/pdf',
'Content-Length': String(pdf.byteLength),
'Content-Disposition': `inline; filename="${dateiname}"`,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
});
}

Some files were not shown because too many files have changed in this diff Show More