481 lines
14 KiB
TypeScript
481 lines
14 KiB
TypeScript
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;
|