Baustein 5: Praxisaufgaben mit Anhaengen und Abnahme
- Status offen / in Arbeit / zur Abnahme / abgenommen, mit Zeitstempeln - Notizfeld fuer den Azubi, Dateianhaenge bis 25 MB - Speicherpfad nur aus selbst erzeugter UUID, nie aus dem Dateinamen - Auslieferung ueber /anhaenge/<kennung> mit Rechtepruefung bei jedem Abruf, CSP sandbox und nosniff; SVG ausdruecklich nicht erlaubt - Anhaenge haengen an Aufgabe UND Azubi - Ausbilder hakt Kriterien einzeln ab, Note und Rueckmeldung, Abnahme sperrt - Zurueckgeben zur Nacharbeit - "Was auf dich wartet" zeigt offene Abnahmen - Abgenommene Aufgaben zaehlen im Wochenfortschritt mit Behoben: Next.js deckelt Serveraktionen ab Werk bei 1 MB - damit waere kein Handyfoto durchgekommen. bodySizeLimit jetzt 30 MB, Vorabpruefung im Browser, client_max_body_size im Proxy-Beispiel auf 30m. Geaendert: Seiten verweigern fremde Daten mit 404 statt mit einem Fehler. Eigene Seiten fuer "Nicht gefunden" und Stoerfaelle. 24 neue Tests, insgesamt 157. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@@ -26,6 +26,51 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [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
|
## [0.4.0] – 2026-09-10
|
||||||
|
|
||||||
Merkkarten mit Wiederholung nach Zeitabstand – der tägliche kleine Stapel
|
Merkkarten mit Wiederholung nach Zeitabstand – der tägliche kleine Stapel
|
||||||
@@ -185,6 +230,7 @@ anmelden und verwalten.
|
|||||||
- `npm run konto:anlegen` für das erste Ausbilderkonto
|
- `npm run konto:anlegen` für das erste Ausbilderkonto
|
||||||
|
|
||||||
[Unveröffentlicht]: #unveröffentlicht
|
[Unveröffentlicht]: #unveröffentlicht
|
||||||
|
[0.5.0]: #050--2026-09-10
|
||||||
[0.4.0]: #040--2026-09-10
|
[0.4.0]: #040--2026-09-10
|
||||||
[0.3.0]: #030--2026-09-10
|
[0.3.0]: #030--2026-09-10
|
||||||
[0.2.0]: #020--2026-09-10
|
[0.2.0]: #020--2026-09-10
|
||||||
|
|||||||
@@ -7,6 +7,40 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
## 2026-09-10 · Baustein 4 – Merkkarten
|
||||||
|
|
||||||
| Von | Bis | Dauer | Tätigkeit |
|
| Von | Bis | Dauer | Tätigkeit |
|
||||||
|
|||||||
@@ -164,3 +164,47 @@ kommen zuletzt, damit zuerst der Rückstand abgebaut wird.
|
|||||||
Fach 4 heißt: Abstand über drei Tage, also mindestens dreimal in Folge gekonnt.
|
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.
|
Das ist die Schwelle, ab der die Karte im Wochenfortschritt als erledigt zählt.
|
||||||
Wer sie einmal richtig hatte, hat sie noch nicht gelernt.
|
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.
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ und – der wichtigste Teil – den gesetzlich vorgeschriebenen Ausbildungsnachw
|
|||||||
Gebaut für **einen Ausbilder und ein bis drei Auszubildende**. Kein
|
Gebaut für **einen Ausbilder und ein bis drei Auszubildende**. Kein
|
||||||
Lernplattform-Baukasten, keine Mandanten, keine Cloud.
|
Lernplattform-Baukasten, keine Mandanten, keine Cloud.
|
||||||
|
|
||||||
> **Stand:** Baustein 1 von 10 ist fertig (Fundament). Der
|
> **Stand:** Bausteine 1 bis 5 von 10 sind fertig – Fundament, Inhalte,
|
||||||
> [Fahrplan](ROADMAP.md) zeigt, was folgt, das
|
> Wochenplan, Merkkarten, Praxisaufgaben. Der [Fahrplan](ROADMAP.md) zeigt,
|
||||||
> [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
|
> was folgt, das [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@ docker compose exec lernbar sh -c \
|
|||||||
'node werkzeuge/konto.js --name "Vorname Nachname" --email ausbilder@betrieb.de --rolle ausbilder'
|
'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
|
> Solange das Konto-Werkzeug im Abbild noch nicht liegt, geht es vom
|
||||||
> vom Entwicklungsrechner aus:
|
> Entwicklungsrechner aus:
|
||||||
> `npm run konto:anlegen -- --name "…" --email … --rolle ausbilder`
|
> `npm run konto:anlegen -- --name "…" --email … --rolle ausbilder`
|
||||||
|
|
||||||
Danach im Browser anmelden, unter **Verwaltung** das Azubikonto anlegen und
|
Danach im Browser anmelden, unter **Verwaltung** das Azubikonto anlegen und
|
||||||
@@ -75,7 +75,7 @@ server {
|
|||||||
ssl_certificate /etc/ssl/lernbar.crt;
|
ssl_certificate /etc/ssl/lernbar.crt;
|
||||||
ssl_certificate_key /etc/ssl/lernbar.key;
|
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 / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
@@ -136,8 +136,6 @@ Datei und Stelle. Prüfen ohne Start:
|
|||||||
npm run inhalte:pruefen
|
npm run inhalte:pruefen
|
||||||
```
|
```
|
||||||
|
|
||||||
*(Wird mit Baustein 2 scharf geschaltet.)*
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Datensicherung
|
## Datensicherung
|
||||||
|
|||||||
@@ -56,7 +56,14 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
|
|||||||
|
|
||||||
## 0.4 – Nachweise
|
## 0.4 – Nachweise
|
||||||
|
|
||||||
- [ ] **B5 · Praxisaufgaben mit Anhängen und Abnahme**
|
- [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
|
||||||
- [ ] **B6 · Checklisten mit Durchläufen und PDF**
|
- [ ] **B6 · Checklisten mit Durchläufen und PDF**
|
||||||
- [ ] **B7 · Berichtsheft: Tagesnotizen, Vorschläge, Prüfung, Abzeichnen, PDF**
|
- [ ] **B7 · Berichtsheft: Tagesnotizen, Vorschläge, Prüfung, Abzeichnen, PDF**
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 100 KiB |
@@ -7,6 +7,12 @@ const konfiguration: NextConfig = {
|
|||||||
serverExternalPackages: ['better-sqlite3', '@node-rs/argon2', 'pdfkit'],
|
serverExternalPackages: ['better-sqlite3', '@node-rs/argon2', 'pdfkit'],
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
reactStrictMode: true,
|
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 },
|
eslint: { ignoreDuringBuilds: true },
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ async function main() {
|
|||||||
await seite.waitForSelector('h1');
|
await seite.waitForSelector('h1');
|
||||||
await bild(seite, '04-wochenplan');
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
await abmelden(seite);
|
await abmelden(seite);
|
||||||
|
|
||||||
// --- Azubi ---
|
// --- Azubi ---
|
||||||
@@ -101,6 +110,14 @@ async function main() {
|
|||||||
await seite.waitForSelector('h1');
|
await seite.waitForSelector('h1');
|
||||||
await bild(seite, '14-merkkarten');
|
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}/glossar`);
|
await seite.goto(`${BASIS}/glossar`);
|
||||||
await seite.waitForSelector('h1');
|
await seite.waitForSelector('h1');
|
||||||
await bild(seite, '15-glossar');
|
await bild(seite, '15-glossar');
|
||||||
|
|||||||
@@ -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,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auffangnetz für den angemeldeten Bereich.
|
||||||
|
*
|
||||||
|
* Verweigerte Berechtigungen landen **nicht** hier, sondern auf der Seite
|
||||||
|
* „Nicht gefunden" – siehe erfordereSichtAufAzubi. Hier bleibt der echte
|
||||||
|
* Störfall übrig. Der Fehlertext selbst wird im Produktionsbetrieb von Next.js
|
||||||
|
* geschwärzt und steht nur im Serverprotokoll; deshalb gibt es hier keine
|
||||||
|
* Fallunterscheidung nach Meldung, sondern nur das Kennzeichen zur Fehlersuche.
|
||||||
|
*/
|
||||||
|
export default function Fehlerseite({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('[lernbar] Fehler in der Oberfläche:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="karte-grund stapel" style={{ maxWidth: '38rem', margin: '2rem auto', textAlign: 'center' }}>
|
||||||
|
<p style={{ fontSize: '2.2rem', margin: 0 }} aria-hidden="true">
|
||||||
|
⚠
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 style={{ marginBottom: '0.2rem' }}>Da ist etwas schiefgegangen</h1>
|
||||||
|
|
||||||
|
<p className="leise">
|
||||||
|
Der Vorgang ließ sich nicht abschließen. Der Fehler steht im Protokoll des Servers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="reihe" style={{ justifyContent: 'center' }}>
|
||||||
|
<Link href="/" className="knopf">
|
||||||
|
Zur Startseite
|
||||||
|
</Link>
|
||||||
|
<button type="button" className="knopf knopf-zweit" onClick={reset}>
|
||||||
|
Noch einmal versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error.digest ? (
|
||||||
|
<p className="leise" style={{ marginBottom: 0 }}>
|
||||||
|
Kennzeichen für die Fehlersuche: <code>{error.digest}</code>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ const fuerAzubi: Navigationseintrag[] = [
|
|||||||
{ pfad: '/', titel: 'Woche' },
|
{ pfad: '/', titel: 'Woche' },
|
||||||
{ pfad: '/handouts', titel: 'Handouts' },
|
{ pfad: '/handouts', titel: 'Handouts' },
|
||||||
{ pfad: '/karten', titel: 'Merkkarten' },
|
{ pfad: '/karten', titel: 'Merkkarten' },
|
||||||
|
{ pfad: '/aufgaben', titel: 'Aufgaben' },
|
||||||
{ pfad: '/glossar', titel: 'Glossar' },
|
{ pfad: '/glossar', titel: 'Glossar' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ const fuerAusbilder: Navigationseintrag[] = [
|
|||||||
{ pfad: '/wochenplan', titel: 'Wochenplan' },
|
{ pfad: '/wochenplan', titel: 'Wochenplan' },
|
||||||
{ pfad: '/handouts', titel: 'Handouts' },
|
{ pfad: '/handouts', titel: 'Handouts' },
|
||||||
{ pfad: '/karten', titel: 'Merkkarten' },
|
{ pfad: '/karten', titel: 'Merkkarten' },
|
||||||
|
{ pfad: '/aufgaben', titel: 'Aufgaben' },
|
||||||
{ pfad: '/glossar', titel: 'Glossar' },
|
{ pfad: '/glossar', titel: 'Glossar' },
|
||||||
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Nicht gefunden" im angemeldeten Bereich.
|
||||||
|
*
|
||||||
|
* Diese Seite erscheint auch dann, wenn jemand Daten anfragt, die ihn nichts
|
||||||
|
* angehen. Die Meldung ist deshalb absichtlich unbestimmt: Ob es die Daten
|
||||||
|
* gibt, wird nicht verraten.
|
||||||
|
*/
|
||||||
|
export default function NichtGefunden() {
|
||||||
|
return (
|
||||||
|
<div className="karte-grund stapel" style={{ maxWidth: '36rem', margin: '2rem auto', textAlign: 'center' }}>
|
||||||
|
<p style={{ fontSize: '2.2rem', margin: 0 }} aria-hidden="true">
|
||||||
|
🔍
|
||||||
|
</p>
|
||||||
|
<h1 style={{ marginBottom: '0.2rem' }}>Nicht gefunden</h1>
|
||||||
|
<p className="leise">
|
||||||
|
Diese Seite gibt es nicht – oder sie gehört nicht zu deinem Bereich.
|
||||||
|
</p>
|
||||||
|
<div className="reihe" style={{ justifyContent: 'center' }}>
|
||||||
|
<Link href="/" className="knopf">
|
||||||
|
Zur Startseite
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,10 @@ import { erfordereBenutzer } from '@/lib/auth/rechte';
|
|||||||
import { ausbildungNachAzubi, ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
|
import { ausbildungNachAzubi, ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
|
||||||
import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
|
import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
|
||||||
import { aktuelleWochensicht, kurzstand } from '@/lib/planung/sicht';
|
import { aktuelleWochensicht, kurzstand } from '@/lib/planung/sicht';
|
||||||
import { alsDatumText, heute, kalenderwocheText } from '@/lib/zeit';
|
import { alsDatumText, alsZeitpunktText, heute, kalenderwocheText } from '@/lib/zeit';
|
||||||
|
import { inhalte } from '@/lib/inhalte';
|
||||||
|
import { wartetAufAbnahme } from '@/lib/repos/aufgaben';
|
||||||
|
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||||
import { WochenAnsicht } from './WochenAnsicht';
|
import { WochenAnsicht } from './WochenAnsicht';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -135,15 +138,48 @@ function AusbilderUeberblick({ ausbilderId, name }: { ausbilderId: string; name:
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="karte" aria-labelledby="wartet">
|
<WartetAufMich ausbilderId={ausbilderId} />
|
||||||
<h2 id="wartet" style={{ fontSize: '1rem' }}>
|
|
||||||
Was auf dich wartet
|
|
||||||
</h2>
|
|
||||||
<p className="leise" style={{ marginBottom: 0 }}>
|
|
||||||
Offene Abnahmen, unkorrigierte Lernzielkontrollen und nicht abgezeichnete Berichtshefte erscheinen
|
|
||||||
hier, sobald diese Bausteine stehen.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Was auf den Ausbilder wartet ------------------------------------------
|
||||||
|
|
||||||
|
function WartetAufMich({ ausbilderId }: { ausbilderId: string }) {
|
||||||
|
const meine = ausbildungenVonAusbilder(ausbilderId);
|
||||||
|
const alle = inhalte();
|
||||||
|
const abnahmen = wartetAufAbnahme(meine.map(({ azubi }) => azubi.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="karte stapel-eng" aria-labelledby="wartet">
|
||||||
|
<h2 id="wartet" style={{ fontSize: '1rem', marginBottom: 0 }}>
|
||||||
|
Was auf dich wartet
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{abnahmen.length === 0 ? (
|
||||||
|
<p className="leise" style={{ marginBottom: 0 }}>
|
||||||
|
Nichts zur Abnahme. Unkorrigierte Lernzielkontrollen und nicht abgezeichnete Berichtshefte kommen
|
||||||
|
hier dazu, sobald diese Bausteine stehen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="offen-liste">
|
||||||
|
{abnahmen.map((stand) => {
|
||||||
|
const aufgabe = alle.aufgabeNach.get(stand.aufgabeId);
|
||||||
|
const azubi = benutzerNachId(stand.benutzerId);
|
||||||
|
return (
|
||||||
|
<li key={stand.id}>
|
||||||
|
<span>
|
||||||
|
<Link href={`/aufgaben/${stand.aufgabeId}?azubi=${stand.benutzerId}`}>
|
||||||
|
<strong>{stand.aufgabeId}</strong> {aufgabe?.titel ?? 'Aufgabe'}
|
||||||
|
</Link>
|
||||||
|
{azubi ? <span className="leise"> · {azubi.name}</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className="leise">abgegeben {alsZeitpunktText(stand.abgegebenAm)}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { erfordereBenutzer, erfordereZugriffAufAzubi } from '@/lib/auth/rechte';
|
import { erfordereBenutzer, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
|
||||||
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
||||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||||
import { wochensicht } from '@/lib/planung/sicht';
|
import { wochensicht } from '@/lib/planung/sicht';
|
||||||
@@ -37,7 +37,7 @@ export default async function WochenSeite({
|
|||||||
|
|
||||||
// Der Kern: Ob dieser Betrachter diesen Azubi sehen darf, entscheidet der
|
// Der Kern: Ob dieser Betrachter diesen Azubi sehen darf, entscheidet der
|
||||||
// Server – nicht die Oberfläche und nicht die Adresszeile.
|
// Server – nicht die Oberfläche und nicht die Adresszeile.
|
||||||
erfordereZugriffAufAzubi(benutzer, azubiId);
|
erfordereSichtAufAzubi(benutzer, azubiId);
|
||||||
|
|
||||||
const ausbildung = ausbildungNachAzubi(azubiId);
|
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||||
if (!ausbildung) notFound();
|
if (!ausbildung) notFound();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import { erfordereAusbilder, erfordereBewertungsrecht } from '@/lib/auth/rechte';
|
import { erfordereAusbilder, erfordereSichtAufAzubi } from '@/lib/auth/rechte';
|
||||||
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
||||||
import { benutzerNachId } from '@/lib/repos/benutzer';
|
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||||
import { inhalte } from '@/lib/inhalte';
|
import { inhalte } from '@/lib/inhalte';
|
||||||
@@ -35,7 +35,7 @@ export default async function WochenplanSeite({ searchParams }: { searchParams:
|
|||||||
const azubiId = azubiParameter ?? meine[0]!.azubi.id;
|
const azubiId = azubiParameter ?? meine[0]!.azubi.id;
|
||||||
if (!azubiParameter) redirect(`/wochenplan?azubi=${azubiId}`);
|
if (!azubiParameter) redirect(`/wochenplan?azubi=${azubiId}`);
|
||||||
|
|
||||||
erfordereBewertungsrecht(ausbilder, azubiId);
|
erfordereSichtAufAzubi(ausbilder, azubiId);
|
||||||
|
|
||||||
const azubi = benutzerNachId(azubiId);
|
const azubi = benutzerNachId(azubiId);
|
||||||
const ausbildung = ausbildungNachAzubi(azubiId);
|
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { angemeldeterBenutzer } from '@/lib/auth/sitzung';
|
||||||
|
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
|
||||||
|
import { ausbildungVonAzubi } from '@/lib/auth/rechte';
|
||||||
|
import { anhangLesen, anhangNachId } from '@/lib/repos/anhaenge';
|
||||||
|
import { istBild } from '@/lib/anhaenge/dateien';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert einen Anhang aus.
|
||||||
|
*
|
||||||
|
* Zwei Dinge sind hier wichtig:
|
||||||
|
* 1. Der Pfad kommt ausschließlich aus der Datenbank. Es gibt keinen Weg,
|
||||||
|
* über die Adresse eine beliebige Datei anzufordern.
|
||||||
|
* 2. Die Berechtigung wird bei **jedem** Abruf geprüft. Wer den Anhang
|
||||||
|
* hochgeladen hat, darf ihn sehen; sein Ausbilder auch, sonst niemand.
|
||||||
|
*/
|
||||||
|
export async function GET(_anfrage: Request, kontext: { params: Promise<{ id: string }> }) {
|
||||||
|
const benutzer = await angemeldeterBenutzer();
|
||||||
|
if (!benutzer) return new Response('Nicht angemeldet.', { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await kontext.params;
|
||||||
|
const eintrag = anhangNachId(id);
|
||||||
|
if (!eintrag) return new Response('Nicht gefunden.', { status: 404 });
|
||||||
|
|
||||||
|
const bezug = ausbildungVonAzubi(eintrag.benutzerId);
|
||||||
|
if (!darfAzubiDatenSehen(benutzer, eintrag.benutzerId, bezug)) {
|
||||||
|
// Bewusst 404 statt 403: Ob es diesen Anhang gibt, geht Fremde nichts an.
|
||||||
|
return new Response('Nicht gefunden.', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inhalt = await anhangLesen(eintrag);
|
||||||
|
if (!inhalt) return new Response('Die Datei fehlt im Speicher.', { status: 410 });
|
||||||
|
|
||||||
|
// Bilder und PDF dürfen im Browser aufgehen, alles andere wird geladen.
|
||||||
|
const anzeigbar = istBild(eintrag.mimeTyp) || eintrag.mimeTyp === 'application/pdf';
|
||||||
|
const art = anzeigbar ? 'inline' : 'attachment';
|
||||||
|
|
||||||
|
return new Response(new Uint8Array(inhalt), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': eintrag.mimeTyp,
|
||||||
|
'Content-Length': String(inhalt.byteLength),
|
||||||
|
'Content-Disposition': `${art}; filename="${eintrag.dateiname}"; filename*=UTF-8''${encodeURIComponent(eintrag.dateiname)}`,
|
||||||
|
// Kein öffentlicher Zwischenspeicher – der Inhalt hängt am angemeldeten Benutzer.
|
||||||
|
'Cache-Control': 'private, max-age=300',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
// Schutz davor, dass ein hochgeladenes SVG oder HTML im Kontext der
|
||||||
|
// Anwendung ausgeführt wird. Anhänge sind Daten, kein Programm.
|
||||||
|
'Content-Security-Policy': "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1100,3 +1100,127 @@ a.glossarbegriff:hover {
|
|||||||
.bewerten { gap: 0.4rem; }
|
.bewerten { gap: 0.4rem; }
|
||||||
.bewerten-knopf { min-height: 84px; }
|
.bewerten-knopf { min-height: 84px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Praxisaufgaben --- */
|
||||||
|
|
||||||
|
.aufgaben-nummer {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2.8rem;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: var(--radius-klein);
|
||||||
|
background: var(--bernstein);
|
||||||
|
color: #26170a;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.aufgaben-nummer-gross { font-size: 1rem; padding: 0.25rem 0.75rem; }
|
||||||
|
|
||||||
|
.aufgabenliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.aufgabenzeile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: var(--flaeche);
|
||||||
|
border: 1px solid var(--rand-zart);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.aufgabenzeile:hover { background: var(--flaeche-kraeftig); color: var(--text); }
|
||||||
|
.aufgaben-titel { flex: 1; font-weight: 650; }
|
||||||
|
|
||||||
|
.statuswahl { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.kriterienliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
|
||||||
|
.kriterienliste li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2rem 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
border-bottom: 1px solid var(--rand-zart);
|
||||||
|
}
|
||||||
|
.kriterienliste li[data-erfuellt="ja"] { color: var(--gut); }
|
||||||
|
.kriterienliste li[data-erfuellt="ja"] > span:first-child { font-weight: 700; }
|
||||||
|
|
||||||
|
.kriterien {
|
||||||
|
border: 1px solid var(--rand);
|
||||||
|
border-radius: var(--radius-klein);
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.kriterien legend { font-weight: 650; padding: 0 0.35rem; font-size: 0.95rem; }
|
||||||
|
.kriterium { padding: 0.3rem 0; }
|
||||||
|
.kriterium + .kriterium { border-top: 1px solid var(--rand-zart); }
|
||||||
|
|
||||||
|
.notiz-anzeige {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: var(--grund);
|
||||||
|
border: 1px solid var(--rand-zart);
|
||||||
|
border-radius: var(--radius-klein);
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Anhänge --- */
|
||||||
|
|
||||||
|
.anhangliste {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.anhangliste li { display: grid; gap: 0.3rem; }
|
||||||
|
|
||||||
|
.anhang-kachel {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--grund);
|
||||||
|
border: 1px solid var(--rand);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.anhang-kachel:hover { border-color: var(--petrol); color: var(--text); }
|
||||||
|
.anhang-kachel img {
|
||||||
|
width: 100%;
|
||||||
|
height: 110px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-klein);
|
||||||
|
background: var(--flaeche);
|
||||||
|
}
|
||||||
|
.anhang-symbol {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 110px;
|
||||||
|
border-radius: var(--radius-klein);
|
||||||
|
background: var(--flaeche-kraeftig);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-leise);
|
||||||
|
}
|
||||||
|
.anhang-name {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px dashed var(--rand);
|
||||||
|
border-radius: var(--radius-klein);
|
||||||
|
background: var(--grund);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
HOECHSTGROESSE,
|
||||||
|
istBild,
|
||||||
|
istInnerhalb,
|
||||||
|
megabyte,
|
||||||
|
pruefeDatei,
|
||||||
|
saubererName,
|
||||||
|
speicherpfad,
|
||||||
|
} from './dateien';
|
||||||
|
|
||||||
|
describe('Dateien prüfen', () => {
|
||||||
|
it('nimmt ein Foto an', () => {
|
||||||
|
expect(pruefeDatei('aufbau.jpg', 'image/jpeg', 2_000_000)).toEqual({ endung: '.jpg' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt ein PDF an', () => {
|
||||||
|
expect(pruefeDatei('netzplan.pdf', 'application/pdf', 500_000)).toEqual({ endung: '.pdf' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kommt mit einem Anhang am MIME-Typ zurecht', () => {
|
||||||
|
expect(pruefeDatei('notiz.txt', 'text/plain; charset=utf-8', 100)).toEqual({ endung: '.txt' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist unerlaubte Typen ab', () => {
|
||||||
|
for (const typ of ['application/x-msdownload', 'image/svg+xml', 'text/html', 'application/zip', '']) {
|
||||||
|
const ergebnis = pruefeDatei('etwas', typ, 1000);
|
||||||
|
expect(ergebnis, `Typ ${typ} hätte abgewiesen werden müssen`).toHaveProperty('fehler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist SVG ausdrücklich ab – es kann Skripte enthalten', () => {
|
||||||
|
expect(pruefeDatei('plan.svg', 'image/svg+xml', 1000)).toHaveProperty('fehler');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist eine leere Datei ab', () => {
|
||||||
|
expect(pruefeDatei('leer.jpg', 'image/jpeg', 0)).toHaveProperty('fehler');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist eine zu große Datei ab und nennt die Grenze', () => {
|
||||||
|
const ergebnis = pruefeDatei('riesig.jpg', 'image/jpeg', HOECHSTGROESSE + 1);
|
||||||
|
expect(ergebnis).toHaveProperty('fehler');
|
||||||
|
expect((ergebnis as { fehler: string }).fehler).toContain('25');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt genau die Höchstgröße noch an', () => {
|
||||||
|
expect(pruefeDatei('gerade-noch.jpg', 'image/jpeg', HOECHSTGROESSE)).toEqual({ endung: '.jpg' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ist beim MIME-Typ unempfindlich gegen Großschreibung', () => {
|
||||||
|
expect(pruefeDatei('foto.PNG', 'IMAGE/PNG', 1000)).toEqual({ endung: '.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist einen unbrauchbaren Dateinamen ab', () => {
|
||||||
|
expect(pruefeDatei('...', 'image/jpeg', 1000)).toHaveProperty('fehler');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dateinamen säubern', () => {
|
||||||
|
it('lässt einen normalen Namen stehen', () => {
|
||||||
|
expect(saubererName('Aufbau Halle 2.jpg')).toBe('Aufbau Halle 2.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt Verzeichnisanteile', () => {
|
||||||
|
expect(saubererName('../../etc/passwd')).toBe('passwd');
|
||||||
|
expect(saubererName('C:\\Users\\x\\geheim.jpg')).toBe('geheim.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt Steuerzeichen und Anführungszeichen', () => {
|
||||||
|
expect(saubererName('bild"; rm -rf.jpg')).toBe('bild; rm -rf.jpg');
|
||||||
|
expect(saubererName('zeile\numbruch.png')).toBe('zeileumbruch.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zieht mehrfache Punkte zusammen', () => {
|
||||||
|
expect(saubererName('foto..........jpg')).toBe('foto.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kürzt sehr lange Namen', () => {
|
||||||
|
expect(saubererName('a'.repeat(500) + '.jpg').length).toBeLessThanOrEqual(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt bei einem nur aus Punkten bestehenden Namen nichts zurück', () => {
|
||||||
|
expect(saubererName('..')).toBe('');
|
||||||
|
expect(saubererName(' ')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Speicherpfad', () => {
|
||||||
|
it('baut den Pfad aus Jahr und Kennung', () => {
|
||||||
|
const kennung = '3f2504e0-4f89-41d3-9a0c-0305e82c3301';
|
||||||
|
expect(speicherpfad(kennung, '.jpg', 2026)).toBe(`2026/${kennung}.jpg`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verweigert alles, was keine UUID ist – der Pfad darf nie aus einer Eingabe kommen', () => {
|
||||||
|
for (const boese of ['../../etc/passwd', 'nicht-uuid', '', '../3f2504e0-4f89-41d3-9a0c-0305e82c3301']) {
|
||||||
|
expect(() => speicherpfad(boese, '.jpg', 2026)).toThrow(/UUID/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pfad liegt im Anhangverzeichnis', () => {
|
||||||
|
const wurzel = path.join(path.sep, 'daten', 'anhaenge');
|
||||||
|
|
||||||
|
it('erkennt einen Pfad darunter', () => {
|
||||||
|
expect(istInnerhalb(wurzel, path.join(wurzel, '2026', 'a.jpg'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennt das Verzeichnis selbst', () => {
|
||||||
|
expect(istInnerhalb(wurzel, wurzel)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennt einen Ausbruch nach oben', () => {
|
||||||
|
expect(istInnerhalb(wurzel, path.join(wurzel, '..', '..', 'etc', 'passwd'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt sich nicht durch einen Namensanfang täuschen', () => {
|
||||||
|
expect(istInnerhalb(wurzel, `${wurzel}-geheim/a.jpg`)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Anzeige', () => {
|
||||||
|
it('schreibt Größen lesbar', () => {
|
||||||
|
expect(megabyte(512)).toBe('512 Byte');
|
||||||
|
expect(megabyte(2048)).toBe('2 KB');
|
||||||
|
expect(megabyte(3_500_000)).toBe('3,3 MB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennt Bilder', () => {
|
||||||
|
expect(istBild('image/jpeg')).toBe(true);
|
||||||
|
expect(istBild('application/pdf')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regeln für hochgeladene Dateien – ohne Dateisystem, damit sie sich testen
|
||||||
|
* lassen.
|
||||||
|
*
|
||||||
|
* Der Grundsatz: Nichts, was der Browser schickt, landet ungeprüft im
|
||||||
|
* Dateisystem. Der Speicherpfad wird ausschließlich aus einer selbst erzeugten
|
||||||
|
* Kennung gebildet, der Anzeigename wird nur gesäubert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 25 MiB – ein Foto vom Aufbau passt locker, ein Video soll es nicht sein. */
|
||||||
|
export const HOECHSTGROESSE = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erlaubte Dateitypen. Bewusst eine kurze Positivliste: Fotos vom Aufbau,
|
||||||
|
* Bildschirmfotos, ein Netzplan als PDF, gelegentlich eine Textdatei.
|
||||||
|
*/
|
||||||
|
export const ERLAUBTE_TYPEN: Record<string, string> = {
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'image/gif': '.gif',
|
||||||
|
'image/heic': '.heic',
|
||||||
|
'application/pdf': '.pdf',
|
||||||
|
'text/plain': '.txt',
|
||||||
|
'text/csv': '.csv',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Pruefergebnis = { fehler: string } | { endung: string };
|
||||||
|
|
||||||
|
export function pruefeDatei(name: string, mimeTyp: string, groesse: number): Pruefergebnis {
|
||||||
|
if (groesse <= 0) {
|
||||||
|
return { fehler: 'Die Datei ist leer.' };
|
||||||
|
}
|
||||||
|
if (groesse > HOECHSTGROESSE) {
|
||||||
|
return {
|
||||||
|
fehler: `Die Datei ist ${megabyte(groesse)} groß – erlaubt sind höchstens ${megabyte(HOECHSTGROESSE)}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const typ = mimeTyp.split(';')[0]!.trim().toLowerCase();
|
||||||
|
const endung = ERLAUBTE_TYPEN[typ];
|
||||||
|
if (!endung) {
|
||||||
|
return {
|
||||||
|
fehler: `Dateien vom Typ „${typ || 'unbekannt'}" sind nicht erlaubt. Möglich sind Bilder, PDF und Textdateien.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saubererName(name) === '') {
|
||||||
|
return { fehler: 'Der Dateiname ist unbrauchbar.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { endung };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function megabyte(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} Byte`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`.replace('.', ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Säubert einen Dateinamen für die **Anzeige**. Er wird nie zum Bilden eines
|
||||||
|
* Pfades benutzt – aber er landet im Content-Disposition-Kopf und in der
|
||||||
|
* Oberfläche, und dort hat weder ein Zeilenumbruch noch ein Anführungszeichen
|
||||||
|
* etwas verloren.
|
||||||
|
*/
|
||||||
|
export function saubererName(name: string): string {
|
||||||
|
return path
|
||||||
|
.basename(name)
|
||||||
|
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||||
|
.replace(/["\\]/g, '')
|
||||||
|
.replace(/\.{2,}/g, '.')
|
||||||
|
.replace(/^[.\s]+|[.\s]+$/g, '')
|
||||||
|
.slice(0, 120)
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Speicherpfad, relativ zum Anhangverzeichnis: `<jahr>/<kennung><endung>`.
|
||||||
|
*
|
||||||
|
* Nach Jahren unterteilt, damit nach drei Ausbildungsjahren nicht alles in
|
||||||
|
* einem Verzeichnis liegt. Der Dateiname ist die selbst erzeugte Kennung –
|
||||||
|
* damit ist ein Ausbruch aus dem Verzeichnis ausgeschlossen.
|
||||||
|
*/
|
||||||
|
export function speicherpfad(kennung: string, endung: string, jahr: number): string {
|
||||||
|
if (!/^[0-9a-f-]{36}$/i.test(kennung)) {
|
||||||
|
throw new Error('Die Kennung eines Anhangs muss eine UUID sein.');
|
||||||
|
}
|
||||||
|
return `${jahr}/${kennung}${endung}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft, dass ein aus der Datenbank gelesener Pfad im Anhangverzeichnis liegt. */
|
||||||
|
export function istInnerhalb(wurzel: string, vollerPfad: string): boolean {
|
||||||
|
const auf = path.resolve(wurzel);
|
||||||
|
const ziel = path.resolve(vollerPfad);
|
||||||
|
return ziel === auf || ziel.startsWith(auf + path.sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fasst zusammen, was am Bildschirm über einen Anhang steht. */
|
||||||
|
export function istBild(mimeTyp: string): boolean {
|
||||||
|
return mimeTyp.startsWith('image/');
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
import { redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { datenbank, schema } from '../db';
|
import { datenbank, schema } from '../db';
|
||||||
import { angemeldeterBenutzer } from './sitzung';
|
import { angemeldeterBenutzer } from './sitzung';
|
||||||
@@ -45,6 +45,20 @@ export function erfordereZugriffAufAzubi(betrachter: Betrachter, azubiId: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dasselbe für Seiten – endet mit „nicht gefunden" statt mit einem Fehler.
|
||||||
|
*
|
||||||
|
* Bewusst 404 und nicht 403: Ob es zu dieser Kennung überhaupt Daten gibt, geht
|
||||||
|
* einen Unbefugten nichts an. Serveraktionen nehmen weiter die werfende
|
||||||
|
* Fassung, dort wäre eine Weiterleitung sinnlos.
|
||||||
|
*/
|
||||||
|
export function erfordereSichtAufAzubi(betrachter: Betrachter, azubiId: string): void {
|
||||||
|
const bezug = ausbildungVonAzubi(azubiId);
|
||||||
|
if (!darfAzubiDatenSehen(betrachter, azubiId, bezug)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function erfordereBewertungsrecht(betrachter: Betrachter, azubiId: string): void {
|
export function erfordereBewertungsrecht(betrachter: Betrachter, azubiId: string): void {
|
||||||
const bezug = ausbildungVonAzubi(azubiId);
|
const bezug = ausbildungVonAzubi(azubiId);
|
||||||
if (!darfBewerten(betrachter, azubiId, bezug)) {
|
if (!darfBewerten(betrachter, azubiId, bezug)) {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Baustein 5: Praxisaufgaben und Dateianhänge.
|
||||||
|
--
|
||||||
|
-- Ziel, Auftrag, Material und Bewertungskriterien stehen in der Aufgabendatei
|
||||||
|
-- unter content/aufgaben/. Hier steht, wie weit der Azubi damit ist.
|
||||||
|
|
||||||
|
CREATE TABLE aufgabe_status (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
|
||||||
|
aufgabe_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'offen'
|
||||||
|
CHECK (status IN ('offen', 'in_arbeit', 'zur_abnahme', 'abgenommen')),
|
||||||
|
notiz TEXT NOT NULL DEFAULT '',
|
||||||
|
-- Welche Bewertungskriterien der Ausbilder abgehakt hat, als JSON-Liste der
|
||||||
|
-- Positionen in der Aufgabendatei, z. B. [0,2,3].
|
||||||
|
kriterien_erfuellt TEXT NOT NULL DEFAULT '[]',
|
||||||
|
bewertung TEXT,
|
||||||
|
rueckmeldung TEXT,
|
||||||
|
begonnen_am TEXT,
|
||||||
|
abgegeben_am TEXT,
|
||||||
|
abgenommen_von TEXT REFERENCES benutzer (id),
|
||||||
|
abgenommen_am TEXT,
|
||||||
|
geaendert_am TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX aufgabe_status_idx ON aufgabe_status (benutzer_id, aufgabe_id);
|
||||||
|
CREATE INDEX aufgabe_status_status_idx ON aufgabe_status (status);
|
||||||
|
|
||||||
|
-- Anhänge sind bewusst allgemein gehalten: Sie hängen später auch an
|
||||||
|
-- Checklisten-Durchläufen und Berichtsheftwochen.
|
||||||
|
CREATE TABLE anhang (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
bezug_typ TEXT NOT NULL CHECK (bezug_typ IN ('aufgabe', 'checkliste', 'bericht')),
|
||||||
|
bezug_id TEXT NOT NULL,
|
||||||
|
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
|
||||||
|
dateiname TEXT NOT NULL,
|
||||||
|
mime_typ TEXT NOT NULL,
|
||||||
|
groesse INTEGER NOT NULL,
|
||||||
|
-- Pfad relativ zum Anhangverzeichnis. Nie aus einer Eingabe gebildet.
|
||||||
|
pfad TEXT NOT NULL,
|
||||||
|
beschriftung TEXT,
|
||||||
|
hochgeladen_am TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX anhang_bezug_idx ON anhang (bezug_typ, bezug_id);
|
||||||
|
CREATE INDEX anhang_benutzer_idx ON anhang (benutzer_id);
|
||||||
@@ -124,6 +124,56 @@ export const kartenVerlauf = sqliteTable(
|
|||||||
(t) => [index('karten_verlauf_benutzer_idx').on(t.benutzerId, t.bewertetAm)],
|
(t) => [index('karten_verlauf_benutzer_idx').on(t.benutzerId, t.bewertetAm)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const aufgabenStatus = ['offen', 'in_arbeit', 'zur_abnahme', 'abgenommen'] as const;
|
||||||
|
export type AufgabenStatus = (typeof aufgabenStatus)[number];
|
||||||
|
|
||||||
|
export const aufgabeStatus = sqliteTable(
|
||||||
|
'aufgabe_status',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
benutzerId: text('benutzer_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => benutzer.id, { onDelete: 'cascade' }),
|
||||||
|
aufgabeId: text('aufgabe_id').notNull(),
|
||||||
|
status: text('status', { enum: aufgabenStatus }).notNull().default('offen'),
|
||||||
|
notiz: text('notiz').notNull().default(''),
|
||||||
|
kriterienErfuellt: text('kriterien_erfuellt').notNull().default('[]'),
|
||||||
|
bewertung: text('bewertung'),
|
||||||
|
rueckmeldung: text('rueckmeldung'),
|
||||||
|
begonnenAm: text('begonnen_am'),
|
||||||
|
abgegebenAm: text('abgegeben_am'),
|
||||||
|
abgenommenVon: text('abgenommen_von').references(() => benutzer.id),
|
||||||
|
abgenommenAm: text('abgenommen_am'),
|
||||||
|
geaendertAm: text('geaendert_am').notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('aufgabe_status_idx').on(t.benutzerId, t.aufgabeId),
|
||||||
|
index('aufgabe_status_status_idx').on(t.status),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const bezugTypen = ['aufgabe', 'checkliste', 'bericht'] as const;
|
||||||
|
export type BezugTyp = (typeof bezugTypen)[number];
|
||||||
|
|
||||||
|
export const anhang = sqliteTable(
|
||||||
|
'anhang',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
bezugTyp: text('bezug_typ', { enum: bezugTypen }).notNull(),
|
||||||
|
bezugId: text('bezug_id').notNull(),
|
||||||
|
benutzerId: text('benutzer_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => benutzer.id, { onDelete: 'cascade' }),
|
||||||
|
dateiname: text('dateiname').notNull(),
|
||||||
|
mimeTyp: text('mime_typ').notNull(),
|
||||||
|
groesse: integer('groesse').notNull(),
|
||||||
|
pfad: text('pfad').notNull(),
|
||||||
|
beschriftung: text('beschriftung'),
|
||||||
|
hochgeladenAm: text('hochgeladen_am').notNull(),
|
||||||
|
},
|
||||||
|
(t) => [index('anhang_bezug_idx').on(t.bezugTyp, t.bezugId), index('anhang_benutzer_idx').on(t.benutzerId)],
|
||||||
|
);
|
||||||
|
|
||||||
export type Benutzer = typeof benutzer.$inferSelect;
|
export type Benutzer = typeof benutzer.$inferSelect;
|
||||||
export type Sitzung = typeof sitzung.$inferSelect;
|
export type Sitzung = typeof sitzung.$inferSelect;
|
||||||
export type Ausbildung = typeof ausbildung.$inferSelect;
|
export type Ausbildung = typeof ausbildung.$inferSelect;
|
||||||
@@ -131,6 +181,18 @@ export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
|
|||||||
export type Wochenplanung = typeof wochenplanung.$inferSelect;
|
export type Wochenplanung = typeof wochenplanung.$inferSelect;
|
||||||
export type Karte = typeof karte.$inferSelect;
|
export type Karte = typeof karte.$inferSelect;
|
||||||
export type KartenVerlauf = typeof kartenVerlauf.$inferSelect;
|
export type KartenVerlauf = typeof kartenVerlauf.$inferSelect;
|
||||||
|
export type AufgabeStatus = typeof aufgabeStatus.$inferSelect;
|
||||||
|
export type Anhang = typeof anhang.$inferSelect;
|
||||||
|
|
||||||
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
|
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
|
||||||
export const alleTabellen = { benutzer, sitzung, ausbildung, handoutGelesen, wochenplanung, karte, kartenVerlauf };
|
export const alleTabellen = {
|
||||||
|
benutzer,
|
||||||
|
sitzung,
|
||||||
|
ausbildung,
|
||||||
|
handoutGelesen,
|
||||||
|
wochenplanung,
|
||||||
|
karte,
|
||||||
|
kartenVerlauf,
|
||||||
|
aufgabeStatus,
|
||||||
|
anhang,
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Checkliste, Kontrolle, Woche } from '../inhalte/schema';
|
|||||||
import { gelesenSet } from '../repos/fortschritt';
|
import { gelesenSet } from '../repos/fortschritt';
|
||||||
import { planVon } from '../repos/wochenplanung';
|
import { planVon } from '../repos/wochenplanung';
|
||||||
import { uebersicht as kartenUebersicht } from '../repos/karten';
|
import { uebersicht as kartenUebersicht } from '../repos/karten';
|
||||||
|
import { anzahlAbgenommen } from '../repos/aufgaben';
|
||||||
import { heute } from '../zeit';
|
import { heute } from '../zeit';
|
||||||
import { aktuelleWoche } from './wochen';
|
import { aktuelleWoche } from './wochen';
|
||||||
import { wochenFortschritt, type Fortschritt } from './fortschritt';
|
import { wochenFortschritt, type Fortschritt } from './fortschritt';
|
||||||
@@ -76,9 +77,9 @@ export function wochensicht(ausbildungId: string, azubiId: string, wochenNummer:
|
|||||||
const fortschritt = wochenFortschritt({
|
const fortschritt = wochenFortschritt({
|
||||||
handouts: { erledigt: handouts.filter((h) => h.gelesen).length, gesamt: handouts.length },
|
handouts: { erledigt: handouts.filter((h) => h.gelesen).length, gesamt: handouts.length },
|
||||||
karten: { erledigt: kartenStand.gelernt, gesamt: kartenStand.gesamt },
|
karten: { erledigt: kartenStand.gelernt, gesamt: kartenStand.gesamt },
|
||||||
|
aufgaben: { erledigt: anzahlAbgenommen(azubiId, aufgaben.map((a) => a.id)), gesamt: aufgaben.length },
|
||||||
// Diese Bereiche werden mit ihren Bausteinen scharf geschaltet. Bis dahin
|
// Diese Bereiche werden mit ihren Bausteinen scharf geschaltet. Bis dahin
|
||||||
// zeigen sie ehrlich an, was die Woche vorsieht und noch offen ist.
|
// zeigen sie ehrlich an, was die Woche vorsieht und noch offen ist.
|
||||||
aufgaben: { erledigt: 0, gesamt: aufgaben.length },
|
|
||||||
checklisten: { erledigt: 0, gesamt: checklisten.length },
|
checklisten: { erledigt: 0, gesamt: checklisten.length },
|
||||||
kontrolle: { erledigt: 0, gesamt: kontrolle ? 1 : 0 },
|
kontrolle: { erledigt: 0, gesamt: kontrolle ? 1 : 0 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { and, asc, eq } from 'drizzle-orm';
|
||||||
|
import { datenbank, schema } from '../db';
|
||||||
|
import { anhangWurzel } from '../pfade';
|
||||||
|
import { jetzt } from '../zeit';
|
||||||
|
import { istInnerhalb, pruefeDatei, saubererName, speicherpfad } from '../anhaenge/dateien';
|
||||||
|
import type { Anhang, BezugTyp } from '../db/schema';
|
||||||
|
|
||||||
|
/** Dateianhänge: Datei im Dateisystem, Verweis in der Datenbank. */
|
||||||
|
|
||||||
|
export function anhaengeVon(bezugTyp: BezugTyp, bezugId: string): Anhang[] {
|
||||||
|
return datenbank()
|
||||||
|
.select()
|
||||||
|
.from(schema.anhang)
|
||||||
|
.where(and(eq(schema.anhang.bezugTyp, bezugTyp), eq(schema.anhang.bezugId, bezugId)))
|
||||||
|
.orderBy(asc(schema.anhang.hochgeladenAm))
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function anhangNachId(id: string): Anhang | null {
|
||||||
|
return datenbank().select().from(schema.anhang).where(eq(schema.anhang.id, id)).get() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Ablageergebnis = { fehler: string } | { anhang: Anhang };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt eine hochgeladene Datei entgegen.
|
||||||
|
*
|
||||||
|
* Reihenfolge mit Absicht: erst prüfen, dann schreiben, dann eintragen.
|
||||||
|
* Schlägt der Eintrag fehl, wird die Datei wieder entfernt – sonst sammeln
|
||||||
|
* sich verwaiste Dateien im Volume an.
|
||||||
|
*/
|
||||||
|
export async function anhangAblegen(werte: {
|
||||||
|
bezugTyp: BezugTyp;
|
||||||
|
bezugId: string;
|
||||||
|
benutzerId: string;
|
||||||
|
datei: File;
|
||||||
|
beschriftung?: string;
|
||||||
|
}): Promise<Ablageergebnis> {
|
||||||
|
const { datei } = werte;
|
||||||
|
const geprueft = pruefeDatei(datei.name, datei.type, datei.size);
|
||||||
|
if ('fehler' in geprueft) return geprueft;
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const jahr = new Date().getUTCFullYear();
|
||||||
|
const relativ = speicherpfad(id, geprueft.endung, jahr);
|
||||||
|
const ziel = path.join(anhangWurzel, relativ);
|
||||||
|
|
||||||
|
if (!istInnerhalb(anhangWurzel, ziel)) {
|
||||||
|
return { fehler: 'Ungültiger Ablageort.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(ziel), { recursive: true });
|
||||||
|
const inhalt = Buffer.from(await datei.arrayBuffer());
|
||||||
|
await fs.writeFile(ziel, inhalt);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const datensatz: Anhang = {
|
||||||
|
id,
|
||||||
|
bezugTyp: werte.bezugTyp,
|
||||||
|
bezugId: werte.bezugId,
|
||||||
|
benutzerId: werte.benutzerId,
|
||||||
|
dateiname: saubererName(datei.name),
|
||||||
|
mimeTyp: datei.type.split(';')[0]!.trim().toLowerCase(),
|
||||||
|
groesse: inhalt.byteLength,
|
||||||
|
pfad: relativ,
|
||||||
|
beschriftung: werte.beschriftung?.trim() || null,
|
||||||
|
hochgeladenAm: jetzt(),
|
||||||
|
};
|
||||||
|
|
||||||
|
datenbank().insert(schema.anhang).values(datensatz).run();
|
||||||
|
return { anhang: datensatz };
|
||||||
|
} catch (fehler) {
|
||||||
|
await fs.rm(ziel, { force: true });
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löscht Eintrag und Datei. Die Datei zuletzt – ein verwaister Eintrag wäre schlimmer. */
|
||||||
|
export async function anhangLoeschen(id: string): Promise<void> {
|
||||||
|
const eintrag = anhangNachId(id);
|
||||||
|
if (!eintrag) return;
|
||||||
|
|
||||||
|
datenbank().delete(schema.anhang).where(eq(schema.anhang.id, id)).run();
|
||||||
|
|
||||||
|
const ziel = path.join(anhangWurzel, eintrag.pfad);
|
||||||
|
if (istInnerhalb(anhangWurzel, ziel)) {
|
||||||
|
await fs.rm(ziel, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest den Inhalt eines Anhangs. Der Pfad kommt aus der Datenbank, nie aus einer Eingabe. */
|
||||||
|
export async function anhangLesen(eintrag: Anhang): Promise<Buffer | null> {
|
||||||
|
const ziel = path.join(anhangWurzel, eintrag.pfad);
|
||||||
|
if (!istInnerhalb(anhangWurzel, ziel)) return null;
|
||||||
|
try {
|
||||||
|
return await fs.readFile(ziel);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { datenbank, schema } from '../db';
|
||||||
|
import { jetzt } from '../zeit';
|
||||||
|
import type { AufgabeStatus, AufgabenStatus } from '../db/schema';
|
||||||
|
|
||||||
|
/** Bearbeitungsstand der Praxisaufgaben. */
|
||||||
|
|
||||||
|
export function standVon(benutzerId: string, aufgabeId: string): AufgabeStatus | null {
|
||||||
|
return (
|
||||||
|
datenbank()
|
||||||
|
.select()
|
||||||
|
.from(schema.aufgabeStatus)
|
||||||
|
.where(and(eq(schema.aufgabeStatus.benutzerId, benutzerId), eq(schema.aufgabeStatus.aufgabeId, aufgabeId)))
|
||||||
|
.get() ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function staendeVon(benutzerId: string): Map<string, AufgabeStatus> {
|
||||||
|
const zeilen = datenbank()
|
||||||
|
.select()
|
||||||
|
.from(schema.aufgabeStatus)
|
||||||
|
.where(eq(schema.aufgabeStatus.benutzerId, benutzerId))
|
||||||
|
.all();
|
||||||
|
return new Map(zeilen.map((z) => [z.aufgabeId, z]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt bei Bedarf eine Zeile an – der Stand entsteht erst, wenn etwas passiert. */
|
||||||
|
export function sorgeFuerStand(benutzerId: string, aufgabeId: string): AufgabeStatus {
|
||||||
|
const vorhanden = standVon(benutzerId, aufgabeId);
|
||||||
|
if (vorhanden) return vorhanden;
|
||||||
|
|
||||||
|
const datensatz: AufgabeStatus = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
benutzerId,
|
||||||
|
aufgabeId,
|
||||||
|
status: 'offen',
|
||||||
|
notiz: '',
|
||||||
|
kriterienErfuellt: '[]',
|
||||||
|
bewertung: null,
|
||||||
|
rueckmeldung: null,
|
||||||
|
begonnenAm: null,
|
||||||
|
abgegebenAm: null,
|
||||||
|
abgenommenVon: null,
|
||||||
|
abgenommenAm: null,
|
||||||
|
geaendertAm: jetzt(),
|
||||||
|
};
|
||||||
|
|
||||||
|
datenbank().insert(schema.aufgabeStatus).values(datensatz).run();
|
||||||
|
return datensatz;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die abgehakten Kriterien als Positionsliste. */
|
||||||
|
export function erfuellteKriterien(stand: AufgabeStatus | null | undefined): number[] {
|
||||||
|
if (!stand) return [];
|
||||||
|
try {
|
||||||
|
const gelesen: unknown = JSON.parse(stand.kriterienErfuellt);
|
||||||
|
if (!Array.isArray(gelesen)) return [];
|
||||||
|
return gelesen.filter((n): n is number => Number.isInteger(n) && n >= 0);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schreibe(id: string, werte: Partial<AufgabeStatus>): void {
|
||||||
|
datenbank()
|
||||||
|
.update(schema.aufgabeStatus)
|
||||||
|
.set({ ...werte, geaendertAm: jetzt() })
|
||||||
|
.where(eq(schema.aufgabeStatus.id, id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Azubi setzt den Bearbeitungsstand. */
|
||||||
|
export function statusSetzen(benutzerId: string, aufgabeId: string, status: AufgabenStatus): AufgabeStatus {
|
||||||
|
const stand = sorgeFuerStand(benutzerId, aufgabeId);
|
||||||
|
|
||||||
|
const werte: Partial<AufgabeStatus> = { status };
|
||||||
|
if (status === 'in_arbeit' && !stand.begonnenAm) werte.begonnenAm = jetzt();
|
||||||
|
if (status === 'zur_abnahme') werte.abgegebenAm = jetzt();
|
||||||
|
if (status === 'offen') {
|
||||||
|
werte.abgegebenAm = null;
|
||||||
|
werte.begonnenAm = null;
|
||||||
|
}
|
||||||
|
if (status !== 'abgenommen') {
|
||||||
|
// Eine zurückgenommene Abgabe ist keine Abnahme mehr.
|
||||||
|
werte.abgenommenAm = null;
|
||||||
|
werte.abgenommenVon = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
schreibe(stand.id, werte);
|
||||||
|
return { ...stand, ...werte };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notizSetzen(benutzerId: string, aufgabeId: string, notiz: string): void {
|
||||||
|
const stand = sorgeFuerStand(benutzerId, aufgabeId);
|
||||||
|
schreibe(stand.id, { notiz });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Ausbilder hakt Kriterien ab und gibt eine Rückmeldung. */
|
||||||
|
export function bewertungSetzen(
|
||||||
|
benutzerId: string,
|
||||||
|
aufgabeId: string,
|
||||||
|
werte: { kriterien: number[]; bewertung: string | null; rueckmeldung: string | null },
|
||||||
|
): void {
|
||||||
|
const stand = sorgeFuerStand(benutzerId, aufgabeId);
|
||||||
|
schreibe(stand.id, {
|
||||||
|
kriterienErfuellt: JSON.stringify([...new Set(werte.kriterien)].sort((a, b) => a - b)),
|
||||||
|
bewertung: werte.bewertung,
|
||||||
|
rueckmeldung: werte.rueckmeldung,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abnehmen(benutzerId: string, aufgabeId: string, ausbilderId: string): void {
|
||||||
|
const stand = sorgeFuerStand(benutzerId, aufgabeId);
|
||||||
|
schreibe(stand.id, { status: 'abgenommen', abgenommenVon: ausbilderId, abgenommenAm: jetzt() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zurückgeben: der Azubi soll nacharbeiten. */
|
||||||
|
export function zurueckgeben(benutzerId: string, aufgabeId: string): void {
|
||||||
|
const stand = sorgeFuerStand(benutzerId, aufgabeId);
|
||||||
|
schreibe(stand.id, { status: 'in_arbeit', abgegebenAm: null, abgenommenAm: null, abgenommenVon: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Aufgaben, die auf eine Abnahme warten – für „Was wartet auf mich". */
|
||||||
|
export function wartetAufAbnahme(benutzerIds: string[]): AufgabeStatus[] {
|
||||||
|
if (benutzerIds.length === 0) return [];
|
||||||
|
return datenbank()
|
||||||
|
.select()
|
||||||
|
.from(schema.aufgabeStatus)
|
||||||
|
.where(eq(schema.aufgabeStatus.status, 'zur_abnahme'))
|
||||||
|
.all()
|
||||||
|
.filter((z) => benutzerIds.includes(z.benutzerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie viele Aufgaben einer Liste sind abgenommen? */
|
||||||
|
export function anzahlAbgenommen(benutzerId: string, aufgabeIds: string[]): number {
|
||||||
|
const staende = staendeVon(benutzerId);
|
||||||
|
return aufgabeIds.filter((id) => staende.get(id)?.status === 'abgenommen').length;
|
||||||
|
}
|
||||||