This commit is contained in:
2026-07-26 18:11:57 +02:00
parent d408e01ab5
commit 21a3b34471
139 changed files with 32872 additions and 0 deletions
+480
View File
@@ -0,0 +1,480 @@
import { useEffect, useMemo, useState } from "react";
import { SUGGESTION_STATS, WEEK_THEMES, type Suggestion } from "./data/suggestions";
import {
formatDE,
getDayInfo,
typeLabel,
weekWorkdays,
} from "./lib/calendar";
import { exportWeekDocx } from "./lib/exportDocx";
import { exportWeekPdf } from "./lib/exportPdf";
import {
pickRandomSuggestions,
pickWeekSet,
suggestionCount,
weekNeedsSchool,
} from "./lib/suggest";
import {
createEmptyWeek,
ensureWeek,
loadState,
nextWeekMonday,
pickTheme,
prevWeekMonday,
saveState,
STORAGE_KEY,
STORAGE_KEY_LEGACY,
type AppState,
type WeekEntry,
} from "./lib/storage";
import { missingExportFields } from "./lib/weekExport";
import { TRAINEE } from "./data/holidays-bw";
import "./App.css";
const OFFER_COUNT = 4;
const TARGET_ACTIVITIES = 8;
const MIN_BETRIEB = 4;
function withWeek(state: AppState, monday: string): AppState {
if (state.weeks[monday]) return state;
return {
...state,
weeks: { ...state.weeks, [monday]: createEmptyWeek(monday) },
};
}
function selectionFromWeek(w: WeekEntry | undefined): Suggestion[] {
if (!w) return [];
return [
...w.activities.map((text, i) => ({
id: -1000 - i,
text,
type: "betrieb" as const,
category: "Gespeichert",
weight: 1,
})),
...w.schoolTopics
.filter((t) => t !== "Ferien")
.map((text, i) => ({
id: -2000 - i,
text,
type: "schule" as const,
category: "Gespeichert",
weight: 1,
})),
];
}
function draftFields(monday: string, selected: Suggestion[]) {
const activities = selected.filter((s) => s.type === "betrieb").map((s) => s.text);
let schoolTopics = selected.filter((s) => s.type === "schule").map((s) => s.text);
if (!weekNeedsSchool(monday)) schoolTopics = ["Ferien"];
return { activities, schoolTopics };
}
function App() {
const [state, setState] = useState<AppState>(() => {
const initial = loadState();
return withWeek(initial, initial.currentWeekMonday);
});
const [offer, setOffer] = useState<Suggestion[]>([]);
const [selected, setSelected] = useState<Suggestion[]>([]);
const [flash, setFlash] = useState("");
const [exporting, setExporting] = useState(false);
const monday = state.currentWeekMonday;
const week = state.weeks[monday] ?? createEmptyWeek(monday);
const needsSchool = weekNeedsSchool(monday);
const betriebCount = selected.filter((s) => s.type === "betrieb").length;
const canExport = betriebCount >= MIN_BETRIEB && !exporting;
useEffect(() => {
setState((prev) => withWeek(prev, prev.currentWeekMonday));
}, [state.currentWeekMonday]);
useEffect(() => {
saveState(state);
}, [state]);
// Auswahl + Vorschläge laden, wenn die offene Woche wechselt
useEffect(() => {
const w = state.weeks[monday];
const fromSaved = selectionFromWeek(w);
setSelected(fromSaved);
setOffer(
pickRandomSuggestions(
monday,
OFFER_COUNT,
state.usedSuggestionIds,
fromSaved.map((s) => s.text),
),
);
// nur bei Wochenwechsel
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [monday]);
const dayPills = useMemo(
() =>
weekWorkdays(monday).map((d) => {
const info = getDayInfo(d);
return { date: d, info };
}),
[monday],
);
const showToast = (msg: string) => {
setFlash(msg);
window.setTimeout(() => setFlash(""), 2200);
};
const persistDraft = (nextSelected: Suggestion[]) => {
setSelected(nextSelected);
const { activities, schoolTopics } = draftFields(monday, nextSelected);
setState((prev) => {
const base = withWeek(prev, monday);
return {
...base,
weeks: {
...base.weeks,
[monday]: {
...base.weeks[monday],
activities,
schoolTopics,
},
},
};
});
};
const refreshOffer = (count = OFFER_COUNT) => {
setOffer(
pickRandomSuggestions(
monday,
count,
state.usedSuggestionIds,
selected.map((s) => s.text),
),
);
};
const toggleSelect = (s: Suggestion) => {
const exists = selected.some((x) => x.text === s.text);
const next = exists
? selected.filter((x) => x.text !== s.text)
: [...selected, s];
persistDraft(next);
};
const removeSelected = (text: string) => {
persistDraft(selected.filter((s) => s.text !== text));
};
const autoFillWeek = () => {
const picks = pickWeekSet(
monday,
state.usedSuggestionIds,
selected.map((s) => s.text),
);
const texts = new Set(selected.map((s) => s.text));
const merged = [...selected];
for (const p of picks) {
if (!texts.has(p.text)) {
merged.push(p);
texts.add(p.text);
}
}
persistDraft(merged);
setOffer(
pickRandomSuggestions(
monday,
OFFER_COUNT,
state.usedSuggestionIds,
merged.map((s) => s.text),
),
);
showToast("Woche mit Vorschlägen gefüllt");
};
const buildWeekFromSelection = (): WeekEntry => {
const { activities, schoolTopics: schoolFromSel } = draftFields(monday, selected);
let schoolTopics = schoolFromSel;
if (needsSchool && schoolTopics.length === 0) {
const schoolPick = pickRandomSuggestions(
monday,
4,
state.usedSuggestionIds,
[],
).filter((s) => s.type === "schule");
schoolTopics = schoolPick.slice(0, 3).map((s) => s.text);
}
return {
...week,
activities,
schoolTopics,
weekTheme: week.weekTheme || pickTheme(),
done: true,
};
};
const finishWeek = async (andExport: "docx" | "pdf" | "both") => {
if (betriebCount < MIN_BETRIEB) {
showToast(`Mindestens ${MIN_BETRIEB} Betriebs-Vorschläge auswählen`);
return;
}
setExporting(true);
try {
const finished = buildWeekFromSelection();
if (!finished.weekTheme.trim()) finished.weekTheme = pickTheme();
if (!finished.frameworkRefs.length) {
finished.frameworkRefs = [...week.frameworkRefs];
}
if (!needsSchool && finished.schoolTopics.length === 0) {
finished.schoolTopics = ["Ferien"];
}
const missing = missingExportFields(finished);
if (missing.length) {
showToast(`Export unvollständig: ${missing.join(", ")}`);
return;
}
const usedIds = [...state.usedSuggestionIds];
for (const s of selected) {
if (s.id > 0 && !usedIds.includes(s.id)) usedIds.push(s.id);
}
const nextMon = nextWeekMonday(monday);
setState((prev) => {
const base = {
...prev,
usedSuggestionIds: usedIds,
weeks: { ...prev.weeks },
};
ensureWeek(base, monday);
base.weeks[monday] = finished;
base.currentWeekMonday = nextMon;
ensureWeek(base, nextMon);
return base;
});
// Ein Klick: gleicher Inhalt (WeekExportContent) in DOCX und PDF
if (andExport === "docx" || andExport === "both") await exportWeekDocx(finished);
if (andExport === "pdf" || andExport === "both") exportWeekPdf(finished);
showToast(`Woche ${finished.number} exportiert weiter zur nächsten`);
} catch (err) {
console.error(err);
showToast("Export fehlgeschlagen bitte erneut versuchen");
} finally {
setExporting(false);
}
};
const goWeek = (mon: string) => {
setState((prev) => withWeek({ ...prev, currentWeekMonday: mon }, mon));
};
const rerollTheme = () => {
setState((prev) => {
const base = withWeek(prev, monday);
const themes = WEEK_THEMES.filter((t) => t !== base.weeks[monday].weekTheme);
const next = themes[Math.floor(Math.random() * themes.length)] || pickTheme();
return {
...base,
weeks: {
...base.weeks,
[monday]: { ...base.weeks[monday], weekTheme: next },
},
};
});
};
const progressPct = Math.min(100, Math.round((betriebCount / TARGET_ACTIVITIES) * 100));
return (
<div className="app">
<header className="hero">
<div className="brand">Berichtsheft</div>
<p className="sub">
{TRAINEE.name} · Büromanagement · Kfz · Abt. {TRAINEE.department}
</p>
<p className="meta">
{suggestionCount()} Vorschläge aus {SUGGESTION_STATS.sourceFiles} Heften ·{" "}
{Math.round(SUGGESTION_STATS.originalShareBetrieb * 100)}% Originalton
</p>
</header>
{flash && <div className="toast">{flash}</div>}
<main className="workspace">
<div className="week-nav">
<button type="button" className="nav-btn" onClick={() => goWeek(prevWeekMonday(monday))}>
Vorherige
</button>
<div className="week-title">
<div className="week-heading">
<h1>Woche Nr. {week.number}</h1>
{week.done && <span className="done-badge">Fertig</span>}
</div>
<p>
{formatDE(week.from)} {formatDE(week.to)}
</p>
<p className="year">{week.yearLabel}</p>
</div>
<button type="button" className="nav-btn" onClick={() => goWeek(nextWeekMonday(monday))}>
Nächste
</button>
</div>
<div className="day-pills" aria-label="Wochentage">
{dayPills.map(({ date, info }) => (
<span key={date} className={`pill type-${info.type}`}>
{info.weekdayLabel.slice(0, 2)} · {typeLabel(info.type)}
{info.holidayName ? ` (${info.holidayName})` : ""}
{info.schoolHolidayName && info.type !== "feiertag" ? " · Ferien" : ""}
</span>
))}
</div>
<div className="theme-row">
<span>
Thema der Woche: <strong>{week.weekTheme}</strong>
</span>
<button type="button" className="linkish" onClick={rerollTheme}>
Anderes Thema
</button>
</div>
<p className="hint center">
{needsSchool
? "Di/Do Berufsschule Schul-Vorschläge können mit gewählt werden"
: "Ferien / keine Schule diese Woche (alles Betrieb)"}
</p>
<hr className="divider" />
<div className="offer-head">
<h2>Vorschläge wählen</h2>
<div className="offer-actions">
<button type="button" className="secondary" onClick={() => refreshOffer(4)}>
4 neue
</button>
<button type="button" className="secondary" onClick={() => refreshOffer(8)}>
8 neue
</button>
<button type="button" className="secondary" onClick={autoFillWeek}>
Woche automatisch füllen
</button>
</div>
</div>
<div className="progress-row" aria-live="polite">
<div className="progress-track">
<div className="progress-fill" style={{ width: `${progressPct}%` }} />
</div>
<span className="progress-label">
Betrieb {betriebCount}/{TARGET_ACTIVITIES}
{betriebCount < MIN_BETRIEB ? ` · mind. ${MIN_BETRIEB} für Export` : " · bereit"}
</span>
</div>
<p className="hint">Antippen zum Auswählen oder Abwählen · kein Tippen nötig</p>
<div className="offer-grid">
{offer.map((s) => {
const active = selected.some((x) => x.text === s.text);
return (
<button
key={`${s.id}-${s.text}`}
type="button"
className={`offer ${active ? "selected" : ""} type-${s.type}`}
onClick={() => toggleSelect(s)}
aria-pressed={active}
>
<span className="offer-cat">
{s.type === "schule" ? "Schule" : s.category}
</span>
{s.text}
</button>
);
})}
</div>
<section className="selected-block">
<h2>Gewählt ({selected.length})</h2>
{selected.length === 0 ? (
<p className="hint">Noch nichts gewählt Vorschläge antippen oder automatisch füllen.</p>
) : (
<ul className="selected-list">
{selected.map((s) => (
<li key={s.text}>
<span className={`tag type-${s.type}`}>
{s.type === "schule" ? "Schule" : "Betrieb"}
</span>
<span className="txt">{s.text}</span>
<button
type="button"
className="icon"
aria-label="Entfernen"
onClick={() => removeSelected(s.text)}
>
×
</button>
</li>
))}
</ul>
)}
</section>
<div className="actions">
<button
type="button"
className="primary"
onClick={() => finishWeek("both")}
disabled={!canExport}
>
{exporting
? "Erstelle …"
: "Berichtsheft erstellen (DOCX + PDF) & nächste Woche"}
</button>
<button
type="button"
className="secondary"
onClick={() => finishWeek("docx")}
disabled={!canExport}
>
Nur DOCX
</button>
<button
type="button"
className="secondary"
onClick={() => finishWeek("pdf")}
disabled={!canExport}
>
Nur PDF
</button>
</div>
</main>
<footer className="footer">
<button
type="button"
className="linkish"
onClick={() => {
if (!confirm("Fortschritt zurücksetzen?")) return;
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY_LEGACY);
const fresh = loadState();
setState(fresh);
setSelected([]);
showToast("Zurückgesetzt");
}}
>
Fortschritt zurücksetzen
</button>
</footer>
</div>
);
}
export default App;