// ----------------------------- // 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, gewerk){ // Kommentare werden je Gewerk verglichen (kommentarVon(), js/utils.js) — // ein neuer ELT-Kommentar ist für die HLS-Besprechung keine Neuigkeit. // Ohne ausdrückliches Gewerk gilt das des Zusammenhangs. const g = gewerk === undefined ? kommentarGewerk() : gewerk; const prev = (prevWeek && p.projectUid) ? prevWeek.projects.find(x => x.projectUid === p.projectUid) : null; if(!prev){ return { bucket: 'new', reason: 'Neues Projekt' }; } const kNow = kommentarVon(p, g); const kPrev = kommentarVon(prev, g); const statusNow = (kNow.status || '').trim(); const statusPrev = (kPrev.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 !== '' && kNow.statusAt && kNow.statusAt !== (kPrev.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 => `${esc(t)}`).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 `
${esc(p.nr)} ${esc(p.name)} ${tagsHtml} ${esc(reason)}
${renderBoardDrawer(p, allPeople, locked, weekLabel, BERICHT_OPTS)}
`; } // ---------- 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; } // Ohne Recht „Anwesenheit erfassen“ (Rollen & Rechte) nur Anzeige. const locked = isWeekLocked(w) || !hatRecht('wb.anwesenheit'); const att = w.attendance || {}; // Nur die Personen des gewählten Gewerks — jedes Gewerk erfasst die // Anwesenheit seiner eigenen Besprechung (personenDesGewerks(), js/utils.js). const people = personenDesGewerks(w, wochenberichtGewerk); // 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 = `
Anwesenheit${icon('chevronRight')}
Für dieses Gewerk sind in dieser Sitzung keine Projektbeteiligten hinterlegt.
`; 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 ? '' : `
`; 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 ``; }).join(''); container.innerHTML = `
Anwesenheit ${esc(wochenberichtGewerk || "")}${anwesend} von ${people.length}${icon('chevronRight')}
${actions}
${tiles}
`; } // 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 = {}; personenDesGewerks(w, wochenberichtGewerk).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 => `` ).join(''); const platzhalter = wochenberichtGewerk ? '' : ``; return ``; } // 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; berichtVerlaufAlle = false; // neues Projekt zeigt wieder nur die jüngsten Einträge 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() || !hatRecht('wb.besprochen')) return; const w = currentWeek(); const p = (w.projects || []).find(x => x.id === pid); if(!p) return; kommentarSetzen(p, wochenberichtGewerk, { 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 istAb = x => kommentarVon(x, wochenberichtGewerk).besprochen; const offen = liste.slice(idx + 1).find(x => !istAb(x)) || liste.find(x => !istAb(x)); berichtAuswahl = offen ? offen.projectUid : p.projectUid; renderWochenbericht(); } // berichtNaechstes: das Projekt direkt nach pid in der sichtbaren Liste // (dieselbe Reihenfolge und Suche wie die Pfeiltaste ↓), sonst null. function berichtNaechstes(w, pid){ const liste = berichtProjekte(w).filter(passtZurSuche); const idx = liste.findIndex(x => x.id === pid); return idx >= 0 ? (liste[idx + 1] || null) : null; } // berichtWeiter: springt zum nächsten Projekt, OHNE es abzuhaken — für ein // Projekt, das heute nicht besprochen wird. Parameter: pid. Keine Rückgabe. function berichtWeiter(pid){ const naechstes = berichtNaechstes(currentWeek(), pid); if(naechstes) waehleBerichtProjekt(naechstes.projectUid || null); } // 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). // Nur der Haken DIESES Gewerks — die anderen Gewerke besprechen das // Projekt in ihrer eigenen Sitzung. berichtProjekte(w).forEach(p => kommentarSetzen(p, wochenberichtGewerk, { besprochen: !!wert })); save(); renderWochenbericht(); } // ---------- Abwesenheit ---------- // abwesendeBeteiligte: die einem Projekt zugeordneten Personen, die heute // laut ihrer EINGETRAGENEN Abwesenheit fehlen (Urlaub, Krank, Fortbildung … // aus der Benutzerverwaltung, db.globalPeople[].absences) — Eriks Wunsch. // Früher wurde stattdessen die Anwesenheitsliste am Fuß des Berichts // ausgewertet: Wer dort nicht abgehakt war, "fehlte" — auch wenn er nur // nicht in dieser Besprechung saß, und umgekehrt fehlte ein Urlauber nicht, // solange niemand die Anwesenheit erfasst hatte. Punkte, deren Zuständige // abwesend sind, vertagt man im Meeting besser. // Zugeordnet = in irgendeiner Rolle (Haupt oder Unterstützung) im Projekt. // Die Person der Sitzung wird über ihre Kennung, ersatzweise den vollen // Namen, ihrem Konto zugeordnet (wie personGewerk(), js/utils.js). // Parameter: p (Projekt), w (Woche). Rückgabe: Array von Texten // "Name (Urlaub bis 28.10.)". function abwesendeBeteiligte(p, w){ const heute = heuteIso(); const konten = Array.isArray(db.globalPeople) ? db.globalPeople : []; 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) return; const konto = konten.find(x => x.id === person.id) || (person.fullName ? konten.find(x => x.fullName === person.fullName) : null); const a = konto && (konto.absences || []).find(x => x.start && x.end && x.start <= heute && heute <= x.end); if(!a) return; const name = person.fullName || person.name; const bis = a.end > heute ? ' bis ' + formatWeekDate(a.end).replace(/\d{4}$/, '') : ''; const text = name + ' (' + (a.type || 'abwesend') + bis + ')'; if(!namen.some(n => n.startsWith(name + ' ('))) namen.push(text); }); 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 = `
Bitte ein Gewerk wählen: ${renderGewerkDropdown()}
`; 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 += `
Diese Sitzung ist gesperrt. Bearbeiten nur nach Entsperren möglich. ${hatRecht('s.entsperren') ? '' : ''}
`; } if(liste.length === 0){ html += renderBerichtKopf(w, liste, prevWeek, locked); html += `
Für ${esc(wochenberichtGewerk)} steht in dieser Sitzung kein Projekt auf der Tagesordnung.
`; 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 += `
${renderBerichtKopf(w, liste, prevWeek, locked)}
${renderBerichtDetail(aktiv, w, prevWeek, locked)}
`; 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 => kommentarVon(p, wochenberichtGewerk).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 || !hatRecht('wb.besprochen')) ? '' : (ab > 0 ? `` : ``); const sucheAktiv = berichtSucheOffen || !!berichtSuche; const sucheHtml = sucheAktiv ? `` : ``; return `
${renderGewerkDropdown()} ${ab} / ${liste.length} besprochen ${resetBtn} ${sucheHtml}
`; } // 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 = `
Kein Projekt passt zu „${esc(berichtSuche)}".
`; 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 ? `
${esc(gruppe)}
` : ''; gruppeVorher = gruppe; const name = (p.nr ? p.nr + ' · ' : '') + p.name; return `${gruppenkopf}
${String(position).padStart(2, '0')} ${info.bucket === 'unchanged' ? '' : ``} ${esc(name)}
`; }).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 ''; // Kommentar und Fortschritt nur mit Recht „Kommentar und Fortschritt // bearbeiten“, sonst wie gesperrt als Anzeige (Rollen & Rechte). const kLocked = locked || !hatRecht('wb.kommentar'); 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 `
${esc(p.nr || '—')} ${info.bucket === 'unchanged' ? '' : `${esc(info.reason)}`}

