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:
co-authored by
Claude Opus 5
parent
1af5613e9b
commit
103a603eb4
@@ -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[] = [
|
||||
{ pfad: '/', titel: 'Überblick' },
|
||||
{ pfad: '/wochenplan', titel: 'Wochenplan' },
|
||||
{ pfad: '/handouts', titel: 'Handouts' },
|
||||
{ pfad: '/glossar', titel: 'Glossar' },
|
||||
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
||||
|
||||
+121
-42
@@ -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 „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 (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user