gute
This commit is contained in:
+480
@@ -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;
|
||||
Reference in New Issue
Block a user