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>
This commit is contained in:
Florian Wilde
2026-09-10 09:04:39 +02:00
co-authored by Claude Opus 5
parent 1af5613e9b
commit 103a603eb4
29 changed files with 2158 additions and 47 deletions
+230
View File
@@ -0,0 +1,230 @@
import Link from 'next/link';
import { Fortschrittsbalken } from '@/komponenten/Fortschrittsbalken';
import type { Wochensicht } from '@/lib/planung/sicht';
import { alsDatumText, kalenderwocheText } from '@/lib/zeit';
const statusText: Record<string, { text: string; ton: string; zeichen: string }> = {
geplant: { text: 'geplant', ton: 'neutral', zeichen: '○' },
laufend: { text: 'läuft', ton: 'warnung', zeichen: '▶' },
abgeschlossen: { text: 'abgeschlossen', ton: 'gut', zeichen: '✓' },
};
/**
* Die Wochenansicht – für den Azubi die Startseite, für den Ausbilder dieselbe
* Woche in der Ansicht seines Azubis.
*/
export function WochenAnsicht({
sicht,
istAzubi,
wochenLink,
}: {
sicht: Wochensicht;
istAzubi: boolean;
/** Vorlage für den Verweis auf eine andere Woche, `{nr}` wird ersetzt. */
wochenLink: string;
}) {
const zurWoche = (nummer: number) => wochenLink.replace('{nr}', String(nummer));
const { woche, planung, fortschritt, tage } = sicht;
const status = statusText[planung.status] ?? statusText.geplant!;
return (
<div className="stapel">
<header className="stapel-eng">
<div className="reihe" style={{ gap: '0.6rem' }}>
<span className="wochen-nummer">Woche {woche.nummer}</span>
<span className="marke-status" data-ton={status.ton}>
{status.zeichen} {status.text}
</span>
<span className="leise">
{alsDatumText(planung.geplantVon)} – {alsDatumText(planung.geplantBis)} · {kalenderwocheText(planung.geplantVon)}
</span>
</div>
<h1 style={{ marginBottom: '0.2rem' }}>{woche.titel}</h1>
<p className="leitfrage">{woche.leitfrage}</p>
<p className="leise" style={{ marginBottom: 0 }}>{woche.ziel}</p>
</header>
{planung.tatsaechlichVon && planung.tatsaechlichVon !== planung.geplantVon ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
Tatsächlich begonnen am {alsDatumText(planung.tatsaechlichVon)}, geplant war der{' '}
{alsDatumText(planung.geplantVon)}.
</p>
) : null}
{planung.notiz ? (
<p className="hinweis hinweis-warnung" style={{ marginBottom: 0 }}>
<strong>Notiz des Ausbilders:</strong> {planung.notiz}
</p>
) : null}
<section className="karte stapel">
<Fortschrittsbalken fortschritt={fortschritt} />
{fortschritt.offen.length > 0 ? (
<div>
<h2 style={{ fontSize: '1rem', marginBottom: '0.4rem' }}>
{istAzubi ? 'Das ist diese Woche noch offen' : 'Offen bei deinem Azubi'}
</h2>
<ul className="offen-liste">
{fortschritt.offen.map((bereich) => (
<li key={bereich.schluessel}>
<span>{bereich.titel}</span>
<span className="leise">
{bereich.zaehlwerk.erledigt} von {bereich.zaehlwerk.gesamt}
</span>
</li>
))}
</ul>
</div>
) : fortschritt.gesamt > 0 ? (
<p className="hinweis hinweis-gut" style={{ marginBottom: 0 }}>
✓ Diese Woche ist vollständig abgearbeitet.
</p>
) : null}
</section>
<section aria-labelledby="tage" className="stapel">
<h2 id="tage">Die Woche</h2>
<ol className="wochentage">
{tage.map((tag) => (
<li key={tag.tag} className={`wochentag${tag.istHeute ? ' wochentag-heute' : ''}`}>
<div className="wochentag-kopf">
<strong>{tag.tag}</strong>
<span className="leise">{alsDatumText(tag.datum)}</span>
{tag.istHeute ? <span className="marke-status" data-ton="warnung">● heute</span> : null}
</div>
<div className="wochentag-inhalt">
<div>
<span className="wochentag-marke">Theorie</span>
{tag.theorie.length === 0 ? (
<span className="leise">–</span>
) : (
<ul className="theorie-liste">
{tag.theorie.map((handout) => {
const gelesen = sicht.handouts.find((h) => h.handout.id === handout.id)?.gelesen ?? false;
return (
<li key={handout.id}>
<Link href={`/handouts/${handout.id}`}>
<span className="handout-kuerzel">{handout.id}</span> {handout.titel}
</Link>
{gelesen ? (
<span className="marke-status" data-ton="gut">
✓ gelesen
</span>
) : null}
</li>
);
})}
</ul>
)}
</div>
<div>
<span className="wochentag-marke">Praxis</span>
<span>{tag.praxis || '–'}</span>
</div>
</div>
</li>
))}
</ol>
</section>
<div className="raster">
<section className="karte" aria-labelledby="woche-handouts">
<h2 id="woche-handouts" style={{ fontSize: '1rem' }}>
Handouts dieser Woche
</h2>
{sicht.handouts.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{sicht.handouts.map(({ handout, gelesen }) => (
<li key={handout.id} className="reihe" style={{ gap: '0.5rem', flexWrap: 'nowrap' }}>
<span className="handout-kuerzel">{handout.id}</span>
<Link href={`/handouts/${handout.id}`} style={{ flex: 1 }}>
{handout.titel}
</Link>
<span aria-label={gelesen ? 'gelesen' : 'noch nicht gelesen'} title={gelesen ? 'gelesen' : 'offen'}>
{gelesen ? '✓' : '○'}
</span>
</li>
))}
</ul>
)}
</section>
<section className="karte" aria-labelledby="woche-aufgaben">
<h2 id="woche-aufgaben" style={{ fontSize: '1rem' }}>
Praxisaufgaben
</h2>
{sicht.aufgaben.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{sicht.aufgaben.map((aufgabe) => (
<li key={aufgabe.id}>
<strong>{aufgabe.id}</strong> {aufgabe.titel}
<br />
<span className="leise">{aufgabe.zeitrahmen}</span>
</li>
))}
</ul>
)}
</section>
<section className="karte" aria-labelledby="woche-checklisten">
<h2 id="woche-checklisten" style={{ fontSize: '1rem' }}>
Checklisten
</h2>
{sicht.checklisten.length === 0 ? (
<p className="leise" style={{ marginBottom: 0 }}>Keine.</p>
) : (
<ul className="stapel-eng" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{sicht.checklisten.map((liste) => (
<li key={liste.id}>{liste.titel}</li>
))}
</ul>
)}
</section>
<section className="karte" aria-labelledby="woche-kontrolle">
<h2 id="woche-kontrolle" style={{ fontSize: '1rem' }}>
Lernzielkontrolle
</h2>
{sicht.kontrolle ? (
<p style={{ marginBottom: 0 }}>
{sicht.kontrolle.titel}
<br />
<span className="leise">
{sicht.kontrolle.fragen.length} Fragen ·{' '}
{sicht.kontrolle.fragen.reduce((summe, f) => summe + f.punkte, 0)} Punkte
{sicht.kontrolle.dauer_minuten ? ` · ${sicht.kontrolle.dauer_minuten} Minuten` : ''}
</span>
</p>
) : (
<p className="leise" style={{ marginBottom: 0 }}>Diese Woche keine.</p>
)}
</section>
</div>
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Andere Wochen">
{sicht.vorherige ? (
<Link href={zurWoche(sicht.vorherige)} className="knopf knopf-still">
← Woche {sicht.vorherige}
</Link>
) : (
<span />
)}
{sicht.naechste ? (
<Link href={zurWoche(sicht.naechste)} className="knopf knopf-still">
Woche {sicht.naechste} →
</Link>
) : (
<span />
)}
</nav>
</div>
);
}
+1
View File
@@ -14,6 +14,7 @@ const fuerAzubi: Navigationseintrag[] = [
const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/', titel: 'Überblick' },
{ pfad: '/wochenplan', titel: 'Wochenplan' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
+121 -42
View File
@@ -1,69 +1,148 @@
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
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';
/**
* Vorläufige Startseite. Ab Baustein 3 steht hier der Wochenplan.
*/
export default async function StartSeite() {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle === 'ausbilder') {
const meine = ausbildungenVonAusbilder(benutzer.id);
return benutzer.rolle === 'ausbilder' ? (
<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 (
<div className="stapel">
<div>
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1>
<p className="leise">
{kalenderwocheText(heute())} · {alsDatumText(heute())}
</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>
<h1>Guten Tag, {name.split(' ')[0]}</h1>
<p className="hinweis hinweis-warnung">
Zu deinem Konto ist noch keine Ausbildung hinterlegt. Sag deinem Ausbilder Bescheid – ohne
Ausbildungsverhältnis gibt es keinen Wochenplan.
</p>
</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 &bdquo;Wochenplan&ldquo;
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 (
<div className="stapel">
<div>
<h1>Guten Tag, {benutzer.name.split(' ')[0]}</h1>
<h1>Guten Tag, {name.split(' ')[0]}</h1>
<p className="leise">
{kalenderwocheText(heute())} · {alsDatumText(heute())}
</p>
</div>
<section className="karte">
{ausbildung ? (
<p style={{ marginBottom: 0 }}>
Deine Ausbildung läuft seit {alsDatumText(ausbildung.beginn)}
{ausbildung.probezeitBis ? ` (Probezeit bis ${alsDatumText(ausbildung.probezeitBis)})` : ''}.
</p>
) : (
<p style={{ marginBottom: 0 }}>
Zu deinem Konto ist noch keine Ausbildung hinterlegt. Sag deinem Ausbilder Bescheid.
</p>
)}
{meine.length === 0 ? (
<p className="hinweis hinweis-warnung">
Noch kein Azubikonto angelegt. <Link href="/verwaltung">Jetzt eines anlegen</Link>.
</p>
) : (
<section className="stapel" aria-labelledby="stand">
<h2 id="stand">Wo deine Auszubildenden stehen</h2>
{meine.map(({ ausbildung, azubi }) => {
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>
</div>
);
+13 -2
View File
@@ -6,6 +6,8 @@ import { passwortMangel } from '@/lib/auth/passwort';
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
import { alsDatumText } from '@/lib/zeit';
import { inhalte } from '@/lib/inhalte';
import { planAuffrischen } from '@/lib/repos/wochenplanung';
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.' };
const azubi = await benutzerAnlegen({ name, email, passwort, rolle: 'azubi' });
ausbildungAnlegen({
const neueAusbildung = ausbildungAnlegen({
azubiId: azubi.id,
ausbilderId: ausbilder.id,
beginn,
@@ -39,8 +41,17 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
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');
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> {
@@ -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>
);
}
+112
View File
@@ -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.' };
}
+172
View File
@@ -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 &bdquo;Folgende Wochen mitverschieben&ldquo;.
</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&nbsp;+&nbsp;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>
);
}