${esc(p.name)}

${subText}
${fehlen.length ? `Heute abwesend: ${esc(fehlen.join(', '))} — eventuell vertagen.` : ''} ${renderBoardDrawer(p, allPeople, kLocked, w.label, Object.assign({}, BERICHT_OPTS, { ohneVerlauf: true }))}
${!locked && hatRecht('proj.plan') ? `` : ''} ${!locked && hatRecht('wb.besprochen') ? `` : ''}
${renderBerichtVerlauf(p, w, kLocked)}`; } // ---------- Verlauf der Vorwochen (Eriks Wahl "B") ---------- // // Löst das Scrollrad über dem Kommentarfeld (renderVorwocheRad()) und den // aufklappbaren Kachel-Streifen (renderProjektVerlauf(), bleibt für // Projektliste/Projektbereich) im Wochenbericht ab. Darstellung wie im // Projekt-Dashboard (renderPlanKommentarVerlauf(), plan-info.js): eine // senkrechte Zeitleiste, hier als eigener Abschnitt über die volle Breite // unter der Bearbeitungsmaske. Über dem Kommentarfeld bleibt nur die schmale // Zeile "Zuletzt KW …" (renderBerichtZuletzt()). // // Ein Eintrag entsteht nur, wenn sich der Kommentar DES GEWÄHLTEN GEWERKS // gegenüber der Sitzung davor geändert hat; Sitzungen dazwischen ohne neuen // Text stehen als grauer Vermerk am Eintrag. Geänderte Leistungsphasen seit // dem vorigen Eintrag erscheinen als Chips ("LPH 5: 60 → 75 %"), statt in // jedem Eintrag alle Werte zu wiederholen. Keine neue Datenhaltung — jede // Sitzung hält ihre eigene Kopie des Projekts (projectVersionsForUid()). const BERICHT_VERLAUF_KURZ = 3; // berichtVerlaufAlle: "Alle N anzeigen" aufgeklappt; waehleBerichtProjekt() // setzt es beim Projektwechsel zurück. let berichtVerlaufAlle = false; // berichtKurzKw: "KW 43 / 2026" -> "KW 43" (Jahr nur, wenn nicht das laufende) function berichtKurzKw(label){ const m = /^(KW\s*\d+)\s*\/\s*(\d{4})$/.exec(String(label || '').trim()); if(!m) return label || ''; return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2]; } // berichtVerlaufDaten: die früheren Kommentare eines Projekts für ein Gewerk. // Parameter: p (Projekt der Sitzung w), w (aktuelle Sitzung), g (Gewerk oder // null). Rückgabe: { sitzungen, eintraege: [{week, html, autor, at, ruhe, chips}] }, // neueste zuerst. function berichtVerlaufDaten(p, w, g){ const bis = weekSortKey(w.label); const versionen = projectVersionsForUid(p.projectUid) .filter(v => weekSortKey(v.week.label).localeCompare(bis) < 0); const roh = []; let vorher = ''; versionen.forEach((v, i) => { const k = kommentarVon(v.project, g); const text = statusPlainText(k.status); if(text && text !== vorher){ roh.push({ i, week: v.week, project: v.project, html: sanitizeStatusHtml(k.status || ''), autor: k.statusAuthor || '', at: k.statusAt || '' }); } vorher = text; }); const eintraege = roh.map((e, n) => { // Sitzungen nach diesem Eintrag bis zum nächsten (bzw. bis zur aktuellen) const bisIdx = n + 1 < roh.length ? roh[n + 1].i : versionen.length; const still = versionen.slice(e.i + 1, bisIdx).map(v => berichtKurzKw(v.week.label)); const ruhe = still.length === 0 ? '' : (still.length === 1 ? still[0] : still[0] + ' bis ' + still[still.length - 1]) + ' ohne neuen Kommentar'; // LPH-Änderungen gegenüber dem vorigen Eintrag (beim ältesten gegenüber // der Sitzung direkt davor, sofern es eine gibt) const basis = n > 0 ? roh[n - 1].project : (e.i > 0 ? versionen[e.i - 1].project : null); const chips = []; if(basis){ const alt = basis.lph || {}, neu = e.project.lph || {}; LPHS.forEach(l => { const a = alt[l] === undefined || alt[l] === '' ? '' : String(alt[l]); const b = neu[l] === undefined || neu[l] === '' ? '' : String(neu[l]); if(a !== b && b !== '') chips.push(l + ': ' + (a || '0') + ' → ' + b + ' %'); }); } return { week: e.week, html: e.html, autor: e.autor, at: e.at, ruhe, chips }; }).reverse(); return { sitzungen: versionen.length, eintraege }; } // berichtVerlaufDatum: ISO-Zeitstempel -> "20.10." (leer, wenn unbrauchbar) function berichtVerlaufDatum(at){ const d = at ? new Date(at) : null; if(!d || isNaN(d)) return ''; return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); } // renderBerichtZuletzt: NICHT MEHR AUFGERUFEN (Eriks Wunsch — der Kommentar // der Vorwoche steht seit der Übernahme beim Sitzungswechsel ohnehin im // Feld). Früher: die schmale Zeile über dem Kommentarfeld — der // jüngste frühere Kommentar dieses Gewerks. Rückgabe: HTML (leer ohne Verlauf). function renderBerichtZuletzt(p, w){ if(!p.projectUid) return ''; const e = berichtVerlaufDaten(p, w, wochenberichtGewerk).eintraege[0]; if(!e) return ''; // Mit Formatierung (fett, kursiv, Farbe, Zeilen) wie im Verlauf darunter — // e.html ist bereits über sanitizeStatusHtml() bereinigt. return `
Zuletzt ${esc(berichtKurzKw(e.week.label))}
${e.html}
`; } // renderBerichtVerlauf: der Abschnitt "Verlauf der Vorwochen" unter der // Bearbeitungsmaske. Parameter: p, w, locked. Rückgabe: HTML. function renderBerichtVerlauf(p, w, locked){ if(!p || !p.projectUid) return ''; const g = wochenberichtGewerk; const { sitzungen, eintraege } = berichtVerlaufDaten(p, w, g); const gText = g || 'alle Gewerke'; let inhalt; if(eintraege.length === 0){ inhalt = `
${sitzungen === 0 ? 'Dieses Projekt erscheint zum ersten Mal, es gibt noch keine frühere Sitzung.' : 'In den früheren Sitzungen gibt es noch keinen Kommentar für ' + esc(gText) + '.'}
`; } else { const zeigen = berichtVerlaufAlle ? eintraege : eintraege.slice(0, BERICHT_VERLAUF_KURZ); inhalt = `
    ${zeigen.map((e, i) => { const datum = berichtVerlaufDatum(e.at); const wer = [e.autor, datum].filter(Boolean).join(', '); // onmousedown + preventDefault wie beim bisherigen "übernehmen": sonst // verliert das Kommentarfeld zuerst den Fokus und speichert dazwischen. const knopf = locked ? '' : ``; return `
  1. ${esc(berichtKurzKw(e.week.label))}${wer ? `${esc(wer)}` : ''} ${e.html} ${e.ruhe ? `${esc(e.ruhe)}` : ''} ${e.chips.map(c => `${esc(c)}`).join('')} ${knopf}
  2. `; }).join('')}
`; } const mehr = eintraege.length > BERICHT_VERLAUF_KURZ ? `` : ''; const anzahl = eintraege.length === 1 ? '1 Kommentar' : eintraege.length + ' Kommentare'; return `

Verlauf der Vorwochen

${esc(gText)}, ${anzahl} in ${sitzungen === 1 ? '1 Sitzung' : sitzungen + ' Sitzungen'} ${mehr}
${inhalt}
`; } // berichtVerlaufUmschalten: "Alle N anzeigen" / "Weniger anzeigen". Zeichnet // nur den Verlauf neu, damit ein angefangener Kommentar unberührt bleibt. function berichtVerlaufUmschalten(){ berichtVerlaufAlle = !berichtVerlaufAlle; const w = currentWeek(); const p = w && aktuellesBerichtProjekt(w); const el = document.getElementById('berichtVerlauf'); if(!p || !el) return; el.outerHTML = renderBerichtVerlauf(p, w, isWeekLocked(w)); } // berichtVerlaufUebernehmen: schreibt den Kommentar jener Sitzung in das // Kommentarfeld der aktuellen (takeCommentFromHistory(), js/board/board.js). function berichtVerlaufUebernehmen(weekLabel){ const feld = document.querySelector('#berichtDetail .comment-block .comment-field'); if(feld) takeCommentFromHistory(feld, weekLabel); } // ---------- 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
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(kommentarVon(p, wochenberichtGewerk).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 => ``).join('') || 'Niemand im Teilnehmerkreis.'; // 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; // Fälligkeit vor heute ergäbe einen Balken mit negativer Breite (Start ist // immer heute) — lieber gleich ablehnen. if(ende && ende < formatDateISO(new Date())){ fehler.textContent = 'Das Fälligkeitsdatum liegt in der Vergangenheit.'; fehler.hidden = false; return; } 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 leute = personenDesGewerks(w, wochenberichtGewerk); const anwesend = leute.filter(x => (w.attendance || {})[x.id]).map(x => x.fullName || x.name); const fehlend = leute.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(kommentarVon(p, wochenberichtGewerk).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
, kein