Files
projektuebersicht/public/js/wochenbericht/wochenbericht.js
T
erikandClaude Opus 5 93505e0e48 Beteiligte in die Projekteinstellungen, TODOs aus Protokoll, Bewegung
Beteiligten-Zuordnung verlagert: p.assign ist jetzt ein Stammdatum und
nur noch in den Projekteinstellungen editierbar (planCycleAssign()).
Wochenbericht und Projektboard zeigen sie nur noch an, mit Verweis
dorthin (springZuEinstellungen()). cycleAssign() ist damit entfallen.

Neu "TODOs aus Protokoll": ein externes Besprechungsprotokoll als PDF
wird per KI eingelesen und daraus in einem zweiten KI-Schritt Aufgaben
abgeleitet. Eigener Bereich in der Aufgaben-/GANTT-Leiste, bewusst
unabhaengig von den gespeicherten Protokollen - nichts davon wird
abgelegt. Drei Schritte in einem Dialog mit fester Fusszeile; die
Vorschlagsliste als kompakte, aufklappbare Zeilen, weil ein echter
Import leicht 20 Vorschlaege liefert.

Sichtbarkeit je Aufgabe im GANTT: neue Spalte plan_todos.show_in_gantt
(Vorgabe 1, idempotente Migration) und eine Checkbox im Aufgaben-Dialog.
Eine terminierte Aufgabe kann damit gezielt aus dem Terminplan
herausgehalten werden, bleibt sonst aber eine vollwertige Aufgabe.
plan-build-gantt.js und plan-drag.js pruefen dieselbe Bedingung, damit
Zeilenzahl und Umsortierung nicht auseinanderlaufen.

KI-Timeout dritte Stufe: ein abweichender, aber entfernter Endpunkt
(Drittanbieter-Proxy) bekommt 2 Minuten statt der 45 Sekunden der
echten Anbieter-API. Gemessen: die Punkte-Erkennung eines realen
Protokolls brauchte dort 39 Sekunden, lag also stets knapp unter dem
alten Limit und brach bei jeder Schwankung ab.

Bewegung: Hover- und Klick-Rueckmeldung als Token-Satz in :root,
angewandt auf die anklickbaren Zeilen, Schalter und Dialoge. Die
Akzentleiste ist ein inset-Schatten statt eines positionierten
Elements, damit Drag-and-Drop unberuehrt bleibt; prefers-reduced-motion
war bisher gar nicht vorhanden und kippt jetzt einfach die Tokens.

Plan Checker: optionaler Projektabgleich des Plankopfs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-17 15:14:26 +02:00

