Baustein 3: Wochenplan als Startseite, Fortschritt, Wochen verschieben
- Wochenplan ist die Startseite des Azubis: Thema, Leitfrage, fuenf Tage mit Theorie und Praxis, Fortschrittsbalken, was noch offen ist - Ausbilder sieht je Azubi die laufende Woche mit Stand und offenen Punkten - /woche/<nr> fuer jede Woche, Ausbilder waehlt ueber ?azubi= den Stand; Zugriff wird serverseitig bei jedem Aufruf geprueft - Tabelle wochenplanung mit geplantem und tatsaechlichem Datum, Zustand und Notiz fuer den Azubi - Verschieben per Datumsfeld, wahlweise mit allen folgenden Wochen - Reihenfolge per Ziehen und Ablegen sowie per Tastatur (Alt + Pfeil) - Warnung bei sich ueberlappenden Terminen - Plan entsteht automatisch beim Anlegen eines Azubikontos - Planungs- und Fortschrittslogik als reine Funktionen, 36 Tests dafuer - Insgesamt 104 Tests Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@@ -26,6 +26,45 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [0.3.0] – 2026-09-10
|
||||||
|
|
||||||
|
Der Wochenplan wird zur Startseite, und der Ausbilder kann ihn umplanen.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Wochenplan als Startseite des Azubis**: Woche, Thema, Leitfrage, Wochenziel,
|
||||||
|
die fünf Tage mit Theorie- und Praxisteil, hervorgehobener heutiger Tag,
|
||||||
|
Fortschrittsbalken und eine Liste dessen, was noch offen ist
|
||||||
|
- Der Ausbilder sieht auf seiner Startseite je Azubi die laufende Woche mit
|
||||||
|
Fortschritt und offenen Punkten, mit Sprung in die Wocheneinzelansicht
|
||||||
|
- `/woche/<nr>` zeigt jede beliebige Woche; der Ausbilder wählt über `?azubi=`,
|
||||||
|
wessen Stand er sieht – geprüft wird das serverseitig bei jedem Aufruf
|
||||||
|
- Tabelle `wochenplanung` mit geplantem und tatsächlichem Datum, Zustand
|
||||||
|
(`geplant` / `laufend` / `abgeschlossen`) und einer Notiz für den Azubi
|
||||||
|
- Wochenplanseite für den Ausbilder:
|
||||||
|
- Plan aus den Wochen des Repositorys erzeugen und fehlende Wochen anhängen
|
||||||
|
- Woche auf ein neues Datum verlegen, wahlweise mit allen folgenden Wochen;
|
||||||
|
ein beliebiges Datum wird auf den Montag seiner Woche gerundet
|
||||||
|
- Reihenfolge per Ziehen und Ablegen ändern – die Terminfolge bleibt fest,
|
||||||
|
nur die Zuordnung wechselt, damit der Plan lückenlos bleibt
|
||||||
|
- Dasselbe mit der Tastatur über Alt + Pfeil hoch/runter, damit die Funktion
|
||||||
|
nicht auf die Maus angewiesen ist
|
||||||
|
- Zustand je Woche setzen; beim Start wird das tatsächliche Datum vermerkt
|
||||||
|
- Notiz je Woche, die der Azubi in seiner Wochenansicht sieht
|
||||||
|
- Warnung, wenn sich nach einem Einzelverschieben Termine überlappen
|
||||||
|
- Der Wochenplan entsteht automatisch, sobald ein Azubikonto angelegt wird
|
||||||
|
- Fortschrittsrechnung als reine Funktion über Zählwerte, damit sie mit den
|
||||||
|
weiteren Bausteinen wachsen kann, ohne sich zu verzweigen
|
||||||
|
- 36 Tests für Planerzeugung, Verschieben, Umsortieren, „welche Woche ist dran"
|
||||||
|
und die Fortschrittsrechnung
|
||||||
|
|
||||||
|
### Angemerkt
|
||||||
|
|
||||||
|
- Praxisaufgaben, Checklisten und Lernzielkontrollen erscheinen im
|
||||||
|
Fortschritt bereits mit ihrer Sollzahl, lassen sich aber erst mit den
|
||||||
|
Bausteinen 5 bis 8 abhaken. Das ist Absicht: Die Woche zeigt, was ansteht,
|
||||||
|
auch wenn die Anwendung es noch nicht mitzählen kann
|
||||||
|
|
||||||
## [0.2.0] – 2026-09-10
|
## [0.2.0] – 2026-09-10
|
||||||
|
|
||||||
Die Inhalte kommen ins Spiel: Handouts, Suche, Glossar – alles aus Dateien
|
Die Inhalte kommen ins Spiel: Handouts, Suche, Glossar – alles aus Dateien
|
||||||
@@ -105,5 +144,6 @@ 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.3.0]: #030--2026-09-10
|
||||||
[0.2.0]: #020--2026-09-10
|
[0.2.0]: #020--2026-09-10
|
||||||
[0.1.0]: #010--2026-09-10
|
[0.1.0]: #010--2026-09-10
|
||||||
|
|||||||
@@ -7,6 +7,38 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-09-10 · Baustein 3 – Wochenplan
|
||||||
|
|
||||||
|
| Von | Bis | Dauer | Tätigkeit |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 09:05 | 09:20 | 0:15 | Datenmodell der Wochenplanung, Migration, Drizzle-Schema |
|
||||||
|
| 09:20 | 09:45 | 0:25 | Planungslogik als reine Funktionen: erzeugen, verschieben, umsortieren, „welche Woche ist dran", Überschneidungen; dazu die Fortschrittsrechnung |
|
||||||
|
| 09:45 | 09:55 | 0:10 | 36 Tests dafür – liefen beim ersten Anlauf durch |
|
||||||
|
| 09:55 | 10:10 | 0:15 | Repository-Schicht, Wochensicht aus Inhalt + Planung + Fortschritt |
|
||||||
|
| 10:10 | 10:35 | 0:25 | Wochenansicht, Startseiten für beide Rollen, Wocheneinzelansicht |
|
||||||
|
| 10:35 | 10:55 | 0:20 | Wochenplanseite: verlegen, umsortieren mit Ziehen und Tastatur, Zustand, Notizen |
|
||||||
|
| 10:55 | 11:10 | 0:15 | Durchstich im Browser, Screenshots, Dokumente nachgezogen |
|
||||||
|
|
||||||
|
**Summe Baustein 3: rund 2 h 05 min**
|
||||||
|
|
||||||
|
### Was aufgefallen ist
|
||||||
|
|
||||||
|
- Beim Umsortieren war die erste Idee, die Termine mitzuziehen. Falsch: Dann
|
||||||
|
entstehen Lücken und Überschneidungen. Richtig ist, die Folge der
|
||||||
|
Kalenderwochen als festen Rahmen zu behandeln und nur die Zuordnung zu
|
||||||
|
ändern – der Plan bleibt so immer lückenlos.
|
||||||
|
- Der Lint-Fehler „azubiId is defined but never used" im Verlegen-Formular war
|
||||||
|
kein Stilproblem, sondern ein echter Bug: Dem Formular fehlte das versteckte
|
||||||
|
Feld, das die Serveraktion ausliest. Ein Lauf hätte den Fehler zwar auch
|
||||||
|
gezeigt, aber erst nach dem Klick.
|
||||||
|
- Ziehen und Ablegen allein reicht nicht. Wer mit der Tastatur arbeitet, kommt
|
||||||
|
damit nicht weiter, deshalb tun es die Pfeilknöpfe und Alt + Pfeil genauso.
|
||||||
|
- Die Erfolgsmeldung nach „Wochen einplanen" ist nicht zu sehen, weil das
|
||||||
|
Formular danach verschwindet – die erscheinende Planliste ist die
|
||||||
|
Rückmeldung. Kein Fehler, aber es hat mich im Testskript zunächst gekostet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-09-10 · Baustein 2 – Inhalte
|
## 2026-09-10 · Baustein 2 – Inhalte
|
||||||
|
|
||||||
| Von | Bis | Dauer | Tätigkeit |
|
| Von | Bis | Dauer | Tätigkeit |
|
||||||
|
|||||||
@@ -35,7 +35,15 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
|
|||||||
|
|
||||||
## 0.3 – Woche und Wiederholung
|
## 0.3 – Woche und Wiederholung
|
||||||
|
|
||||||
- [ ] **B3 · Wochenplan als Startseite, Fortschritt, Wochen verschieben**
|
- [x] **B3 · Wochenplan als Startseite, Fortschritt, Wochen verschieben**
|
||||||
|
- [x] Wochenplan ist die Startseite des Azubis: Thema, Leitfrage, fünf Tage
|
||||||
|
mit Theorie und Praxis, Fortschrittsbalken, was noch offen ist
|
||||||
|
- [x] Ausbilder sieht dieselbe Woche plus den Stand seiner Azubis
|
||||||
|
- [x] Geplantes und tatsächliches Datum je Woche
|
||||||
|
- [x] Verschieben per Datumsfeld, wahlweise mit allen folgenden Wochen
|
||||||
|
- [x] Reihenfolge per Ziehen und Ablegen **und** per Tastatur
|
||||||
|
- [x] Zustand je Woche (geplant / läuft / abgeschlossen) und Notiz für den Azubi
|
||||||
|
- [x] Warnung bei sich überlappenden Terminen
|
||||||
- [ ] **B4 · Merkkarten mit Wiederholung nach Zeitabstand (SM-2)**
|
- [ ] **B4 · Merkkarten mit Wiederholung nach Zeitabstand (SM-2)**
|
||||||
|
|
||||||
## 0.4 – Nachweise
|
## 0.4 – Nachweise
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
@@ -74,11 +74,15 @@ async function main() {
|
|||||||
await seite.waitForSelector('#neu-name');
|
await seite.waitForSelector('#neu-name');
|
||||||
await bild(seite, '03-verwaltung');
|
await bild(seite, '03-verwaltung');
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/wochenplan`);
|
||||||
|
await seite.waitForSelector('h1');
|
||||||
|
await bild(seite, '04-wochenplan');
|
||||||
|
|
||||||
await abmelden(seite);
|
await abmelden(seite);
|
||||||
|
|
||||||
// --- Azubi ---
|
// --- Azubi ---
|
||||||
await anmelden(seite, AZUBI);
|
await anmelden(seite, AZUBI);
|
||||||
await bild(seite, '10-azubi-start');
|
await bild(seite, '10-azubi-woche');
|
||||||
|
|
||||||
await seite.goto(`${BASIS}/handouts`);
|
await seite.goto(`${BASIS}/handouts`);
|
||||||
await seite.waitForSelector('h1');
|
await seite.waitForSelector('h1');
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
|
||||||
|
import type { Wochensicht } from '@/lib/planung/sicht';
|
||||||
|
import { alsDatumText, kalenderwocheText } from '@/lib/zeit';
|
||||||
|
|
||||||
|
const statusText: Record<string, { text: string; ton: string; zeichen: string }> = {
|
||||||
|
geplant: { text: 'geplant', ton: 'neutral', zeichen: '○' },
|
||||||
|
laufend: { text: 'läuft', ton: 'warnung', zeichen: '▶' },
|
||||||
|
abgeschlossen: { text: 'abgeschlossen', ton: 'gut', zeichen: '✓' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Wochenansicht – für den Azubi die Startseite, für den Ausbilder dieselbe
|
||||||
|
* Woche in der Ansicht seines Azubis.
|
||||||
|
*/
|
||||||
|
export function WochenAnsicht({
|
||||||
|
sicht,
|
||||||
|
istAzubi,
|
||||||
|
wochenLink,
|
||||||
|
}: {
|
||||||
|
sicht: Wochensicht;
|
||||||
|
istAzubi: boolean;
|
||||||
|
/** Vorlage für den Verweis auf eine andere Woche, `{nr}` wird ersetzt. */
|
||||||
|
wochenLink: string;
|
||||||
|
}) {
|
||||||
|
const zurWoche = (nummer: number) => wochenLink.replace('{nr}', String(nummer));
|
||||||
|
const { woche, planung, fortschritt, tage } = sicht;
|
||||||
|
const status = statusText[planung.status] ?? statusText.geplant!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stapel">
|
||||||
|
<header className="stapel-eng">
|
||||||
|
<div className="reihe" style={{ gap: '0.6rem' }}>
|
||||||
|
<span className="wochen-nummer">Woche {woche.nummer}</span>
|
||||||
|
<span className="marke-status" data-ton={status.ton}>
|
||||||
|
{status.zeichen} {status.text}
|
||||||
|
</span>
|
||||||
|
<span className="leise">
|
||||||
|
{alsDatumText(planung.geplantVon)} – {alsDatumText(planung.geplantBis)} · {kalenderwocheText(planung.geplantVon)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 style={{ marginBottom: '0.2rem' }}>{woche.titel}</h1>
|
||||||
|
<p className="leitfrage">{woche.leitfrage}</p>
|
||||||
|
<p className="leise" style={{ marginBottom: 0 }}>{woche.ziel}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{planung.tatsaechlichVon && planung.tatsaechlichVon !== planung.geplantVon ? (
|
||||||
|
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
|
||||||
|
Tatsächlich begonnen am {alsDatumText(planung.tatsaechlichVon)}, geplant war der{' '}
|
||||||
|
{alsDatumText(planung.geplantVon)}.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{planung.notiz ? (
|
||||||
|
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
|
||||||
|
<strong>Notiz des Ausbilders:</strong> {planung.notiz}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="karte stapel">
|
||||||
|
<Fortschrittsbalken fortschritt={fortschritt} />
|
||||||
|
|
||||||
|
{fortschritt.offen.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1rem', marginBottom: '0.4rem' }}>
|
||||||
|
{istAzubi ? 'Das ist diese Woche noch offen' : 'Offen bei deinem Azubi'}
|
||||||
|
</h2>
|
||||||
|
<ul className="offen-liste">
|
||||||
|
{fortschritt.offen.map((bereich) => (
|
||||||
|
<li key={bereich.schluessel}>
|
||||||
|
<span>{bereich.titel}</span>
|
||||||
|
<span className="leise">
|
||||||
|
{bereich.zaehlwerk.erledigt} von {bereich.zaehlwerk.gesamt}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : fortschritt.gesamt > 0 ? (
|
||||||
|
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
|
||||||
|
✓ Diese Woche ist vollständig abgearbeitet.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="tage" className="stapel">
|
||||||
|
<h2 id="tage">Die Woche</h2>
|
||||||
|
<ol className="wochentage">
|
||||||
|
{tage.map((tag) => (
|
||||||
|
<li key={tag.tag} className={`wochentag${tag.istHeute ? ' wochentag-heute' : ''}`}>
|
||||||
|
<div className="wochentag-kopf">
|
||||||
|
<strong>{tag.tag}</strong>
|
||||||
|
<span className="leise">{alsDatumText(tag.datum)}</span>
|
||||||
|
{tag.istHeute ? <span className="marke-status" data-ton="warnung">● heute</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wochentag-inhalt">
|
||||||
|
<div>
|
||||||
|
<span className="wochentag-marke">Theorie</span>
|
||||||
|
{tag.theorie.length === 0 ? (
|
||||||
|
<span className="leise">–</span>
|
||||||
|
) : (
|
||||||
|
<ul className="theorie-liste">
|
||||||
|
{tag.theorie.map((handout) => {
|
||||||
|
const gelesen = sicht.handouts.find((h) => h.handout.id === handout.id)?.gelesen ?? false;
|
||||||
|
return (
|
||||||
|
<li key={handout.id}>
|
||||||
|
<Link href={`/handouts/${handout.id}`}>
|
||||||
|
<span className="handout-kuerzel">{handout.id}</span> {handout.titel}
|
||||||
|
</Link>
|
||||||
|
{gelesen ? (
|
||||||
|
<span className="marke-status" data-ton="gut">
|
||||||
|
✓ gelesen
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="wochentag-marke">Praxis</span>
|
||||||
|
<span>{tag.praxis || '–'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="raster">
|
||||||
|
<section className="karte" aria-labelledby="woche-handouts">
|
||||||
|
<h2 id="woche-handouts" style={{ fontSize: '1rem' }}>
|
||||||
|
Handouts dieser Woche
|
||||||
|
</h2>
|
||||||
|
{sicht.handouts.length === 0 ? (
|
||||||
|
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="stapel-eng" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||||
|
{sicht.handouts.map(({ handout, gelesen }) => (
|
||||||
|
<li key={handout.id} className="reihe" style={{ gap: '0.5rem', flexWrap: 'nowrap' }}>
|
||||||
|
<span className="handout-kuerzel">{handout.id}</span>
|
||||||
|
<Link href={`/handouts/${handout.id}`} style={{ flex: 1 }}>
|
||||||
|
{handout.titel}
|
||||||
|
</Link>
|
||||||
|
<span aria-label={gelesen ? 'gelesen' : 'noch nicht gelesen'} title={gelesen ? 'gelesen' : 'offen'}>
|
||||||
|
{gelesen ? '✓' : '○'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="karte" aria-labelledby="woche-aufgaben">
|
||||||
|
<h2 id="woche-aufgaben" style={{ fontSize: '1rem' }}>
|
||||||
|
Praxisaufgaben
|
||||||
|
</h2>
|
||||||
|
{sicht.aufgaben.length === 0 ? (
|
||||||
|
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
|
||||||
|
{sicht.aufgaben.map((aufgabe) => (
|
||||||
|
<li key={aufgabe.id}>
|
||||||
|
<strong>{aufgabe.id}</strong> {aufgabe.titel}
|
||||||
|
<br />
|
||||||
|
<span className="leise">{aufgabe.zeitrahmen}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="karte" aria-labelledby="woche-checklisten">
|
||||||
|
<h2 id="woche-checklisten" style={{ fontSize: '1rem' }}>
|
||||||
|
Checklisten
|
||||||
|
</h2>
|
||||||
|
{sicht.checklisten.length === 0 ? (
|
||||||
|
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
|
||||||
|
{sicht.checklisten.map((liste) => (
|
||||||
|
<li key={liste.id}>{liste.titel}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="karte" aria-labelledby="woche-kontrolle">
|
||||||
|
<h2 id="woche-kontrolle" style={{ fontSize: '1rem' }}>
|
||||||
|
Lernzielkontrolle
|
||||||
|
</h2>
|
||||||
|
{sicht.kontrolle ? (
|
||||||
|
<p style={{ marginBottom: 0 }}>
|
||||||
|
{sicht.kontrolle.titel}
|
||||||
|
<br />
|
||||||
|
<span className="leise">
|
||||||
|
{sicht.kontrolle.fragen.length} Fragen ·{' '}
|
||||||
|
{sicht.kontrolle.fragen.reduce((summe, f) => summe + f.punkte, 0)} Punkte
|
||||||
|
{sicht.kontrolle.dauer_minuten ? ` · ${sicht.kontrolle.dauer_minuten} Minuten` : ''}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="leise" style={{ marginBottom: 0 }}>Diese Woche keine.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Andere Wochen">
|
||||||
|
{sicht.vorherige ? (
|
||||||
|
<Link href={zurWoche(sicht.vorherige)} className="knopf knopf-still">
|
||||||
|
← Woche {sicht.vorherige}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
{sicht.naechste ? (
|
||||||
|
<Link href={zurWoche(sicht.naechste)} className="knopf knopf-still">
|
||||||
|
Woche {sicht.naechste} →
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ const fuerAzubi: Navigationseintrag[] = [
|
|||||||
|
|
||||||
const fuerAusbilder: Navigationseintrag[] = [
|
const fuerAusbilder: Navigationseintrag[] = [
|
||||||
{ pfad: '/', titel: 'Überblick' },
|
{ pfad: '/', titel: 'Überblick' },
|
||||||
|
{ pfad: '/wochenplan', titel: 'Wochenplan' },
|
||||||
{ pfad: '/handouts', titel: 'Handouts' },
|
{ pfad: '/handouts', titel: 'Handouts' },
|
||||||
{ pfad: '/glossar', titel: 'Glossar' },
|
{ pfad: '/glossar', titel: 'Glossar' },
|
||||||
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
||||||
|
|||||||
@@ -1,69 +1,148 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||||
import { ausbildungNachAzubi, ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
|
import { ausbildungNachAzubi, ausbildungenVonAusbilder } from '@/lib/repos/ausbildung';
|
||||||
import { alsDatumText, kalenderwocheText, heute } from '@/lib/zeit';
|
import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
|
||||||
|
import { aktuelleWochensicht, kurzstand } from '@/lib/planung/sicht';
|
||||||
|
import { alsDatumText, heute, kalenderwocheText } from '@/lib/zeit';
|
||||||
|
import { WochenAnsicht } from './WochenAnsicht';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
/**
|
|
||||||
* Vorläufige Startseite. Ab Baustein 3 steht hier der Wochenplan.
|
|
||||||
*/
|
|
||||||
export default async function StartSeite() {
|
export default async function StartSeite() {
|
||||||
const benutzer = await erfordereBenutzer();
|
const benutzer = await erfordereBenutzer();
|
||||||
|
|
||||||
if (benutzer.rolle === 'ausbilder') {
|
return benutzer.rolle === 'ausbilder' ? (
|
||||||
const meine = ausbildungenVonAusbilder(benutzer.id);
|
<AusbilderUeberblick ausbilderId={benutzer.id} name={benutzer.name} />
|
||||||
|
) : (
|
||||||
|
<AzubiWoche azubiId={benutzer.id} name={benutzer.name} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Azubi: der Wochenplan ist die Startseite ------------------------------
|
||||||
|
|
||||||
|
function AzubiWoche({ azubiId, name }: { azubiId: string; name: string }) {
|
||||||
|
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||||
|
|
||||||
|
if (!ausbildung) {
|
||||||
return (
|
return (
|
||||||
<div className="stapel">
|
<div className="stapel">
|
||||||
<div>
|
<h1>Guten Tag, {name.split(' ')[0]}</h1>
|
||||||
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1>
|
<p className="hinweis hinweis-warnung">
|
||||||
<p className="leise">
|
Zu deinem Konto ist noch keine Ausbildung hinterlegt. Sag deinem Ausbilder Bescheid – ohne
|
||||||
{kalenderwocheText(heute())} · {alsDatumText(heute())}
|
Ausbildungsverhältnis gibt es keinen Wochenplan.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="karte stapel">
|
|
||||||
<h2 style={{ marginBottom: 0 }}>Ausbildungen</h2>
|
|
||||||
{meine.length === 0 ? (
|
|
||||||
<p style={{ marginBottom: 0 }}>
|
|
||||||
Noch kein Azubikonto. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
|
|
||||||
{meine.map(({ ausbildung, azubi }) => (
|
|
||||||
<li key={ausbildung.id}>
|
|
||||||
<strong>{azubi.name}</strong> · seit {alsDatumText(ausbildung.beginn)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ausbildung = ausbildungNachAzubi(benutzer.id);
|
const sicht = aktuelleWochensicht(ausbildung.id, azubiId);
|
||||||
|
|
||||||
|
if (!sicht) {
|
||||||
|
return (
|
||||||
|
<div className="stapel">
|
||||||
|
<h1>Guten Tag, {name.split(' ')[0]}</h1>
|
||||||
|
<p className="hinweis hinweis-warnung">
|
||||||
|
Für deine Ausbildung ist noch kein Wochenplan angelegt. Dein Ausbilder kann ihn unter „Wochenplan“
|
||||||
|
erzeugen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <WochenAnsicht sicht={sicht} istAzubi wochenLink="/woche/{nr}" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ausbilder: wer steht wo -----------------------------------------------
|
||||||
|
|
||||||
|
function AusbilderUeberblick({ ausbilderId, name }: { ausbilderId: string; name: string }) {
|
||||||
|
const meine = ausbildungenVonAusbilder(ausbilderId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stapel">
|
<div className="stapel">
|
||||||
<div>
|
<div>
|
||||||
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1>
|
<h1>Guten Tag, {name.split(' ')[0]}</h1>
|
||||||
<p className="leise">
|
<p className="leise">
|
||||||
{kalenderwocheText(heute())} · {alsDatumText(heute())}
|
{kalenderwocheText(heute())} · {alsDatumText(heute())}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="karte">
|
{meine.length === 0 ? (
|
||||||
{ausbildung ? (
|
<p className="hinweis hinweis-warnung">
|
||||||
<p style={{ marginBottom: 0 }}>
|
Noch kein Azubikonto angelegt. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
|
||||||
Deine Ausbildung läuft seit {alsDatumText(ausbildung.beginn)}
|
</p>
|
||||||
{ausbildung.probezeitBis ? ` (Probezeit bis ${alsDatumText(ausbildung.probezeitBis)})` : ''}.
|
) : (
|
||||||
</p>
|
<section className="stapel" aria-labelledby="stand">
|
||||||
) : (
|
<h2 id="stand">Wo deine Auszubildenden stehen</h2>
|
||||||
<p style={{ marginBottom: 0 }}>
|
|
||||||
Zu deinem Konto ist noch keine Ausbildung hinterlegt. Sag deinem Ausbilder Bescheid.
|
{meine.map(({ ausbildung, azubi }) => {
|
||||||
</p>
|
const stand = kurzstand(ausbildung.id, azubi.id);
|
||||||
)}
|
|
||||||
|
return (
|
||||||
|
<article key={ausbildung.id} className="karte stapel">
|
||||||
|
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ marginBottom: '0.1rem' }}>{azubi.name}</h3>
|
||||||
|
<p className="leise" style={{ margin: 0 }}>
|
||||||
|
Ausbildung seit {alsDatumText(ausbildung.beginn)}
|
||||||
|
{azubi.aktiv === 1 ? '' : ' · Konto gesperrt'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href={`/wochenplan?azubi=${azubi.id}`} className="knopf knopf-zweit">
|
||||||
|
Wochenplan
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stand ? (
|
||||||
|
<>
|
||||||
|
<div className="karte-grund stapel-eng">
|
||||||
|
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||||
|
<strong>
|
||||||
|
Woche {stand.wochenNummer} · {stand.titel}
|
||||||
|
</strong>
|
||||||
|
<span className="leise">
|
||||||
|
{alsDatumText(stand.geplantVon)} – {alsDatumText(stand.geplantBis)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Fortschrittsbalken fortschritt={stand.fortschritt} beschriftung="Stand der Woche" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stand.fortschritt.offen.length > 0 ? (
|
||||||
|
<ul className="offen-liste">
|
||||||
|
{stand.fortschritt.offen.map((bereich) => (
|
||||||
|
<li key={bereich.schluessel}>
|
||||||
|
<span>{bereich.titel}</span>
|
||||||
|
<span className="leise">
|
||||||
|
{bereich.zaehlwerk.erledigt} von {bereich.zaehlwerk.gesamt}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Link href={`/woche/${stand.wochenNummer}?azubi=${azubi.id}`}>
|
||||||
|
Die Woche im Einzelnen ansehen →
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
|
||||||
|
Für {azubi.name} ist noch kein Wochenplan angelegt.{' '}
|
||||||
|
<Link href={`/wochenplan?azubi=${azubi.id}`}>Jetzt erzeugen</Link>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="karte" aria-labelledby="wartet">
|
||||||
|
<h2 id="wartet" style={{ fontSize: '1rem' }}>
|
||||||
|
Was auf dich wartet
|
||||||
|
</h2>
|
||||||
|
<p className="leise" style={{ marginBottom: 0 }}>
|
||||||
|
Offene Abnahmen, unkorrigierte Lernzielkontrollen und nicht abgezeichnete Berichtshefte erscheinen
|
||||||
|
hier, sobald diese Bausteine stehen.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { passwortMangel } from '@/lib/auth/passwort';
|
|||||||
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
|
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
|
||||||
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
|
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
|
||||||
import { alsDatumText } from '@/lib/zeit';
|
import { alsDatumText } from '@/lib/zeit';
|
||||||
|
import { inhalte } from '@/lib/inhalte';
|
||||||
|
import { planAuffrischen } from '@/lib/repos/wochenplanung';
|
||||||
|
|
||||||
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
|
|||||||
if (benutzerNachEmail(email)) return { fehler: 'Diese E-Mail-Adresse ist schon vergeben.' };
|
if (benutzerNachEmail(email)) return { fehler: 'Diese E-Mail-Adresse ist schon vergeben.' };
|
||||||
|
|
||||||
const azubi = await benutzerAnlegen({ name, email, passwort, rolle: 'azubi' });
|
const azubi = await benutzerAnlegen({ name, email, passwort, rolle: 'azubi' });
|
||||||
ausbildungAnlegen({
|
const neueAusbildung = ausbildungAnlegen({
|
||||||
azubiId: azubi.id,
|
azubiId: azubi.id,
|
||||||
ausbilderId: ausbilder.id,
|
ausbilderId: ausbilder.id,
|
||||||
beginn,
|
beginn,
|
||||||
@@ -39,8 +41,17 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
|
|||||||
ende: datumMuster.test(ende) ? ende : null,
|
ende: datumMuster.test(ende) ? ende : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Der Wochenplan entsteht gleich mit – ohne ihn ist die Startseite leer.
|
||||||
|
const wochen = inhalte().wochen.map((w) => w.nummer);
|
||||||
|
const eingeplant = planAuffrischen(neueAusbildung.id, beginn, wochen);
|
||||||
|
|
||||||
revalidatePath('/verwaltung');
|
revalidatePath('/verwaltung');
|
||||||
return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${alsDatumText(beginn)} eingerichtet.` };
|
revalidatePath('/');
|
||||||
|
return {
|
||||||
|
erfolg:
|
||||||
|
`Konto für ${name} angelegt und Ausbildung ab ${alsDatumText(beginn)} eingerichtet. ` +
|
||||||
|
`${eingeplant} ${eingeplant === 1 ? 'Woche' : 'Wochen'} eingeplant.`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { erfordereBenutzer, erfordereZugriffAufAzubi } from '@/lib/auth/rechte';
|
||||||
|
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
||||||
|
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||||
|
import { wochensicht } from '@/lib/planung/sicht';
|
||||||
|
import { WochenAnsicht } from '../../WochenAnsicht';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ nummer: string }> }): Promise<Metadata> {
|
||||||
|
const { nummer } = await params;
|
||||||
|
return { title: `Woche ${nummer}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine bestimmte Woche. Der Azubi sieht nur seine eigene; der Ausbilder gibt
|
||||||
|
* über `?azubi=` an, wessen Stand er sehen will – geprüft wird das serverseitig.
|
||||||
|
*/
|
||||||
|
export default async function WochenSeite({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ nummer: string }>;
|
||||||
|
searchParams: Promise<{ azubi?: string }>;
|
||||||
|
}) {
|
||||||
|
const benutzer = await erfordereBenutzer();
|
||||||
|
const { nummer } = await params;
|
||||||
|
const { azubi: azubiParameter } = await searchParams;
|
||||||
|
|
||||||
|
const wochenNummer = Number(nummer);
|
||||||
|
if (!Number.isInteger(wochenNummer) || wochenNummer < 1) notFound();
|
||||||
|
|
||||||
|
const azubiId = benutzer.rolle === 'azubi' ? benutzer.id : (azubiParameter ?? '');
|
||||||
|
if (!azubiId) notFound();
|
||||||
|
|
||||||
|
// Der Kern: Ob dieser Betrachter diesen Azubi sehen darf, entscheidet der
|
||||||
|
// Server – nicht die Oberfläche und nicht die Adresszeile.
|
||||||
|
erfordereZugriffAufAzubi(benutzer, azubiId);
|
||||||
|
|
||||||
|
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||||
|
if (!ausbildung) notFound();
|
||||||
|
|
||||||
|
const sicht = wochensicht(ausbildung.id, azubiId, wochenNummer);
|
||||||
|
if (!sicht) notFound();
|
||||||
|
|
||||||
|
const istAzubi = benutzer.rolle === 'azubi';
|
||||||
|
const azubi = istAzubi ? null : benutzerNachId(azubiId);
|
||||||
|
const anhang = istAzubi ? '' : `?azubi=${azubiId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stapel">
|
||||||
|
{azubi ? (
|
||||||
|
<nav className="leise nicht-drucken" aria-label="Wo du bist">
|
||||||
|
<Link href="/">Überblick</Link>
|
||||||
|
{' › '}
|
||||||
|
<Link href={`/wochenplan?azubi=${azubiId}`}>Wochenplan {azubi.name}</Link>
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<WochenAnsicht sicht={sicht} istAzubi={istAzubi} wochenLink={`/woche/{nr}${anhang}`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useActionState } from 'react';
|
||||||
|
import { Absenden, Meldung } from '@/komponenten/Formularmeldung';
|
||||||
|
import { planErzeugen, wochenNotiz, wocheVerlegen, wochenStatusSetzen, type Rueckmeldung } from './aktionen';
|
||||||
|
|
||||||
|
export function PlanErzeugenFormular({ azubiId, text }: { azubiId: string; text: string }) {
|
||||||
|
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(planErzeugen, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={aktion} className="stapel-eng">
|
||||||
|
<Meldung zustand={zustand} />
|
||||||
|
<input type="hidden" name="azubiId" value={azubiId} />
|
||||||
|
<Absenden className="knopf knopf-akzent">{text}</Absenden>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerlegenFormular({
|
||||||
|
azubiId,
|
||||||
|
wochen,
|
||||||
|
}: {
|
||||||
|
azubiId: string;
|
||||||
|
wochen: { nummer: number; titel: string; geplantVon: string }[];
|
||||||
|
}) {
|
||||||
|
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(wocheVerlegen, {});
|
||||||
|
const erste = wochen[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={aktion} className="stapel">
|
||||||
|
<Meldung zustand={zustand} />
|
||||||
|
<input type="hidden" name="azubiId" value={azubiId} />
|
||||||
|
|
||||||
|
<div className="reihe" style={{ alignItems: 'flex-end' }}>
|
||||||
|
<div style={{ flex: '2 1 240px' }}>
|
||||||
|
<label htmlFor="verlegen-woche">Woche</label>
|
||||||
|
<select id="verlegen-woche" name="wochenNummer" defaultValue={erste?.nummer}>
|
||||||
|
{wochen.map((w) => (
|
||||||
|
<option key={w.nummer} value={w.nummer}>
|
||||||
|
Woche {w.nummer} – {w.titel}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: '1 1 170px' }}>
|
||||||
|
<label htmlFor="verlegen-datum">Neuer Beginn</label>
|
||||||
|
<input id="verlegen-datum" name="neuerMontag" type="date" defaultValue={erste?.geplantVon} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Absenden className="knopf">Verlegen</Absenden>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="ankreuzfeld">
|
||||||
|
<input type="checkbox" name="folgende" value="an" defaultChecked />
|
||||||
|
<span>
|
||||||
|
Folgende Wochen mitverschieben
|
||||||
|
<span className="leise" style={{ display: 'block' }}>
|
||||||
|
Der Normalfall: Fällt eine Woche aus, rutscht der ganze Rest hinterher. Ohne Haken wird nur diese
|
||||||
|
eine Woche verlegt – dann können sich Termine überlappen.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="leise" style={{ margin: 0 }}>
|
||||||
|
Ein beliebiges Datum wird auf den Montag seiner Woche gerundet. Abgeschlossene Wochen bleiben stehen.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusFormular({
|
||||||
|
azubiId,
|
||||||
|
wochenNummer,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
azubiId: string;
|
||||||
|
wochenNummer: number;
|
||||||
|
status: 'geplant' | 'laufend' | 'abgeschlossen';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form action={wochenStatusSetzen} className="reihe" style={{ gap: '0.35rem' }}>
|
||||||
|
<input type="hidden" name="azubiId" value={azubiId} />
|
||||||
|
<input type="hidden" name="wochenNummer" value={wochenNummer} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="status"
|
||||||
|
value="laufend"
|
||||||
|
className="knopf knopf-still"
|
||||||
|
disabled={status === 'laufend'}
|
||||||
|
>
|
||||||
|
Startet jetzt
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="status"
|
||||||
|
value="abgeschlossen"
|
||||||
|
className="knopf knopf-still"
|
||||||
|
disabled={status === 'abgeschlossen'}
|
||||||
|
>
|
||||||
|
Abschließen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="status"
|
||||||
|
value="geplant"
|
||||||
|
className="knopf knopf-still"
|
||||||
|
disabled={status === 'geplant'}
|
||||||
|
>
|
||||||
|
Zurücksetzen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotizFormular({
|
||||||
|
azubiId,
|
||||||
|
wochenNummer,
|
||||||
|
notiz,
|
||||||
|
}: {
|
||||||
|
azubiId: string;
|
||||||
|
wochenNummer: number;
|
||||||
|
notiz: string;
|
||||||
|
}) {
|
||||||
|
const [zustand, aktion] = useActionState<Rueckmeldung, FormData>(wochenNotiz, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={aktion} className="stapel-eng">
|
||||||
|
<Meldung zustand={zustand} />
|
||||||
|
<input type="hidden" name="azubiId" value={azubiId} />
|
||||||
|
<input type="hidden" name="wochenNummer" value={wochenNummer} />
|
||||||
|
<label htmlFor={`notiz-${wochenNummer}`}>Notiz zu dieser Woche (sieht der Azubi)</label>
|
||||||
|
<textarea
|
||||||
|
id={`notiz-${wochenNummer}`}
|
||||||
|
name="notiz"
|
||||||
|
defaultValue={notiz}
|
||||||
|
rows={2}
|
||||||
|
maxLength={500}
|
||||||
|
placeholder="z. B. Dienstag Berufsschule, Mittwoch Kundeneinsatz Halle 3"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Absenden className="knopf knopf-zweit">Notiz speichern</Absenden>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { wocheSchieben } from './aktionen';
|
||||||
|
|
||||||
|
export type Planeintrag = {
|
||||||
|
wochenNummer: number;
|
||||||
|
titel: string;
|
||||||
|
geplantVon: string;
|
||||||
|
geplantBis: string;
|
||||||
|
geplantVonText: string;
|
||||||
|
geplantBisText: string;
|
||||||
|
kalenderwoche: string;
|
||||||
|
tatsaechlichVonText: string | null;
|
||||||
|
status: 'geplant' | 'laufend' | 'abgeschlossen';
|
||||||
|
notiz: string | null;
|
||||||
|
handouts: number;
|
||||||
|
aufgaben: number;
|
||||||
|
hatKontrolle: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusMarke: Record<Planeintrag['status'], { text: string; ton: string; zeichen: string }> = {
|
||||||
|
geplant: { text: 'geplant', ton: 'neutral', zeichen: '○' },
|
||||||
|
laufend: { text: 'läuft', ton: 'warnung', zeichen: '▶' },
|
||||||
|
abgeschlossen: { text: 'abgeschlossen', ton: 'gut', zeichen: '✓' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Wochenplan zum Umsortieren.
|
||||||
|
*
|
||||||
|
* Ziehen und Ablegen mit der Maus, und – genauso wichtig – mit der Tastatur:
|
||||||
|
* Eintrag anwählen, mit den Pfeiltasten verschieben. Reines Ziehen wäre für
|
||||||
|
* Tastaturbedienung eine Sackgasse.
|
||||||
|
*/
|
||||||
|
export function PlanListe({ eintraege, azubiId }: { eintraege: Planeintrag[]; azubiId: string }) {
|
||||||
|
const [gezogen, setzeGezogen] = useState<number | null>(null);
|
||||||
|
const [ueber, setzeUeber] = useState<number | null>(null);
|
||||||
|
const [laeuft, starte] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function verschiebeAn(wochenNummer: number, neueStelle: number) {
|
||||||
|
if (neueStelle < 0 || neueStelle >= eintraege.length) return;
|
||||||
|
|
||||||
|
const daten = new FormData();
|
||||||
|
daten.set('azubiId', azubiId);
|
||||||
|
daten.set('wochenNummer', String(wochenNummer));
|
||||||
|
daten.set('neueStelle', String(neueStelle));
|
||||||
|
|
||||||
|
starte(async () => {
|
||||||
|
await wocheSchieben(daten);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function beiTaste(ereignis: React.KeyboardEvent, wochenNummer: number, stelle: number) {
|
||||||
|
if (ereignis.key === 'ArrowUp' && (ereignis.altKey || ereignis.ctrlKey)) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
verschiebeAn(wochenNummer, stelle - 1);
|
||||||
|
}
|
||||||
|
if (ereignis.key === 'ArrowDown' && (ereignis.altKey || ereignis.ctrlKey)) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
verschiebeAn(wochenNummer, stelle + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol className={`planliste${laeuft ? ' planliste-wartet' : ''}`}>
|
||||||
|
{eintraege.map((eintrag, stelle) => {
|
||||||
|
const marke = statusMarke[eintrag.status];
|
||||||
|
const wirdGezogen = gezogen === eintrag.wochenNummer;
|
||||||
|
const istZiel = ueber === stelle && gezogen !== null && !wirdGezogen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={eintrag.wochenNummer}
|
||||||
|
className={`planzeile${wirdGezogen ? ' planzeile-gezogen' : ''}${istZiel ? ' planzeile-ziel' : ''}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setzeGezogen(eintrag.wochenNummer)}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setzeGezogen(null);
|
||||||
|
setzeUeber(null);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setzeUeber(stelle);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (gezogen !== null && gezogen !== eintrag.wochenNummer) verschiebeAn(gezogen, stelle);
|
||||||
|
setzeGezogen(null);
|
||||||
|
setzeUeber(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="planzeile-griff" aria-hidden="true">
|
||||||
|
⠿
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="planzeile-inhalt">
|
||||||
|
<div className="reihe" style={{ gap: '0.5rem' }}>
|
||||||
|
<span className="wochen-nummer wochen-nummer-klein">Woche {eintrag.wochenNummer}</span>
|
||||||
|
<span className="marke-status" data-ton={marke.ton}>
|
||||||
|
{marke.zeichen} {marke.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href={`/woche/${eintrag.wochenNummer}?azubi=${azubiId}`} className="planzeile-titel">
|
||||||
|
{eintrag.titel}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<p className="leise" style={{ margin: 0 }}>
|
||||||
|
{eintrag.geplantVonText} – {eintrag.geplantBisText} · {eintrag.kalenderwoche}
|
||||||
|
{eintrag.tatsaechlichVonText ? ` · tatsächlich ab ${eintrag.tatsaechlichVonText}` : ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="leise" style={{ margin: 0 }}>
|
||||||
|
{eintrag.handouts} Handouts · {eintrag.aufgaben} Aufgaben
|
||||||
|
{eintrag.hatKontrolle ? ' · Lernzielkontrolle' : ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{eintrag.notiz ? (
|
||||||
|
<p className="planzeile-notiz">
|
||||||
|
<strong>Notiz:</strong> {eintrag.notiz}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="planzeile-knoepfe">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="knopf knopf-still"
|
||||||
|
onClick={() => verschiebeAn(eintrag.wochenNummer, stelle - 1)}
|
||||||
|
onKeyDown={(e) => beiTaste(e, eintrag.wochenNummer, stelle)}
|
||||||
|
disabled={stelle === 0 || laeuft}
|
||||||
|
title="Eine Woche früher (auch mit Alt + Pfeil hoch)"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">↑</span>
|
||||||
|
<span className="nur-lesehilfe">Woche {eintrag.wochenNummer} nach oben</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="knopf knopf-still"
|
||||||
|
onClick={() => verschiebeAn(eintrag.wochenNummer, stelle + 1)}
|
||||||
|
onKeyDown={(e) => beiTaste(e, eintrag.wochenNummer, stelle)}
|
||||||
|
disabled={stelle === eintraege.length - 1 || laeuft}
|
||||||
|
title="Eine Woche später (auch mit Alt + Pfeil runter)"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">↓</span>
|
||||||
|
<span className="nur-lesehilfe">Woche {eintrag.wochenNummer} nach unten</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { erfordereAusbilder, erfordereBewertungsrecht } from '@/lib/auth/rechte';
|
||||||
|
import { ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
||||||
|
import { inhalte } from '@/lib/inhalte';
|
||||||
|
import {
|
||||||
|
notizSetzen,
|
||||||
|
planAuffrischen,
|
||||||
|
statusSetzen,
|
||||||
|
wocheUmsortieren,
|
||||||
|
wocheVerschieben,
|
||||||
|
} from '@/lib/repos/wochenplanung';
|
||||||
|
import { heute } from '@/lib/zeit';
|
||||||
|
import { wochenStatus, type WochenStatus } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
||||||
|
|
||||||
|
const datumMuster = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Aktionen hier prüfen zuerst, ob dieser Ausbilder für diesen Azubi
|
||||||
|
* zuständig ist. Die Azubikennung kommt aus dem Formular und ist damit
|
||||||
|
* grundsätzlich manipulierbar – deshalb wird sie nie geglaubt.
|
||||||
|
*/
|
||||||
|
async function zustaendigeAusbildung(azubiId: string) {
|
||||||
|
const ausbilder = await erfordereAusbilder();
|
||||||
|
erfordereBewertungsrecht(ausbilder, azubiId);
|
||||||
|
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||||
|
if (!ausbildung) throw new Error('Zu diesem Konto gibt es keine Ausbildung.');
|
||||||
|
return ausbildung;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Seiten auffrischen, auf denen die Planung sichtbar ist. */
|
||||||
|
function aktualisiere() {
|
||||||
|
revalidatePath('/wochenplan');
|
||||||
|
revalidatePath('/');
|
||||||
|
revalidatePath('/woche/[nummer]', 'page');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function planErzeugen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||||
|
const azubiId = String(formular.get('azubiId') ?? '');
|
||||||
|
const ausbildung = await zustaendigeAusbildung(azubiId);
|
||||||
|
|
||||||
|
const nummern = inhalte().wochen.map((w) => w.nummer);
|
||||||
|
const angelegt = planAuffrischen(ausbildung.id, ausbildung.beginn, nummern);
|
||||||
|
|
||||||
|
aktualisiere();
|
||||||
|
return angelegt === 0
|
||||||
|
? { erfolg: 'Der Plan war schon vollständig – es gab nichts nachzutragen.' }
|
||||||
|
: { erfolg: `${angelegt} ${angelegt === 1 ? 'Woche' : 'Wochen'} eingeplant.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wocheVerlegen(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||||
|
const azubiId = String(formular.get('azubiId') ?? '');
|
||||||
|
const ausbildung = await zustaendigeAusbildung(azubiId);
|
||||||
|
|
||||||
|
const wochenNummer = Number(formular.get('wochenNummer'));
|
||||||
|
const neuerMontag = String(formular.get('neuerMontag') ?? '').trim();
|
||||||
|
const folgende = String(formular.get('folgende') ?? '') === 'an';
|
||||||
|
|
||||||
|
if (!Number.isInteger(wochenNummer)) return { fehler: 'Wochennummer fehlt.' };
|
||||||
|
if (!datumMuster.test(neuerMontag)) return { fehler: 'Bitte ein gültiges Datum wählen.' };
|
||||||
|
|
||||||
|
const geaendert = wocheVerschieben(ausbildung.id, wochenNummer, neuerMontag, folgende);
|
||||||
|
|
||||||
|
aktualisiere();
|
||||||
|
if (geaendert === 0) return { erfolg: 'Der Termin stand schon so.' };
|
||||||
|
return {
|
||||||
|
erfolg: folgende
|
||||||
|
? `Woche ${wochenNummer} verlegt, ${geaendert - 1} folgende ${geaendert - 1 === 1 ? 'Woche' : 'Wochen'} mitverschoben.`
|
||||||
|
: `Woche ${wochenNummer} verlegt.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wocheSchieben(formular: FormData): Promise<void> {
|
||||||
|
const azubiId = String(formular.get('azubiId') ?? '');
|
||||||
|
const ausbildung = await zustaendigeAusbildung(azubiId);
|
||||||
|
|
||||||
|
const wochenNummer = Number(formular.get('wochenNummer'));
|
||||||
|
const neueStelle = Number(formular.get('neueStelle'));
|
||||||
|
if (!Number.isInteger(wochenNummer) || !Number.isInteger(neueStelle)) return;
|
||||||
|
|
||||||
|
wocheUmsortieren(ausbildung.id, wochenNummer, neueStelle);
|
||||||
|
aktualisiere();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wochenStatusSetzen(formular: FormData): Promise<void> {
|
||||||
|
const azubiId = String(formular.get('azubiId') ?? '');
|
||||||
|
const ausbildung = await zustaendigeAusbildung(azubiId);
|
||||||
|
|
||||||
|
const wochenNummer = Number(formular.get('wochenNummer'));
|
||||||
|
const status = String(formular.get('status') ?? '') as WochenStatus;
|
||||||
|
if (!Number.isInteger(wochenNummer) || !wochenStatus.includes(status)) return;
|
||||||
|
|
||||||
|
statusSetzen(ausbildung.id, wochenNummer, status, heute());
|
||||||
|
aktualisiere();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wochenNotiz(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||||
|
const azubiId = String(formular.get('azubiId') ?? '');
|
||||||
|
const ausbildung = await zustaendigeAusbildung(azubiId);
|
||||||
|
|
||||||
|
const wochenNummer = Number(formular.get('wochenNummer'));
|
||||||
|
const notiz = String(formular.get('notiz') ?? '');
|
||||||
|
if (!Number.isInteger(wochenNummer)) return { fehler: 'Wochennummer fehlt.' };
|
||||||
|
if (notiz.length > 500) return { fehler: 'Die Notiz ist zu lang – höchstens 500 Zeichen.' };
|
||||||
|
|
||||||
|
notizSetzen(ausbildung.id, wochenNummer, notiz);
|
||||||
|
aktualisiere();
|
||||||
|
return { erfolg: notiz.trim() ? 'Notiz gespeichert.' : 'Notiz entfernt.' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
import { erfordereAusbilder, erfordereBewertungsrecht } from '@/lib/auth/rechte';
|
||||||
|
import { ausbildungenVonAusbilder, ausbildungNachAzubi } from '@/lib/repos/ausbildung';
|
||||||
|
import { benutzerNachId } from '@/lib/repos/benutzer';
|
||||||
|
import { inhalte } from '@/lib/inhalte';
|
||||||
|
import { planVon } from '@/lib/repos/wochenplanung';
|
||||||
|
import { ueberschneidungen } from '@/lib/planung/wochen';
|
||||||
|
import { alsDatumText, kalenderwocheText } from '@/lib/zeit';
|
||||||
|
import { PlanListe, type Planeintrag } from './PlanListe';
|
||||||
|
import { NotizFormular, PlanErzeugenFormular, StatusFormular, VerlegenFormular } from './Formulare';
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Wochenplan' };
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function WochenplanSeite({ searchParams }: { searchParams: Promise<{ azubi?: string }> }) {
|
||||||
|
const ausbilder = await erfordereAusbilder();
|
||||||
|
const { azubi: azubiParameter } = await searchParams;
|
||||||
|
|
||||||
|
const meine = ausbildungenVonAusbilder(ausbilder.id);
|
||||||
|
if (meine.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="stapel">
|
||||||
|
<h1>Wochenplan</h1>
|
||||||
|
<p className="hinweis hinweis-warnung">
|
||||||
|
Es gibt noch kein Azubikonto. <Link href="/verwaltung">Erst eines anlegen</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Auswahl den ersten Azubi nehmen – bei ein bis drei Azubis ist eine
|
||||||
|
// eigene Auswahlseite Aufwand ohne Nutzen.
|
||||||
|
const azubiId = azubiParameter ?? meine[0]!.azubi.id;
|
||||||
|
if (!azubiParameter) redirect(`/wochenplan?azubi=${azubiId}`);
|
||||||
|
|
||||||
|
erfordereBewertungsrecht(ausbilder, azubiId);
|
||||||
|
|
||||||
|
const azubi = benutzerNachId(azubiId);
|
||||||
|
const ausbildung = ausbildungNachAzubi(azubiId);
|
||||||
|
if (!azubi || !ausbildung) notFound();
|
||||||
|
|
||||||
|
const alle = inhalte();
|
||||||
|
const plan = planVon(ausbildung.id);
|
||||||
|
const fehlende = alle.wochen.filter((w) => !plan.some((z) => z.wochenNummer === w.nummer));
|
||||||
|
const kollisionen = ueberschneidungen(plan);
|
||||||
|
|
||||||
|
const eintraege: Planeintrag[] = plan.map((zeile) => {
|
||||||
|
const woche = alle.wocheNach.get(zeile.wochenNummer);
|
||||||
|
return {
|
||||||
|
wochenNummer: zeile.wochenNummer,
|
||||||
|
titel: woche?.titel ?? `Woche ${zeile.wochenNummer} (im Repository nicht mehr vorhanden)`,
|
||||||
|
geplantVon: zeile.geplantVon,
|
||||||
|
geplantBis: zeile.geplantBis,
|
||||||
|
geplantVonText: alsDatumText(zeile.geplantVon),
|
||||||
|
geplantBisText: alsDatumText(zeile.geplantBis),
|
||||||
|
kalenderwoche: kalenderwocheText(zeile.geplantVon),
|
||||||
|
tatsaechlichVonText: zeile.tatsaechlichVon ? alsDatumText(zeile.tatsaechlichVon) : null,
|
||||||
|
status: zeile.status,
|
||||||
|
notiz: zeile.notiz,
|
||||||
|
handouts: woche?.handouts.length ?? 0,
|
||||||
|
aufgaben: woche?.aufgaben.length ?? 0,
|
||||||
|
hatKontrolle: Boolean(woche?.lernzielkontrolle),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stapel">
|
||||||
|
<div>
|
||||||
|
<h1>Wochenplan</h1>
|
||||||
|
<p className="leise">
|
||||||
|
{azubi.name} · Ausbildung seit {alsDatumText(ausbildung.beginn)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meine.length > 1 ? (
|
||||||
|
<nav className="reihe nicht-drucken" aria-label="Azubi wählen">
|
||||||
|
{meine.map(({ azubi: person }) => (
|
||||||
|
<Link
|
||||||
|
key={person.id}
|
||||||
|
href={`/wochenplan?azubi=${person.id}`}
|
||||||
|
className={`knopf ${person.id === azubiId ? '' : 'knopf-zweit'}`}
|
||||||
|
>
|
||||||
|
{person.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{plan.length === 0 ? (
|
||||||
|
<section className="karte stapel">
|
||||||
|
<h2 style={{ marginBottom: 0 }}>Noch kein Plan</h2>
|
||||||
|
<p style={{ marginBottom: 0 }}>
|
||||||
|
Für {azubi.name} ist noch keine Woche eingeplant. Der Plan legt die {alle.wochen.length} Wochen aus
|
||||||
|
dem Repository ab Montag der Beginnwoche ({alsDatumText(ausbildung.beginn)}) fortlaufend an. Termine
|
||||||
|
lassen sich danach jederzeit verschieben.
|
||||||
|
</p>
|
||||||
|
<PlanErzeugenFormular azubiId={azubiId} text={`${alle.wochen.length} Wochen einplanen`} />
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{fehlende.length > 0 ? (
|
||||||
|
<section className="karte stapel-eng">
|
||||||
|
<p style={{ margin: 0 }}>
|
||||||
|
Im Repository gibt es {fehlende.length} {fehlende.length === 1 ? 'Woche' : 'Wochen'}, die hier
|
||||||
|
noch nicht eingeplant {fehlende.length === 1 ? 'ist' : 'sind'}:{' '}
|
||||||
|
{fehlende.map((w) => `Woche ${w.nummer}`).join(', ')}.
|
||||||
|
</p>
|
||||||
|
<PlanErzeugenFormular azubiId={azubiId} text="Fehlende Wochen anhängen" />
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{kollisionen.length > 0 ? (
|
||||||
|
<p className="hinweis hinweis-warnung">
|
||||||
|
Diese Wochen liegen auf demselben Termin:{' '}
|
||||||
|
{kollisionen.map(([a, b]) => `${a} und ${b}`).join(', ')}. Das kann gewollt sein – wenn nicht,
|
||||||
|
verlege eine davon mit gesetztem Haken „Folgende Wochen mitverschieben“.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="karte stapel" aria-labelledby="verlegen">
|
||||||
|
<h2 id="verlegen" style={{ marginBottom: 0 }}>
|
||||||
|
Woche verlegen
|
||||||
|
</h2>
|
||||||
|
<VerlegenFormular
|
||||||
|
azubiId={azubiId}
|
||||||
|
wochen={eintraege.map((e) => ({ nummer: e.wochenNummer, titel: e.titel, geplantVon: e.geplantVon }))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="stapel" aria-labelledby="reihenfolge">
|
||||||
|
<div>
|
||||||
|
<h2 id="reihenfolge" style={{ marginBottom: '0.2rem' }}>
|
||||||
|
Reihenfolge
|
||||||
|
</h2>
|
||||||
|
<p className="leise" style={{ margin: 0 }}>
|
||||||
|
Zieh eine Woche an ihre neue Stelle, oder nimm die Pfeile – mit der Tastatur geht es mit
|
||||||
|
Alt + Pfeil hoch/runter. Die Termine bleiben, wo sie sind; es ändert sich nur, welche
|
||||||
|
Woche wann liegt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PlanListe eintraege={eintraege} azubiId={azubiId} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="stapel" aria-labelledby="einzeln">
|
||||||
|
<h2 id="einzeln">Zustand und Notizen</h2>
|
||||||
|
{eintraege.map((eintrag) => (
|
||||||
|
<article key={eintrag.wochenNummer} className="karte stapel-eng">
|
||||||
|
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||||
|
<strong>
|
||||||
|
Woche {eintrag.wochenNummer} · {eintrag.titel}
|
||||||
|
</strong>
|
||||||
|
<span className="leise">
|
||||||
|
{eintrag.geplantVonText} – {eintrag.geplantBisText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<StatusFormular azubiId={azubiId} wochenNummer={eintrag.wochenNummer} status={eintrag.status} />
|
||||||
|
<NotizFormular
|
||||||
|
azubiId={azubiId}
|
||||||
|
wochenNummer={eintrag.wochenNummer}
|
||||||
|
notiz={eintrag.notiz ?? ''}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -696,3 +696,185 @@ a.glossarbegriff:hover {
|
|||||||
.handout-zuordnung { border: 1px solid #999; }
|
.handout-zuordnung { border: 1px solid #999; }
|
||||||
a.glossarbegriff { text-decoration: none; }
|
a.glossarbegriff { text-decoration: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Wochenplan --- */
|
||||||
|
|
||||||
|
.wochen-nummer {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.2rem 0.7rem;
|
||||||
|
border-radius: var(--radius-klein);
|
||||||
|
background: var(--petrol);
|
||||||
|
color: var(--text-auf-petrol);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.wochen-nummer-klein { font-size: 0.8rem; padding: 0.1rem 0.5rem; }
|
||||||
|
|
||||||
|
.leitfrage {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--petrol);
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
}
|
||||||
|
:root[data-thema="dunkel"] .leitfrage { color: var(--petrol-hell); }
|
||||||
|
|
||||||
|
.offen-liste {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 1px solid var(--rand-zart);
|
||||||
|
}
|
||||||
|
.offen-liste li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.45rem 0;
|
||||||
|
border-bottom: 1px solid var(--rand-zart);
|
||||||
|
}
|
||||||
|
.offen-liste li::before {
|
||||||
|
content: "○";
|
||||||
|
color: var(--text-leise);
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
.offen-liste li > span:first-of-type { flex: 1; }
|
||||||
|
|
||||||
|
.wochentage {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wochentag {
|
||||||
|
border: 1px solid var(--rand-zart);
|
||||||
|
border-left: 4px solid var(--rand);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--flaeche);
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
}
|
||||||
|
.wochentag-heute {
|
||||||
|
border-left-color: var(--bernstein);
|
||||||
|
background: var(--warnung-flaeche);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wochentag-kopf {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wochentag-inhalt {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.wochentag-inhalt { grid-template-columns: 1fr 1fr; gap: 1.25rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.wochentag-marke {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-leise);
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theorie-liste {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.theorie-liste li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.theorie-liste li + li { margin-top: 0.3rem; }
|
||||||
|
|
||||||
|
/* --- Planliste zum Umsortieren --- */
|
||||||
|
|
||||||
|
.planliste {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.planliste-wartet { opacity: 0.6; pointer-events: none; }
|
||||||
|
|
||||||
|
.planzeile {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.6rem;
|
||||||
|
background: var(--flaeche);
|
||||||
|
border: 1px solid var(--rand-zart);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
}
|
||||||
|
.planzeile-gezogen { opacity: 0.45; }
|
||||||
|
.planzeile-ziel {
|
||||||
|
border-color: var(--bernstein);
|
||||||
|
box-shadow: inset 0 3px 0 var(--bernstein);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planzeile-griff {
|
||||||
|
cursor: grab;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
user-select: none;
|
||||||
|
padding-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planzeile-inhalt { flex: 1; display: grid; gap: 0.25rem; }
|
||||||
|
|
||||||
|
.planzeile-titel {
|
||||||
|
font-weight: 650;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.planzeile-titel:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.planzeile-notiz {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--warnung-flaeche);
|
||||||
|
border-left: 3px solid var(--bernstein);
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 0 var(--radius-klein) var(--radius-klein) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planzeile-knoepfe {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
.planzeile-knoepfe .knopf { min-height: 34px; padding: 0.1rem 0.5rem; }
|
||||||
|
|
||||||
|
.ankreuzfeld {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-weight: 400;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ankreuzfeld input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: var(--petrol);
|
||||||
|
}
|
||||||
|
.ankreuzfeld > span:last-child { font-weight: 600; font-size: 0.95rem; }
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.planzeile-griff,
|
||||||
|
.planzeile-knoepfe { display: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Fortschritt } from '@/lib/planung/fortschritt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fortschritt als Balken mit Beschriftung. Die Zahl steht immer daneben –
|
||||||
|
* ein Balken allein ist für jemanden mit Sehschwäche wertlos, und die
|
||||||
|
* Statusfarbe trägt hier bewusst keine eigene Bedeutung.
|
||||||
|
*/
|
||||||
|
export function Fortschrittsbalken({
|
||||||
|
fortschritt,
|
||||||
|
beschriftung,
|
||||||
|
}: {
|
||||||
|
fortschritt: Fortschritt;
|
||||||
|
beschriftung?: string;
|
||||||
|
}) {
|
||||||
|
const { erledigt, gesamt, prozent } = fortschritt;
|
||||||
|
|
||||||
|
if (gesamt === 0) {
|
||||||
|
return <p className="leise" style={{ margin: 0 }}>Für diese Woche ist nichts zum Abhaken hinterlegt.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="reihe" style={{ justifyContent: 'space-between', gap: '0.5rem', marginBottom: '0.3rem' }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.92rem' }}>{beschriftung ?? 'Diese Woche erledigt'}</span>
|
||||||
|
<span className="leise">
|
||||||
|
{erledigt} von {gesamt} · {prozent} %
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="balken"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={prozent}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={`${beschriftung ?? 'Diese Woche erledigt'}: ${erledigt} von ${gesamt}`}
|
||||||
|
>
|
||||||
|
<span style={{ width: `${prozent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Baustein 3: Wochenplanung.
|
||||||
|
--
|
||||||
|
-- Welche Woche des Lehrgangs liegt wann? Der Inhalt der Woche steht in
|
||||||
|
-- content/wochen/, hier steht nur die Terminlage – und die verschiebt sich
|
||||||
|
-- ständig: Berufsschule, Kundeneinsatz, Krankheit.
|
||||||
|
--
|
||||||
|
-- geplant_von/geplant_bis Montag und Freitag der eingeplanten Woche
|
||||||
|
-- tatsaechlich_von wann sie wirklich begonnen hat (erst beim Start gesetzt)
|
||||||
|
|
||||||
|
CREATE TABLE wochenplanung (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
ausbildung_id TEXT NOT NULL REFERENCES ausbildung (id) ON DELETE CASCADE,
|
||||||
|
wochen_nummer INTEGER NOT NULL,
|
||||||
|
geplant_von TEXT NOT NULL,
|
||||||
|
geplant_bis TEXT NOT NULL,
|
||||||
|
tatsaechlich_von TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'geplant'
|
||||||
|
CHECK (status IN ('geplant', 'laufend', 'abgeschlossen')),
|
||||||
|
notiz TEXT,
|
||||||
|
geaendert_am TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX wochenplanung_woche_idx ON wochenplanung (ausbildung_id, wochen_nummer);
|
||||||
|
CREATE INDEX wochenplanung_termin_idx ON wochenplanung (ausbildung_id, geplant_von);
|
||||||
|
CREATE INDEX wochenplanung_status_idx ON wochenplanung (ausbildung_id, status);
|
||||||
@@ -63,10 +63,37 @@ export const handoutGelesen = sqliteTable(
|
|||||||
(t) => [primaryKey({ columns: [t.benutzerId, t.handoutId] }), index('handout_gelesen_benutzer_idx').on(t.benutzerId)],
|
(t) => [primaryKey({ columns: [t.benutzerId, t.handoutId] }), index('handout_gelesen_benutzer_idx').on(t.benutzerId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Zustände einer eingeplanten Woche – als Aufzählung, nicht als freier Text. */
|
||||||
|
export const wochenStatus = ['geplant', 'laufend', 'abgeschlossen'] as const;
|
||||||
|
export type WochenStatus = (typeof wochenStatus)[number];
|
||||||
|
|
||||||
|
export const wochenplanung = sqliteTable(
|
||||||
|
'wochenplanung',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
ausbildungId: text('ausbildung_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ausbildung.id, { onDelete: 'cascade' }),
|
||||||
|
wochenNummer: integer('wochen_nummer').notNull(),
|
||||||
|
geplantVon: text('geplant_von').notNull(),
|
||||||
|
geplantBis: text('geplant_bis').notNull(),
|
||||||
|
tatsaechlichVon: text('tatsaechlich_von'),
|
||||||
|
status: text('status', { enum: wochenStatus }).notNull().default('geplant'),
|
||||||
|
notiz: text('notiz'),
|
||||||
|
geaendertAm: text('geaendert_am').notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('wochenplanung_woche_idx').on(t.ausbildungId, t.wochenNummer),
|
||||||
|
index('wochenplanung_termin_idx').on(t.ausbildungId, t.geplantVon),
|
||||||
|
index('wochenplanung_status_idx').on(t.ausbildungId, t.status),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
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;
|
||||||
export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
|
export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
|
||||||
|
export type Wochenplanung = typeof wochenplanung.$inferSelect;
|
||||||
|
|
||||||
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
|
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
|
||||||
export const alleTabellen = { benutzer, sitzung, ausbildung, handoutGelesen };
|
export const alleTabellen = { benutzer, sitzung, ausbildung, handoutGelesen, wochenplanung };
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Fortschritt einer Woche.
|
||||||
|
*
|
||||||
|
* Bewusst als reine Rechnung über Zählwerte: Die einzelnen Bausteine liefern
|
||||||
|
* ihre Zahlen (gelesene Handouts, abgenommene Aufgaben, abgeschlossene
|
||||||
|
* Checklisten …), hier entsteht daraus ein Balken und eine Liste dessen, was
|
||||||
|
* noch offen ist. So wächst die Rechnung mit, ohne sich zu verzweigen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Zaehlwerk = { erledigt: number; gesamt: number };
|
||||||
|
|
||||||
|
export const leer: Zaehlwerk = { erledigt: 0, gesamt: 0 };
|
||||||
|
|
||||||
|
export type Wochenstand = {
|
||||||
|
handouts: Zaehlwerk;
|
||||||
|
karten: Zaehlwerk;
|
||||||
|
aufgaben: Zaehlwerk;
|
||||||
|
checklisten: Zaehlwerk;
|
||||||
|
kontrolle: Zaehlwerk;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Teilbereich = { schluessel: keyof Wochenstand; titel: string; zaehlwerk: Zaehlwerk };
|
||||||
|
|
||||||
|
const bezeichnungen: Record<keyof Wochenstand, string> = {
|
||||||
|
handouts: 'Handouts gelesen',
|
||||||
|
karten: 'Merkkarten gelernt',
|
||||||
|
aufgaben: 'Praxisaufgaben abgenommen',
|
||||||
|
checklisten: 'Checklisten abgeschlossen',
|
||||||
|
kontrolle: 'Lernzielkontrolle abgegeben',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Fortschritt = {
|
||||||
|
erledigt: number;
|
||||||
|
gesamt: number;
|
||||||
|
prozent: number;
|
||||||
|
/** Nur die Teilbereiche, in denen es diese Woche überhaupt etwas zu tun gibt. */
|
||||||
|
bereiche: Teilbereich[];
|
||||||
|
offen: Teilbereich[];
|
||||||
|
vollstaendig: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function wochenFortschritt(stand: Wochenstand): Fortschritt {
|
||||||
|
const bereiche: Teilbereich[] = (Object.keys(bezeichnungen) as (keyof Wochenstand)[])
|
||||||
|
.map((schluessel) => ({ schluessel, titel: bezeichnungen[schluessel], zaehlwerk: stand[schluessel] }))
|
||||||
|
.filter((b) => b.zaehlwerk.gesamt > 0);
|
||||||
|
|
||||||
|
const erledigt = bereiche.reduce((summe, b) => summe + Math.min(b.zaehlwerk.erledigt, b.zaehlwerk.gesamt), 0);
|
||||||
|
const gesamt = bereiche.reduce((summe, b) => summe + b.zaehlwerk.gesamt, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
erledigt,
|
||||||
|
gesamt,
|
||||||
|
prozent: anteil(erledigt, gesamt),
|
||||||
|
bereiche,
|
||||||
|
offen: bereiche.filter((b) => b.zaehlwerk.erledigt < b.zaehlwerk.gesamt),
|
||||||
|
vollstaendig: gesamt > 0 && erledigt >= gesamt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anteil in Prozent, kaufmännisch gerundet.
|
||||||
|
*
|
||||||
|
* Zwei Feinheiten, die im Alltag auffallen würden:
|
||||||
|
* - Ohne etwas zu tun gibt es 100 Prozent, nicht 0 – eine leere Woche ist
|
||||||
|
* nicht „nichts geschafft", sondern „nichts zu tun".
|
||||||
|
* - 99 statt 100 wird nicht auf 100 gerundet. Wer einen vollen Balken sieht,
|
||||||
|
* aber noch eine offene Aufgabe hat, hält die Anzeige für kaputt.
|
||||||
|
*/
|
||||||
|
export function anteil(erledigt: number, gesamt: number): number {
|
||||||
|
if (gesamt <= 0) return 100;
|
||||||
|
if (erledigt <= 0) return 0;
|
||||||
|
if (erledigt >= gesamt) return 100;
|
||||||
|
return Math.min(99, Math.max(1, Math.round((erledigt / gesamt) * 100)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { inhalte, type Handout, type Aufgabe } from '../inhalte';
|
||||||
|
import type { Checkliste, Kontrolle, Woche } from '../inhalte/schema';
|
||||||
|
import { gelesenSet } from '../repos/fortschritt';
|
||||||
|
import { planVon } from '../repos/wochenplanung';
|
||||||
|
import { heute } from '../zeit';
|
||||||
|
import { aktuelleWoche } from './wochen';
|
||||||
|
import { leer, wochenFortschritt, type Fortschritt } from './fortschritt';
|
||||||
|
import type { Wochenplanung } from '../db/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt aus Inhalt (Datei), Planung (Datenbank) und Fortschritt (Datenbank)
|
||||||
|
* die Wochenansicht zusammen. Alles, was die Startseite braucht, kommt hier her.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Tagessicht = {
|
||||||
|
tag: string;
|
||||||
|
theorie: Handout[];
|
||||||
|
praxis: string;
|
||||||
|
/** Der Tag, der im Kalender zu diesem Wochentag gehört. */
|
||||||
|
datum: string;
|
||||||
|
istHeute: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Wochensicht = {
|
||||||
|
woche: Woche;
|
||||||
|
planung: Wochenplanung;
|
||||||
|
fortschritt: Fortschritt;
|
||||||
|
tage: Tagessicht[];
|
||||||
|
handouts: { handout: Handout; gelesen: boolean }[];
|
||||||
|
aufgaben: Aufgabe[];
|
||||||
|
checklisten: Checkliste[];
|
||||||
|
kontrolle: Kontrolle | null;
|
||||||
|
vorherige: number | null;
|
||||||
|
naechste: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function wochensicht(ausbildungId: string, azubiId: string, wochenNummer: number): Wochensicht | null {
|
||||||
|
const alle = inhalte();
|
||||||
|
const woche = alle.wocheNach.get(wochenNummer);
|
||||||
|
if (!woche) return null;
|
||||||
|
|
||||||
|
const plan = planVon(ausbildungId);
|
||||||
|
const planung = plan.find((z) => z.wochenNummer === wochenNummer);
|
||||||
|
if (!planung) return null;
|
||||||
|
|
||||||
|
const gelesen = gelesenSet(azubiId);
|
||||||
|
const heuteDatum = heute();
|
||||||
|
|
||||||
|
const handouts = woche.handouts
|
||||||
|
.map((id) => alle.handoutNach.get(id))
|
||||||
|
.filter((h) => h !== undefined)
|
||||||
|
.map((handout) => ({ handout, gelesen: gelesen.has(handout.id) }));
|
||||||
|
|
||||||
|
const tage: Tagessicht[] = woche.tage.map((tag, stelle) => {
|
||||||
|
const datum = tagVersetzt(planung.geplantVon, stelle);
|
||||||
|
return {
|
||||||
|
tag: tag.tag,
|
||||||
|
praxis: tag.praxis,
|
||||||
|
datum,
|
||||||
|
istHeute: datum === heuteDatum,
|
||||||
|
theorie: tag.theorie.map((id) => alle.handoutNach.get(id)).filter((h) => h !== undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const aufgaben = woche.aufgaben.map((id) => alle.aufgabeNach.get(id)).filter((a) => a !== undefined);
|
||||||
|
const checklisten = woche.checklisten.map((id) => alle.checklisteNach.get(id)).filter((c) => c !== undefined);
|
||||||
|
const kontrolle = woche.lernzielkontrolle ? (alle.kontrolleNach.get(woche.lernzielkontrolle) ?? null) : null;
|
||||||
|
|
||||||
|
const fortschritt = wochenFortschritt({
|
||||||
|
handouts: { erledigt: handouts.filter((h) => h.gelesen).length, gesamt: handouts.length },
|
||||||
|
// Diese Bereiche werden mit ihren Bausteinen scharf geschaltet. Bis dahin
|
||||||
|
// zeigen sie ehrlich an, was die Woche vorsieht und noch offen ist.
|
||||||
|
karten: leer,
|
||||||
|
aufgaben: { erledigt: 0, gesamt: aufgaben.length },
|
||||||
|
checklisten: { erledigt: 0, gesamt: checklisten.length },
|
||||||
|
kontrolle: { erledigt: 0, gesamt: kontrolle ? 1 : 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const nummern = plan.map((z) => z.wochenNummer);
|
||||||
|
const stelle = plan.findIndex((z) => z.wochenNummer === wochenNummer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
woche,
|
||||||
|
planung,
|
||||||
|
fortschritt,
|
||||||
|
tage,
|
||||||
|
handouts,
|
||||||
|
aufgaben,
|
||||||
|
checklisten,
|
||||||
|
kontrolle,
|
||||||
|
vorherige: stelle > 0 ? (nummern[stelle - 1] ?? null) : null,
|
||||||
|
naechste: stelle < nummern.length - 1 ? (nummern[stelle + 1] ?? null) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Woche, die gerade dran ist. */
|
||||||
|
export function aktuelleWochensicht(ausbildungId: string, azubiId: string): Wochensicht | null {
|
||||||
|
const plan = planVon(ausbildungId);
|
||||||
|
const dran = aktuelleWoche(plan, heute());
|
||||||
|
if (!dran) return null;
|
||||||
|
return wochensicht(ausbildungId, azubiId, dran.wochenNummer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kurzstand für die Ausbilderübersicht – ohne den ganzen Wochenaufbau. */
|
||||||
|
export type Kurzstand = {
|
||||||
|
wochenNummer: number;
|
||||||
|
titel: string;
|
||||||
|
geplantVon: string;
|
||||||
|
geplantBis: string;
|
||||||
|
status: Wochenplanung['status'];
|
||||||
|
fortschritt: Fortschritt;
|
||||||
|
handoutsGelesen: number;
|
||||||
|
handoutsGesamt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function kurzstand(ausbildungId: string, azubiId: string): Kurzstand | null {
|
||||||
|
const sicht = aktuelleWochensicht(ausbildungId, azubiId);
|
||||||
|
if (!sicht) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
wochenNummer: sicht.woche.nummer,
|
||||||
|
titel: sicht.woche.titel,
|
||||||
|
geplantVon: sicht.planung.geplantVon,
|
||||||
|
geplantBis: sicht.planung.geplantBis,
|
||||||
|
status: sicht.planung.status,
|
||||||
|
fortschritt: sicht.fortschritt,
|
||||||
|
handoutsGelesen: sicht.handouts.filter((h) => h.gelesen).length,
|
||||||
|
handoutsGesamt: sicht.handouts.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagVersetzt(montag: string, tage: number): string {
|
||||||
|
const d = new Date(`${montag}T12:00:00Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() + tage);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { aktuelleWoche, erzeugePlan, sortiereUm, ueberschneidungen, verschiebe, type Planzeile } from './wochen';
|
||||||
|
import { anteil, leer, wochenFortschritt, type Wochenstand } from './fortschritt';
|
||||||
|
|
||||||
|
function plan(): Planzeile[] {
|
||||||
|
return erzeugePlan('2026-09-07', [1, 2, 3, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Plan erzeugen', () => {
|
||||||
|
it('legt fortlaufende Wochen ab dem Montag des Beginns an', () => {
|
||||||
|
const zeilen = plan();
|
||||||
|
|
||||||
|
expect(zeilen.map((z) => z.geplantVon)).toEqual(['2026-09-07', '2026-09-14', '2026-09-21', '2026-09-28']);
|
||||||
|
expect(zeilen.map((z) => z.geplantBis)).toEqual(['2026-09-11', '2026-09-18', '2026-09-25', '2026-10-02']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beginnt auch dann am Montag, wenn die Ausbildung mitten in der Woche startet', () => {
|
||||||
|
// 2026-09-09 ist ein Mittwoch.
|
||||||
|
expect(erzeugePlan('2026-09-09', [1])[0]!.geplantVon).toBe('2026-09-07');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sortiert die Wochennummern, egal in welcher Reihenfolge sie kommen', () => {
|
||||||
|
const zeilen = erzeugePlan('2026-09-07', [3, 1, 2]);
|
||||||
|
expect(zeilen.map((z) => z.wochenNummer)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startet alles im Zustand geplant', () => {
|
||||||
|
expect(plan().every((z) => z.status === 'geplant' && z.tatsaechlichVon === null)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Woche verschieben', () => {
|
||||||
|
it('verschiebt nur die gewählte Woche, wenn nichts anderes verlangt ist', () => {
|
||||||
|
const verschoben = verschiebe(plan(), 2, '2026-09-21', false);
|
||||||
|
|
||||||
|
expect(termin(verschoben, 1)).toBe('2026-09-07');
|
||||||
|
expect(termin(verschoben, 2)).toBe('2026-09-21');
|
||||||
|
expect(termin(verschoben, 3)).toBe('2026-09-21'); // unverändert – Überschneidung in Kauf genommen
|
||||||
|
expect(termin(verschoben, 4)).toBe('2026-09-28');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schiebt auf Wunsch alle folgenden Wochen mit', () => {
|
||||||
|
const verschoben = verschiebe(plan(), 2, '2026-09-21', true);
|
||||||
|
|
||||||
|
expect(termin(verschoben, 1)).toBe('2026-09-07');
|
||||||
|
expect(termin(verschoben, 2)).toBe('2026-09-21');
|
||||||
|
expect(termin(verschoben, 3)).toBe('2026-09-28');
|
||||||
|
expect(termin(verschoben, 4)).toBe('2026-10-05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zieht den Plan auch nach vorne', () => {
|
||||||
|
const verschoben = verschiebe(plan(), 3, '2026-09-14', true);
|
||||||
|
|
||||||
|
expect(termin(verschoben, 3)).toBe('2026-09-14');
|
||||||
|
expect(termin(verschoben, 4)).toBe('2026-09-21');
|
||||||
|
expect(termin(verschoben, 1)).toBe('2026-09-07'); // davor bleibt stehen
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rundet ein beliebiges Datum auf den Montag', () => {
|
||||||
|
// 2026-09-24 ist ein Donnerstag.
|
||||||
|
const verschoben = verschiebe(plan(), 2, '2026-09-24', false);
|
||||||
|
expect(termin(verschoben, 2)).toBe('2026-09-21');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt abgeschlossene Wochen stehen, auch wenn sie danach liegen', () => {
|
||||||
|
const mitAbgeschlossener = plan().map((z) => (z.wochenNummer === 3 ? { ...z, status: 'abgeschlossen' as const } : z));
|
||||||
|
const verschoben = verschiebe(mitAbgeschlossener, 2, '2026-09-21', true);
|
||||||
|
|
||||||
|
expect(termin(verschoben, 3)).toBe('2026-09-21'); // steht, weil abgeschlossen
|
||||||
|
expect(termin(verschoben, 4)).toBe('2026-10-05'); // rutscht mit
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ändert nichts, wenn das Ziel schon der aktuelle Termin ist', () => {
|
||||||
|
const vorher = plan();
|
||||||
|
expect(verschiebe(vorher, 2, '2026-09-14', true)).toEqual(vorher);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ändert nichts bei einer unbekannten Wochennummer', () => {
|
||||||
|
const vorher = plan();
|
||||||
|
expect(verschiebe(vorher, 99, '2026-10-05', true)).toEqual(vorher);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behält Zustand und tatsächlichen Beginn bei', () => {
|
||||||
|
const mitStand = plan().map((z) =>
|
||||||
|
z.wochenNummer === 2 ? { ...z, status: 'laufend' as const, tatsaechlichVon: '2026-09-15' } : z,
|
||||||
|
);
|
||||||
|
const verschoben = verschiebe(mitStand, 2, '2026-09-21', true);
|
||||||
|
const zwei = verschoben.find((z) => z.wochenNummer === 2)!;
|
||||||
|
|
||||||
|
expect(zwei.status).toBe('laufend');
|
||||||
|
expect(zwei.tatsaechlichVon).toBe('2026-09-15');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reihenfolge ändern (Ziehen und Ablegen)', () => {
|
||||||
|
it('setzt eine Woche nach hinten und schiebt die dazwischen nach vorn', () => {
|
||||||
|
const umsortiert = sortiereUm(plan(), 1, 2);
|
||||||
|
|
||||||
|
expect(reihenfolge(umsortiert)).toEqual([2, 3, 1, 4]);
|
||||||
|
// Die Terminfolge bleibt lückenlos dieselbe.
|
||||||
|
expect(termine(umsortiert)).toEqual(['2026-09-07', '2026-09-14', '2026-09-21', '2026-09-28']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt eine Woche nach vorn', () => {
|
||||||
|
const umsortiert = sortiereUm(plan(), 4, 0);
|
||||||
|
expect(reihenfolge(umsortiert)).toEqual([4, 1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt die Termine unverändert – nur die Zuordnung wechselt', () => {
|
||||||
|
const vorher = plan();
|
||||||
|
const umsortiert = sortiereUm(vorher, 3, 0);
|
||||||
|
|
||||||
|
expect(termine(umsortiert)).toEqual(termine(vorher));
|
||||||
|
expect(termin(umsortiert, 3)).toBe('2026-09-07');
|
||||||
|
expect(termin(umsortiert, 1)).toBe('2026-09-14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ändert nichts, wenn die Stelle dieselbe bleibt', () => {
|
||||||
|
const vorher = plan();
|
||||||
|
expect(sortiereUm(vorher, 2, 1)).toEqual(vorher);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fängt Stellen außerhalb des Plans ab', () => {
|
||||||
|
expect(reihenfolge(sortiereUm(plan(), 1, 99))).toEqual([2, 3, 4, 1]);
|
||||||
|
expect(reihenfolge(sortiereUm(plan(), 4, -5))).toEqual([4, 1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ändert nichts bei einer unbekannten Wochennummer', () => {
|
||||||
|
const vorher = plan();
|
||||||
|
expect(sortiereUm(vorher, 99, 0)).toEqual(vorher);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Aktuelle Woche bestimmen', () => {
|
||||||
|
it('nimmt eine ausdrücklich laufende Woche, auch wenn ihr Termin vorbei ist', () => {
|
||||||
|
const zeilen = plan().map((z) => (z.wochenNummer === 1 ? { ...z, status: 'laufend' as const } : z));
|
||||||
|
expect(aktuelleWoche(zeilen, '2026-09-30')!.wochenNummer).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt die Woche, in die das heutige Datum fällt', () => {
|
||||||
|
expect(aktuelleWoche(plan(), '2026-09-16')!.wochenNummer).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt am Wochenende die Woche, in der der Freitag lag – nicht die nächste', () => {
|
||||||
|
// Samstag, 2026-09-12: liegt hinter dem Freitag der Woche 1.
|
||||||
|
expect(aktuelleWoche(plan(), '2026-09-12')!.wochenNummer).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt vor dem Beginn die erste Woche', () => {
|
||||||
|
expect(aktuelleWoche(plan(), '2026-08-01')!.wochenNummer).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt am Ende die letzte offene Woche statt einer abgeschlossenen', () => {
|
||||||
|
const zeilen = plan().map((z) => (z.wochenNummer <= 3 ? { ...z, status: 'abgeschlossen' as const } : z));
|
||||||
|
expect(aktuelleWoche(zeilen, '2026-12-01')!.wochenNummer).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt die letzte Woche, wenn alles abgeschlossen ist', () => {
|
||||||
|
const zeilen = plan().map((z) => ({ ...z, status: 'abgeschlossen' as const }));
|
||||||
|
expect(aktuelleWoche(zeilen, '2026-12-01')!.wochenNummer).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kommt mit einem leeren Plan zurecht', () => {
|
||||||
|
expect(aktuelleWoche([], '2026-09-10')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Überschneidungen finden', () => {
|
||||||
|
it('meldet nichts bei einem lückenlosen Plan', () => {
|
||||||
|
expect(ueberschneidungen(plan())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet zwei Wochen auf demselben Termin', () => {
|
||||||
|
const verschoben = verschiebe(plan(), 2, '2026-09-21', false);
|
||||||
|
expect(ueberschneidungen(verschoben)).toContainEqual([2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Fortschritt einer Woche', () => {
|
||||||
|
const nichts: Wochenstand = { handouts: leer, karten: leer, aufgaben: leer, checklisten: leer, kontrolle: leer };
|
||||||
|
|
||||||
|
it('zählt nur Bereiche, in denen es etwas zu tun gibt', () => {
|
||||||
|
const stand = wochenFortschritt({ ...nichts, handouts: { erledigt: 2, gesamt: 4 } });
|
||||||
|
|
||||||
|
expect(stand.bereiche.map((b) => b.schluessel)).toEqual(['handouts']);
|
||||||
|
expect(stand.gesamt).toBe(4);
|
||||||
|
expect(stand.prozent).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fasst mehrere Bereiche zusammen', () => {
|
||||||
|
const stand = wochenFortschritt({
|
||||||
|
...nichts,
|
||||||
|
handouts: { erledigt: 4, gesamt: 4 },
|
||||||
|
aufgaben: { erledigt: 0, gesamt: 1 },
|
||||||
|
kontrolle: { erledigt: 1, gesamt: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stand.erledigt).toBe(5);
|
||||||
|
expect(stand.gesamt).toBe(6);
|
||||||
|
expect(stand.offen.map((b) => b.schluessel)).toEqual(['aufgaben']);
|
||||||
|
expect(stand.vollstaendig).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennt eine vollständig erledigte Woche', () => {
|
||||||
|
const stand = wochenFortschritt({ ...nichts, handouts: { erledigt: 3, gesamt: 3 } });
|
||||||
|
expect(stand.vollstaendig).toBe(true);
|
||||||
|
expect(stand.prozent).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nennt eine Woche ohne Inhalt nicht unvollständig', () => {
|
||||||
|
const stand = wochenFortschritt(nichts);
|
||||||
|
expect(stand.prozent).toBe(100);
|
||||||
|
expect(stand.offen).toEqual([]);
|
||||||
|
expect(stand.vollstaendig).toBe(false); // es gibt nichts zu erledigen
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt sich durch zu hohe Zählwerte nicht über 100 treiben', () => {
|
||||||
|
const stand = wochenFortschritt({ ...nichts, handouts: { erledigt: 9, gesamt: 3 } });
|
||||||
|
expect(stand.erledigt).toBe(3);
|
||||||
|
expect(stand.prozent).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Anteil in Prozent', () => {
|
||||||
|
it('rundet kaufmännisch', () => {
|
||||||
|
expect(anteil(1, 3)).toBe(33);
|
||||||
|
expect(anteil(2, 3)).toBe(67);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt nie 100, solange noch etwas offen ist', () => {
|
||||||
|
expect(anteil(199, 200)).toBe(99);
|
||||||
|
expect(anteil(200, 200)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt nie 0, sobald etwas erledigt ist', () => {
|
||||||
|
expect(anteil(1, 500)).toBe(1);
|
||||||
|
expect(anteil(0, 500)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt bei nichts zu tun 100 zurück', () => {
|
||||||
|
expect(anteil(0, 0)).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Hilfsmittel ---
|
||||||
|
|
||||||
|
function termin(zeilen: Planzeile[], nummer: number): string {
|
||||||
|
return zeilen.find((z) => z.wochenNummer === nummer)!.geplantVon;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reihenfolge(zeilen: Planzeile[]): number[] {
|
||||||
|
return [...zeilen].sort((a, b) => a.geplantVon.localeCompare(b.geplantVon)).map((z) => z.wochenNummer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function termine(zeilen: Planzeile[]): string[] {
|
||||||
|
return [...zeilen].map((z) => z.geplantVon).sort();
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { montagDerWoche, plusTage } from '../zeit';
|
||||||
|
import type { WochenStatus } from '../db/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Rechenregeln der Wochenplanung – ohne Datenbank, damit sie sich
|
||||||
|
* vollständig testen lassen. Die Repository-Schicht holt die Datensätze,
|
||||||
|
* ruft hier die Rechnung auf und schreibt das Ergebnis zurück.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Planzeile = {
|
||||||
|
wochenNummer: number;
|
||||||
|
geplantVon: string;
|
||||||
|
geplantBis: string;
|
||||||
|
tatsaechlichVon: string | null;
|
||||||
|
status: WochenStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen Plan aus fortlaufenden Kalenderwochen ab dem Ausbildungsbeginn.
|
||||||
|
* Jede Woche beginnt am Montag – auch wenn die Ausbildung an einem Mittwoch
|
||||||
|
* anfängt, denn der Wochenplan ist auf Montag bis Freitag gebaut.
|
||||||
|
*/
|
||||||
|
export function erzeugePlan(beginn: string, wochenNummern: number[]): Planzeile[] {
|
||||||
|
const ersterMontag = montagDerWoche(beginn);
|
||||||
|
|
||||||
|
return [...wochenNummern]
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((nummer, laufend) => {
|
||||||
|
const von = plusTage(ersterMontag, laufend * 7);
|
||||||
|
return {
|
||||||
|
wochenNummer: nummer,
|
||||||
|
geplantVon: von,
|
||||||
|
geplantBis: plusTage(von, 4),
|
||||||
|
tatsaechlichVon: null,
|
||||||
|
status: 'geplant' as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschiebt eine Woche auf einen neuen Montag.
|
||||||
|
*
|
||||||
|
* `folgendeMitverschieben` ist der Normalfall im Alltag: Fällt eine Woche aus,
|
||||||
|
* rutscht der ganze Rest hinterher. Ohne das Kennzeichen wird nur diese eine
|
||||||
|
* Woche verlegt – dann können sich Termine überlappen, was der Aufrufer
|
||||||
|
* hinnehmen muss (manchmal ist genau das gewollt).
|
||||||
|
*/
|
||||||
|
export function verschiebe(
|
||||||
|
plan: Planzeile[],
|
||||||
|
wochenNummer: number,
|
||||||
|
neuerMontag: string,
|
||||||
|
folgendeMitverschieben: boolean,
|
||||||
|
): Planzeile[] {
|
||||||
|
const betroffen = plan.find((z) => z.wochenNummer === wochenNummer);
|
||||||
|
if (!betroffen) return plan;
|
||||||
|
|
||||||
|
const ziel = montagDerWoche(neuerMontag);
|
||||||
|
const verschiebungTage = tageZwischen(betroffen.geplantVon, ziel);
|
||||||
|
if (verschiebungTage === 0) return plan;
|
||||||
|
|
||||||
|
const nachSortiert = [...plan].sort(nachTermin);
|
||||||
|
const stelleBetroffen = nachSortiert.findIndex((z) => z.wochenNummer === wochenNummer);
|
||||||
|
|
||||||
|
return plan.map((zeile) => {
|
||||||
|
const stelle = nachSortiert.findIndex((z) => z.wochenNummer === zeile.wochenNummer);
|
||||||
|
const istBetroffen = zeile.wochenNummer === wochenNummer;
|
||||||
|
const istSpaeter = stelle > stelleBetroffen;
|
||||||
|
|
||||||
|
if (!istBetroffen && !(folgendeMitverschieben && istSpaeter)) return zeile;
|
||||||
|
// Abgeschlossene Wochen bleiben stehen – sie sind Vergangenheit.
|
||||||
|
if (!istBetroffen && zeile.status === 'abgeschlossen') return zeile;
|
||||||
|
|
||||||
|
const von = plusTage(zeile.geplantVon, verschiebungTage);
|
||||||
|
return { ...zeile, geplantVon: von, geplantBis: plusTage(von, 4) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt eine Woche an eine andere Stelle der Reihenfolge – das ist die
|
||||||
|
* Rechnung hinter dem Ziehen und Ablegen.
|
||||||
|
*
|
||||||
|
* Die Termine selbst bleiben, wo sie sind: Es gibt eine feste Folge von
|
||||||
|
* Kalenderwochen, und die Wochen des Lehrgangs werden neu darauf verteilt.
|
||||||
|
* So bleibt der Plan lückenlos, egal wie oft umsortiert wird.
|
||||||
|
*/
|
||||||
|
export function sortiereUm(plan: Planzeile[], wochenNummer: number, neueStelle: number): Planzeile[] {
|
||||||
|
const nachSortiert = [...plan].sort(nachTermin);
|
||||||
|
const alteStelle = nachSortiert.findIndex((z) => z.wochenNummer === wochenNummer);
|
||||||
|
if (alteStelle === -1) return plan;
|
||||||
|
|
||||||
|
const ziel = Math.max(0, Math.min(neueStelle, nachSortiert.length - 1));
|
||||||
|
if (ziel === alteStelle) return plan;
|
||||||
|
|
||||||
|
// Die Terminfolge ist der feste Rahmen.
|
||||||
|
const termine = nachSortiert.map((z) => ({ von: z.geplantVon, bis: z.geplantBis }));
|
||||||
|
|
||||||
|
const umsortiert = [...nachSortiert];
|
||||||
|
const [herausgenommen] = umsortiert.splice(alteStelle, 1);
|
||||||
|
umsortiert.splice(ziel, 0, herausgenommen!);
|
||||||
|
|
||||||
|
return umsortiert.map((zeile, stelle) => ({
|
||||||
|
...zeile,
|
||||||
|
geplantVon: termine[stelle]!.von,
|
||||||
|
geplantBis: termine[stelle]!.bis,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche Woche ist gerade dran?
|
||||||
|
*
|
||||||
|
* Reihenfolge der Überlegung:
|
||||||
|
* 1. Läuft eine Woche ausdrücklich? Dann die.
|
||||||
|
* 2. Fällt heute in eine geplante Woche? Dann die.
|
||||||
|
* 3. Sonst die nächste, die noch nicht abgeschlossen ist.
|
||||||
|
* 4. Sonst die letzte – die Ausbildung ist durch.
|
||||||
|
*/
|
||||||
|
export function aktuelleWoche(plan: Planzeile[], heute: string): Planzeile | null {
|
||||||
|
if (plan.length === 0) return null;
|
||||||
|
const nachSortiert = [...plan].sort(nachTermin);
|
||||||
|
|
||||||
|
const laufend = nachSortiert.find((z) => z.status === 'laufend');
|
||||||
|
if (laufend) return laufend;
|
||||||
|
|
||||||
|
const heuteDrin = nachSortiert.find((z) => z.geplantVon <= heute && heute <= z.geplantBis);
|
||||||
|
if (heuteDrin) return heuteDrin;
|
||||||
|
|
||||||
|
const naechste = nachSortiert.find((z) => z.status !== 'abgeschlossen' && z.geplantVon > heute);
|
||||||
|
if (naechste) return naechste;
|
||||||
|
|
||||||
|
const offeneVergangene = nachSortiert.filter((z) => z.status !== 'abgeschlossen');
|
||||||
|
if (offeneVergangene.length > 0) return offeneVergangene.at(-1)!;
|
||||||
|
|
||||||
|
return nachSortiert.at(-1)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Termine im Plan, die sich überlappen – nach dem Verschieben einer einzelnen Woche möglich. */
|
||||||
|
export function ueberschneidungen(plan: Planzeile[]): [number, number][] {
|
||||||
|
const nachSortiert = [...plan].sort(nachTermin);
|
||||||
|
const treffer: [number, number][] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < nachSortiert.length; i += 1) {
|
||||||
|
const vorher = nachSortiert[i - 1]!;
|
||||||
|
const jetzt = nachSortiert[i]!;
|
||||||
|
if (jetzt.geplantVon <= vorher.geplantBis) {
|
||||||
|
treffer.push([vorher.wochenNummer, jetzt.wochenNummer]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return treffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nachTermin(a: Planzeile, b: Planzeile): number {
|
||||||
|
if (a.geplantVon !== b.geplantVon) return a.geplantVon.localeCompare(b.geplantVon);
|
||||||
|
return a.wochenNummer - b.wochenNummer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tageZwischen(von: string, bis: string): number {
|
||||||
|
const a = Date.UTC(Number(von.slice(0, 4)), Number(von.slice(5, 7)) - 1, Number(von.slice(8, 10)));
|
||||||
|
const b = Date.UTC(Number(bis.slice(0, 4)), Number(bis.slice(5, 7)) - 1, Number(bis.slice(8, 10)));
|
||||||
|
return Math.round((b - a) / (24 * 3600 * 1000));
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { and, asc, eq } from 'drizzle-orm';
|
||||||
|
import { datenbank, schema } from '../db';
|
||||||
|
import { jetzt, plusTage } from '../zeit';
|
||||||
|
import { erzeugePlan, sortiereUm, verschiebe, type Planzeile } from '../planung/wochen';
|
||||||
|
import type { Wochenplanung, WochenStatus } from '../db/schema';
|
||||||
|
|
||||||
|
/** Zugriff auf die Wochenplanung. Die Rechnung steckt in ../planung/wochen. */
|
||||||
|
|
||||||
|
export function planVon(ausbildungId: string): Wochenplanung[] {
|
||||||
|
return datenbank()
|
||||||
|
.select()
|
||||||
|
.from(schema.wochenplanung)
|
||||||
|
.where(eq(schema.wochenplanung.ausbildungId, ausbildungId))
|
||||||
|
.orderBy(asc(schema.wochenplanung.geplantVon), asc(schema.wochenplanung.wochenNummer))
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zeileVon(ausbildungId: string, wochenNummer: number): Wochenplanung | null {
|
||||||
|
return (
|
||||||
|
datenbank()
|
||||||
|
.select()
|
||||||
|
.from(schema.wochenplanung)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.wochenplanung.ausbildungId, ausbildungId),
|
||||||
|
eq(schema.wochenplanung.wochenNummer, wochenNummer),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get() ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt fehlende Planzeilen an. Wird beim Anlegen einer Ausbildung aufgerufen
|
||||||
|
* und noch einmal, wenn im Repository neue Wochen dazugekommen sind.
|
||||||
|
*
|
||||||
|
* Bestehende Zeilen werden nie angefasst – sonst wäre jede Terminverschiebung
|
||||||
|
* beim nächsten Start wieder weg.
|
||||||
|
*/
|
||||||
|
export function planAuffrischen(ausbildungId: string, beginn: string, wochenNummern: number[]): number {
|
||||||
|
const db = datenbank();
|
||||||
|
const bestand = planVon(ausbildungId);
|
||||||
|
const vorhanden = new Set(bestand.map((z) => z.wochenNummer));
|
||||||
|
const fehlende = wochenNummern.filter((n) => !vorhanden.has(n));
|
||||||
|
if (fehlende.length === 0) return 0;
|
||||||
|
|
||||||
|
// Neue Wochen hängen sich hinten an den bestehenden Plan an, statt ihn
|
||||||
|
// umzuwerfen. Gibt es noch nichts, ist der Ausbildungsbeginn der Anker.
|
||||||
|
const letzterMontag = bestand.at(-1)?.geplantVon;
|
||||||
|
const anker = letzterMontag ? plusTage(letzterMontag, 7) : beginn;
|
||||||
|
const zuSchreiben = erzeugePlan(anker, fehlende);
|
||||||
|
|
||||||
|
const wann = jetzt();
|
||||||
|
db.insert(schema.wochenplanung)
|
||||||
|
.values(
|
||||||
|
zuSchreiben.map((zeile) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
ausbildungId,
|
||||||
|
wochenNummer: zeile.wochenNummer,
|
||||||
|
geplantVon: zeile.geplantVon,
|
||||||
|
geplantBis: zeile.geplantBis,
|
||||||
|
tatsaechlichVon: null,
|
||||||
|
status: 'geplant' as const,
|
||||||
|
notiz: null,
|
||||||
|
geaendertAm: wann,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return zuSchreiben.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt einen gerechneten Plan zurück – nur die Zeilen, die sich geändert haben. */
|
||||||
|
function schreibeZurueck(ausbildungId: string, vorher: Wochenplanung[], nachher: Planzeile[]): number {
|
||||||
|
const db = datenbank();
|
||||||
|
const nachNummer = new Map(nachher.map((z) => [z.wochenNummer, z]));
|
||||||
|
const wann = jetzt();
|
||||||
|
let geaendert = 0;
|
||||||
|
|
||||||
|
for (const alt of vorher) {
|
||||||
|
const neu = nachNummer.get(alt.wochenNummer);
|
||||||
|
if (!neu) continue;
|
||||||
|
if (neu.geplantVon === alt.geplantVon && neu.geplantBis === alt.geplantBis) continue;
|
||||||
|
|
||||||
|
db.update(schema.wochenplanung)
|
||||||
|
.set({ geplantVon: neu.geplantVon, geplantBis: neu.geplantBis, geaendertAm: wann })
|
||||||
|
.where(eq(schema.wochenplanung.id, alt.id))
|
||||||
|
.run();
|
||||||
|
geaendert += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return geaendert;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wocheVerschieben(
|
||||||
|
ausbildungId: string,
|
||||||
|
wochenNummer: number,
|
||||||
|
neuerMontag: string,
|
||||||
|
folgendeMitverschieben: boolean,
|
||||||
|
): number {
|
||||||
|
const bestand = planVon(ausbildungId);
|
||||||
|
const gerechnet = verschiebe(bestand, wochenNummer, neuerMontag, folgendeMitverschieben);
|
||||||
|
return schreibeZurueck(ausbildungId, bestand, gerechnet);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wocheUmsortieren(ausbildungId: string, wochenNummer: number, neueStelle: number): number {
|
||||||
|
const bestand = planVon(ausbildungId);
|
||||||
|
const gerechnet = sortiereUm(bestand, wochenNummer, neueStelle);
|
||||||
|
return schreibeZurueck(ausbildungId, bestand, gerechnet);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusSetzen(
|
||||||
|
ausbildungId: string,
|
||||||
|
wochenNummer: number,
|
||||||
|
status: WochenStatus,
|
||||||
|
heuteDatum: string,
|
||||||
|
): void {
|
||||||
|
const zeile = zeileVon(ausbildungId, wochenNummer);
|
||||||
|
if (!zeile) return;
|
||||||
|
|
||||||
|
const db = datenbank();
|
||||||
|
|
||||||
|
// Es läuft immer höchstens eine Woche.
|
||||||
|
if (status === 'laufend') {
|
||||||
|
db.update(schema.wochenplanung)
|
||||||
|
.set({ status: 'geplant', geaendertAm: jetzt() })
|
||||||
|
.where(and(eq(schema.wochenplanung.ausbildungId, ausbildungId), eq(schema.wochenplanung.status, 'laufend')))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.wochenplanung)
|
||||||
|
.set({
|
||||||
|
status,
|
||||||
|
tatsaechlichVon: status === 'laufend' ? (zeile.tatsaechlichVon ?? heuteDatum) : zeile.tatsaechlichVon,
|
||||||
|
geaendertAm: jetzt(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.wochenplanung.id, zeile.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notizSetzen(ausbildungId: string, wochenNummer: number, notiz: string): void {
|
||||||
|
const zeile = zeileVon(ausbildungId, wochenNummer);
|
||||||
|
if (!zeile) return;
|
||||||
|
|
||||||
|
datenbank()
|
||||||
|
.update(schema.wochenplanung)
|
||||||
|
.set({ notiz: notiz.trim() || null, geaendertAm: jetzt() })
|
||||||
|
.where(eq(schema.wochenplanung.id, zeile.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||