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>
|
||||
);
|
||||
}
|
||||
@@ -696,3 +696,185 @@ a.glossarbegriff:hover {
|
||||
.handout-zuordnung { border: 1px solid #999; }
|
||||
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);
|
||||
+28
-1
@@ -63,10 +63,37 @@ export const handoutGelesen = sqliteTable(
|
||||
(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 Sitzung = typeof sitzung.$inferSelect;
|
||||
export type Ausbildung = typeof ausbildung.$inferSelect;
|
||||
export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
|
||||
export type Wochenplanung = typeof wochenplanung.$inferSelect;
|
||||
|
||||
/** 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();
|
||||
}
|
||||
Reference in New Issue
Block a user