Baustein 6: Checklisten mit Durchlaeufen und PDF-Export
- Durchlauf aus einer Vorlage, mit Datum, Ticket und Kunde - Schritt fuer Schritt abhaken, Bemerkung je Schritt - Fortschritt gesamt und je Gruppe (Vorbereiten/Durchfuehren/Pruefen/Abschliessen) - Abschliessen erst, wenn alles abgehakt ist; Meldung nennt die offenen Schritte - Abgeschlossene Durchlaeufe sind gesperrt und bleiben als Nachweis - Titel und Schritttexte werden beim Start kopiert, nicht verwiesen - PDF-Export mit gezeichneten Ankreuzkaestchen und Unterschriftenzeilen - Gemeinsame PDF-Grundlage fuer alle kuenftigen Ausgaben - Ausbilder liest mit und darf das PDF abrufen, hakt aber nicht ab - Abgeschlossene Durchlaeufe zaehlen im Wochenfortschritt mit Behoben beim Durchstich: Die Fusszeile wurde unterhalb des Satzspiegels gesetzt, wodurch PDFKit je Fusszeile eine leere Seite anhaengte - aus zwei Seiten wurden sechs. Ein Test zaehlt jetzt die Seitenobjekte im PDF. 29 neue Tests, insgesamt 186. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -26,6 +26,43 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [0.6.0] – 2026-09-10
|
||||||
|
|
||||||
|
Checklisten mit Durchläufen – und der erste PDF-Export.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- Aus einer Checklistenvorlage wird ein **Durchlauf**, der zu einem Datum und
|
||||||
|
wahlweise zu einem Ticket und einem Kunden oder Bereich gehört
|
||||||
|
- Schritt für Schritt abhaken, je Schritt eine Bemerkung
|
||||||
|
- Fortschritt gesamt **und je Gruppe** (Vorbereiten, Durchführen, Prüfen,
|
||||||
|
Abschließen), jeweils als Balken mit Zahlen
|
||||||
|
- Ein Durchlauf lässt sich erst abschließen, wenn jeder Schritt abgehakt ist;
|
||||||
|
die Meldung nennt, wie viele Schritte noch offen sind
|
||||||
|
- Abgeschlossene Durchläufe sind gesperrt und lassen sich nicht löschen – sie
|
||||||
|
sind der Nachweis. Wieder öffnen geht, verwerfen nur, solange nichts abgehakt ist
|
||||||
|
- **PDF-Export** eines Durchlaufs: Kopfangaben, alle Gruppen mit gezeichneten
|
||||||
|
Ankreuzkästchen, Bemerkungen, Unterschriftenzeilen für Bearbeiter und
|
||||||
|
Ausbilder, Fußzeile mit Herkunft und Seitenzahl
|
||||||
|
- Gemeinsame PDF-Grundlage (`src/lib/pdf/grundlage.ts`) für alle künftigen
|
||||||
|
Ausgaben – das Berichtsheft setzt darauf auf
|
||||||
|
- Der Ausbilder sieht die Durchläufe seiner Azubis und kann das PDF abrufen,
|
||||||
|
hakt aber nichts für sie ab: Im Nachweis soll keine Arbeit stehen, die der
|
||||||
|
Azubi nicht gemacht hat
|
||||||
|
- Titel und Schritttexte werden beim Start aus der Vorlage **kopiert**. Ein
|
||||||
|
abgeschlossener Durchlauf bleibt damit lesbar, auch wenn die Vorlage im
|
||||||
|
Repository später geändert oder gelöscht wird
|
||||||
|
- Abgeschlossene Durchläufe zählen im Wochenfortschritt mit
|
||||||
|
- 29 Tests für Gruppenfortschritt, Abschlussregel, PDF-Erzeugung und
|
||||||
|
Zeichenvorrat
|
||||||
|
|
||||||
|
### Angemerkt
|
||||||
|
|
||||||
|
- Die PDF-Ausgabe nutzt die Standardschriften jedes Betrachters (Helvetica).
|
||||||
|
Das spart eine eingebettete Schriftdatei, beschränkt den Zeichenvorrat aber
|
||||||
|
auf WinAnsi. Umlaute, ß, €, § und ° sind enthalten; Zeichen wie ✓, ○ oder ▲
|
||||||
|
nicht. `fuerPdf()` ersetzt sie, Ankreuzkästchen und Haken werden **gezeichnet**
|
||||||
|
|
||||||
## [0.5.0] – 2026-09-10
|
## [0.5.0] – 2026-09-10
|
||||||
|
|
||||||
Praxisaufgaben: bearbeiten, belegen, abnehmen.
|
Praxisaufgaben: bearbeiten, belegen, abnehmen.
|
||||||
@@ -230,6 +267,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.6.0]: #060--2026-09-10
|
||||||
[0.5.0]: #050--2026-09-10
|
[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
|
||||||
|
|||||||
@@ -7,6 +7,41 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-09-10 · Baustein 6 – Checklisten und PDF
|
||||||
|
|
||||||
|
| Von | Bis | Dauer | Tätigkeit |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 20:35 | 20:42 | 0:07 | Datenmodell, Migration, Schema |
|
||||||
|
| 20:42 | 20:48 | 0:06 | Gruppenfortschritt als reine Rechnung, 16 Tests |
|
||||||
|
| 20:48 | 20:52 | 0:04 | PDF-Grundlage und Checklisten-PDF |
|
||||||
|
| 20:52 | 21:00 | 0:08 | Repository, Aktionen, Seiten, PDF-Route |
|
||||||
|
| 21:00 | 21:10 | 0:10 | Durchstich; PDF gerendert und den Fußzeilenfehler gefunden |
|
||||||
|
| 21:10 | 21:20 | 0:10 | Fehler behoben, Regressionstest, Dokumente |
|
||||||
|
|
||||||
|
**Summe Baustein 6: rund 45 min**
|
||||||
|
|
||||||
|
### Was aufgefallen ist
|
||||||
|
|
||||||
|
- **Das PDF hatte sechs Seiten, vier davon leer.** Aufgefallen ist das erst
|
||||||
|
beim Ansehen – die Prüfung „Status 200, Content-Type application/pdf" war
|
||||||
|
grün. Ursache: Die Fußzeile wird unterhalb des Satzspiegels gesetzt, und
|
||||||
|
PDFKit hält das für überlaufenden Text und legt eine neue Seite an. Bei zwei
|
||||||
|
Seiten mal zwei Textaufrufen macht das vier leere Seiten. Behoben, indem der
|
||||||
|
untere Rand für die Dauer der Fußzeile auf 0 gesetzt wird. Ein Test zählt
|
||||||
|
jetzt die Seitenobjekte im PDF und hätte den Fehler gefunden.
|
||||||
|
- Headless-Chromium rendert keine PDFs. Für den Blick aufs Ergebnis musste
|
||||||
|
der Browser mit Fenster starten (`headless: false`) – auf diesem Rechner
|
||||||
|
geht das, auf einem Server nicht. Für die spätere Prüfstrecke heißt das:
|
||||||
|
Seitenzahl und Inhalt maschinell prüfen, das Aussehen von Hand.
|
||||||
|
- Titel und Schritttexte werden beim Start des Durchlaufs kopiert statt
|
||||||
|
verwiesen. Ein Nachweis, der sich rückwirkend ändert, wenn jemand die
|
||||||
|
Vorlage anfasst, wäre keiner.
|
||||||
|
- Der Ausbilder darf das PDF abrufen und mitlesen, aber nicht abhaken. Erst
|
||||||
|
beim Schreiben der Aktion ist mir aufgefallen, dass „darf sehen" und „darf
|
||||||
|
bearbeiten" hier auseinanderfallen – daher zwei getrennte Prüffunktionen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-09-10 · Baustein 5 – Praxisaufgaben
|
## 2026-09-10 · Baustein 5 – Praxisaufgaben
|
||||||
|
|
||||||
| Von | Bis | Dauer | Tätigkeit |
|
| Von | Bis | Dauer | Tätigkeit |
|
||||||
|
|||||||
@@ -208,3 +208,52 @@ Der Abstand ist Absicht: Eine zu große Datei soll die **eigene**, verständlich
|
|||||||
Meldung bekommen und nicht den Rumpfgrößenfehler des Gerüsts, der nur im
|
Meldung bekommen und nicht den Rumpfgrößenfehler des Gerüsts, der nur im
|
||||||
Serverprotokoll auftaucht. Zusätzlich prüft der Browser vorab, damit am
|
Serverprotokoll auftaucht. Zusätzlich prüft der Browser vorab, damit am
|
||||||
Mobilfunk nicht erst 30 MB übertragen und dann abgelehnt werden.
|
Mobilfunk nicht erst 30 MB übertragen und dann abgelehnt werden.
|
||||||
|
|
||||||
|
## E23 · PDF mit Standardschriften statt eingebetteter Schrift
|
||||||
|
|
||||||
|
Die PDF-Ausgabe benutzt Helvetica, also eine der Schriften, die jeder
|
||||||
|
PDF-Betrachter mitbringt. Das spart eine eingebettete Schriftdatei im
|
||||||
|
Repository und hält die Dateien klein (ein Checklistenblatt wiegt rund 7 KB).
|
||||||
|
|
||||||
|
Der Preis ist der Zeichenvorrat **WinAnsi**. Enthalten sind alle deutschen
|
||||||
|
Umlaute, ß, €, § und °. Nicht enthalten sind Zeichen wie ✓, ○, ▲ oder Emoji.
|
||||||
|
Deshalb:
|
||||||
|
|
||||||
|
- `fuerPdf()` ersetzt typografische Feinheiten (Anführungszeichen,
|
||||||
|
Auslassungspunkte, geschützte Leerzeichen) und wirft nicht Darstellbares weg.
|
||||||
|
- Ankreuzkästchen und Haken werden **gezeichnet**, nicht gesetzt. Das sieht in
|
||||||
|
jedem Betrachter gleich aus und ist unabhängig von der Schrift.
|
||||||
|
|
||||||
|
Sollte später ein Zeichen außerhalb von WinAnsi wirklich gebraucht werden, ist
|
||||||
|
der Weg das Einbetten einer freien Schrift – nicht das Aufweichen dieser Regel.
|
||||||
|
|
||||||
|
## E24 · Fußzeilen brauchen einen aufgehobenen unteren Rand
|
||||||
|
|
||||||
|
PDFKit legt eine neue Seite an, sobald Text unterhalb des Satzspiegels
|
||||||
|
geschrieben wird. Eine Fußzeile steht dort per Definition. Ohne Gegenmaßnahme
|
||||||
|
entsteht je Seite und je Textaufruf eine leere Folgeseite.
|
||||||
|
|
||||||
|
`fusszeilen()` setzt deshalb `page.margins.bottom` kurzzeitig auf 0 und stellt
|
||||||
|
den Wert danach wieder her. Ein Test zählt die Seitenobjekte im erzeugten PDF –
|
||||||
|
damit fällt ein Rückfall sofort auf.
|
||||||
|
|
||||||
|
## E25 · Ein Durchlauf kopiert die Texte seiner Vorlage
|
||||||
|
|
||||||
|
`checkliste_lauf.titel` und `checkliste_schritt.text` werden beim Start aus der
|
||||||
|
Vorlage übernommen, statt zur Anzeigezeit nachgeschlagen zu werden.
|
||||||
|
|
||||||
|
Grund: Ein abgeschlossener Durchlauf ist ein Nachweis. Er muss auch in zwei
|
||||||
|
Jahren noch dasselbe aussagen – auch wenn die Vorlage unter `content/`
|
||||||
|
inzwischen umformuliert, erweitert oder gelöscht wurde. Die Seite zeigt einen
|
||||||
|
Hinweis, wenn es die Vorlage nicht mehr gibt, bleibt aber vollständig lesbar.
|
||||||
|
|
||||||
|
## E26 · Mitlesen und Bearbeiten sind getrennte Rechte
|
||||||
|
|
||||||
|
Beim Checklisten-Durchlauf fallen die beiden Rechte auseinander:
|
||||||
|
|
||||||
|
- **Lesen** darf der Eigentümer und sein Ausbilder (`laufZumLesen`).
|
||||||
|
- **Bearbeiten** darf nur der Eigentümer (`laufZumBearbeiten`).
|
||||||
|
|
||||||
|
Der Ausbilder hakt also nichts für seinen Azubi ab. Im Nachweis soll keine
|
||||||
|
Arbeit stehen, die der Azubi nicht selbst gemacht hat. Für die Abnahme gibt es
|
||||||
|
die Praxisaufgaben – dort ist es genau andersherum.
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ 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:** Bausteine 1 bis 5 von 10 sind fertig – Fundament, Inhalte,
|
> **Stand:** Bausteine 1 bis 6 von 10 sind fertig – Fundament, Inhalte,
|
||||||
> Wochenplan, Merkkarten, Praxisaufgaben. Der [Fahrplan](ROADMAP.md) zeigt,
|
> Wochenplan, Merkkarten, Praxisaufgaben, Checklisten. Der
|
||||||
> was folgt, das [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
|
> [Fahrplan](ROADMAP.md) zeigt, was folgt, das
|
||||||
|
> [Änderungsprotokoll](CHANGELOG.md), was schon drin ist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,14 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
|
|||||||
- [x] Anhänge nur über Kennung, Berechtigung bei jedem Abruf geprüft
|
- [x] Anhänge nur über Kennung, Berechtigung bei jedem Abruf geprüft
|
||||||
- [x] „Was auf dich wartet" zeigt offene Abnahmen
|
- [x] „Was auf dich wartet" zeigt offene Abnahmen
|
||||||
- [x] Aufgaben zählen im Wochenfortschritt mit
|
- [x] Aufgaben zählen im Wochenfortschritt mit
|
||||||
- [ ] **B6 · Checklisten mit Durchläufen und PDF**
|
- [x] **B6 · Checklisten mit Durchläufen und PDF**
|
||||||
|
- [x] Durchlauf aus einer Vorlage, mit Datum, Ticket und Kunde
|
||||||
|
- [x] Schritt für Schritt abhaken, Bemerkung je Schritt
|
||||||
|
- [x] Fortschritt je Gruppe (Vorbereiten / Durchführen / Prüfen / Abschließen)
|
||||||
|
- [x] Abschließen erst, wenn alles abgehakt ist
|
||||||
|
- [x] Abgeschlossene Durchläufe bleiben als Nachweis und sind gesperrt
|
||||||
|
- [x] PDF-Export mit Unterschriftenzeilen
|
||||||
|
- [x] Ausbilder liest mit, hakt aber nicht für den Azubi ab
|
||||||
- [ ] **B7 · Berichtsheft: Tagesnotizen, Vorschläge, Prüfung, Abzeichnen, PDF**
|
- [ ] **B7 · Berichtsheft: Tagesnotizen, Vorschläge, Prüfung, Abzeichnen, PDF**
|
||||||
|
|
||||||
## 0.5 – Bewertung
|
## 0.5 – Bewertung
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 500 KiB After Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
@@ -118,6 +118,17 @@ async function main() {
|
|||||||
await seite.waitForSelector('h1');
|
await seite.waitForSelector('h1');
|
||||||
await bild(seite, '17-aufgabe-azubi');
|
await bild(seite, '17-aufgabe-azubi');
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/checklisten`);
|
||||||
|
await seite.waitForSelector('h1');
|
||||||
|
await bild(seite, '18-checklisten');
|
||||||
|
|
||||||
|
const lauf = await seite.$('.laufzeile-haupt');
|
||||||
|
if (lauf) {
|
||||||
|
await lauf.click();
|
||||||
|
await seite.waitForSelector('.schrittliste');
|
||||||
|
await bild(seite, '19-checklisten-lauf');
|
||||||
|
}
|
||||||
|
|
||||||
await seite.goto(`${BASIS}/glossar`);
|
await seite.goto(`${BASIS}/glossar`);
|
||||||
await seite.waitForSelector('h1');
|
await seite.waitForSelector('h1');
|
||||||
await bild(seite, '15-glossar');
|
await bild(seite, '15-glossar');
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useActionState } from 'react';
|
||||||
|
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
|
||||||
|
import {
|
||||||
|
durchlaufAbschliessen,
|
||||||
|
durchlaufStarten,
|
||||||
|
kopfdatenSpeichern,
|
||||||
|
schrittBemerkung,
|
||||||
|
type Rueckmeldung,
|
||||||
|
} from './aktionen';
|
||||||
|
|
||||||
|
export function StartFormular({
|
||||||
|
checklisteId,
|
||||||
|
mitTicket,
|
||||||
|
heuteDatum,
|
||||||
|
}: {
|
||||||
|
checklisteId: string;
|
||||||
|
mitTicket: boolean;
|
||||||
|
heuteDatum: string;
|
||||||
|
}) {
|
||||||
|
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(durchlaufStarten, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={aktion} className="stapel-eng">
|
||||||
|
<Meldung zustand={zustand} />
|
||||||
|
<input type="hidden" name="checklisteId" value={checklisteId} />
|
||||||
|
|
||||||
|
<div className="reihe" style={{ gap: '0.4rem', alignItems: 'flex-end' }}>
|
||||||
|
<div style={{ flex: '1 1 130px' }}>
|
||||||
|
<label htmlFor={`datum-${checklisteId}`}>Datum</label>
|
||||||
|
<input id={`datum-${checklisteId}`} name="datum" type="date" defaultValue={heuteDatum} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mitTicket ? (
|
||||||
|
<div style={{ flex: '1 1 110px' }}>
|
||||||
|
<label htmlFor={`ticket-${checklisteId}`}>Ticket</label>
|
||||||
|
<input id={`ticket-${checklisteId}`} name="ticket" type="text" maxLength={80} placeholder="z. B. 4711" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="feld" style={{ marginTop: 0 }}>
|
||||||
|
<label htmlFor={`kunde-${checklisteId}`}>Kunde oder Bereich</label>
|
||||||
|
<input
|
||||||
|
id={`kunde-${checklisteId}`}
|
||||||
|
name="kunde"
|
||||||
|
type="text"
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="z. B. Buchhaltung, Halle 2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Absenden className="knopf knopf-akzent">Durchlauf starten</Absenden>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KopfdatenFormular({
|
||||||
|
laufId,
|
||||||
|
datum,
|
||||||
|
ticket,
|
||||||
|
kunde,
|
||||||
|
bemerkung,
|
||||||
|
gesperrt,
|
||||||
|
}: {
|
||||||
|
laufId: string;
|
||||||
|
datum: string;
|
||||||
|
ticket: string;
|
||||||
|
kunde: string;
|
||||||
|
bemerkung: string;
|
||||||
|
gesperrt: boolean;
|
||||||
|
}) {
|
||||||
|
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(kopfdatenSpeichern, {});
|
||||||
|
|
||||||
|
if (gesperrt) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={aktion} className="stapel-eng">
|
||||||
|
<Meldung zustand={zustand} />
|
||||||
|
<input type="hidden" name="laufId" value={laufId} />
|
||||||
|
|
||||||
|
<div className="reihe" style={{ alignItems: 'flex-end' }}>
|
||||||
|
<div style={{ flex: '1 1 140px' }}>
|
||||||
|
<label htmlFor="lauf-datum">Datum</label>
|
||||||
|
<input id="lauf-datum" name="datum" type="date" defaultValue={datum} required />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '1 1 120px' }}>
|
||||||
|
<label htmlFor="lauf-ticket">Ticket</label>
|
||||||
|
<input id="lauf-ticket" name="ticket" type="text" maxLength={80} defaultValue={ticket} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '2 1 200px' }}>
|
||||||
|
<label htmlFor="lauf-kunde">Kunde oder Bereich</label>
|
||||||
|
<input id="lauf-kunde" name="kunde" type="text" maxLength={120} defaultValue={kunde} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="feld" style={{ marginTop: 0 }}>
|
||||||
|
<label htmlFor="lauf-bemerkung">Bemerkung zum ganzen Durchlauf</label>
|
||||||
|
<textarea
|
||||||
|
id="lauf-bemerkung"
|
||||||
|
name="bemerkung"
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
defaultValue={bemerkung}
|
||||||
|
placeholder="Besonderheiten, Abweichungen, was beim nächsten Mal anders laufen sollte."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Absenden className="knopf knopf-zweit">Angaben speichern</Absenden>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BemerkungFormular({
|
||||||
|
laufId,
|
||||||
|
schrittId,
|
||||||
|
bemerkung,
|
||||||
|
gesperrt,
|
||||||
|
}: {
|
||||||
|
laufId: string;
|
||||||
|
schrittId: string;
|
||||||
|
bemerkung: string;
|
||||||
|
gesperrt: boolean;
|
||||||
|
}) {
|
||||||
|
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(schrittBemerkung, {});
|
||||||
|
|
||||||
|
if (gesperrt) {
|
||||||
|
return bemerkung ? <p className="schritt-bemerkung">{bemerkung}</p> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="schritt-notiz" open={Boolean(bemerkung)}>
|
||||||
|
<summary>{bemerkung ? 'Bemerkung' : 'Bemerkung hinzufügen'}</summary>
|
||||||
|
<form action={aktion} className="stapel-eng">
|
||||||
|
<Meldung zustand={zustand} />
|
||||||
|
<input type="hidden" name="laufId" value={laufId} />
|
||||||
|
<input type="hidden" name="schrittId" value={schrittId} />
|
||||||
|
<label htmlFor={`bem-${schrittId}`} className="nur-lesehilfe">
|
||||||
|
Bemerkung zu diesem Schritt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={`bem-${schrittId}`}
|
||||||
|
name="bemerkung"
|
||||||
|
rows={2}
|
||||||
|
maxLength={1000}
|
||||||
|
defaultValue={bemerkung}
|
||||||
|
placeholder="z. B. Netzteil auffällig, Ersatz bestellt"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Absenden className="knopf knopf-still">Speichern</Absenden>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AbschlussFormular({ laufId, offen }: { laufId: string; offen: number }) {
|
||||||
|
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(durchlaufAbschliessen, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={aktion} className="stapel-eng">
|
||||||
|
<Meldung zustand={zustand} />
|
||||||
|
<input type="hidden" name="laufId" value={laufId} />
|
||||||
|
<Absenden className="knopf knopf-akzent">
|
||||||
|
{offen === 0 ? 'Durchlauf abschließen' : `Abschließen (noch ${offen} offen)`}
|
||||||
|
</Absenden>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||||
|
import { darfAzubiDatenSehen } from '@/lib/auth/regeln';
|
||||||
|
import { ausbildungVonAzubi } from '@/lib/auth/rechte';
|
||||||
|
import { inhalte } from '@/lib/inhalte';
|
||||||
|
import {
|
||||||
|
bemerkungSetzen,
|
||||||
|
laufAbschliessen,
|
||||||
|
laufBemerkung,
|
||||||
|
laufKopfdaten,
|
||||||
|
laufLoeschen,
|
||||||
|
laufNachId,
|
||||||
|
laufStarten,
|
||||||
|
laufWiederOeffnen,
|
||||||
|
schrittSetzen,
|
||||||
|
standVon,
|
||||||
|
} from '@/lib/repos/checklisten';
|
||||||
|
import { darfAbschliessen } from '@/lib/checklisten/fortschritt';
|
||||||
|
|
||||||
|
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
||||||
|
|
||||||
|
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt einen Durchlauf und prüft, ob der Anmelder ihn **bearbeiten** darf.
|
||||||
|
*
|
||||||
|
* Bearbeiten darf nur, wem der Durchlauf gehört. Der Ausbilder darf mitlesen
|
||||||
|
* (siehe `laufZumLesen`), aber nicht für seinen Azubi abhaken – sonst stünde
|
||||||
|
* im Nachweis eine Arbeit, die der Azubi nie gemacht hat.
|
||||||
|
*/
|
||||||
|
async function laufZumBearbeiten(laufId: string) {
|
||||||
|
const benutzer = await erfordereBenutzer();
|
||||||
|
const lauf = laufNachId(laufId);
|
||||||
|
if (!lauf) throw new Error('Diesen Durchlauf gibt es nicht.');
|
||||||
|
if (lauf.benutzerId !== benutzer.id) throw new Error('Kein Zugriff auf diesen Durchlauf.');
|
||||||
|
return { benutzer, lauf };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zum Lesen: der eigene Durchlauf oder der eines betreuten Azubis. */
|
||||||
|
export async function laufZumLesen(laufId: string) {
|
||||||
|
const benutzer = await erfordereBenutzer();
|
||||||
|
const lauf = laufNachId(laufId);
|
||||||
|
if (!lauf) return null;
|
||||||
|
|
||||||
|
const bezug = ausbildungVonAzubi(lauf.benutzerId);
|
||||||
|
if (!darfAzubiDatenSehen(benutzer, lauf.benutzerId, bezug)) return null;
|
||||||
|
|
||||||
|
return { benutzer, lauf };
|
||||||
|
}
|
||||||
|
|
||||||
|
function aktualisiere(laufId: string) {
|
||||||
|
revalidatePath('/checklisten');
|
||||||
|
revalidatePath(`/checklisten/lauf/${laufId}`);
|
||||||
|
revalidatePath('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function durchlaufStarten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||||
|
const benutzer = await erfordereBenutzer();
|
||||||
|
if (benutzer.rolle !== 'azubi') {
|
||||||
|
return { fehler: 'Durchläufe führt der Azubi – der Ausbilder liest mit.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const checklisteId = String(formular.get('checklisteId') ?? '');
|
||||||
|
const vorlage = inhalte().checklisteNach.get(checklisteId);
|
||||||
|
if (!vorlage) return { fehler: 'Diese Checkliste gibt es nicht.' };
|
||||||
|
|
||||||
|
const datum = String(formular.get('datum') ?? '').trim();
|
||||||
|
if (!datumMuster.test(datum)) return { fehler: 'Bitte ein gültiges Datum wählen.' };
|
||||||
|
|
||||||
|
const ticket = String(formular.get('ticket') ?? '');
|
||||||
|
const kunde = String(formular.get('kunde') ?? '');
|
||||||
|
if (ticket.length > 80) return { fehler: 'Die Ticketangabe ist zu lang.' };
|
||||||
|
if (kunde.length > 120) return { fehler: 'Die Kundenangabe ist zu lang.' };
|
||||||
|
|
||||||
|
const lauf = laufStarten({ benutzerId: benutzer.id, vorlage, datum, ticket, kunde });
|
||||||
|
|
||||||
|
revalidatePath('/checklisten');
|
||||||
|
redirect(`/checklisten/lauf/${lauf.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function schrittUmschalten(formular: FormData): Promise<void> {
|
||||||
|
const laufId = String(formular.get('laufId') ?? '');
|
||||||
|
const schrittId = String(formular.get('schrittId') ?? '');
|
||||||
|
const erledigt = String(formular.get('erledigt') ?? '') === '1';
|
||||||
|
|
||||||
|
const { lauf } = await laufZumBearbeiten(laufId);
|
||||||
|
if (lauf.abgeschlossenAm) return; // abgeschlossene Durchläufe sind Nachweis, nicht Arbeitsblatt
|
||||||
|
|
||||||
|
schrittSetzen(laufId, schrittId, erledigt);
|
||||||
|
aktualisiere(laufId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function schrittBemerkung(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||||
|
const laufId = String(formular.get('laufId') ?? '');
|
||||||
|
const schrittId = String(formular.get('schrittId') ?? '');
|
||||||
|
const bemerkung = String(formular.get('bemerkung') ?? '');
|
||||||
|
|
||||||
|
const { lauf } = await laufZumBearbeiten(laufId);
|
||||||
|
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist abgeschlossen.' };
|
||||||
|
if (bemerkung.length > 1000) return { fehler: 'Die Bemerkung ist zu lang – höchstens 1000 Zeichen.' };
|
||||||
|
|
||||||
|
bemerkungSetzen(laufId, schrittId, bemerkung);
|
||||||
|
aktualisiere(laufId);
|
||||||
|
return { erfolg: bemerkung.trim() ? 'Bemerkung gespeichert.' : 'Bemerkung entfernt.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kopfdatenSpeichern(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||||
|
const laufId = String(formular.get('laufId') ?? '');
|
||||||
|
const { lauf } = await laufZumBearbeiten(laufId);
|
||||||
|
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist abgeschlossen.' };
|
||||||
|
|
||||||
|
const datum = String(formular.get('datum') ?? '').trim();
|
||||||
|
if (!datumMuster.test(datum)) return { fehler: 'Bitte ein gültiges Datum wählen.' };
|
||||||
|
|
||||||
|
const bemerkung = String(formular.get('bemerkung') ?? '');
|
||||||
|
if (bemerkung.length > 2000) return { fehler: 'Die Bemerkung ist zu lang – höchstens 2000 Zeichen.' };
|
||||||
|
|
||||||
|
laufKopfdaten(laufId, {
|
||||||
|
datum,
|
||||||
|
ticket: String(formular.get('ticket') ?? ''),
|
||||||
|
kunde: String(formular.get('kunde') ?? ''),
|
||||||
|
});
|
||||||
|
laufBemerkung(laufId, bemerkung);
|
||||||
|
|
||||||
|
aktualisiere(laufId);
|
||||||
|
return { erfolg: 'Angaben gespeichert.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function durchlaufAbschliessen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||||
|
const laufId = String(formular.get('laufId') ?? '');
|
||||||
|
const { lauf } = await laufZumBearbeiten(laufId);
|
||||||
|
if (lauf.abgeschlossenAm) return { fehler: 'Der Durchlauf ist schon abgeschlossen.' };
|
||||||
|
|
||||||
|
const stand = standVon(laufId);
|
||||||
|
if (!darfAbschliessen(stand)) {
|
||||||
|
const offen = stand.gesamt - stand.erledigt;
|
||||||
|
return {
|
||||||
|
fehler: `Es ${offen === 1 ? 'ist noch ein Schritt' : `sind noch ${offen} Schritte`} offen. Ein Durchlauf wird erst abgeschlossen, wenn alles abgehakt ist.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
laufAbschliessen(laufId);
|
||||||
|
aktualisiere(laufId);
|
||||||
|
return { erfolg: 'Durchlauf abgeschlossen. Er bleibt als Nachweis erhalten.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function durchlaufWiederOeffnen(formular: FormData): Promise<void> {
|
||||||
|
const laufId = String(formular.get('laufId') ?? '');
|
||||||
|
await laufZumBearbeiten(laufId);
|
||||||
|
laufWiederOeffnen(laufId);
|
||||||
|
aktualisiere(laufId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function durchlaufVerwerfen(formular: FormData): Promise<void> {
|
||||||
|
const laufId = String(formular.get('laufId') ?? '');
|
||||||
|
const { lauf } = await laufZumBearbeiten(laufId);
|
||||||
|
// Ein abgeschlossener Durchlauf ist ein Nachweis und wird nicht gelöscht.
|
||||||
|
if (lauf.abgeschlossenAm) return;
|
||||||
|
|
||||||
|
laufLoeschen(laufId);
|
||||||
|
revalidatePath('/checklisten');
|
||||||
|
redirect('/checklisten');
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { inhalte } from '@/lib/inhalte';
|
||||||
|
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||||
|
import { standVon } from '@/lib/repos/checklisten';
|
||||||
|
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
|
||||||
|
import { AbschlussFormular, BemerkungFormular, KopfdatenFormular } from '../../Formulare';
|
||||||
|
import { durchlaufVerwerfen, durchlaufWiederOeffnen, laufZumLesen, schrittUmschalten } from '../../aktionen';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ laufId: string }> }): Promise<Metadata> {
|
||||||
|
const { laufId } = await params;
|
||||||
|
const gelesen = await laufZumLesen(laufId);
|
||||||
|
return { title: gelesen ? gelesen.lauf.titel : 'Durchlauf' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LaufSeite({ params }: { params: Promise<{ laufId: string }> }) {
|
||||||
|
const { laufId } = await params;
|
||||||
|
|
||||||
|
// Enthält die Rechteprüfung: Wer nicht darf, bekommt null – und damit 404.
|
||||||
|
const gelesen = await laufZumLesen(laufId);
|
||||||
|
if (!gelesen) notFound();
|
||||||
|
|
||||||
|
const { benutzer, lauf } = gelesen;
|
||||||
|
const eigener = lauf.benutzerId === benutzer.id;
|
||||||
|
const gesperrt = Boolean(lauf.abgeschlossenAm) || !eigener;
|
||||||
|
|
||||||
|
const alle = inhalte();
|
||||||
|
const vorlage = alle.checklisteNach.get(lauf.checklisteId);
|
||||||
|
const stand = standVon(laufId);
|
||||||
|
const bearbeiter = eigener ? benutzer : benutzerNachId(lauf.benutzerId);
|
||||||
|
|
||||||
|
const positionen = (vorlage?.rahmenplan ?? [])
|
||||||
|
.map((p) => alle.positionNach.get(p))
|
||||||
|
.filter((p) => p !== undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="stapel">
|
||||||
|
<nav className="leise nicht-drucken" aria-label="Wo du bist">
|
||||||
|
<Link href="/checklisten">Checklisten</Link>
|
||||||
|
{!eigener && bearbeiter ? ` › ${bearbeiter.name}` : ''}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header className="stapel-eng">
|
||||||
|
<div className="reihe" style={{ gap: '0.6rem' }}>
|
||||||
|
{lauf.abgeschlossenAm ? (
|
||||||
|
<span className="marke-status" data-ton="gut">
|
||||||
|
✓ abgeschlossen am {alsZeitpunktText(lauf.abgeschlossenAm)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="marke-status" data-ton="warnung">
|
||||||
|
◐ in Arbeit
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="leise">
|
||||||
|
{alsDatumText(lauf.datum)}
|
||||||
|
{lauf.ticket ? ` · Ticket ${lauf.ticket}` : ''}
|
||||||
|
{lauf.kunde ? ` · ${lauf.kunde}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 style={{ marginBottom: '0.2rem' }}>{lauf.titel}</h1>
|
||||||
|
{vorlage ? <p className="handout-worum">{vorlage.worum}</p> : null}
|
||||||
|
{bearbeiter && !eigener ? <p className="leise">Bearbeitet von {bearbeiter.name}</p> : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!vorlage ? (
|
||||||
|
<p className="hinweis hinweis-warnung">
|
||||||
|
Die Vorlage <code>{lauf.checklisteId}</code> gibt es im Repository nicht mehr. Der Durchlauf bleibt
|
||||||
|
vollständig lesbar – Titel und Schritttexte wurden beim Start mitkopiert.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{positionen.length > 0 ? (
|
||||||
|
<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>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="karte stapel-eng" aria-labelledby="gesamtstand">
|
||||||
|
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||||
|
<h2 id="gesamtstand" style={{ margin: 0, fontSize: '1rem' }}>
|
||||||
|
Fortschritt
|
||||||
|
</h2>
|
||||||
|
<span className="leise">
|
||||||
|
{stand.erledigt} von {stand.gesamt} Schritten · {stand.prozent} %
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="balken"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={stand.erledigt}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={stand.gesamt}
|
||||||
|
aria-label={`${stand.erledigt} von ${stand.gesamt} Schritten erledigt`}
|
||||||
|
>
|
||||||
|
<span style={{ width: `${stand.prozent}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="gruppenstand">
|
||||||
|
{stand.gruppen.map((gruppe) => (
|
||||||
|
<li key={gruppe.gruppeId}>
|
||||||
|
<span>{gruppe.titel}</span>
|
||||||
|
<span className="balken" style={{ width: '70px' }}>
|
||||||
|
<span style={{ width: `${gruppe.prozent}%` }} />
|
||||||
|
</span>
|
||||||
|
<span className="leise">
|
||||||
|
{gruppe.erledigt}/{gruppe.gesamt}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{stand.gruppen.map((gruppe) => (
|
||||||
|
<section key={gruppe.gruppeId} className="stapel-eng" aria-labelledby={`g-${gruppe.gruppeId}`}>
|
||||||
|
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||||
|
<h2 id={`g-${gruppe.gruppeId}`} style={{ margin: 0 }}>
|
||||||
|
{gruppe.titel}
|
||||||
|
</h2>
|
||||||
|
<span className={`marke-status${gruppe.vollstaendig ? '' : ''}`} data-ton={gruppe.vollstaendig ? 'gut' : 'neutral'}>
|
||||||
|
{gruppe.vollstaendig ? '✓' : '○'} {gruppe.erledigt} von {gruppe.gesamt}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="schrittliste">
|
||||||
|
{gruppe.schritte.map((schritt) => (
|
||||||
|
<li key={schritt.schrittId} data-erledigt={schritt.erledigt ? 'ja' : 'nein'}>
|
||||||
|
<form action={schrittUmschalten} className="schritt-haken">
|
||||||
|
<input type="hidden" name="laufId" value={laufId} />
|
||||||
|
<input type="hidden" name="schrittId" value={schritt.schrittId} />
|
||||||
|
<input type="hidden" name="erledigt" value={schritt.erledigt ? '0' : '1'} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="hakenknopf"
|
||||||
|
disabled={gesperrt}
|
||||||
|
aria-pressed={schritt.erledigt}
|
||||||
|
title={gesperrt ? 'Gesperrt' : schritt.erledigt ? 'Haken entfernen' : 'Als erledigt abhaken'}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{schritt.erledigt ? '✓' : ''}</span>
|
||||||
|
<span className="nur-lesehilfe">
|
||||||
|
{schritt.text} – {schritt.erledigt ? 'erledigt' : 'offen'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="schritt-inhalt">
|
||||||
|
<span className="schritt-text">{schritt.text}</span>
|
||||||
|
<BemerkungFormular
|
||||||
|
laufId={laufId}
|
||||||
|
schrittId={schritt.schrittId}
|
||||||
|
bemerkung={schritt.bemerkung ?? ''}
|
||||||
|
gesperrt={gesperrt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{eigener && !lauf.abgeschlossenAm ? (
|
||||||
|
<section className="karte-grund stapel nicht-drucken" aria-labelledby="angaben">
|
||||||
|
<h2 id="angaben" style={{ marginBottom: 0 }}>
|
||||||
|
Angaben zum Durchlauf
|
||||||
|
</h2>
|
||||||
|
<KopfdatenFormular
|
||||||
|
laufId={laufId}
|
||||||
|
datum={lauf.datum}
|
||||||
|
ticket={lauf.ticket ?? ''}
|
||||||
|
kunde={lauf.kunde ?? ''}
|
||||||
|
bemerkung={lauf.bemerkung ?? ''}
|
||||||
|
gesperrt={false}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : lauf.bemerkung ? (
|
||||||
|
<section className="karte stapel-eng" aria-labelledby="bemerkung">
|
||||||
|
<h2 id="bemerkung" style={{ marginBottom: 0, fontSize: '1rem' }}>
|
||||||
|
Bemerkung zum Durchlauf
|
||||||
|
</h2>
|
||||||
|
<p className="notiz-anzeige">{lauf.bemerkung}</p>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="reihe nicht-drucken">
|
||||||
|
<a href={`/checklisten/lauf/${laufId}/pdf`} className="knopf knopf-zweit" target="_blank" rel="noreferrer">
|
||||||
|
Als PDF öffnen
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{eigener && !lauf.abgeschlossenAm ? (
|
||||||
|
<AbschlussFormular laufId={laufId} offen={stand.gesamt - stand.erledigt} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{eigener && lauf.abgeschlossenAm ? (
|
||||||
|
<form action={durchlaufWiederOeffnen}>
|
||||||
|
<input type="hidden" name="laufId" value={laufId} />
|
||||||
|
<button type="submit" className="knopf knopf-still">
|
||||||
|
Wieder öffnen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{eigener && !lauf.abgeschlossenAm && stand.erledigt === 0 ? (
|
||||||
|
<form action={durchlaufVerwerfen}>
|
||||||
|
<input type="hidden" name="laufId" value={laufId} />
|
||||||
|
<button type="submit" className="knopf knopf-still">
|
||||||
|
Verwerfen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lauf.abgeschlossenAm ? (
|
||||||
|
<p className="leise" style={{ marginBottom: 0 }}>
|
||||||
|
Abgeschlossene Durchläufe bleiben als Nachweis erhalten und lassen sich nicht löschen.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { inhalte } from '@/lib/inhalte';
|
||||||
|
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||||
|
import { standVon } from '@/lib/repos/checklisten';
|
||||||
|
import { checklistenPdf } from '@/lib/pdf/checkliste';
|
||||||
|
import { alsDatumText, alsZeitpunktText } from '@/lib/zeit';
|
||||||
|
import { laufZumLesen } from '../../../aktionen';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Durchlauf als PDF.
|
||||||
|
*
|
||||||
|
* Die Rechteprüfung steckt in `laufZumLesen` – dieselbe Funktion, die auch die
|
||||||
|
* Seite benutzt. Wer nicht darf, bekommt 404 statt 403: Ob es den Durchlauf
|
||||||
|
* gibt, geht einen Unbefugten nichts an.
|
||||||
|
*/
|
||||||
|
export async function GET(_anfrage: Request, kontext: { params: Promise<{ laufId: string }> }) {
|
||||||
|
const { laufId } = await kontext.params;
|
||||||
|
|
||||||
|
const gelesen = await laufZumLesen(laufId);
|
||||||
|
if (!gelesen) return new Response('Nicht gefunden.', { status: 404 });
|
||||||
|
|
||||||
|
const { lauf } = gelesen;
|
||||||
|
const vorlage = inhalte().checklisteNach.get(lauf.checklisteId);
|
||||||
|
const bearbeiter = benutzerNachId(lauf.benutzerId);
|
||||||
|
const stand = standVon(laufId);
|
||||||
|
|
||||||
|
const pdf = await checklistenPdf(
|
||||||
|
{
|
||||||
|
titel: lauf.titel,
|
||||||
|
checklisteId: lauf.checklisteId,
|
||||||
|
bearbeiter: bearbeiter?.name ?? 'unbekannt',
|
||||||
|
datum: alsDatumText(lauf.datum),
|
||||||
|
ticket: lauf.ticket,
|
||||||
|
kunde: lauf.kunde,
|
||||||
|
bemerkung: lauf.bemerkung,
|
||||||
|
gestartetAm: alsZeitpunktText(lauf.gestartetAm),
|
||||||
|
abgeschlossenAm: lauf.abgeschlossenAm ? alsZeitpunktText(lauf.abgeschlossenAm) : null,
|
||||||
|
worum: vorlage?.worum,
|
||||||
|
},
|
||||||
|
stand,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateiname = `Checkliste_${lauf.checklisteId}_${lauf.datum}.pdf`.replace(/[^\w.\-]/g, '_');
|
||||||
|
|
||||||
|
return new Response(new Uint8Array(pdf), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Length': String(pdf.byteLength),
|
||||||
|
'Content-Disposition': `inline; filename="${dateiname}"`,
|
||||||
|
'Cache-Control': 'private, no-store',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
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 { laeufeVon, standVon } from '@/lib/repos/checklisten';
|
||||||
|
import { alsDatumText, alsZeitpunktText, heute } from '@/lib/zeit';
|
||||||
|
import { StartFormular } from './Formulare';
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Checklisten' };
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function ChecklistenSeite() {
|
||||||
|
const benutzer = await erfordereBenutzer();
|
||||||
|
const alle = inhalte();
|
||||||
|
const istAzubi = benutzer.rolle === 'azubi';
|
||||||
|
|
||||||
|
// Der Ausbilder sieht die Durchläufe seiner Azubis, der Azubi seine eigenen.
|
||||||
|
const beteiligte = istAzubi
|
||||||
|
? [{ id: benutzer.id, name: benutzer.name }]
|
||||||
|
: ausbildungenVonAusbilder(benutzer.id).map(({ azubi }) => ({ id: azubi.id, name: azubi.name }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stapel">
|
||||||
|
<div>
|
||||||
|
<h1>Checklisten</h1>
|
||||||
|
<p className="leise">
|
||||||
|
{alle.checklisten.length} Vorlagen für wiederkehrende Arbeiten. Aus einer Vorlage wird ein Durchlauf –
|
||||||
|
und der bleibt als Nachweis erhalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="stapel" aria-labelledby="vorlagen">
|
||||||
|
<h2 id="vorlagen">Vorlagen</h2>
|
||||||
|
|
||||||
|
<div className="raster">
|
||||||
|
{alle.checklisten.map((vorlage) => {
|
||||||
|
const schritte = vorlage.gruppen.reduce((summe, g) => summe + g.schritte.length, 0);
|
||||||
|
const block = alle.blockNach.get(vorlage.block);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section key={vorlage.id} className="karte stapel-eng">
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: '1rem', marginBottom: '0.15rem' }}>{vorlage.titel}</h3>
|
||||||
|
<p className="leise" style={{ margin: 0 }}>
|
||||||
|
{schritte} Schritte in {vorlage.gruppen.length} Gruppen
|
||||||
|
{block ? ` · ${block.titel}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.93rem' }}>{vorlage.worum}</p>
|
||||||
|
|
||||||
|
{istAzubi ? (
|
||||||
|
<StartFormular checklisteId={vorlage.id} mitTicket={vorlage.mit_ticket} heuteDatum={heute()} />
|
||||||
|
) : (
|
||||||
|
<p className="leise" style={{ margin: 0 }}>
|
||||||
|
Quelle: <code>content/checklisten/{vorlage.id}.yaml</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{beteiligte.map((person) => {
|
||||||
|
const laeufe = laeufeVon(person.id);
|
||||||
|
const offene = laeufe.filter((l) => !l.abgeschlossenAm);
|
||||||
|
const fertige = laeufe.filter((l) => l.abgeschlossenAm);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section key={person.id} className="stapel" aria-labelledby={`laeufe-${person.id}`}>
|
||||||
|
<h2 id={`laeufe-${person.id}`}>
|
||||||
|
{istAzubi ? 'Deine Durchläufe' : `Durchläufe von ${person.name}`}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{laeufe.length === 0 ? (
|
||||||
|
<p className="karte" style={{ marginBottom: 0 }}>
|
||||||
|
Noch kein Durchlauf gestartet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{offene.length > 0 ? (
|
||||||
|
<div className="stapel-eng">
|
||||||
|
<h3 style={{ fontSize: '0.95rem', marginBottom: 0 }}>Offen</h3>
|
||||||
|
{offene.map((lauf) => (
|
||||||
|
<Laufzeile key={lauf.id} laufId={lauf.id} titel={lauf.titel} datum={lauf.datum}
|
||||||
|
ticket={lauf.ticket} kunde={lauf.kunde} abgeschlossenAm={null} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{fertige.length > 0 ? (
|
||||||
|
<div className="stapel-eng">
|
||||||
|
<h3 style={{ fontSize: '0.95rem', marginBottom: 0 }}>
|
||||||
|
Abgeschlossen ({fertige.length})
|
||||||
|
</h3>
|
||||||
|
{fertige.map((lauf) => (
|
||||||
|
<Laufzeile key={lauf.id} laufId={lauf.id} titel={lauf.titel} datum={lauf.datum}
|
||||||
|
ticket={lauf.ticket} kunde={lauf.kunde} abgeschlossenAm={lauf.abgeschlossenAm} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Laufzeile({
|
||||||
|
laufId,
|
||||||
|
titel,
|
||||||
|
datum,
|
||||||
|
ticket,
|
||||||
|
kunde,
|
||||||
|
abgeschlossenAm,
|
||||||
|
}: {
|
||||||
|
laufId: string;
|
||||||
|
titel: string;
|
||||||
|
datum: string;
|
||||||
|
ticket: string | null;
|
||||||
|
kunde: string | null;
|
||||||
|
abgeschlossenAm: string | null;
|
||||||
|
}) {
|
||||||
|
const stand = standVon(laufId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="laufzeile">
|
||||||
|
<Link href={`/checklisten/lauf/${laufId}`} className="laufzeile-haupt">
|
||||||
|
<span className="laufzeile-titel">{titel}</span>
|
||||||
|
<span className="leise">
|
||||||
|
{alsDatumText(datum)}
|
||||||
|
{ticket ? ` · Ticket ${ticket}` : ''}
|
||||||
|
{kunde ? ` · ${kunde}` : ''}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="laufzeile-stand">
|
||||||
|
<span className="leise">
|
||||||
|
{stand.erledigt} / {stand.gesamt}
|
||||||
|
</span>
|
||||||
|
<span className="balken" style={{ width: '90px' }}>
|
||||||
|
<span style={{ width: `${stand.prozent}%` }} />
|
||||||
|
</span>
|
||||||
|
{abgeschlossenAm ? (
|
||||||
|
<span className="marke-status" data-ton="gut" title={alsZeitpunktText(abgeschlossenAm)}>
|
||||||
|
✓ fertig
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="marke-status" data-ton="warnung">
|
||||||
|
◐ offen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ const fuerAzubi: Navigationseintrag[] = [
|
|||||||
{ pfad: '/handouts', titel: 'Handouts' },
|
{ pfad: '/handouts', titel: 'Handouts' },
|
||||||
{ pfad: '/karten', titel: 'Merkkarten' },
|
{ pfad: '/karten', titel: 'Merkkarten' },
|
||||||
{ pfad: '/aufgaben', titel: 'Aufgaben' },
|
{ pfad: '/aufgaben', titel: 'Aufgaben' },
|
||||||
|
{ pfad: '/checklisten', titel: 'Checklisten' },
|
||||||
{ pfad: '/glossar', titel: 'Glossar' },
|
{ pfad: '/glossar', titel: 'Glossar' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ const fuerAusbilder: Navigationseintrag[] = [
|
|||||||
{ pfad: '/handouts', titel: 'Handouts' },
|
{ pfad: '/handouts', titel: 'Handouts' },
|
||||||
{ pfad: '/karten', titel: 'Merkkarten' },
|
{ pfad: '/karten', titel: 'Merkkarten' },
|
||||||
{ pfad: '/aufgaben', titel: 'Aufgaben' },
|
{ pfad: '/aufgaben', titel: 'Aufgaben' },
|
||||||
|
{ pfad: '/checklisten', titel: 'Checklisten' },
|
||||||
{ pfad: '/glossar', titel: 'Glossar' },
|
{ pfad: '/glossar', titel: 'Glossar' },
|
||||||
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1224,3 +1224,120 @@ input[type="file"] {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Checklisten --- */
|
||||||
|
|
||||||
|
.laufzeile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
background: var(--flaeche);
|
||||||
|
border: 1px solid var(--rand-zart);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laufzeile-haupt {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.laufzeile-haupt:hover { color: var(--petrol); }
|
||||||
|
.laufzeile-titel { font-weight: 650; }
|
||||||
|
|
||||||
|
.laufzeile-stand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.laufzeile-stand .balken { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.gruppenstand { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.35rem; }
|
||||||
|
.gruppenstand li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 70px 3rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.gruppenstand .leise { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.schrittliste { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.35rem; }
|
||||||
|
.schrittliste li {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
background: var(--flaeche);
|
||||||
|
border: 1px solid var(--rand-zart);
|
||||||
|
border-radius: var(--radius-klein);
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
}
|
||||||
|
.schrittliste li[data-erledigt="ja"] {
|
||||||
|
background: var(--gut-flaeche);
|
||||||
|
border-color: var(--gut);
|
||||||
|
}
|
||||||
|
.schrittliste li[data-erledigt="ja"] .schritt-text { color: var(--gut); }
|
||||||
|
|
||||||
|
.schritt-haken { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.hakenknopf {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: var(--radius-klein);
|
||||||
|
border: 2px solid var(--rand);
|
||||||
|
background: var(--grund);
|
||||||
|
color: var(--gut);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.hakenknopf:hover:not(:disabled) { border-color: var(--petrol); }
|
||||||
|
.hakenknopf[aria-pressed="true"] { border-color: var(--gut); background: var(--gut-flaeche); }
|
||||||
|
.hakenknopf:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.schritt-inhalt { flex: 1; display: grid; gap: 0.25rem; }
|
||||||
|
.schritt-text { line-height: 1.45; }
|
||||||
|
|
||||||
|
.schritt-notiz > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-leise);
|
||||||
|
list-style: none;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
min-height: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.schritt-notiz > summary::-webkit-details-marker { display: none; }
|
||||||
|
.schritt-notiz > summary::before { content: "+"; font-weight: 700; }
|
||||||
|
.schritt-notiz[open] > summary::before { content: "−"; }
|
||||||
|
.schritt-notiz > form { margin-top: 0.35rem; }
|
||||||
|
|
||||||
|
.schritt-bemerkung {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-leise);
|
||||||
|
border-left: 3px solid var(--rand);
|
||||||
|
padding-left: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hakenknopf { width: 38px; height: 38px; }
|
||||||
|
.laufzeile-stand { width: 100%; justify-content: space-between; }
|
||||||
|
.gruppenstand li { grid-template-columns: 1fr 60px 3rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.schritt-notiz > summary { display: none; }
|
||||||
|
.schritt-notiz[open] > form { display: none; }
|
||||||
|
.hakenknopf { border-color: #666; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { anteil, darfAbschliessen, laufstand, type Schrittstand } from './fortschritt';
|
||||||
|
|
||||||
|
function schritt(gruppeId: string, nummer: number, erledigt = false): Schrittstand {
|
||||||
|
return {
|
||||||
|
gruppeId,
|
||||||
|
schrittId: `${gruppeId[0]}${nummer}`,
|
||||||
|
text: `Schritt ${nummer} in ${gruppeId}`,
|
||||||
|
reihenfolge: nummer,
|
||||||
|
erledigt,
|
||||||
|
bemerkung: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const beispiel: Schrittstand[] = [
|
||||||
|
schritt('vorbereiten', 1, true),
|
||||||
|
schritt('vorbereiten', 2, true),
|
||||||
|
schritt('durchfuehren', 3, true),
|
||||||
|
schritt('durchfuehren', 4),
|
||||||
|
schritt('durchfuehren', 5),
|
||||||
|
schritt('pruefen', 6),
|
||||||
|
schritt('abschliessen', 7),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Fortschritt eines Durchlaufs', () => {
|
||||||
|
it('zählt gesamt richtig', () => {
|
||||||
|
const stand = laufstand(beispiel);
|
||||||
|
expect(stand.erledigt).toBe(3);
|
||||||
|
expect(stand.gesamt).toBe(7);
|
||||||
|
expect(stand.prozent).toBe(43);
|
||||||
|
expect(stand.vollstaendig).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rechnet je Gruppe getrennt', () => {
|
||||||
|
const stand = laufstand(beispiel);
|
||||||
|
const nachId = Object.fromEntries(stand.gruppen.map((g) => [g.gruppeId, g]));
|
||||||
|
|
||||||
|
expect(nachId.vorbereiten!.erledigt).toBe(2);
|
||||||
|
expect(nachId.vorbereiten!.gesamt).toBe(2);
|
||||||
|
expect(nachId.vorbereiten!.prozent).toBe(100);
|
||||||
|
expect(nachId.vorbereiten!.vollstaendig).toBe(true);
|
||||||
|
|
||||||
|
expect(nachId.durchfuehren!.erledigt).toBe(1);
|
||||||
|
expect(nachId.durchfuehren!.gesamt).toBe(3);
|
||||||
|
expect(nachId.durchfuehren!.vollstaendig).toBe(false);
|
||||||
|
|
||||||
|
expect(nachId.pruefen!.prozent).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hält die feste Reihenfolge der Gruppen ein, egal wie die Schritte ankommen', () => {
|
||||||
|
const gemischt = [...beispiel].reverse();
|
||||||
|
const stand = laufstand(gemischt);
|
||||||
|
expect(stand.gruppen.map((g) => g.gruppeId)).toEqual([
|
||||||
|
'vorbereiten',
|
||||||
|
'durchfuehren',
|
||||||
|
'pruefen',
|
||||||
|
'abschliessen',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sortiert die Schritte innerhalb einer Gruppe nach ihrer Reihenfolge', () => {
|
||||||
|
const gemischt = [schritt('durchfuehren', 5), schritt('durchfuehren', 3), schritt('durchfuehren', 4)];
|
||||||
|
const stand = laufstand(gemischt);
|
||||||
|
expect(stand.gruppen[0]!.schritte.map((s) => s.reihenfolge)).toEqual([3, 4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt Gruppen weg, für die es keine Schritte gibt', () => {
|
||||||
|
const nurZwei = [schritt('vorbereiten', 1), schritt('pruefen', 2)];
|
||||||
|
expect(laufstand(nurZwei).gruppen.map((g) => g.gruppeId)).toEqual(['vorbereiten', 'pruefen']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hängt unbekannte Gruppen hinten an, statt sie zu verschlucken', () => {
|
||||||
|
const mitFremder = [...beispiel, schritt('nachbereiten', 8)];
|
||||||
|
const stand = laufstand(mitFremder);
|
||||||
|
expect(stand.gruppen.at(-1)!.gruppeId).toBe('nachbereiten');
|
||||||
|
expect(stand.gesamt).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nennt den nächsten offenen Schritt', () => {
|
||||||
|
expect(laufstand(beispiel).naechster?.reihenfolge).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nennt keinen nächsten Schritt mehr, wenn alles erledigt ist', () => {
|
||||||
|
const fertig = beispiel.map((s) => ({ ...s, erledigt: true }));
|
||||||
|
const stand = laufstand(fertig);
|
||||||
|
expect(stand.naechster).toBeNull();
|
||||||
|
expect(stand.vollstaendig).toBe(true);
|
||||||
|
expect(stand.prozent).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kommt mit einem leeren Durchlauf zurecht', () => {
|
||||||
|
const stand = laufstand([]);
|
||||||
|
expect(stand.gruppen).toEqual([]);
|
||||||
|
expect(stand.vollstaendig).toBe(false);
|
||||||
|
expect(stand.naechster).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verändert die übergebene Liste nicht', () => {
|
||||||
|
const kopie = JSON.parse(JSON.stringify(beispiel));
|
||||||
|
laufstand(beispiel);
|
||||||
|
expect(beispiel).toEqual(kopie);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('übernimmt eigene Gruppentitel', () => {
|
||||||
|
const stand = laufstand([schritt('vorbereiten', 1)], { vorbereiten: 'Erst mal alles holen' });
|
||||||
|
expect(stand.gruppen[0]!.titel).toBe('Erst mal alles holen');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Abschließen', () => {
|
||||||
|
it('geht erst, wenn jeder Schritt abgehakt ist', () => {
|
||||||
|
expect(darfAbschliessen(laufstand(beispiel))).toBe(false);
|
||||||
|
expect(darfAbschliessen(laufstand(beispiel.map((s) => ({ ...s, erledigt: true }))))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('geht bei einem leeren Durchlauf nicht', () => {
|
||||||
|
expect(darfAbschliessen(laufstand([]))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Anteil in Prozent', () => {
|
||||||
|
it('zeigt nie 100, solange etwas offen ist', () => {
|
||||||
|
expect(anteil(99, 100)).toBe(99);
|
||||||
|
expect(anteil(100, 100)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt nie 0, sobald etwas erledigt ist', () => {
|
||||||
|
expect(anteil(1, 300)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt bei einer leeren Liste 0 zurück – anders als beim Wochenfortschritt', () => {
|
||||||
|
// Eine Woche ohne Inhalt ist "nichts zu tun". Ein Durchlauf ohne Schritte
|
||||||
|
// ist dagegen kaputt und soll nicht als erledigt erscheinen.
|
||||||
|
expect(anteil(0, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Fortschritt eines Checklisten-Durchlaufs, je Gruppe und gesamt.
|
||||||
|
*
|
||||||
|
* Reine Rechnung ohne Datenbank – die Aufrufer holen die Schritte und fragen
|
||||||
|
* hier nach dem Ergebnis.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Schrittstand = {
|
||||||
|
gruppeId: string;
|
||||||
|
schrittId: string;
|
||||||
|
text: string;
|
||||||
|
reihenfolge: number;
|
||||||
|
erledigt: boolean;
|
||||||
|
bemerkung: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Gruppenstand = {
|
||||||
|
gruppeId: string;
|
||||||
|
titel: string;
|
||||||
|
schritte: Schrittstand[];
|
||||||
|
erledigt: number;
|
||||||
|
gesamt: number;
|
||||||
|
prozent: number;
|
||||||
|
vollstaendig: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Laufstand = {
|
||||||
|
gruppen: Gruppenstand[];
|
||||||
|
erledigt: number;
|
||||||
|
gesamt: number;
|
||||||
|
prozent: number;
|
||||||
|
vollstaendig: boolean;
|
||||||
|
/** Der erste noch offene Schritt – darauf springt die Oberfläche. */
|
||||||
|
naechster: Schrittstand | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Die vier festen Gruppen einer Checkliste, in genau dieser Reihenfolge. */
|
||||||
|
export const gruppenReihenfolge = ['vorbereiten', 'durchfuehren', 'pruefen', 'abschliessen'] as const;
|
||||||
|
|
||||||
|
export const gruppenTitel: Record<string, string> = {
|
||||||
|
vorbereiten: 'Vorbereiten',
|
||||||
|
durchfuehren: 'Durchführen',
|
||||||
|
pruefen: 'Prüfen',
|
||||||
|
abschliessen: 'Abschließen',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function laufstand(schritte: Schrittstand[], titelJeGruppe: Record<string, string> = gruppenTitel): Laufstand {
|
||||||
|
const nachReihenfolge = [...schritte].sort((a, b) => a.reihenfolge - b.reihenfolge);
|
||||||
|
|
||||||
|
const gruppen: Gruppenstand[] = [];
|
||||||
|
for (const gruppeId of gruppenReihenfolge) {
|
||||||
|
const eigene = nachReihenfolge.filter((s) => s.gruppeId === gruppeId);
|
||||||
|
if (eigene.length === 0) continue;
|
||||||
|
|
||||||
|
const erledigt = eigene.filter((s) => s.erledigt).length;
|
||||||
|
gruppen.push({
|
||||||
|
gruppeId,
|
||||||
|
titel: titelJeGruppe[gruppeId] ?? gruppeId,
|
||||||
|
schritte: eigene,
|
||||||
|
erledigt,
|
||||||
|
gesamt: eigene.length,
|
||||||
|
prozent: anteil(erledigt, eigene.length),
|
||||||
|
vollstaendig: erledigt === eigene.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gruppen, die nicht zur festen Reihenfolge gehören, hängen hinten an –
|
||||||
|
// damit geht bei einer erweiterten Vorlage nichts verloren.
|
||||||
|
const bekannte = new Set<string>(gruppenReihenfolge);
|
||||||
|
for (const schritt of nachReihenfolge) {
|
||||||
|
if (bekannte.has(schritt.gruppeId)) continue;
|
||||||
|
bekannte.add(schritt.gruppeId);
|
||||||
|
const eigene = nachReihenfolge.filter((s) => s.gruppeId === schritt.gruppeId);
|
||||||
|
const erledigt = eigene.filter((s) => s.erledigt).length;
|
||||||
|
gruppen.push({
|
||||||
|
gruppeId: schritt.gruppeId,
|
||||||
|
titel: titelJeGruppe[schritt.gruppeId] ?? schritt.gruppeId,
|
||||||
|
schritte: eigene,
|
||||||
|
erledigt,
|
||||||
|
gesamt: eigene.length,
|
||||||
|
prozent: anteil(erledigt, eigene.length),
|
||||||
|
vollstaendig: erledigt === eigene.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const erledigt = nachReihenfolge.filter((s) => s.erledigt).length;
|
||||||
|
const gesamt = nachReihenfolge.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
gruppen,
|
||||||
|
erledigt,
|
||||||
|
gesamt,
|
||||||
|
prozent: anteil(erledigt, gesamt),
|
||||||
|
vollstaendig: gesamt > 0 && erledigt === gesamt,
|
||||||
|
naechster: nachReihenfolge.find((s) => !s.erledigt) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anteil in Prozent.
|
||||||
|
*
|
||||||
|
* Wie beim Wochenfortschritt: nie 100, solange etwas offen ist, und nie 0,
|
||||||
|
* sobald etwas erledigt wurde. Ein voller Balken mit offenem Schritt sieht
|
||||||
|
* nach einem Fehler aus.
|
||||||
|
*/
|
||||||
|
export function anteil(erledigt: number, gesamt: number): number {
|
||||||
|
if (gesamt <= 0) return 0;
|
||||||
|
if (erledigt <= 0) return 0;
|
||||||
|
if (erledigt >= gesamt) return 100;
|
||||||
|
return Math.min(99, Math.max(1, Math.round((erledigt / gesamt) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Darf der Durchlauf abgeschlossen werden? */
|
||||||
|
export function darfAbschliessen(stand: Laufstand): boolean {
|
||||||
|
return stand.vollstaendig;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Baustein 6: Checklisten-Durchläufe.
|
||||||
|
--
|
||||||
|
-- Die Checkliste selbst ist eine Vorlage und liegt als Datei unter
|
||||||
|
-- content/checklisten/. Hier steht, wer sie wann für welchen Vorgang
|
||||||
|
-- abgearbeitet hat – das ist der Nachweis, der bleiben muss.
|
||||||
|
|
||||||
|
CREATE TABLE checkliste_lauf (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
|
||||||
|
checkliste_id TEXT NOT NULL,
|
||||||
|
-- Der Titel wird beim Start aus der Vorlage kopiert. Bewusst redundant:
|
||||||
|
-- Ein abgeschlossener Durchlauf muss auch dann noch lesbar sein, wenn die
|
||||||
|
-- Vorlage im Repository später umbenannt oder gelöscht wird.
|
||||||
|
titel TEXT NOT NULL,
|
||||||
|
datum TEXT NOT NULL,
|
||||||
|
ticket TEXT,
|
||||||
|
kunde TEXT,
|
||||||
|
bemerkung TEXT,
|
||||||
|
gestartet_am TEXT NOT NULL,
|
||||||
|
abgeschlossen_am TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX checkliste_lauf_benutzer_idx ON checkliste_lauf (benutzer_id, datum);
|
||||||
|
CREATE INDEX checkliste_lauf_vorlage_idx ON checkliste_lauf (checkliste_id);
|
||||||
|
|
||||||
|
CREATE TABLE checkliste_schritt (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
lauf_id TEXT NOT NULL REFERENCES checkliste_lauf (id) ON DELETE CASCADE,
|
||||||
|
gruppe_id TEXT NOT NULL,
|
||||||
|
schritt_id TEXT NOT NULL,
|
||||||
|
-- Auch der Schritttext wird kopiert, aus demselben Grund wie der Titel.
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
reihenfolge INTEGER NOT NULL,
|
||||||
|
erledigt INTEGER NOT NULL DEFAULT 0 CHECK (erledigt IN (0, 1)),
|
||||||
|
bemerkung TEXT,
|
||||||
|
erledigt_am TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX checkliste_schritt_idx ON checkliste_schritt (lauf_id, schritt_id);
|
||||||
|
CREATE INDEX checkliste_schritt_lauf_idx ON checkliste_schritt (lauf_id, reihenfolge);
|
||||||
@@ -174,6 +174,49 @@ export const anhang = sqliteTable(
|
|||||||
(t) => [index('anhang_bezug_idx').on(t.bezugTyp, t.bezugId), index('anhang_benutzer_idx').on(t.benutzerId)],
|
(t) => [index('anhang_bezug_idx').on(t.bezugTyp, t.bezugId), index('anhang_benutzer_idx').on(t.benutzerId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const checklisteLauf = sqliteTable(
|
||||||
|
'checkliste_lauf',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
benutzerId: text('benutzer_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => benutzer.id, { onDelete: 'cascade' }),
|
||||||
|
checklisteId: text('checkliste_id').notNull(),
|
||||||
|
titel: text('titel').notNull(),
|
||||||
|
datum: text('datum').notNull(),
|
||||||
|
ticket: text('ticket'),
|
||||||
|
kunde: text('kunde'),
|
||||||
|
bemerkung: text('bemerkung'),
|
||||||
|
gestartetAm: text('gestartet_am').notNull(),
|
||||||
|
abgeschlossenAm: text('abgeschlossen_am'),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('checkliste_lauf_benutzer_idx').on(t.benutzerId, t.datum),
|
||||||
|
index('checkliste_lauf_vorlage_idx').on(t.checklisteId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const checklisteSchritt = sqliteTable(
|
||||||
|
'checkliste_schritt',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
laufId: text('lauf_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => checklisteLauf.id, { onDelete: 'cascade' }),
|
||||||
|
gruppeId: text('gruppe_id').notNull(),
|
||||||
|
schrittId: text('schritt_id').notNull(),
|
||||||
|
text: text('text').notNull(),
|
||||||
|
reihenfolge: integer('reihenfolge').notNull(),
|
||||||
|
erledigt: integer('erledigt').notNull().default(0),
|
||||||
|
bemerkung: text('bemerkung'),
|
||||||
|
erledigtAm: text('erledigt_am'),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('checkliste_schritt_idx').on(t.laufId, t.schrittId),
|
||||||
|
index('checkliste_schritt_lauf_idx').on(t.laufId, t.reihenfolge),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
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;
|
||||||
@@ -183,6 +226,8 @@ 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 AufgabeStatus = typeof aufgabeStatus.$inferSelect;
|
||||||
export type Anhang = typeof anhang.$inferSelect;
|
export type Anhang = typeof anhang.$inferSelect;
|
||||||
|
export type ChecklisteLauf = typeof checklisteLauf.$inferSelect;
|
||||||
|
export type ChecklisteSchritt = typeof checklisteSchritt.$inferSelect;
|
||||||
|
|
||||||
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
|
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
|
||||||
export const alleTabellen = {
|
export const alleTabellen = {
|
||||||
@@ -195,4 +240,6 @@ export const alleTabellen = {
|
|||||||
kartenVerlauf,
|
kartenVerlauf,
|
||||||
aufgabeStatus,
|
aufgabeStatus,
|
||||||
anhang,
|
anhang,
|
||||||
|
checklisteLauf,
|
||||||
|
checklisteSchritt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { checklistenPdf, type Laufangaben } from './checkliste';
|
||||||
|
import { fuerPdf } from './grundlage';
|
||||||
|
import { laufstand, type Schrittstand } from '../checklisten/fortschritt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein PDF lässt sich nicht sinnvoll Pixel für Pixel prüfen. Was sich prüfen
|
||||||
|
* lässt und wehtut, wenn es kaputtgeht: Ist es ein gültiges PDF, hat es die
|
||||||
|
* erwartete Seitenzahl, und stehen keine leeren Seiten darin?
|
||||||
|
*/
|
||||||
|
|
||||||
|
function seitenzahl(pdf: Buffer): number {
|
||||||
|
// Die Seitenobjekte stehen unkomprimiert im Dokument.
|
||||||
|
return (pdf.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schritt(gruppeId: string, nummer: number, erledigt = true, bemerkung: string | null = null): Schrittstand {
|
||||||
|
return {
|
||||||
|
gruppeId,
|
||||||
|
schrittId: `s${nummer}`,
|
||||||
|
text: `Schritt ${nummer}: etwas tun`,
|
||||||
|
reihenfolge: nummer,
|
||||||
|
erledigt,
|
||||||
|
bemerkung,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const angaben: Laufangaben = {
|
||||||
|
titel: 'Windows 11 installieren und übergeben',
|
||||||
|
checklisteId: '02-windows-installieren',
|
||||||
|
bearbeiter: 'Jonas Berger',
|
||||||
|
datum: '10.09.2026',
|
||||||
|
ticket: '4711',
|
||||||
|
kunde: 'Buchhaltung, Platz 12',
|
||||||
|
bemerkung: 'Gerät lief auffällig langsam – alter Chipsatztreiber.',
|
||||||
|
gestartetAm: '10.09.2026, 08:15',
|
||||||
|
abgeschlossenAm: '10.09.2026, 11:40',
|
||||||
|
worum: 'Von der gesicherten Ausgangslage bis zum unterschriebenen Übergabeprotokoll.',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Checklisten-PDF', () => {
|
||||||
|
it('erzeugt ein gültiges PDF', async () => {
|
||||||
|
const stand = laufstand([schritt('vorbereiten', 1), schritt('durchfuehren', 2)]);
|
||||||
|
const pdf = await checklistenPdf(angaben, stand);
|
||||||
|
|
||||||
|
expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-');
|
||||||
|
expect(pdf.byteLength).toBeGreaterThan(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legt keine leeren Seiten an – die Fußzeile darf keine Seite erzeugen', async () => {
|
||||||
|
// Der Fehler, den dieser Test verhindert: Die Fußzeile wird unterhalb des
|
||||||
|
// Satzspiegels gesetzt. Ohne aufgehobenen unteren Rand hält PDFKit das für
|
||||||
|
// überlaufenden Text und hängt je Fußzeile eine leere Seite an.
|
||||||
|
const stand = laufstand([schritt('vorbereiten', 1), schritt('durchfuehren', 2)]);
|
||||||
|
const pdf = await checklistenPdf(angaben, stand);
|
||||||
|
|
||||||
|
expect(seitenzahl(pdf)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bleibt auch bei vielen Schritten bei einer nachvollziehbaren Seitenzahl', async () => {
|
||||||
|
const viele = Array.from({ length: 60 }, (_, i) =>
|
||||||
|
schritt(['vorbereiten', 'durchfuehren', 'pruefen', 'abschliessen'][i % 4]!, i + 1),
|
||||||
|
);
|
||||||
|
const pdf = await checklistenPdf(angaben, laufstand(viele));
|
||||||
|
|
||||||
|
const seiten = seitenzahl(pdf);
|
||||||
|
expect(seiten).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(seiten).toBeLessThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kommt mit einem leeren Durchlauf zurecht', async () => {
|
||||||
|
const pdf = await checklistenPdf({ ...angaben, bemerkung: null, ticket: null, kunde: null }, laufstand([]));
|
||||||
|
expect(seitenzahl(pdf)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verkraftet sehr lange Schritttexte und Bemerkungen', async () => {
|
||||||
|
const lang = laufstand([
|
||||||
|
{
|
||||||
|
gruppeId: 'durchfuehren',
|
||||||
|
schrittId: 's1',
|
||||||
|
text: 'Sehr langer Schritt. '.repeat(40),
|
||||||
|
reihenfolge: 1,
|
||||||
|
erledigt: true,
|
||||||
|
bemerkung: 'Eine ebenfalls sehr lange Bemerkung. '.repeat(30),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const pdf = await checklistenPdf(angaben, lang);
|
||||||
|
expect(seitenzahl(pdf)).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(pdf.byteLength).toBeGreaterThan(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Zeichenvorrat für das PDF', () => {
|
||||||
|
it('lässt deutsche Umlaute und ß unangetastet', () => {
|
||||||
|
expect(fuerPdf('Übergabeprotokoll für Größe, Maß und Fußzeile – Straße')).toBe(
|
||||||
|
'Übergabeprotokoll für Größe, Maß und Fußzeile – Straße',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt Euro, Paragraf und Grad stehen', () => {
|
||||||
|
expect(fuerPdf('120 € nach § 5, bei 50 °C')).toBe('120 € nach § 5, bei 50 °C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ersetzt typografische Anführungszeichen durch gerade', () => {
|
||||||
|
expect(fuerPdf('„so" und »so« und ‚so’')).toBe('"so" und "so" und \'so\'');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ersetzt Zeichen, die die Standardschriften nicht kennen', () => {
|
||||||
|
expect(fuerPdf('Haken ✓ Kreis ○ Dreieck ▲ Punkt •')).toBe('Haken x Kreis - Dreieck - Punkt -');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('macht aus Auslassungspunkten drei Punkte', () => {
|
||||||
|
expect(fuerPdf('und so weiter …')).toBe('und so weiter ...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('führt geschützte Leerzeichen auf gewöhnliche zurück', () => {
|
||||||
|
expect(fuerPdf('5 MB')).toBe('5 MB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft alles weg, was gar nicht darstellbar wäre', () => {
|
||||||
|
expect(fuerPdf('Text mit Emoji 🙂 und Pfeil →')).toBe('Text mit Emoji und Pfeil ->');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behält Zeilenumbrüche', () => {
|
||||||
|
expect(fuerPdf('Zeile eins\nZeile zwei')).toBe('Zeile eins\nZeile zwei');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
abschnitt,
|
||||||
|
alsPuffer,
|
||||||
|
angabe,
|
||||||
|
FARBEN,
|
||||||
|
fuerPdf,
|
||||||
|
fusszeilen,
|
||||||
|
INHALTSBREITE,
|
||||||
|
kaestchen,
|
||||||
|
kopf,
|
||||||
|
neuesDokument,
|
||||||
|
platzPruefen,
|
||||||
|
SEITE,
|
||||||
|
unterschriften,
|
||||||
|
type Dokument,
|
||||||
|
} from './grundlage';
|
||||||
|
import type { Laufstand } from '../checklisten/fortschritt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein abgeschlossener Checklisten-Durchlauf als PDF.
|
||||||
|
*
|
||||||
|
* Zweck ist der Nachweis: Wer hat wann welche Schritte abgearbeitet, mit
|
||||||
|
* welchen Bemerkungen. Das Blatt soll ausgedruckt und abgeheftet werden können.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Laufangaben = {
|
||||||
|
titel: string;
|
||||||
|
checklisteId: string;
|
||||||
|
bearbeiter: string;
|
||||||
|
datum: string;
|
||||||
|
ticket: string | null;
|
||||||
|
kunde: string | null;
|
||||||
|
bemerkung: string | null;
|
||||||
|
gestartetAm: string;
|
||||||
|
abgeschlossenAm: string | null;
|
||||||
|
worum?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function checklistenPdf(angaben: Laufangaben, stand: Laufstand): Promise<Buffer> {
|
||||||
|
const dokument = neuesDokument(`Checkliste ${angaben.titel}`, angaben.bearbeiter);
|
||||||
|
|
||||||
|
kopf(dokument, {
|
||||||
|
titel: fuerPdf(angaben.titel),
|
||||||
|
untertitel: angaben.worum ? fuerPdf(angaben.worum) : undefined,
|
||||||
|
rechts: fuerPdf(angaben.bearbeiter),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Kopfangaben ---
|
||||||
|
angabe(dokument, 'Datum', fuerPdf(angaben.datum));
|
||||||
|
angabe(dokument, 'Bearbeiter', fuerPdf(angaben.bearbeiter));
|
||||||
|
if (angaben.ticket) angabe(dokument, 'Ticket', fuerPdf(angaben.ticket));
|
||||||
|
if (angaben.kunde) angabe(dokument, 'Kunde / Bereich', fuerPdf(angaben.kunde));
|
||||||
|
angabe(dokument, 'Begonnen', fuerPdf(angaben.gestartetAm));
|
||||||
|
angabe(
|
||||||
|
dokument,
|
||||||
|
'Abgeschlossen',
|
||||||
|
angaben.abgeschlossenAm ? fuerPdf(angaben.abgeschlossenAm) : 'noch offen',
|
||||||
|
);
|
||||||
|
angabe(dokument, 'Stand', `${stand.erledigt} von ${stand.gesamt} Schritten erledigt`);
|
||||||
|
|
||||||
|
// --- Gruppen mit Schritten ---
|
||||||
|
for (const gruppe of stand.gruppen) {
|
||||||
|
abschnitt(dokument, fuerPdf(gruppe.titel), `${gruppe.erledigt} von ${gruppe.gesamt}`);
|
||||||
|
|
||||||
|
for (const schritt of gruppe.schritte) {
|
||||||
|
zeichneSchritt(dokument, schritt.text, schritt.erledigt, schritt.bemerkung);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bemerkung zum Durchlauf ---
|
||||||
|
if (angaben.bemerkung) {
|
||||||
|
abschnitt(dokument, 'Bemerkung zum Durchlauf');
|
||||||
|
platzPruefen(dokument, 40);
|
||||||
|
dokument
|
||||||
|
.font('Helvetica')
|
||||||
|
.fontSize(10)
|
||||||
|
.fillColor(FARBEN.text)
|
||||||
|
.text(fuerPdf(angaben.bemerkung), SEITE.rand, dokument.y, { width: INHALTSBREITE, align: 'left' });
|
||||||
|
dokument.x = SEITE.rand;
|
||||||
|
}
|
||||||
|
|
||||||
|
unterschriften(dokument, ['Datum, Unterschrift Bearbeiter', 'Datum, Unterschrift Ausbilder']);
|
||||||
|
|
||||||
|
fusszeilen(
|
||||||
|
dokument,
|
||||||
|
fuerPdf(`Lern-Bar - Checkliste ${angaben.checklisteId} - ${angaben.titel} - ${angaben.datum}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
return alsPuffer(dokument);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeichneSchritt(dokument: Dokument, text: string, erledigt: boolean, bemerkung: string | null): void {
|
||||||
|
const textBreite = INHALTSBREITE - 20;
|
||||||
|
const sauber = fuerPdf(text);
|
||||||
|
|
||||||
|
dokument.font('Helvetica').fontSize(10);
|
||||||
|
const hoehe = dokument.heightOfString(sauber, { width: textBreite });
|
||||||
|
const bemerkungHoehe = bemerkung
|
||||||
|
? dokument.font('Helvetica-Oblique').fontSize(9).heightOfString(fuerPdf(bemerkung), { width: textBreite - 10 }) + 3
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
platzPruefen(dokument, hoehe + bemerkungHoehe + 8);
|
||||||
|
|
||||||
|
const y = dokument.y;
|
||||||
|
kaestchen(dokument, SEITE.rand + 1, y + 1.5, erledigt);
|
||||||
|
|
||||||
|
dokument
|
||||||
|
.font('Helvetica')
|
||||||
|
.fontSize(10)
|
||||||
|
.fillColor(erledigt ? FARBEN.text : FARBEN.leise)
|
||||||
|
.text(sauber, SEITE.rand + 20, y, { width: textBreite });
|
||||||
|
|
||||||
|
if (bemerkung) {
|
||||||
|
dokument
|
||||||
|
.font('Helvetica-Oblique')
|
||||||
|
.fontSize(9)
|
||||||
|
.fillColor(FARBEN.leise)
|
||||||
|
.text(fuerPdf(bemerkung), SEITE.rand + 30, dokument.y + 1, { width: textBreite - 10 });
|
||||||
|
}
|
||||||
|
|
||||||
|
dokument.x = SEITE.rand;
|
||||||
|
dokument.y += 5;
|
||||||
|
dokument.fillColor(FARBEN.text);
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsame Grundlage für alle PDF-Ausgaben.
|
||||||
|
*
|
||||||
|
* Zur Schriftwahl: Verwendet werden die in jedem PDF-Betrachter vorhandenen
|
||||||
|
* Standardschriften (Helvetica). Das spart eine eingebettete Schriftdatei und
|
||||||
|
* hält die Ausgabe klein. Der Preis: Diese Schriften können nur den
|
||||||
|
* WinAnsi-Zeichenvorrat. Deutsche Umlaute, ß, €, § und ° sind darin enthalten –
|
||||||
|
* Zeichen wie ✓, ○ oder ▲ **nicht**. Häkchen und Kästchen werden deshalb
|
||||||
|
* gezeichnet, nicht gesetzt. Siehe `kaestchen()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const FARBEN = {
|
||||||
|
petrol: '#1F4E5F',
|
||||||
|
bernstein: '#D98A2B',
|
||||||
|
text: '#16242A',
|
||||||
|
leise: '#4D646D',
|
||||||
|
linie: '#C8D8DE',
|
||||||
|
flaeche: '#EDF3F5',
|
||||||
|
gut: '#1D6F4A',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const SEITE = {
|
||||||
|
breite: 595.28, // A4 in Punkt
|
||||||
|
hoehe: 841.89,
|
||||||
|
rand: 45,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const INHALTSBREITE = SEITE.breite - 2 * SEITE.rand;
|
||||||
|
|
||||||
|
export type Dokument = PDFKit.PDFDocument;
|
||||||
|
|
||||||
|
export type Kopfangaben = {
|
||||||
|
titel: string;
|
||||||
|
untertitel?: string;
|
||||||
|
/** Rechts oben, z. B. der Name des Azubis. */
|
||||||
|
rechts?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Legt ein neues Dokument an. */
|
||||||
|
export function neuesDokument(titel: string, verfasser: string): Dokument {
|
||||||
|
return new PDFDocument({
|
||||||
|
size: 'A4',
|
||||||
|
margins: { top: SEITE.rand, bottom: SEITE.rand + 22, left: SEITE.rand, right: SEITE.rand },
|
||||||
|
info: {
|
||||||
|
Title: titel,
|
||||||
|
Author: verfasser,
|
||||||
|
Creator: 'Lern-Bar',
|
||||||
|
Producer: 'Lern-Bar',
|
||||||
|
CreationDate: new Date(),
|
||||||
|
},
|
||||||
|
autoFirstPage: true,
|
||||||
|
bufferPages: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sammelt das fertige Dokument in einen Puffer. */
|
||||||
|
export function alsPuffer(dokument: Dokument): Promise<Buffer> {
|
||||||
|
return new Promise((erfuellen, ablehnen) => {
|
||||||
|
const teile: Buffer[] = [];
|
||||||
|
dokument.on('data', (stueck: Buffer) => teile.push(stueck));
|
||||||
|
dokument.on('end', () => erfuellen(Buffer.concat(teile)));
|
||||||
|
dokument.on('error', ablehnen);
|
||||||
|
dokument.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kopfzeile mit Titel, Untertitel und farbigem Balken. */
|
||||||
|
export function kopf(dokument: Dokument, angaben: Kopfangaben): void {
|
||||||
|
const oben = SEITE.rand;
|
||||||
|
|
||||||
|
dokument
|
||||||
|
.font('Helvetica-Bold')
|
||||||
|
.fontSize(17)
|
||||||
|
.fillColor(FARBEN.petrol)
|
||||||
|
.text(angaben.titel, SEITE.rand, oben, { width: INHALTSBREITE - 150 });
|
||||||
|
|
||||||
|
if (angaben.rechts) {
|
||||||
|
dokument
|
||||||
|
.font('Helvetica')
|
||||||
|
.fontSize(10)
|
||||||
|
.fillColor(FARBEN.leise)
|
||||||
|
.text(angaben.rechts, SEITE.breite - SEITE.rand - 150, oben + 4, { width: 150, align: 'right' });
|
||||||
|
}
|
||||||
|
|
||||||
|
dokument.x = SEITE.rand;
|
||||||
|
|
||||||
|
if (angaben.untertitel) {
|
||||||
|
dokument
|
||||||
|
.font('Helvetica')
|
||||||
|
.fontSize(10.5)
|
||||||
|
.fillColor(FARBEN.leise)
|
||||||
|
.text(angaben.untertitel, SEITE.rand, dokument.y + 2, { width: INHALTSBREITE });
|
||||||
|
}
|
||||||
|
|
||||||
|
const linieY = dokument.y + 8;
|
||||||
|
dokument
|
||||||
|
.moveTo(SEITE.rand, linieY)
|
||||||
|
.lineTo(SEITE.breite - SEITE.rand, linieY)
|
||||||
|
.lineWidth(2)
|
||||||
|
.strokeColor(FARBEN.bernstein)
|
||||||
|
.stroke();
|
||||||
|
|
||||||
|
dokument.y = linieY + 14;
|
||||||
|
dokument.x = SEITE.rand;
|
||||||
|
dokument.fillColor(FARBEN.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zwischenüberschrift. */
|
||||||
|
export function abschnitt(dokument: Dokument, titel: string, zusatz?: string): void {
|
||||||
|
platzPruefen(dokument, 40);
|
||||||
|
dokument.moveDown(0.6);
|
||||||
|
|
||||||
|
const y = dokument.y;
|
||||||
|
dokument.font('Helvetica-Bold').fontSize(11.5).fillColor(FARBEN.petrol).text(titel, SEITE.rand, y);
|
||||||
|
|
||||||
|
if (zusatz) {
|
||||||
|
dokument
|
||||||
|
.font('Helvetica')
|
||||||
|
.fontSize(9.5)
|
||||||
|
.fillColor(FARBEN.leise)
|
||||||
|
.text(zusatz, SEITE.breite - SEITE.rand - 180, y + 1, { width: 180, align: 'right' });
|
||||||
|
}
|
||||||
|
|
||||||
|
dokument.x = SEITE.rand;
|
||||||
|
dokument.y = Math.max(dokument.y, y + 15);
|
||||||
|
dokument
|
||||||
|
.moveTo(SEITE.rand, dokument.y)
|
||||||
|
.lineTo(SEITE.breite - SEITE.rand, dokument.y)
|
||||||
|
.lineWidth(0.75)
|
||||||
|
.strokeColor(FARBEN.linie)
|
||||||
|
.stroke();
|
||||||
|
dokument.y += 8;
|
||||||
|
dokument.fillColor(FARBEN.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Zeile aus Beschriftung und Wert. */
|
||||||
|
export function angabe(dokument: Dokument, beschriftung: string, wert: string, breite = INHALTSBREITE): void {
|
||||||
|
platzPruefen(dokument, 18);
|
||||||
|
const y = dokument.y;
|
||||||
|
|
||||||
|
dokument.font('Helvetica').fontSize(9).fillColor(FARBEN.leise).text(beschriftung, SEITE.rand, y, { width: 110 });
|
||||||
|
dokument
|
||||||
|
.font('Helvetica')
|
||||||
|
.fontSize(10.5)
|
||||||
|
.fillColor(FARBEN.text)
|
||||||
|
.text(wert || '–', SEITE.rand + 115, y - 1, { width: breite - 115 });
|
||||||
|
|
||||||
|
dokument.x = SEITE.rand;
|
||||||
|
dokument.y = Math.max(dokument.y, y + 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Ankreuzkästchen mit Häkchen.
|
||||||
|
*
|
||||||
|
* Gezeichnet statt gesetzt, weil die Standardschriften kein Häkchen kennen –
|
||||||
|
* und weil ein gezeichnetes Kästchen in jedem Betrachter gleich aussieht.
|
||||||
|
*/
|
||||||
|
export function kaestchen(dokument: Dokument, x: number, y: number, angehakt: boolean, groesse = 9): void {
|
||||||
|
dokument
|
||||||
|
.roundedRect(x, y, groesse, groesse, 1.5)
|
||||||
|
.lineWidth(0.8)
|
||||||
|
.strokeColor(angehakt ? FARBEN.gut : FARBEN.linie)
|
||||||
|
.stroke();
|
||||||
|
|
||||||
|
if (angehakt) {
|
||||||
|
dokument
|
||||||
|
.moveTo(x + groesse * 0.22, y + groesse * 0.52)
|
||||||
|
.lineTo(x + groesse * 0.42, y + groesse * 0.74)
|
||||||
|
.lineTo(x + groesse * 0.8, y + groesse * 0.24)
|
||||||
|
.lineWidth(1.4)
|
||||||
|
.strokeColor(FARBEN.gut)
|
||||||
|
.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unterschriftenzeilen nebeneinander. */
|
||||||
|
export function unterschriften(dokument: Dokument, beschriftungen: string[]): void {
|
||||||
|
platzPruefen(dokument, 70);
|
||||||
|
dokument.moveDown(1.6);
|
||||||
|
|
||||||
|
const spalte = (INHALTSBREITE - (beschriftungen.length - 1) * 20) / beschriftungen.length;
|
||||||
|
const y = dokument.y + 24;
|
||||||
|
|
||||||
|
beschriftungen.forEach((beschriftung, stelle) => {
|
||||||
|
const x = SEITE.rand + stelle * (spalte + 20);
|
||||||
|
dokument.moveTo(x, y).lineTo(x + spalte, y).lineWidth(0.75).strokeColor(FARBEN.leise).stroke();
|
||||||
|
dokument.font('Helvetica').fontSize(8.5).fillColor(FARBEN.leise).text(beschriftung, x, y + 4, { width: spalte });
|
||||||
|
});
|
||||||
|
|
||||||
|
dokument.x = SEITE.rand;
|
||||||
|
dokument.y = y + 20;
|
||||||
|
dokument.fillColor(FARBEN.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sorgt dafür, dass noch genug Platz auf der Seite ist – sonst neue Seite. */
|
||||||
|
export function platzPruefen(dokument: Dokument, benoetigt: number): void {
|
||||||
|
const unterkante = SEITE.hoehe - SEITE.rand - 30;
|
||||||
|
if (dokument.y + benoetigt > unterkante) {
|
||||||
|
dokument.addPage();
|
||||||
|
dokument.x = SEITE.rand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fußzeile auf jeder Seite: Herkunft links, Seitenzahl rechts.
|
||||||
|
* Wird ganz am Ende aufgerufen, wenn die Seitenzahl feststeht.
|
||||||
|
*/
|
||||||
|
export function fusszeilen(dokument: Dokument, links: string): void {
|
||||||
|
const bereich = dokument.bufferedPageRange();
|
||||||
|
|
||||||
|
for (let n = 0; n < bereich.count; n += 1) {
|
||||||
|
dokument.switchToPage(bereich.start + n);
|
||||||
|
|
||||||
|
// Die Fußzeile steht unterhalb des Satzspiegels. Ohne diesen Kniff hält
|
||||||
|
// PDFKit das für überlaufenden Text und legt je Fußzeile eine neue – leere –
|
||||||
|
// Seite an. Der untere Rand wird deshalb kurz aufgehoben.
|
||||||
|
const untererRand = dokument.page.margins.bottom;
|
||||||
|
dokument.page.margins.bottom = 0;
|
||||||
|
|
||||||
|
const y = SEITE.hoehe - SEITE.rand - 12;
|
||||||
|
dokument
|
||||||
|
.moveTo(SEITE.rand, y - 6)
|
||||||
|
.lineTo(SEITE.breite - SEITE.rand, y - 6)
|
||||||
|
.lineWidth(0.5)
|
||||||
|
.strokeColor(FARBEN.linie)
|
||||||
|
.stroke();
|
||||||
|
|
||||||
|
dokument.font('Helvetica').fontSize(8).fillColor(FARBEN.leise);
|
||||||
|
dokument.text(links, SEITE.rand, y, { width: INHALTSBREITE - 80, lineBreak: false });
|
||||||
|
dokument.text(`Seite ${n + 1} von ${bereich.count}`, SEITE.breite - SEITE.rand - 80, y, {
|
||||||
|
width: 80,
|
||||||
|
align: 'right',
|
||||||
|
lineBreak: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
dokument.page.margins.bottom = untererRand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ersetzt Zeichen, die die Standardschriften nicht kennen.
|
||||||
|
*
|
||||||
|
* Der Zeichenvorrat ist WinAnsi. Was darin fehlt, würde sonst als leeres
|
||||||
|
* Rechteck oder gar nicht erscheinen – am ehesten trifft das typografische
|
||||||
|
* Feinheiten aus den Inhaltsdateien.
|
||||||
|
*/
|
||||||
|
export function fuerPdf(text: string): string {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
.replace(/[\u2018\u2019\u201a\u2039\u203a]/g, "'")
|
||||||
|
.replace(/[\u201c\u201d\u201e\u00ab\u00bb]/g, '"')
|
||||||
|
.replace(/[\u2010\u2011\u2012\u2015]/g, '-')
|
||||||
|
.replace(/\u2026/g, '...')
|
||||||
|
.replace(/[\u2713\u2714]/g, 'x')
|
||||||
|
.replace(/[\u25cb\u25cf\u25a0\u25a1\u25b2\u25ba\u2022]/g, '-')
|
||||||
|
// Geschützte und schmale Leerzeichen auf ein gewöhnliches zurückführen.
|
||||||
|
.replace(/[\u00a0\u2007\u2009\u202f]/g, ' ')
|
||||||
|
.replace(/[\u2190-\u21ff]/g, '->')
|
||||||
|
// Was jetzt noch übrig ist und nicht in WinAnsi steht, fliegt raus.
|
||||||
|
// WinAnsi = Latin-1 plus Gedankenstriche und Eurozeichen.
|
||||||
|
.replace(/[^\u0020-\u00ff\u2013\u2014\u20ac\n\t]/g, '')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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 { anzahlAbgenommen } from '../repos/aufgaben';
|
||||||
|
import { anzahlAbgeschlossen } from '../repos/checklisten';
|
||||||
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';
|
||||||
@@ -80,7 +81,10 @@ export function wochensicht(ausbildungId: string, azubiId: string, wochenNummer:
|
|||||||
aufgaben: { erledigt: anzahlAbgenommen(azubiId, aufgaben.map((a) => a.id)), gesamt: aufgaben.length },
|
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.
|
||||||
checklisten: { erledigt: 0, gesamt: checklisten.length },
|
checklisten: {
|
||||||
|
erledigt: anzahlAbgeschlossen(azubiId, checklisten.map((c) => c.id)),
|
||||||
|
gesamt: checklisten.length,
|
||||||
|
},
|
||||||
kontrolle: { erledigt: 0, gesamt: kontrolle ? 1 : 0 },
|
kontrolle: { erledigt: 0, gesamt: kontrolle ? 1 : 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
|
import { datenbank, schema } from '../db';
|
||||||
|
import { heute, jetzt } from '../zeit';
|
||||||
|
import { laufstand, type Laufstand, type Schrittstand } from '../checklisten/fortschritt';
|
||||||
|
import type { Checkliste } from '../inhalte/schema';
|
||||||
|
import type { ChecklisteLauf, ChecklisteSchritt } from '../db/schema';
|
||||||
|
|
||||||
|
/** Checklisten-Durchläufe. Die Vorlage kommt aus der Datei, der Lauf aus der Datenbank. */
|
||||||
|
|
||||||
|
export function laeufeVon(benutzerId: string): ChecklisteLauf[] {
|
||||||
|
return datenbank()
|
||||||
|
.select()
|
||||||
|
.from(schema.checklisteLauf)
|
||||||
|
.where(eq(schema.checklisteLauf.benutzerId, benutzerId))
|
||||||
|
.orderBy(desc(schema.checklisteLauf.datum), desc(schema.checklisteLauf.gestartetAm))
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function laufNachId(id: string): ChecklisteLauf | null {
|
||||||
|
return datenbank().select().from(schema.checklisteLauf).where(eq(schema.checklisteLauf.id, id)).get() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function schritteVon(laufId: string): ChecklisteSchritt[] {
|
||||||
|
return datenbank()
|
||||||
|
.select()
|
||||||
|
.from(schema.checklisteSchritt)
|
||||||
|
.where(eq(schema.checklisteSchritt.laufId, laufId))
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rechnet den Stand eines Durchlaufs aus. */
|
||||||
|
export function standVon(laufId: string, titelJeGruppe?: Record<string, string>): Laufstand {
|
||||||
|
const schritte: Schrittstand[] = schritteVon(laufId).map((z) => ({
|
||||||
|
gruppeId: z.gruppeId,
|
||||||
|
schrittId: z.schrittId,
|
||||||
|
text: z.text,
|
||||||
|
reihenfolge: z.reihenfolge,
|
||||||
|
erledigt: z.erledigt === 1,
|
||||||
|
bemerkung: z.bemerkung,
|
||||||
|
}));
|
||||||
|
return laufstand(schritte, titelJeGruppe);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet einen Durchlauf aus einer Vorlage.
|
||||||
|
*
|
||||||
|
* Titel und Schritttexte werden kopiert, nicht verwiesen: Ein abgeschlossener
|
||||||
|
* Durchlauf ist ein Nachweis und muss auch dann noch lesbar sein, wenn die
|
||||||
|
* Vorlage im Repository später geändert wird.
|
||||||
|
*/
|
||||||
|
export function laufStarten(werte: {
|
||||||
|
benutzerId: string;
|
||||||
|
vorlage: Checkliste;
|
||||||
|
datum?: string;
|
||||||
|
ticket?: string | null;
|
||||||
|
kunde?: string | null;
|
||||||
|
}): ChecklisteLauf {
|
||||||
|
const db = datenbank();
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
const lauf: ChecklisteLauf = {
|
||||||
|
id,
|
||||||
|
benutzerId: werte.benutzerId,
|
||||||
|
checklisteId: werte.vorlage.id,
|
||||||
|
titel: werte.vorlage.titel,
|
||||||
|
datum: werte.datum || heute(),
|
||||||
|
ticket: werte.ticket?.trim() || null,
|
||||||
|
kunde: werte.kunde?.trim() || null,
|
||||||
|
bemerkung: null,
|
||||||
|
gestartetAm: jetzt(),
|
||||||
|
abgeschlossenAm: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const schritte: ChecklisteSchritt[] = [];
|
||||||
|
let reihenfolge = 0;
|
||||||
|
for (const gruppe of werte.vorlage.gruppen) {
|
||||||
|
for (const schritt of gruppe.schritte) {
|
||||||
|
schritte.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
laufId: id,
|
||||||
|
gruppeId: gruppe.id,
|
||||||
|
schrittId: schritt.id,
|
||||||
|
text: schritt.text,
|
||||||
|
reihenfolge: (reihenfolge += 1),
|
||||||
|
erledigt: 0,
|
||||||
|
bemerkung: null,
|
||||||
|
erledigtAm: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drizzle führt die Transaktion sofort aus – anders als better-sqlite3,
|
||||||
|
// wo `transaction()` erst eine aufrufbare Funktion zurückgibt.
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.insert(schema.checklisteLauf).values(lauf).run();
|
||||||
|
if (schritte.length > 0) tx.insert(schema.checklisteSchritt).values(schritte).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
return lauf;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function schrittSetzen(laufId: string, schrittId: string, erledigt: boolean): void {
|
||||||
|
datenbank()
|
||||||
|
.update(schema.checklisteSchritt)
|
||||||
|
.set({ erledigt: erledigt ? 1 : 0, erledigtAm: erledigt ? jetzt() : null })
|
||||||
|
.where(and(eq(schema.checklisteSchritt.laufId, laufId), eq(schema.checklisteSchritt.schrittId, schrittId)))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bemerkungSetzen(laufId: string, schrittId: string, bemerkung: string): void {
|
||||||
|
datenbank()
|
||||||
|
.update(schema.checklisteSchritt)
|
||||||
|
.set({ bemerkung: bemerkung.trim() || null })
|
||||||
|
.where(and(eq(schema.checklisteSchritt.laufId, laufId), eq(schema.checklisteSchritt.schrittId, schrittId)))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function laufBemerkung(laufId: string, bemerkung: string): void {
|
||||||
|
datenbank()
|
||||||
|
.update(schema.checklisteLauf)
|
||||||
|
.set({ bemerkung: bemerkung.trim() || null })
|
||||||
|
.where(eq(schema.checklisteLauf.id, laufId))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function laufKopfdaten(laufId: string, werte: { datum: string; ticket: string | null; kunde: string | null }): void {
|
||||||
|
datenbank()
|
||||||
|
.update(schema.checklisteLauf)
|
||||||
|
.set({ datum: werte.datum, ticket: werte.ticket?.trim() || null, kunde: werte.kunde?.trim() || null })
|
||||||
|
.where(eq(schema.checklisteLauf.id, laufId))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function laufAbschliessen(laufId: string): void {
|
||||||
|
datenbank()
|
||||||
|
.update(schema.checklisteLauf)
|
||||||
|
.set({ abgeschlossenAm: jetzt() })
|
||||||
|
.where(eq(schema.checklisteLauf.id, laufId))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function laufWiederOeffnen(laufId: string): void {
|
||||||
|
datenbank()
|
||||||
|
.update(schema.checklisteLauf)
|
||||||
|
.set({ abgeschlossenAm: null })
|
||||||
|
.where(eq(schema.checklisteLauf.id, laufId))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function laufLoeschen(laufId: string): void {
|
||||||
|
datenbank().delete(schema.checklisteLauf).where(eq(schema.checklisteLauf.id, laufId)).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie viele Durchläufe der genannten Vorlagen hat dieser Benutzer abgeschlossen? */
|
||||||
|
export function anzahlAbgeschlossen(benutzerId: string, checklistenIds: string[]): number {
|
||||||
|
if (checklistenIds.length === 0) return 0;
|
||||||
|
const laeufe = laeufeVon(benutzerId);
|
||||||
|
const erledigte = new Set(
|
||||||
|
laeufe.filter((l) => l.abgeschlossenAm !== null).map((l) => l.checklisteId),
|
||||||
|
);
|
||||||
|
return checklistenIds.filter((id) => erledigte.has(id)).length;
|
||||||
|
}
|
||||||