Files
berichtsheftgenerator/web/src/App.tsx
T
2026-07-26 18:11:57 +02:00

481 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;