// ----------------------------- // 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 => `${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)}
`; } // 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 = `
${esc(title)} (${entries.length})
`; if(entries.length === 0){ html += `
${esc(emptyText)}
`; return html; } html += `
`; html += entries.map(({p, reason}) => expanded ? renderReportCard(p, reason, allPeople, locked, weekLabel, variant) : renderBoardRow(p, allPeople, locked, weekLabel, BERICHT_OPTS) ).join(''); html += `
`; 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 = `
Anwesenheit
Für diese Sitzung sind 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 ${anwesend} von ${people.length} anwesend ${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 = {}; (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 => `` ).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; 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 = `
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.
`; } html += renderBerichtKopf(w, liste, prevWeek, locked); if(liste.length === 0){ html += `
In dieser Sitzung ist noch kein Projekt angelegt.
`; container.innerHTML = html; renderAttendance(); return; } html += `
${renderBerichtDetail(aktiv, w, prevWeek, locked)}
`; 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 `
${renderGewerkDropdown()} ${vergleich} ${ab} von ${liste.length} ${esc(wochenberichtGewerk)}-Projekten besprochen ${locked ? '' : (ab > 0 ? `` : ``)}
`; } // 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 = `
Kein Projekt passt zu „${esc(berichtSuche)}".
`; 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, `
${esc(p.group || 'Ohne Gruppe')}
`) : ''; const istAktiv = aktiv && aktiv.id === p.id; return kopf + `
${esc(p.nr || '—')} ${esc(p.name)} ${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''} ${fehlen.length ? `${esc(fehlen.length)}✗` : ''} ${info.bucket === 'unchanged' ? '' : ``}
`; }).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 `
${nr} / ${liste.length} ${esc(p.nr || '—')}

${esc(p.name)}

${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''} ${tags.map(t => `${esc(t)}`).join('')} ${info.bucket === 'unchanged' ? '' : `${esc(info.reason)}`}
${esc(p.group || 'Ohne Gruppe')}
${fehlen.length ? `
Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.
` : ''} ${renderVorwochenzeile(p, prevWeek)} ${renderBoardDrawer(p, allPeople, locked, w.label, BERICHT_OPTS)} ${locked ? '' : `
`}`; } // 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 `
${esc(prevWeek.label)} ${esc(text)}
`; } // ---------- 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 => ``).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; 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); });