Compare commits
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.)
|
||||
@@ -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.)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.**
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 2. Ein Azubikonto anlegen
|
||||
|
||||
**Verwaltung → Neues Azubikonto**
|
||||
|
||||

|
||||
|
||||
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**
|
||||
|
||||

|
||||
|
||||
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**
|
||||
|
||||

|
||||
|
||||
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**
|
||||
|
||||

|
||||
|
||||
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**
|
||||
|
||||

|
||||
|
||||
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 %).
|
||||
|
||||

|
||||
|
||||
> **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**
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
**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**
|
||||
|
||||

|
||||
|
||||
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**.
|
||||
|
||||

|
||||
|
||||
*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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
Die Handouts sind der Lesestoff: kurz, mit Beispielen, jeweils einem Thema
|
||||
zugeordnet. Die Suche geht über Titel und Volltext.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 11. Merkkarten lernen
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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 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 **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
|
||||
|
||||

|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
|
||||
**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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 503 KiB After Width: | Height: | Size: 506 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 103 KiB |
@@ -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 [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 „in Arbeit“ – 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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> – „{warnung.auszug}“</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> – „{warnung.auszug}“</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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||