alle new
This commit is contained in:
erik committed 2026-10-01 06:46:51 +02:00
1 parent da64472251
commit 5b20adee48
1605 files changed
+396517 -1674

No files matched your search

+873
View File
@@ -0,0 +1,873 @@
// -----------------------------
// 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 der Projekte selbst
// (Kommentar-Editor, LPH, Beteiligte) läuft vollständig über die bestehenden
// Funktionen aus js/board/board.js (editProj/editLph/cycleAssign/
// renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses Modul
// ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die
// Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten).
// 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 Test.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. Im Wochenbericht werden
// nur die beauftragten Leistungsphasen gezeigt (p.beauftragteLP) — im
// Projektboard bleibt es bei allen. Den Verlauf der Vorwochen hängt
// renderBoardDrawer() inzwischen ü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>`;
}
// renderReportSection: baut einen Abschnitt des Berichts (Überschrift mit
// Anzahl, Liste der zugehörigen Projekte). `expanded` steuert, ob die volle
// Karte (renderReportCard) oder die normale einklappbare Board-Zeile
// (renderBoardRow) genutzt wird. Parameter: title, entries (Array
// {p, reason}), allPeople, locked, expanded (Boolean), emptyText. Rückgabe:
// HTML-String.
function renderReportSection(title, entries, allPeople, locked, expanded, emptyText, weekLabel, variant){
let html = `<div class="section-title">${esc(title)} (${entries.length})</div>`;
if(entries.length === 0){
html += `<div class="hint">${esc(emptyText)}</div>`;
return html;
}
html += `<div class="${expanded ? 'report-list' : 'board-list'}">`;
html += entries.map(({p, reason}) => expanded
? renderReportCard(p, reason, allPeople, locked, weekLabel, variant)
: renderBoardRow(p, allPeople, locked, weekLabel, BERICHT_OPTS)
).join('');
html += `</div>`;
return html;
}
// ---------- 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 aus renderBoard(). 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 : [];
if(people.length === 0){
container.innerHTML = `<div class="attendance-block"><div class="attendance-bar">
<span class="attendance-title">Anwesenheit</span></div>
<div class="attendance-grid"><span class="hint">Für diese Sitzung sind keine Projektbeteiligten hinterlegt.</span></div></div>`;
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 ? '' : `
<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>`;
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 = `<div class="attendance-block">
<div class="attendance-bar">
<span class="attendance-title">Anwesenheit</span>
<span class="attendance-count">${anwesend} von ${people.length} anwesend</span>
<span class="attendance-spacer"></span>
${actions}
</div>
<div class="attendance-grid">${tiles}</div>
</div>`;
}
// 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()/cycleAssign() (js/board/board.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;
renderWochenbericht();
const detail = document.getElementById('berichtDetail');
if(detail) detail.scrollIntoView({ block: 'nearest' });
}
// ---------- Besprochen-Haken ----------
// toggleBesprochen: hakt ein Projekt als besprochen ab bzw. nimmt den Haken
// zurück. Der Haken gehört zur Sitzung (Spalte projects.besprochen) und geht
// deshalb durch den normalen Speicherweg. Parameter: pid. Keine Rückgabe.
function toggleBesprochen(pid){
if(guardWeekLocked()) return;
const w = currentWeek();
const p = (w.projects || []).find(x => x.id === pid);
if(!p) return;
p.besprochen = !p.besprochen;
save();
renderWochenbericht();
}
// 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();
(w.projects || []).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 die
// vollständige Projektliste, 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 Liste 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>`;
}
html += renderBerichtKopf(w, liste, prevWeek, locked);
if(liste.length === 0){
html += `<div class="hint">In dieser Sitzung ist noch kein Projekt angelegt.</div>`;
container.innerHTML = html;
renderAttendance();
return;
}
html += `<div class="bericht-split">
<div class="bericht-liste-spalte">
<div class="bericht-suche">
<input type="search" id="berichtSuche" placeholder="Nummer oder Name…"
value="${esc(berichtSuche)}" oninput="setzeBerichtSuche(this.value)"
aria-label="Zu einem Projekt springen">
</div>
<div class="bericht-liste" id="berichtListe"></div>
</div>
<div class="bericht-detail" id="berichtDetail">${renderBerichtDetail(aktiv, w, prevWeek, locked)}</div>
</div>`;
container.innerHTML = html;
renderBerichtListe();
renderAttendance();
}
// renderBerichtKopf: Vergleichs-Hinweis und Fortschrittsanzeige.
// 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;
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.';
return `<div class="bericht-kopf">
${renderGewerkDropdown()}
<span class="hint" style="margin:0">${vergleich}</span>
<span class="bericht-fortschritt">
<span class="bericht-fortschritt-text">${ab} von ${liste.length} ${esc(wochenberichtGewerk)}-Projekten besprochen</span>
<span class="bericht-fortschritt-bar"><i style="width:${pct}%"></i></span>
${locked ? '' : (ab > 0
? `<button type="button" class="secondary small" onclick="setzeAlleBesprochen(false)">Haken zurücksetzen</button>`
: `<button type="button" class="secondary small" onclick="setzeAlleBesprochen(true)">Alle abhaken</button>`)}
</span>
</div>`;
}
// renderBerichtListe: die Tagesordnung links. Eigene Funktion, weil das
// Sprung-Feld sie allein neu zeichnet — das Detail rechts 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 locked = isWeekLocked(w);
const prevWeek = findPreviousWeek(w);
const aktiv = aktuellesBerichtProjekt(w);
const liste = berichtProjekte(w).filter(passtZurSuche);
if(liste.length === 0){
el.innerHTML = `<div class="hint" style="padding:10px">Kein Projekt passt zu „${esc(berichtSuche)}".</div>`;
return;
}
let letzteGruppe = null;
el.innerHTML = liste.map(p => {
const info = classifyProjectChange(p, prevWeek);
const fehlen = abwesendeBeteiligte(p, w);
const kopf = p.group !== letzteGruppe
? (letzteGruppe = p.group, `<div class="bericht-gruppe">${esc(p.group || 'Ohne Gruppe')}</div>`)
: '';
const istAktiv = aktiv && aktiv.id === p.id;
return kopf + `<div class="bericht-zeile${istAktiv ? ' is-aktiv' : ''}${p.besprochen ? ' is-ab' : ''}"
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}" data-pid="${esc(p.id)}"
aria-current="${istAktiv ? 'true' : 'false'}">
<button type="button" class="bericht-haken${p.besprochen ? ' ab' : ''}"
${locked ? 'disabled' : `onclick="event.stopPropagation();toggleBesprochen('${esc(p.id)}')"`}
title="${p.besprochen ? 'Haken zurücknehmen' : 'Als besprochen markieren'}"
aria-label="${esc(p.name)} als besprochen markieren">✓</button>
<span class="bericht-zeile-nr">${esc(p.nr || '—')}</span>
<span class="bericht-zeile-name">${esc(p.name)}</span>
${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''}
${fehlen.length ? `<span class="bericht-abwesend" title="Fehlt heute: ${esc(fehlen.join(', '))}">${esc(fehlen.length)}✗</span>` : ''}
${info.bucket === 'unchanged' ? '' : `<span class="bericht-punkt is-${esc(info.bucket)}" title="${esc(info.reason)}"></span>`}
</div>`;
}).join('');
el.querySelectorAll('.bericht-zeile').forEach(z => {
const waehlen = () => waehleBerichtProjekt(z.dataset.uid || null);
z.addEventListener('click', e => { if(!e.target.closest('.bericht-haken')) 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);
const liste = berichtProjekte(w);
const nr = liste.findIndex(x => x.id === p.id) + 1;
return `<div class="bericht-detail-kopf">
<span class="bericht-detail-zaehler">${nr} / ${liste.length}</span>
<span class="bericht-zeile-nr">${esc(p.nr || '—')}</span>
<h3>${esc(p.name)}</h3>
${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''}
${tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('')}
${info.bucket === 'unchanged' ? '' : `<span class="bericht-kennz is-${esc(info.bucket)}">${esc(info.reason)}</span>`}
</div>
<div class="bericht-detail-meta">${esc(p.group || 'Ohne Gruppe')}</div>
${fehlen.length ? `<div class="bericht-abwesend-hinweis">Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.</div>` : ''}
${renderVorwochenzeile(p, prevWeek)}
${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>`}`;
}
// renderVorwochenzeile: der Kommentar der Vorwoche als eine Zeile ÜBER dem
// Eingabefeld. Die häufigste Frage im Meeting ist "was hatten wir letzte
// Woche vereinbart?" — der Verlauf beantwortet sie zwar, aber erst nach dem
// Aufklappen. Parameter: p, prevWeek. Rückgabe: HTML (leer ohne Vorwoche).
function renderVorwochenzeile(p, prevWeek){
if(!prevWeek || !p.projectUid) return '';
const vor = (prevWeek.projects || []).find(x => x.projectUid === p.projectUid);
if(!vor) return '';
const text = statusPlainText(vor.status);
if(!text) return '';
return `<div class="bericht-vorwoche">
<span class="bericht-vorwoche-label">${esc(prevWeek.label)}</span>
<span class="bericht-vorwoche-text">${esc(text)}</span>
</div>`;
}
// ---------- 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.
const feld = document.querySelector('#wochenbericht .comment-field');
const text = feld ? feld.value : 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+↵ speichert den Kommentar und geht weiter. Die Tasten
// greifen nur im Wochenbericht und nicht, während in einem Eingabefeld
// getippt wird — mit einer Ausnahme: Strg+↵ im Kommentarfeld ist genau der
// Fall, für den es gedacht ist.
document.addEventListener('keydown', function(e){
if(typeof currentTab === 'undefined' || currentTab !== 'wochenbericht') return;
const w = currentWeek();
if(!w) return;
const imFeld = e.target && e.target.matches && e.target.matches('input, textarea, select');
if((e.key === 'Enter') && (e.ctrlKey || e.metaKey)){
const feld = e.target && e.target.closest ? e.target.closest('.comment-field') : null;
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(feld && 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);
});