997 lines
49 KiB
JavaScript
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.
// -----------------------------
// Projektübersicht — Wochenbericht (Änderungs-Digest)
// -----------------------------
// Tab "Wochenbericht": zeigt die Projekte der aktuell gewählten Sitzung
// gruppiert nach dem, was sich seit der VORHERIGEN Sitzung geändert hat —
// "Neu kommentiert" (Status-Text geändert oder Projekt neu angelegt),
// "Fortschritt aktualisiert" (LPH-Prozentwerte oder amtierende Leistungsphase
// geändert) und "Unverändert seit letzter Woche" (eingeklappt, wie die
// normale Board-Zeile). Das war ursprünglich der Zweck des Projektboards
// selbst (wöchentliche Besprechung: Status/Fortschritt je Projekt
// festhalten) — das Board ist inzwischen eine reine Navigationsliste zum
// Projektplan (siehe board.js), dieser Tab übernimmt die eigentliche
// Erfassungs-Arbeit, aber fokussiert auf das, was diese Woche wirklich neu
// ist, statt jedes Mal alle Projekte einzeln durchklicken zu müssen.
//
// Der Vergleich zur Vorwoche läuft über project_uid (siehe
// migrateAddProjectUid() in server.js) — die dauerhafte, wochenunabhängige
// Projekt-Identität, die project_id (wird bei jedem cloneWeek() neu
// vergeben) nicht bieten konnte. Die Bearbeitung von Kommentar und LPH läuft
// vollständig über die bestehenden Funktionen aus js/board/board.js
// (editLph/renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses
// Modul ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die
// Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten). Die
// Beteiligten-Zuordnung zeigt dieser Tab seit ihrer Verlagerung in die
// Projekteinstellungen nur noch zum Lesen, mit Verweis dorthin
// (springZuEinstellungen(), js/gantt.js).
// Die Anwesenheit lag früher im Projektboard; sie gehört hierher, weil in
// diesem Tab die Sitzung abgearbeitet wird.
//
// Läuft wie board.js als klassisches globales Skript (kein Modul/IIFE), nur
// von App.html geladen (nach js/board/board.js).
// findPreviousWeek: liefert die chronologisch vorherige Sitzung zu `week`
// (nach weekSortKey sortiert) oder null, wenn `week` die früheste ist.
// Parameter: week (Woche-Objekt). Rückgabe: Woche-Objekt oder null.
function findPreviousWeek(week){
const sorted = [...db.weeks].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
const idx = sorted.findIndex(w => w.id === week.id);
return idx > 0 ? sorted[idx - 1] : null;
}
// lphSignature: vergleichbarer String aus den LPH-Prozentwerten und der
// amtierenden Leistungsphase eines Projekts, für den Fortschritts-Vergleich
// zur Vorwoche (Reihenfolge-unabhängig durch Sortierung der Schlüssel).
// Parameter: p (Projekt). Rückgabe: String.
function lphSignature(p){
const lph = p.lph || {};
const lphPart = Object.keys(lph).sort().map(k => k + '=' + lph[k]).join(',');
const aktLp = Array.isArray(p.aktuelleLP) ? p.aktuelleLP.slice().sort().join(',') : '';
return lphPart + '|' + aktLp;
}
// classifyProjectChange: ordnet ein Projekt der aktuellen Sitzung einer der
// drei Bericht-Gruppen zu, anhand des Vergleichs mit demselben Projekt
// (gleiche project_uid) in der Vorwoche. Ohne Vorwoche oder ohne Treffer in
// ihr (z. B. gerade erst angelegtes Projekt) gilt es als neu. Parameter: p
// (Projekt der aktuellen Sitzung), prevWeek (Vorwoche-Objekt oder null).
// Rückgabe: { bucket: 'new'|'status'|'progress'|'unchanged', reason: String }.
function classifyProjectChange(p, prevWeek){
const prev = (prevWeek && p.projectUid)
? prevWeek.projects.find(x => x.projectUid === p.projectUid)
: null;
if(!prev){
return { bucket: 'new', reason: 'Neues Projekt' };
}
const statusNow = (p.status || '').trim();
const statusPrev = (prev.status || '').trim();
// Nur ein tatsächlich vorhandener, geänderter Text zählt als neuer
// Kommentar. Seit cloneWeek() (js/weeks.js) den Text der Vorwoche nicht
// mehr mitkopiert, startet jede duplizierte Sitzung mit leerem Kommentar —
// ohne diese Bedingung stünde danach jedes Projekt unter "Neu kommentiert",
// obwohl noch niemand etwas geschrieben hat. Ein geleerter Kommentar
// (statusNow leer, statusPrev nicht) fällt damit in die Fortschritts- bzw.
// Unverändert-Prüfung und wird nicht als Neuigkeit gemeldet.
if(statusNow !== '' && statusNow !== statusPrev){
return { bucket: 'status', reason: 'Neuer Kommentar' };
}
// Auch ein bewusst ÜBERNOMMENER Text zählt als Kommentar dieser Sitzung:
// Der Knopf "übernehmen" im Kommentarverlauf (js/board/board.js) schreibt
// wortgleich den Text der Vorwoche, setzt dabei aber einen neuen
// Zeitstempel. Ohne diese Prüfung landete das Projekt unter "Unverändert",
// obwohl diese Woche ausdrücklich etwas dazu festgehalten wurde.
if(statusNow !== '' && p.statusAt && p.statusAt !== (prev.statusAt || '')){
return { bucket: 'status', reason: 'Kommentar bestätigt' };
}
if(lphSignature(p) !== lphSignature(prev)){
return { bucket: 'progress', reason: 'Fortschritt aktualisiert' };
}
return { bucket: 'unchanged', reason: '' };
}
// BERICHT_OPTS: Darstellungs-Optionen, die dieser Tab an die gemeinsamen
// Render-Funktionen aus js/board/board.js übergibt. dicht ist das einzige,
// was den Wochenbericht noch vom Projektboard unterscheidet (zwei statt drei
// Spalten) — nurBeauftragteLph gilt inzwischen für beide (BOARD_OPTS,
// js/board/board.js). Den Verlauf der Vorwochen hängt renderBoardDrawer()
// überall an (renderProjektVerlauf() in js/utils.js), er braucht deshalb kein
// eigenes Kennzeichen mehr.
const BERICHT_OPTS = { nurBeauftragteLph: true, dicht: true };
// renderReportCard: baut die immer aufgeklappte Karte eines Projekts im
// Bericht (Kopfzeile mit Nr./Name/Tags/Änderungs-Badge, darunter das
// vollständige Detailpanel aus board.js). Parameter: p (Projekt), reason
// (Anzeige-Text des Änderungsgrunds), allPeople, locked, weekLabel,
// variant ('neu'|'fortschritt', Farbe des Kopfbands). Rückgabe: HTML-String.
function renderReportCard(p, reason, allPeople, locked, weekLabel, variant){
const tags = Array.isArray(p.tags) ? p.tags : [];
const tagsHtml = tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
// variant ('neu' | 'fortschritt') färbt das Kopfband der Karte ein, damit
// die Änderungsart beim Scrollen ohne Blick auf die Abschnitts-Überschrift
// erkennbar bleibt. Rein gestalterisch — die Zuordnung selbst trifft
// classifyProjectChange().
const vClass = variant ? ' report-card-' + variant : '';
return `<div class="report-card${vClass}">
<div class="report-card-head report-band">
<span class="board-nr">${esc(p.nr)}</span>
<span class="board-name" onclick="openProjectPlan('${p.id}')" title="Projektplan öffnen">${esc(p.name)}</span>
${tagsHtml}
<span class="report-reason-badge">${esc(reason)}</span>
</div>
${renderBoardDrawer(p, allPeople, locked, weekLabel, BERICHT_OPTS)}
</div>`;
}
// ---------- Anwesenheit der Sitzung ----------
// Die Anwesenheit hing ursprünglich als Zweitfunktion an der Kürzel-Legende
// unter dem Projektboard ("klick für Anwesenheit") — Namenserklärung und
// Eingabemaske am selben Element, und die Legende gibt es inzwischen gar
// nicht mehr. Sie steht jetzt am Fuß des Wochenberichts, also dort, wo die
// Sitzung tatsächlich abgearbeitet wird, listet den vollständigen
// Sitzungs-Kreis (currentWeek().people) und zeigt einen Zähler. Der Datenstand bleibt unverändert binär
// (w.attendance[personId] = true/false, Tabelle attendance) — es gibt
// bewusst kein "entschuldigt"/"nicht erfasst".
//
// Urlaub und Krankheit aus user_absences werden nur als Hinweis am Namen
// angezeigt und setzen den Haken NICHT automatisch: wer im Urlaub ist, kann
// sich trotzdem zuschalten, und die Anwesenheit soll festhalten, wer
// tatsächlich dabei war.
// absenceNoteFor: liefert den Abwesenheitsgrund einer Person am Sitzungstag
// (aus db.globalPeople, der aktuellen Liste aus users/user_absences) oder ''.
// Parameter: personId (String), weekDate (ISO-Datum der Sitzung).
// Rückgabe: String.
function absenceNoteFor(personId, weekDate){
if(!weekDate) return '';
const person = (db.globalPeople || []).find(gp => gp.id === personId);
if(!person || !Array.isArray(person.absences)) return '';
const day = parseDateISO(weekDate);
if(!day) return '';
const hit = person.absences.find(a => {
const from = parseDateISO(a.start), to = parseDateISO(a.end);
return from && to && day >= from && day <= to;
});
return hit ? (hit.type || 'Abwesend') : '';
}
// renderAttendance: baut den Anwesenheitsblock der aktuellen Sitzung.
// Aufgerufen innerhalb dieser Datei (toggleAttendance(), setAllAttendance()
// sowie innerhalb/rund um renderWochenbericht()) — NICHT aus renderBoard(),
// die Anwesenheit ist allein eine Wochenbericht-Angelegenheit. Keine
// Parameter, keine Rückgabe.
function renderAttendance(){
const container = document.getElementById('weekAttendance');
if(!container) return;
const w = currentWeek();
if(!w){ container.innerHTML = ''; return; }
const locked = isWeekLocked(w);
const att = w.attendance || {};
const people = Array.isArray(w.people) ? w.people : [];
// Fokus-Modus (Eriks Wahl "C"): die Anwesenheit ist ein aufklappbarer
// Abschnitt wie Leistungsphasen/Beteiligte, kein dauerhaft sichtbarer Block
// mehr — Zustand in berichtDisclosureState (js/board/board.js).
const offen = berichtDisclosureState.anwesenheit ? 'open' : '';
if(people.length === 0){
container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}>
<summary><span>Anwesenheit</span><span class="chev">${icon('chevronRight')}</span></summary>
<div class="bericht-disclosure-body"><span class="hint" style="margin:0">Für diese Sitzung sind keine Projektbeteiligten hinterlegt.</span></div>
</details>`;
return;
}
const anwesend = people.filter(p => att[p.id]).length;
// onmousedown/preventDefault wie bei den Kacheln unten: Ohne das verliert
// ein offener Kommentar-Editor beim Klick den Fokus, sein Blur-Netz baut den
// ganzen Bericht neu auf, die Schaltfläche verschwindet dabei — und der
// Klick kommt nie an (erst der zweite Versuch wirkte).
const actions = locked ? '' : `<div class="attendance-actions">
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(true)">Alle anwesend</button>
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(false)">Zurücksetzen</button>
</div>`;
const tiles = people.map(p => {
const present = !!att[p.id];
const note = absenceNoteFor(p.id, w.date);
// onmousedown/preventDefault verhindert nur den Fokuswechsel, nicht den
// Klick: sonst verliert ein offener Kommentar-Editor den Fokus, sein
// Blur-Netz baut den ganzen Bericht neu auf, die Kachel wird dabei
// ersetzt — und der Klick käme nie an.
const click = locked ? '' : ` onmousedown="event.preventDefault()" onclick="toggleAttendance('${p.id}')"`;
const title = locked
? `${esc(p.fullName || p.name)} — Sitzung gesperrt`
: `${esc(p.fullName || p.name)} — klicken, um ${present ? 'als abwesend' : 'als anwesend'} zu markieren`;
return `<button type="button" class="attendance-tile${present ? ' present' : ''}${locked ? ' locked-cell' : ''}"
aria-pressed="${present}" title="${title}"${click}>
<span class="attendance-mark">${present ? '✓' : ''}</span>
<span class="attendance-av">${esc(p.name)}</span>
<span class="attendance-name">${esc(p.fullName || p.name)}</span>
${note ? `<span class="attendance-note">${esc(note)}</span>` : ''}
</button>`;
}).join('');
container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}>
<summary><span>Anwesenheit der Sitzung</span><span class="count">${anwesend} von ${people.length}</span><span class="chev">${icon('chevronRight')}</span></summary>
<div class="bericht-disclosure-body">${actions}<div class="attendance-grid">${tiles}</div></div>
</details>`;
}
// toggleAttendance: schaltet die Anwesenheit einer Person für die aktuelle
// Sitzung um (Boolean in w.attendance, Tabelle attendance).
// Parameter: personId (stabile Personen-ID). Keine Rückgabe; bei gesperrter
// Sitzung über guardWeekLocked() abgebrochen.
function toggleAttendance(personId){
if(guardWeekLocked()) return;
const w = currentWeek();
if(!w) return;
if(!w.attendance) w.attendance = {};
w.attendance[personId] = !w.attendance[personId];
save();
// Bewusst nur der Anwesenheitsblock, nicht renderWochenbericht(): ein
// vollständiger Neuaufbau würde die Berichtskarten samt eines gerade
// offenen Kommentar-Editors verwerfen.
renderAttendance();
}
// setAllAttendance: setzt alle Beteiligten der Sitzung auf an- bzw. abwesend —
// für den Normalfall "alle da, bis auf zwei". Parameter: present (Boolean).
// Keine Rückgabe.
function setAllAttendance(present){
if(guardWeekLocked()) return;
const w = currentWeek();
if(!w) return;
if(!w.attendance) w.attendance = {};
(w.people || []).forEach(p => { w.attendance[p.id] = !!present; });
save();
renderAttendance();
}
// renderWochenbericht: baut den kompletten Wochenbericht-Tab für die eine
// offene Sitzung auf (currentWeek(), js/weeks.js) — Hinweis auf die Vorwoche,
// dann die drei Gruppen. Wird nach jedem Laden sowie nach jeder Änderung
// über editLph() (js/board/board.js) bzw. planCycleAssign()
// (js/plan/plan-settings.js) neu aufgerufen, damit ein Projekt sofort in die
// passende Gruppe wandert. Keine Parameter, keine Rückgabe.
// ---------- Tagesordnung: Zustand ----------
// berichtAuswahl: project_uid des Projekts, das gerade rechts im Detail
// steht. Bewusst die wochenunabhängige uid und nicht der Index: Beim
// Speichern eines Kommentars wird die ganze Liste neu gezeichnet, und ein
// Index würde bei geänderter Reihenfolge auf ein anderes Projekt zeigen.
let berichtAuswahl = null;
// berichtSuche: Inhalt des Sprung-Feldes über der Liste. Es FILTERT die
// Tagesordnung nicht dauerhaft — im Meeting muss die Liste vollständig
// bleiben — sondern hebt Treffer hervor und blendet den Rest nur aus,
// solange etwas eingetippt ist.
let berichtSuche = '';
// wochenberichtGewerk: das Gewerk, dessen Besprechung gerade läuft
// ('ELT'|'HLS'|'ARCH') — oder null, bevor die Vorauswahl gelaufen ist. Die
// verschiedenen Gewerke halten ihre Wochenbesprechung zu unterschiedlichen
// Zeitpunkten und jede braucht dabei nur die eigenen Projekte. Bewusst NICHT
// in localStorage: die Wahl gilt nur für diesen Seitenaufruf, damit sie beim
// nächsten Login erneut aus dem eigenen Gewerk hergeleitet wird statt
// möglicherweise für die falsche Besprechung stehenzubleiben.
let wochenberichtGewerk = null;
// wochenberichtGewerkVoreinstellen: setzt wochenberichtGewerk beim ersten
// Aufruf des Wochenberichts auf das eigene Gewerk des angemeldeten Kontos
// (currentUser.department, js/auth.js) — meistens ist genau das die
// Besprechung, die man gerade braucht, ohne jedes Mal manuell wählen zu
// müssen. Nur eine gültige Vorauswahl (ELT/HLS/ARCH) wird übernommen; ohne
// gepflegtes Gewerk (z. B. das Bootstrap-Admin-Konto) bleibt
// wochenberichtGewerk null und das Dropdown in renderBerichtKopf() zeigt die
// Aufforderung, eins zu wählen. Keine Parameter, keine Rückgabe.
function wochenberichtGewerkVoreinstellen(){
if(wochenberichtGewerk) return;
const eigenes = currentUser && currentUser.department;
if(eigenes && GEWERK_LABELS[eigenes]) wochenberichtGewerk = eigenes;
}
// berichtProjekte: die Projekte der Sitzung in der Reihenfolge, in der sie
// abgearbeitet werden — nach Projektgruppe (GROUP_ORDER) und darin nach der
// gepflegten Reihenfolge, wie im Projektboard. Ist ein Gewerk gewählt
// (wochenberichtGewerk), werden nur dessen eigene Projekte aufgenommen
// (p.gewerke, siehe js/projekt-wizard.js) — die Vollständigkeit gilt dann
// innerhalb dieses Gewerks, nicht mehr über alle Gewerke hinweg: die
// gemeinsame, ungefilterte Übersicht bleibt die Projektliste (Tab
// "Projekte"), unverändert von dieser Auswahl. Rückgabe: Array von Projekten.
function berichtProjekte(w){
const projekte = wochenberichtGewerk
? (w.projects || []).filter(p => Array.isArray(p.gewerke) && p.gewerke.includes(wochenberichtGewerk))
: (w.projects || []);
const nachGruppe = {};
projekte.forEach(p => (nachGruppe[p.group] = nachGruppe[p.group] || []).push(p));
const gruppen = GROUP_ORDER.filter(g => nachGruppe[g])
.concat(Object.keys(nachGruppe).filter(g => !GROUP_ORDER.includes(g)));
const out = [];
gruppen.forEach(g => {
nachGruppe[g].sort((a, b) => (a.order || 0) - (b.order || 0));
nachGruppe[g].forEach(p => out.push(p));
});
return out;
}
// waehleWochenberichtGewerk: legt das Gewerk fest (Dropdown im Kopf,
// renderBerichtKopf()) und zeichnet den Wochenbericht neu — jetzt gefiltert
// auf dieses Gewerk. Parameter: g ('ELT'|'HLS'|'ARCH'|''). Keine Rückgabe.
function waehleWochenberichtGewerk(g){
wochenberichtGewerk = g || null;
berichtAuswahl = null; // die bisherige Detailauswahl kann zum anderen Gewerk gehören
renderWochenbericht();
}
// renderGewerkDropdown: das Umschaltfeld für wochenberichtGewerk — ein
// gewöhnliches Dropdown statt eines bildschirmfüllenden Dialogs, da der
// Wechsel meist nur eine kurze Korrektur der Vorauswahl ist (siehe
// wochenberichtGewerkVoreinstellen()), kein eigener Arbeitsschritt. Ohne
// Auswahl steht ein deaktivierter Platzhalter-Eintrag oben. Keine Parameter.
// Rückgabe: HTML-String.
function renderGewerkDropdown(){
const optionen = Object.keys(GEWERK_LABELS).map(g =>
`<option value="${esc(g)}" ${wochenberichtGewerk === g ? 'selected' : ''}>${esc(g)} — ${esc(GEWERK_LABELS[g])}</option>`
).join('');
const platzhalter = wochenberichtGewerk ? '' : `<option value="" selected disabled>Gewerk wählen…</option>`;
return `<select class="bericht-gewerk-select" onchange="waehleWochenberichtGewerk(this.value)" aria-label="Gewerk">
${platzhalter}${optionen}
</select>`;
}
// aktuellesBerichtProjekt: das Projekt hinter berichtAuswahl, mit Rückfall
// auf das erste der Tagesordnung. Rückgabe: Projekt oder null.
function aktuellesBerichtProjekt(w){
const liste = berichtProjekte(w);
if(liste.length === 0) return null;
return liste.find(p => p.projectUid && p.projectUid === berichtAuswahl) || liste[0];
}
// waehleBerichtProjekt: schaltet das Detail auf ein Projekt um.
// Parameter: uid (project_uid). Keine Rückgabe.
function waehleBerichtProjekt(uid){
berichtAuswahl = uid;
vorwocheRadIndex = 0; // neues Projekt beginnt wieder bei der jüngsten Vorwoche
vorwocheRadScrollAkku = 0; // kein halb aufgelaufenes Scrollen vom vorherigen Projekt übernehmen
renderWochenbericht();
const detail = document.getElementById('berichtDetail');
if(detail) detail.scrollIntoView({ block: 'nearest' });
}
// ---------- Besprochen-Haken ----------
// HINWEIS: toggleBesprochen() ist entfallen — die schmale Nummern-Leiste des
// Fokus-Modus (Eriks Wahl "C") hat keinen Platz mehr für einen Haken je
// Zeile, und die Detail-Kopfzeile zeigt bewusst keinen eigenen mehr (siehe
// freigegebener Entwurf). Einzelne Projekte abhaken läuft über
// besprochenUndWeiter() (einzeln, beim Weitergehen), alle zurücknehmen über
// setzeAlleBesprochen(false) ("Zurücksetzen" in der Kopfleiste) — ein
// bewusstes Zurücknehmen nur EINES bereits abgehakten Projekts ohne die
// übrigen anzutasten ist damit nicht mehr vorgesehen.
// besprochenUndWeiter: hakt das aktuelle Projekt ab und springt zum nächsten,
// das noch offen ist. Der Motor des Meetings — ohne ihn sucht man nach jedem
// Projekt von Hand die nächste Zeile. Parameter: pid. Keine Rückgabe.
function besprochenUndWeiter(pid){
if(guardWeekLocked()) return;
const w = currentWeek();
const p = (w.projects || []).find(x => x.id === pid);
if(!p) return;
p.besprochen = true;
save();
const liste = berichtProjekte(w);
const idx = liste.findIndex(x => x.id === pid);
// Erst hinter der aktuellen Stelle suchen, dann von vorn — so landet man
// auch dann beim nächsten offenen Punkt, wenn man mittendrin eingestiegen ist.
const offen = liste.slice(idx + 1).find(x => !x.besprochen)
|| liste.find(x => !x.besprochen);
berichtAuswahl = offen ? offen.projectUid : p.projectUid;
renderWochenbericht();
}
// setzeAlleBesprochen: setzt alle Haken der Sitzung zurück bzw. hakt alles ab.
// Parameter: wert (Boolean). Keine Rückgabe.
function setzeAlleBesprochen(wert){
if(guardWeekLocked()) return;
const w = currentWeek();
// berichtProjekte(w) statt w.projects direkt: Der Knopf wirkt auf die
// gerade sichtbare, nach wochenberichtGewerk gefilterte Liste - sonst
// markierte "Alle abhaken" bei gewähltem Gewerk auch die Projekte der
// ANDEREN Gewerke als besprochen, die in dieser Besprechung nie zu sehen
// waren (widerspricht "Wochenbericht — Gewerke-Trennung": Vollständigkeit
// gilt innerhalb des gewählten Gewerks, nicht über alle hinweg).
berichtProjekte(w).forEach(p => { p.besprochen = !!wert; });
save();
renderWochenbericht();
}
// ---------- Abwesenheit ----------
// abwesendeBeteiligte: die einem Projekt zugeordneten Personen, die heute
// fehlen. "Heute" ist der Anwesenheitsstand dieser Sitzung (w.attendance) —
// er wird am Fuß des Berichts ohnehin erfasst. Punkte, deren Zuständige
// fehlen, vertagt man im Meeting besser.
// Parameter: p (Projekt), w (Woche). Rückgabe: Array von Personennamen.
function abwesendeBeteiligte(p, w){
const att = w.attendance || {};
// Solange niemand als anwesend markiert ist, ist die Anwesenheit schlicht
// noch nicht erfasst — dann wäre "alle fehlen" eine Falschmeldung.
if(!Object.values(att).some(v => v)) return [];
const namen = [];
(w.people || []).forEach(person => {
const rollen = Array.isArray(person.role) ? person.role : [person.role];
const zugeordnet = rollen.some(r => (p.assign || {})[assignKey(person.id, r)] > 0);
if(zugeordnet && !att[person.id] && namen.indexOf(person.name) === -1) namen.push(person.name);
});
return namen;
}
// ---------- Sprung-Feld ----------
// setzeBerichtSuche: übernimmt die Eingabe des Sprung-Feldes. Parameter: wert.
function setzeBerichtSuche(wert){
berichtSuche = String(wert || '').trim().toLowerCase();
renderBerichtListe();
}
// passtZurSuche: prüft ein Projekt gegen das Sprung-Feld (Nummer oder Name).
// Rückgabe: Boolean (immer true bei leerer Eingabe).
function passtZurSuche(p){
if(!berichtSuche) return true;
return ((p.nr || '') + ' ' + (p.name || '')).toLowerCase().includes(berichtSuche);
}
// ---------- Rendern ----------
// renderWochenbericht: die Tagesordnung des Teammeetings — links eine
// schmale Nummern-Leiste über die vollständige Projektliste (Fokus-Modus,
// Eriks Wahl "C" aus dem Darstellungs-Vergleich: Nummer und Änderungs-Punkt
// sichtbar, Nummer und Name im Tooltip), rechts das gewählte Projekt in
// voller Größe.
//
// Diese Aufteilung hat die frühere Abfolge gestapelter Karten abgelöst: Bei
// 29 Projekten war der Bericht 18 Bildschirmseiten lang, und man verlor beim
// Durchgehen die Orientierung. Jetzt bleibt die Leiste stehen, man sieht
// jederzeit, wo man steht und was noch kommt, und scrollt nicht mehr.
//
// Die Liste ist nach PROJEKTGRUPPE geordnet (wie im Projektboard), nicht mehr
// nach Änderungsart: Im Meeting wird jedes Projekt besprochen, ob sich etwas
// geändert hat oder nicht — nach Änderung sortiert müsste man zwischen
// Abschnitten hin- und herspringen. Was sich geändert hat, steht weiterhin an
// jedem Projekt (classifyProjectChange()), nur eben als Kennzeichen statt als
// Sortierkriterium. Keine Parameter, keine Rückgabe.
function renderWochenbericht(){
const w = currentWeek();
const container = document.getElementById('wochenberichtContent');
if(!w || !container) return;
// Vorauswahl aus dem eigenen Gewerk des Kontos (einmal je Seitenaufruf).
// Bleibt sie aus (Konto ohne gepflegtes Gewerk), zeigt das Dropdown selbst
// die Aufforderung — kein bildschirmfüllender Dialog dafür.
wochenberichtGewerkVoreinstellen();
if(!wochenberichtGewerk){
container.innerHTML = `<div class="hint">Bitte ein Gewerk wählen: ${renderGewerkDropdown()}</div>`;
const attEl = document.getElementById('weekAttendance');
if(attEl) attEl.innerHTML = '';
return;
}
const locked = isWeekLocked(w);
const prevWeek = findPreviousWeek(w);
const liste = berichtProjekte(w);
const aktiv = aktuellesBerichtProjekt(w);
let html = '';
if(locked){
html += `<div class="lock-notice">
Diese Sitzung ist gesperrt. Bearbeiten nur nach Entsperren möglich.
<button type="button" class="secondary small" onclick="unlockCurrentWeek()">Entsperren</button>
</div>`;
}
if(liste.length === 0){
html += renderBerichtKopf(w, liste, prevWeek, locked);
html += `<div class="hint">In dieser Sitzung ist noch kein Projekt angelegt.</div>`;
container.innerHTML = html;
renderAttendance();
return;
}
// Eine Fläche (.bericht-panel), nicht zwei lose nebeneinanderstehende Karten
// — Kopfleiste und Leiste|Hauptspalte gehören sichtbar zusammen (Entwurf
// "Fokus-Modus"). Die Kopfleiste bekommt einen eigenen Container, damit das
// Öffnen/Schließen des Sprungfelds (oeffneBerichtSuche()) nur sie neu
// zeichnen muss, nicht die ganze Tagesordnung samt offenem Kommentarfeld.
html += `<div class="bericht-panel">
<div id="berichtKopf">${renderBerichtKopf(w, liste, prevWeek, locked)}</div>
<div class="bericht-layout">
<div class="bericht-rail" id="berichtListe"></div>
<div class="bericht-main" id="berichtDetail">${renderBerichtDetail(aktiv, w, prevWeek, locked)}</div>
</div>
</div>`;
container.innerHTML = html;
renderBerichtListe();
renderAttendance();
}
// berichtSucheOffen: ob das Sprungfeld gerade als Eingabefeld statt als
// Knopf dargestellt wird (Fokus-Modus, Eriks Wahl "C") — der Entwurf zeigt
// die Suche standardmäßig nur als schmalen Knopf, nicht als dauerhaft
// sichtbares Eingabefeld. Bleibt auch offen, solange tatsächlich gesucht
// wird (berichtSuche nicht leer), sonst verschwände der eigene Suchtext beim
// nächsten Neu-Rendern wieder hinter dem Knopf.
let berichtSucheOffen = false;
// oeffneBerichtSuche: blendet das Eingabefeld ein und fokussiert es.
// Keine Parameter, keine Rückgabe.
function oeffneBerichtSuche(){
berichtSucheOffen = true;
renderBerichtKopfNeu();
const feld = document.getElementById('berichtSuche');
if(feld) feld.focus();
}
// schliesseBerichtSucheFallsLeer: fällt beim Verlassen des leeren Feldes
// zurück auf den Knopf — ein leeres Feld brächte sonst dauerhaft unnötige
// Breite in die Kopfleiste. Keine Parameter, keine Rückgabe.
function schliesseBerichtSucheFallsLeer(){
if(berichtSuche) return;
berichtSucheOffen = false;
renderBerichtKopfNeu();
}
// renderBerichtKopfNeu: zeichnet nur die Kopfleiste neu (Gewerk, Fortschritt,
// Sprungfeld) — eigene Funktion aus demselben Grund wie renderBerichtListe():
// ein Klick auf den Such-Knopf darf weder die Leiste noch einen gerade
// offenen Kommentar im Detail antasten. Keine Parameter, keine Rückgabe.
function renderBerichtKopfNeu(){
const w = currentWeek();
const el = document.getElementById('berichtKopf');
if(!w || !el) return;
const prevWeek = findPreviousWeek(w);
const liste = berichtProjekte(w);
const locked = isWeekLocked(w);
el.innerHTML = renderBerichtKopf(w, liste, prevWeek, locked);
}
// renderBerichtKopf: Gewerke-Wahl, Fortschrittsring und das Sprungfeld.
// Parameter: w, liste, prevWeek, locked. Rückgabe: HTML.
function renderBerichtKopf(w, liste, prevWeek, locked){
const ab = liste.filter(p => p.besprochen).length;
const pct = liste.length ? Math.round(ab / liste.length * 100) : 0;
// Der Vergleichs-Hinweis ("Vergleich mit KW …") steht nicht mehr als eigene
// Zeile da, sondern als Titel-Tooltip am Fortschritt — dieselbe Angabe,
// ohne eine weitere, dauerhaft sichtbare Textzeile im Entwurf zu ergänzen.
const vergleich = prevWeek
? `Vergleich mit ${esc(prevWeek.label)}${prevWeek.date ? ' (' + esc(formatWeekDate(prevWeek.date)) + ')' : ''}.`
: 'Dies ist die früheste vorhandene Sitzung — kein Vergleich zur Vorwoche möglich.';
const resetBtn = locked ? '' : (ab > 0
? `<button type="button" class="bericht-reset-link" onclick="setzeAlleBesprochen(false)">Zurücksetzen</button>`
: `<button type="button" class="bericht-reset-link" onclick="setzeAlleBesprochen(true)">Alle abhaken</button>`);
const sucheAktiv = berichtSucheOffen || !!berichtSuche;
const sucheHtml = sucheAktiv
? `<input type="search" id="berichtSuche" class="bericht-suche-input" placeholder="Nummer oder Name…"
value="${esc(berichtSuche)}" oninput="setzeBerichtSuche(this.value)"
onblur="schliesseBerichtSucheFallsLeer()" aria-label="Zu einem Projekt springen">`
: `<button type="button" class="bericht-search-btn" onclick="oeffneBerichtSuche()">Projekt suchen…</button>`;
return `<div class="bericht-topbar">
${renderGewerkDropdown()}
<span class="bericht-ring" style="background:conic-gradient(var(--green) 0% ${pct}%, var(--line-soft) ${pct}% 100%)"><span></span></span>
<span class="bericht-fortschritt-text" title="${esc(vergleich)}">${ab} / ${liste.length} besprochen</span>
${resetBtn}
${sucheHtml}
</div>`;
}
// renderBerichtListe: die Tagesordnung als Leiste mit Nummer, Gruppe UND
// Name direkt sichtbar (Entwurf "Aktenregal", Eriks Wahl aus dem zweiten
// Darstellungs-Vergleich — löst die vorherige schmale Icon-Leiste ab, die
// Nummer und Namen nur im Hover-Tooltip zeigte: "so sehe ich die ganze
// Tagesordnung auf einen Blick"). Gruppiert wie die Projektliste
// (GROUP_ORDER, js/globals.js), mit einer Zwischenüberschrift bei jedem
// Gruppenwechsel — die Positionsnummer zählt dabei über alle Gruppen hinweg
// weiter, sie ist die Position in der VOLLSTÄNDIGEN Tagesordnung. Eigene
// Funktion, weil das Sprung-Feld sie allein neu zeichnet — das Detail und
// vor allem ein gerade getippter Kommentar dürfen dabei nicht
// verlorengehen. Keine Parameter, keine Rückgabe.
function renderBerichtListe(){
const el = document.getElementById('berichtListe');
const w = currentWeek();
if(!el || !w) return;
const prevWeek = findPreviousWeek(w);
const aktiv = aktuellesBerichtProjekt(w);
// voll bleibt UNGEFILTERT: die Kachel-Nummer ist die Position in der
// vollständigen Tagesordnung, damit sie beim Tippen im Sprung-Feld nicht
// ständig neu durchnummeriert wird und mit der Zählung im Detail
// übereinstimmt.
const voll = berichtProjekte(w);
const liste = voll.filter(passtZurSuche);
if(liste.length === 0){
el.innerHTML = `<div class="hint" style="padding:10px 6px">Kein Projekt passt zu „${esc(berichtSuche)}".</div>`;
return;
}
let gruppeVorher = null;
el.innerHTML = liste.map(p => {
const info = classifyProjectChange(p, prevWeek);
const istAktiv = aktiv && aktiv.id === p.id;
const position = voll.findIndex(x => x.id === p.id) + 1;
const gruppe = p.group || 'Ohne Gruppe';
const gruppenkopf = gruppe !== gruppeVorher ? `<div class="bericht-rail-gruppe">${esc(gruppe)}</div>` : '';
gruppeVorher = gruppe;
const name = (p.nr ? p.nr + ' · ' : '') + p.name;
return `${gruppenkopf}<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${p.besprochen ? ' is-ab' : ''}"
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}"
aria-current="${istAktiv ? 'true' : 'false'}">
<span class="bericht-rail-num">${String(position).padStart(2, '0')}</span>
${info.bucket === 'unchanged' ? '' : `<span class="bericht-rail-dot is-${esc(info.bucket)}" aria-hidden="true"></span>`}
<span class="bericht-rail-name">${esc(name)}</span>
</div>`;
}).join('');
el.querySelectorAll('.bericht-rail-item').forEach(z => {
const waehlen = () => waehleBerichtProjekt(z.dataset.uid || null);
z.addEventListener('click', waehlen);
z.addEventListener('keydown', e => {
if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); waehlen(); }
});
});
}
// renderBerichtDetail: das gewählte Projekt rechts — Kopfzeile, der Kommentar
// der Vorwoche, die vollständige Bearbeitungsmaske (renderBoardDrawer() aus
// js/board/board.js, dieselbe wie zuvor) und die Fußzeile mit
// "Besprochen & weiter". Parameter: p, w, prevWeek, locked. Rückgabe: HTML.
function renderBerichtDetail(p, w, prevWeek, locked){
if(!p) return '';
const grouped = peopleByRole(w);
const allPeople = [];
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
const info = classifyProjectChange(p, prevWeek);
const tags = Array.isArray(p.tags) ? p.tags : [];
const fehlen = abwesendeBeteiligte(p, w);
// Reiner Text wie im freigegebenen Entwurf — kein zusätzlicher
// Auftragsstatus-Chip mehr an dieser Stelle (der Status bleibt über die
// Projektliste/-einstellungen erreichbar, "Ein Ort je Angabe").
const subText = esc(p.group || 'Ohne Gruppe') + (tags.length ? ' · ' + tags.map(t => esc(t)).join(' · ') : '');
return `<div class="bericht-head-line">
<span class="bericht-head-nr">${esc(p.nr || '—')}</span>
${info.bucket === 'unchanged' ? '' : `<span class="bericht-change-pill is-${esc(info.bucket)}">${esc(info.reason)}</span>`}
</div>
<h1 class="bericht-h1">${esc(p.name)}</h1>
<div class="bericht-sub">${subText}</div>
${fehlen.length ? `<span class="bericht-abwesend-hinweis">Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''}
${renderVorwocheRad(p, w)}
${renderBoardDrawer(p, allPeople, locked, w.label, BERICHT_OPTS)}
${locked ? '' : `<div class="bericht-detail-fuss">
<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
Aufgabe aus Kommentar…
</button>
<span class="bericht-fuss-spacer"></span>
<button type="button" onclick="besprochenUndWeiter('${esc(p.id)}')">Besprochen &amp; weiter →</button>
</div>`}`;
}
// vorwocheRadIndex: Position im Kommentar-Verlauf des GERADE GEÖFFNETEN
// Projekts (0 = jüngste Vorwoche, höher = weiter zurück). Ein einzelner
// modulweiter Stand genügt, da immer nur ein Projekt im Fokus steht;
// waehleBerichtProjekt() setzt ihn beim Projektwechsel zurück.
let vorwocheRadIndex = 0;
// renderVorwocheRad: löst renderVorwochenzeile() ab, die nur die
// UNMITTELBARE Vorwoche zeigte. Eriks Rückmeldung zum Entwurfsvergleich: mit
// dem Mausrad (oder den beiden Pfeilen) durch MEHRERE vergangene Kommentare
// blättern können, nicht nur eine Vorwoche sehen. Nutzt
// projectStatusHistory() (js/utils.js) — denselben, bereits für den
// Verlaufs-Dialog geprüften Verlauf (nur Sitzungen mit tatsächlich
// geändertem Text, neueste zuerst), abzüglich der aktuellen Sitzung selbst
// (die steht ja bereits im Eingabefeld darunter). Parameter: p, w (aktuelle
// Sitzung). Rückgabe: HTML (leer ohne Verlauf).
function renderVorwocheRad(p, w){
if(!p.projectUid) return '';
const verlauf = projectStatusHistory(p.projectUid).filter(e => e.week.label !== w.label);
if(verlauf.length === 0) return '';
const i = Math.max(0, Math.min(verlauf.length - 1, vorwocheRadIndex));
const eintrag = verlauf[i];
const punkte = verlauf.map((_, idx) => `<span class="dot${idx === i ? ' aktiv' : ''}"></span>`).join('');
return `<div class="bericht-vorwoche-rad" title="Mit dem Mausrad durch den Verlauf blättern" onwheel="vorwocheRadScroll(event)">
<button type="button" class="bericht-vorwoche-nav" onclick="vorwocheRadSchritt(1)" ${i >= verlauf.length - 1 ? 'disabled' : ''} title="Weiter zurück">‹</button>
<div class="bericht-vorwoche-inhalt">
<span class="bericht-vorwoche-label">${esc(eintrag.week.label)}</span>
<span class="bericht-vorwoche-text">${esc(eintrag.text)}</span>
</div>
<button type="button" class="bericht-vorwoche-nav" onclick="vorwocheRadSchritt(-1)" ${i <= 0 ? 'disabled' : ''} title="Neuer">›</button>
<div class="bericht-vorwoche-dots">${punkte}</div>
</div>`;
}
// vorwocheRadSchritt: bewegt vorwocheRadIndex um delta (geklemmt auf den
// vorhandenen Verlauf) und zeichnet den Bericht neu — derselbe vollständige
// Neuaufbau, den auch editLph() auslöst; ein offener
// Kommentar-Editor übersteht das über dasselbe Fokus-Wiederherstellungsnetz
// (pendingCommentFocus, js/board/board.js), eine aufgeklappte Anwesenheit
// über berichtDisclosureState. Parameter: delta (1 = weiter zurück, -1 =
// neuer). Keine Rückgabe.
function vorwocheRadSchritt(delta){
vorwocheRadScrollAkku = 0; // ein gezielter Klick verwirft ein halb aufgelaufenes Scrollen
const w = currentWeek();
const p = aktuellesBerichtProjekt(w);
if(!w || !p) return;
const max = projectStatusHistory(p.projectUid).filter(e => e.week.label !== w.label).length - 1;
vorwocheRadIndex = Math.max(0, Math.min(max, vorwocheRadIndex + delta));
renderWochenbericht();
}
// vorwocheRadScrollAkku/-SCHWELLE: jeder einzelne wheel-Tick löste zunächst
// sofort einen Schritt aus — auf einem Trackpad erzeugt schon ein kurzes
// Antippen mehrere Ticks mit kleinem deltaY, wodurch ein einziges Scrollen
// mehrere Wochen auf einmal übersprang (Erik: "Empfindlichkeit beim Scrollen
// etwas verlangsamen"). Jetzt wird deltaY aufsummiert, bis die Schwelle
// überschritten ist — eine einzelne Mausrad-Rastung (meist ~100) überschreitet
// sie sofort und bleibt damit bei einem Schritt je Rastung, ein kurzes
// Trackpad-Antippen dagegen oft nicht. Erster Richtwert, bei Bedarf
// nachjustieren.
let vorwocheRadScrollAkku = 0;
const VORWOCHE_RAD_SCHWELLE = 50;
// vorwocheRadScroll: Mausrad-/Trackpad-Bedienung desselben Scrollrads —
// runter (deltaY positiv, aufsummiert) blättert weiter zurück, hoch blättert
// zu neueren Einträgen. e.preventDefault() verhindert, dass dieselbe Bewegung
// zusätzlich die Seite scrollt. Parameter: e (WheelEvent). Keine Rückgabe.
function vorwocheRadScroll(e){
e.preventDefault();
vorwocheRadScrollAkku += e.deltaY;
if(Math.abs(vorwocheRadScrollAkku) < VORWOCHE_RAD_SCHWELLE) return;
vorwocheRadSchritt(vorwocheRadScrollAkku > 0 ? 1 : -1);
}
// ---------- Aufgabe aus dem Kommentar ----------
//
// Beschlüsse fallen im Meeting und landen bisher als Fließtext im Kommentar —
// im Terminplan und auf "Meine Aufgaben" tauchen sie nie auf. Dieser Dialog
// macht aus einem Satz eine echte Aufgabe im Plan des Projekts
// (p.plan.todos), also denselben Datensatz, den der Projektbereich anlegt.
// todoAusKommentarPid: Projekt, für das der Dialog gerade offen ist.
let todoAusKommentarPid = null;
// openTodoAusKommentar: öffnet den Dialog. Als Titel wird die erste Zeile des
// Kommentars vorgeschlagen — meistens steht dort der Beschluss.
// Parameter: pid. Keine Rückgabe.
function openTodoAusKommentar(pid){
if(guardWeekLocked()) return;
const w = currentWeek();
const p = (w.projects || []).find(x => x.id === pid);
if(!p) return;
todoAusKommentarPid = pid;
// Vorschlag aus dem gerade im Feld stehenden Text, nicht aus dem
// gespeicherten: Im Meeting tippt man den Beschluss und legt sofort die
// Aufgabe an, ohne vorher zu speichern.
// feld ist seit der Umstellung auf contenteditable (Eriks Wunsch nach
// echter Fett-/Kursiv-/Farbdarstellung statt sichtbarer Markup-Zeichen,
// js/utils.js) ein <div> ohne .value — statusTextMitZeilen() liefert
// daraus reinen Text mit erhaltenen Zeilenumbrüchen, aus dem unten die
// erste Zeile als Titelvorschlag gezogen wird.
const feld = document.querySelector('#wochenbericht .comment-field');
const text = feld ? statusTextMitZeilen(feld.innerHTML) : statusPlainText(p.status);
const ersteZeile = String(text || '').split('\n').map(z => z.replace(/^[-•*\s]+/, '').trim()).find(z => z) || '';
document.getElementById('todoKommentarTitel').value = ersteZeile.slice(0, 120);
document.getElementById('todoKommentarProjekt').textContent = (p.nr ? p.nr + ' · ' : '') + p.name;
// Zuständige: der Teilnehmerkreis dieser Sitzung, die dem Projekt
// zugeordneten Personen vorausgewählt.
const zugeordnet = new Set();
(w.people || []).forEach(person => {
const rollen = Array.isArray(person.role) ? person.role : [person.role];
if(rollen.some(r => (p.assign || {})[assignKey(person.id, r)] > 0)) zugeordnet.add(person.id);
});
document.getElementById('todoKommentarPersonen').innerHTML = (w.people || []).map(person =>
`<label class="person-chip-label"><input type="checkbox" value="${esc(person.id)}"
${zugeordnet.has(person.id) ? 'checked' : ''}>
<span class="person-chip">${esc(person.name)}</span></label>`).join('')
|| '<span class="hint" style="margin:0">Niemand im Teilnehmerkreis.</span>';
// Termin: standardmäßig die kommende Woche — der übliche Horizont bis zur
// nächsten Sitzung.
const inEinerWoche = addDays(new Date(), 7);
document.getElementById('todoKommentarEnde').value = formatDateISO(inEinerWoche);
document.getElementById('todoKommentarFehler').hidden = true;
document.getElementById('todoKommentarModal').style.display = 'flex';
document.getElementById('todoKommentarTitel').focus();
}
function closeTodoAusKommentar(){
document.getElementById('todoKommentarModal').style.display = 'none';
todoAusKommentarPid = null;
}
// saveTodoAusKommentar: legt die Aufgabe im Plan des Projekts an. Sie ist
// danach eine ganz normale plan_todo — im Terminplan sichtbar, auf "Meine
// Aufgaben" des Zuständigen, im GANTT verschiebbar. Keine Parameter.
function saveTodoAusKommentar(){
const w = currentWeek();
const p = (w.projects || []).find(x => x.id === todoAusKommentarPid);
const fehler = document.getElementById('todoKommentarFehler');
if(!p) return;
const titel = document.getElementById('todoKommentarTitel').value.trim();
if(!titel){
fehler.textContent = 'Bitte eine Bezeichnung für die Aufgabe eingeben.';
fehler.hidden = false;
return;
}
const ende = document.getElementById('todoKommentarEnde').value;
const personen = Array.from(document.querySelectorAll('#todoKommentarPersonen input:checked')).map(i => i.value);
if(!p.plan) p.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 };
if(!Array.isArray(p.plan.todos)) p.plan.todos = [];
p.plan.todos.push({
id: uid(),
title: titel,
// Start ist heute: Im Meeting beschlossen heißt ab sofort.
start: formatDateISO(new Date()),
end: ende || formatDateISO(new Date()),
done: false,
progress: 0,
dependsOn: [],
assignees: personen,
});
save();
closeTodoAusKommentar();
renderWochenbericht();
}
// ---------- Protokoll ----------
// openBerichtProtokoll: stellt zusammen, was in dieser Sitzung gegenüber der
// vorherigen anders ist, und zeigt es als fertigen Text zum Kopieren oder
// Verschicken. Wer nicht dabei war, musste sich das bisher selbst
// zusammensuchen — die Daten liegen längst vor, es fehlte nur die
// Zusammenstellung. Keine Parameter, keine Rückgabe.
function openBerichtProtokoll(){
const w = currentWeek();
if(!w) return;
const prevWeek = findPreviousWeek(w);
const zeilen = [];
zeilen.push('Wochenbericht ' + w.label + (w.date ? ' (' + formatWeekDate(w.date) + ')' : ''));
if(prevWeek) zeilen.push('Vergleich mit ' + prevWeek.label);
zeilen.push('');
const anwesend = (w.people || []).filter(x => (w.attendance || {})[x.id]).map(x => x.fullName || x.name);
const fehlend = (w.people || []).filter(x => !(w.attendance || {})[x.id]).map(x => x.fullName || x.name);
if(anwesend.length){
zeilen.push('Anwesend: ' + anwesend.join(', '));
if(fehlend.length) zeilen.push('Entschuldigt: ' + fehlend.join(', '));
zeilen.push('');
}
let anzahl = 0;
berichtProjekte(w).forEach(p => {
const info = classifyProjectChange(p, prevWeek);
if(info.bucket === 'unchanged') return; // nur, was sich geändert hat
anzahl++;
zeilen.push('— ' + (p.nr ? p.nr + ' ' : '') + p.name + ' [' + info.reason + ']');
const text = statusPlainText(p.status);
if(text) zeilen.push(' ' + text);
// Aufgaben, die in dieser Sitzung entstanden sind, gehören ins Protokoll:
// Sie sind das Ergebnis der Besprechung.
const heute = formatDateISO(new Date());
((p.plan && p.plan.todos) || []).filter(t => t.start === heute).forEach(t => {
const wer = (t.assignees || []).map(id => {
const person = (w.people || []).find(x => x.id === id);
return person ? person.name : id;
}).join(', ');
zeilen.push(' → Aufgabe: ' + t.title + (wer ? ' (' + wer + ')' : '') + (t.end ? ' bis ' + formatDateDE(parseDateISO(t.end)) : ''));
});
zeilen.push('');
});
if(anzahl === 0) zeilen.push('Keine Änderungen gegenüber der vorherigen Sitzung.');
const text = zeilen.join('\n');
document.getElementById('protokollText').value = text;
document.getElementById('protokollAnzahl').textContent =
anzahl === 1 ? '1 Projekt mit Änderungen' : anzahl + ' Projekte mit Änderungen';
document.getElementById('protokollModal').style.display = 'flex';
}
function closeBerichtProtokoll(){
document.getElementById('protokollModal').style.display = 'none';
}
// kopiereProtokoll: legt den Text in die Zwischenablage. Rückfall auf die
// Auswahl im Textfeld, falls die Zwischenablage nicht verfügbar ist (ältere
// Browser, unsichere Verbindung). Keine Parameter, keine Rückgabe.
function kopiereProtokoll(){
const feld = document.getElementById('protokollText');
const melde = document.getElementById('protokollHinweis');
const fertig = () => { melde.textContent = 'In die Zwischenablage kopiert.'; setTimeout(() => { melde.textContent = ''; }, 2500); };
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(feld.value).then(fertig).catch(() => { feld.select(); });
} else {
feld.select();
melde.textContent = 'Text ist markiert — mit Strg+C kopieren.';
}
}
// protokollAlsMail: öffnet den Mailer mit dem Protokoll im Rumpf. mailto
// verträgt keine beliebig langen Texte; bei sehr langen Protokollen weist die
// Funktion darauf hin, statt einen abgeschnittenen Entwurf zu öffnen.
function protokollAlsMail(){
const w = currentWeek();
const text = document.getElementById('protokollText').value;
const link = 'mailto:?subject=' + encodeURIComponent('Wochenbericht ' + (w ? w.label : ''))
+ '&body=' + encodeURIComponent(text);
if(link.length > 1800){
document.getElementById('protokollHinweis').textContent =
'Das Protokoll ist für einen Mail-Link zu lang — bitte kopieren und einfügen.';
return;
}
window.location.href = link;
}
// ---------- Tastatur ----------
//
// Wer im Meeting mitschreibt, hat keine Hand für die Maus frei. ↓/↑ wechseln
// das Projekt, Strg+↵ im Kommentarfeld speichert den Kommentar und geht
// weiter. Die Tasten greifen nur im Wochenbericht und nicht, während in
// einem Eingabefeld getippt wird.
document.addEventListener('keydown', function(e){
if(typeof currentTab === 'undefined' || currentTab !== 'wochenbericht') return;
const w = currentWeek();
if(!w) return;
// isContentEditable erfasst das Kommentarfeld (seit der Umstellung auf
// contenteditable ein <div>, kein <textarea> mehr, siehe js/utils.js) —
// ohne diese Ergänzung griffen ↓/↑ dort als Projekt-Wechsel statt als
// normale Cursorbewegung im Text.
const imFeld = e.target && ((e.target.matches && e.target.matches('input, textarea, select')) || e.target.isContentEditable);
if((e.key === 'Enter') && (e.ctrlKey || e.metaKey)){
const feld = e.target && e.target.closest ? e.target.closest('.comment-field') : null;
// Strg+Enter nur abfangen, wenn der Fokus tatsächlich im Kommentarfeld
// liegt — sonst OHNE feld-Prüfung, z. B. in einem Leistungsphasen-
// Eingabefeld (.bericht-lph-input) oder im Titelfeld des "Aufgabe aus
// Kommentar"-Dialogs getippt, sprang die App sofort zum nächsten Projekt
// und baute die Liste neu auf — ein gerade eingetippter, noch nicht per
// onchange/blur gespeicherter Wert ging dabei kommentarlos verloren, und
// ein offen stehender Dialog bezog sich danach lautlos auf das falsche
// (inzwischen gewechselte) Projekt. Ohne feld bricht diese Weiche jetzt
// einfach ab und überlässt Strg+Enter dem normalen Feldverhalten.
if(!feld) return;
const aktiv = aktuellesBerichtProjekt(w);
if(!aktiv) return;
e.preventDefault();
// Der Kommentar speichert sich über den bestehenden Weg selbst; hier
// folgt nur der Sprung zum nächsten offenen Punkt.
if(typeof saveBoardComment === 'function'){
const knopf = feld.closest('.comment-block').querySelector('.comment-editor-foot button');
if(knopf) saveBoardComment(knopf);
}
besprochenUndWeiter(aktiv.id);
return;
}
if(imFeld) return;
if(e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
const liste = berichtProjekte(w).filter(passtZurSuche);
if(liste.length === 0) return;
const aktiv = aktuellesBerichtProjekt(w);
const idx = liste.findIndex(p => aktiv && p.id === aktiv.id);
const ziel = Math.max(0, Math.min(liste.length - 1, (idx < 0 ? 0 : idx) + (e.key === 'ArrowDown' ? 1 : -1)));
e.preventDefault();
waehleBerichtProjekt(liste[ziel].projectUid || null);
});