- Outlook: Kalender-Abo je Konto (eigene Aufgaben, Meilensteine, Abwesenheiten als .ics), einzurichten unter Einstellungen - Teams: Nachricht in einen Kanal bei neuen Aufgaben, mit Link direkt auf die Aufgabe; einzurichten unter Admin → Teams - Sitzungen nach Bedarf laden: beim Start nur die aktuelle und drei davor, im Verlauf „Weitere laden“ in Fünferschritten - Termin aus Mail: Wahl Aufgabe/Meilenstein, Notiz auch bei Meilensteinen, nur aktiv mit eingerichteter KI - Kalender zeigt die KW, auch statt der Browser-Datumsfelder - Pfeile bei den Abwesenheiten, kleinere Server-Korrekturen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1134 lines
56 KiB
JavaScript
1134 lines
56 KiB
JavaScript
// -----------------------------
|
|
// 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 => `<span class="tag-chip">${esc(t)}</span>`).join('');
|
|
// variant ('neu' | 'fortschritt') färbt das Kopfband der Karte ein, damit
|
|
// die Änderungsart beim Scrollen ohne Blick auf die Abschnitts-Überschrift
|
|
// erkennbar bleibt. Rein gestalterisch — die Zuordnung selbst trifft
|
|
// classifyProjectChange().
|
|
const vClass = variant ? ' report-card-' + variant : '';
|
|
return `<div class="report-card${vClass}">
|
|
<div class="report-card-head report-band">
|
|
<span class="board-nr">${esc(p.nr)}</span>
|
|
<span class="board-name" onclick="openProjectPlan('${p.id}')" title="Projektplan öffnen">${esc(p.name)}</span>
|
|
${tagsHtml}
|
|
<span class="report-reason-badge">${esc(reason)}</span>
|
|
</div>
|
|
${renderBoardDrawer(p, allPeople, locked, weekLabel, BERICHT_OPTS)}
|
|
</div>`;
|
|
}
|
|
|
|
// ---------- Anwesenheit der Sitzung ----------
|
|
// Die Anwesenheit hing ursprünglich als Zweitfunktion an der Kürzel-Legende
|
|
// unter dem Projektboard ("klick für Anwesenheit") — Namenserklärung und
|
|
// Eingabemaske am selben Element, und die Legende gibt es inzwischen gar
|
|
// nicht mehr. Sie steht jetzt am Fuß des Wochenberichts, also dort, wo die
|
|
// Sitzung tatsächlich abgearbeitet wird, listet den vollständigen
|
|
// Sitzungs-Kreis (currentWeek().people) und zeigt einen Zähler. Der Datenstand bleibt unverändert binär
|
|
// (w.attendance[personId] = true/false, Tabelle attendance) — es gibt
|
|
// bewusst kein "entschuldigt"/"nicht erfasst".
|
|
//
|
|
// Urlaub und Krankheit aus user_absences werden nur als Hinweis am Namen
|
|
// angezeigt und setzen den Haken NICHT automatisch: wer im Urlaub ist, kann
|
|
// sich trotzdem zuschalten, und die Anwesenheit soll festhalten, wer
|
|
// tatsächlich dabei war.
|
|
|
|
// absenceNoteFor: liefert den Abwesenheitsgrund einer Person am Sitzungstag
|
|
// (aus db.globalPeople, der aktuellen Liste aus users/user_absences) oder ''.
|
|
// Parameter: personId (String), weekDate (ISO-Datum der Sitzung).
|
|
// Rückgabe: String.
|
|
function absenceNoteFor(personId, weekDate){
|
|
if(!weekDate) return '';
|
|
const person = (db.globalPeople || []).find(gp => gp.id === personId);
|
|
if(!person || !Array.isArray(person.absences)) return '';
|
|
const day = parseDateISO(weekDate);
|
|
if(!day) return '';
|
|
const hit = person.absences.find(a => {
|
|
const from = parseDateISO(a.start), to = parseDateISO(a.end);
|
|
return from && to && day >= from && day <= to;
|
|
});
|
|
return hit ? (hit.type || 'Abwesend') : '';
|
|
}
|
|
|
|
// renderAttendance: baut den Anwesenheitsblock der aktuellen Sitzung.
|
|
// Aufgerufen innerhalb dieser Datei (toggleAttendance(), setAllAttendance()
|
|
// sowie innerhalb/rund um renderWochenbericht()) — NICHT aus renderBoard(),
|
|
// die Anwesenheit ist allein eine Wochenbericht-Angelegenheit. Keine
|
|
// Parameter, keine Rückgabe.
|
|
function renderAttendance(){
|
|
const container = document.getElementById('weekAttendance');
|
|
if(!container) return;
|
|
const w = currentWeek();
|
|
if(!w){ container.innerHTML = ''; return; }
|
|
|
|
// 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 = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}>
|
|
<summary><span>Anwesenheit</span><span class="chev">${icon('chevronRight')}</span></summary>
|
|
<div class="bericht-disclosure-body"><span class="hint" style="margin:0">Für dieses Gewerk sind in dieser Sitzung keine Projektbeteiligten hinterlegt.</span></div>
|
|
</details>`;
|
|
return;
|
|
}
|
|
|
|
const anwesend = people.filter(p => att[p.id]).length;
|
|
// onmousedown/preventDefault wie bei den Kacheln unten: Ohne das verliert
|
|
// ein offener Kommentar-Editor beim Klick den Fokus, sein Blur-Netz baut den
|
|
// ganzen Bericht neu auf, die Schaltfläche verschwindet dabei — und der
|
|
// Klick kommt nie an (erst der zweite Versuch wirkte).
|
|
const actions = locked ? '' : `<div class="attendance-actions">
|
|
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(true)">Alle anwesend</button>
|
|
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(false)">Zurücksetzen</button>
|
|
</div>`;
|
|
|
|
const tiles = people.map(p => {
|
|
const present = !!att[p.id];
|
|
const note = absenceNoteFor(p.id, w.date);
|
|
// onmousedown/preventDefault verhindert nur den Fokuswechsel, nicht den
|
|
// Klick: sonst verliert ein offener Kommentar-Editor den Fokus, sein
|
|
// Blur-Netz baut den ganzen Bericht neu auf, die Kachel wird dabei
|
|
// ersetzt — und der Klick käme nie an.
|
|
const click = locked ? '' : ` onmousedown="event.preventDefault()" onclick="toggleAttendance('${p.id}')"`;
|
|
const title = locked
|
|
? `${esc(p.fullName || p.name)} — Sitzung gesperrt`
|
|
: `${esc(p.fullName || p.name)} — klicken, um ${present ? 'als abwesend' : 'als anwesend'} zu markieren`;
|
|
return `<button type="button" class="attendance-tile${present ? ' present' : ''}${locked ? ' locked-cell' : ''}"
|
|
aria-pressed="${present}" title="${title}"${click}>
|
|
<span class="attendance-mark">${present ? '✓' : ''}</span>
|
|
<span class="attendance-av">${esc(p.name)}</span>
|
|
<span class="attendance-name">${esc(p.fullName || p.name)}</span>
|
|
${note ? `<span class="attendance-note">${esc(note)}</span>` : ''}
|
|
</button>`;
|
|
}).join('');
|
|
|
|
container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}>
|
|
<summary><span>Anwesenheit ${esc(wochenberichtGewerk || "")}</span><span class="count">${anwesend} von ${people.length}</span><span class="chev">${icon('chevronRight')}</span></summary>
|
|
<div class="bericht-disclosure-body">${actions}<div class="attendance-grid">${tiles}</div></div>
|
|
</details>`;
|
|
}
|
|
|
|
// toggleAttendance: schaltet die Anwesenheit einer Person für die aktuelle
|
|
// Sitzung um (Boolean in w.attendance, Tabelle attendance).
|
|
// Parameter: personId (stabile Personen-ID). Keine Rückgabe; bei gesperrter
|
|
// Sitzung über guardWeekLocked() abgebrochen.
|
|
function toggleAttendance(personId){
|
|
if(guardWeekLocked()) return;
|
|
const w = currentWeek();
|
|
if(!w) return;
|
|
if(!w.attendance) w.attendance = {};
|
|
w.attendance[personId] = !w.attendance[personId];
|
|
save();
|
|
// Bewusst nur der Anwesenheitsblock, nicht renderWochenbericht(): ein
|
|
// vollständiger Neuaufbau würde die Berichtskarten samt eines gerade
|
|
// offenen Kommentar-Editors verwerfen.
|
|
renderAttendance();
|
|
}
|
|
|
|
// setAllAttendance: setzt alle Beteiligten der Sitzung auf an- bzw. abwesend —
|
|
// für den Normalfall "alle da, bis auf zwei". Parameter: present (Boolean).
|
|
// Keine Rückgabe.
|
|
function setAllAttendance(present){
|
|
if(guardWeekLocked()) return;
|
|
const w = currentWeek();
|
|
if(!w) return;
|
|
if(!w.attendance) w.attendance = {};
|
|
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 =>
|
|
`<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 ----------
|
|
|
|
// 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 = `<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.
|
|
${hatRecht('s.entsperren') ? '<button type="button" class="secondary small" onclick="unlockCurrentWeek()">Entsperren</button>' : ''}
|
|
</div>`;
|
|
}
|
|
|
|
if(liste.length === 0){
|
|
html += renderBerichtKopf(w, liste, prevWeek, locked);
|
|
html += `<div class="hint">Für ${esc(wochenberichtGewerk)} steht in dieser Sitzung kein Projekt auf der Tagesordnung.</div>`;
|
|
container.innerHTML = html;
|
|
renderAttendance();
|
|
return;
|
|
}
|
|
|
|
// Eine Fläche (.bericht-panel), nicht zwei lose nebeneinanderstehende Karten
|
|
// — Kopfleiste und Leiste|Hauptspalte gehören sichtbar zusammen (Entwurf
|
|
// "Fokus-Modus"). Die Kopfleiste bekommt einen eigenen Container, damit das
|
|
// Öffnen/Schließen des Sprungfelds (oeffneBerichtSuche()) nur sie neu
|
|
// zeichnen muss, nicht die ganze Tagesordnung samt offenem Kommentarfeld.
|
|
html += `<div class="bericht-panel">
|
|
<div id="berichtKopf">${renderBerichtKopf(w, liste, prevWeek, locked)}</div>
|
|
<div class="bericht-layout">
|
|
<div class="bericht-rail" id="berichtListe"></div>
|
|
<div class="bericht-main" id="berichtDetail">${renderBerichtDetail(aktiv, w, prevWeek, locked)}</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
container.innerHTML = html;
|
|
renderBerichtListe();
|
|
renderAttendance();
|
|
}
|
|
|
|
// berichtSucheOffen: ob das Sprungfeld gerade als Eingabefeld statt als
|
|
// Knopf dargestellt wird (Fokus-Modus, Eriks Wahl "C") — der Entwurf zeigt
|
|
// die Suche standardmäßig nur als schmalen Knopf, nicht als dauerhaft
|
|
// sichtbares Eingabefeld. Bleibt auch offen, solange tatsächlich gesucht
|
|
// wird (berichtSuche nicht leer), sonst verschwände der eigene Suchtext beim
|
|
// nächsten Neu-Rendern wieder hinter dem Knopf.
|
|
let berichtSucheOffen = false;
|
|
|
|
// oeffneBerichtSuche: blendet das Eingabefeld ein und fokussiert es.
|
|
// Keine Parameter, keine Rückgabe.
|
|
function oeffneBerichtSuche(){
|
|
berichtSucheOffen = true;
|
|
renderBerichtKopfNeu();
|
|
const feld = document.getElementById('berichtSuche');
|
|
if(feld) feld.focus();
|
|
}
|
|
|
|
// schliesseBerichtSucheFallsLeer: fällt beim Verlassen des leeren Feldes
|
|
// zurück auf den Knopf — ein leeres Feld brächte sonst dauerhaft unnötige
|
|
// Breite in die Kopfleiste. Keine Parameter, keine Rückgabe.
|
|
function schliesseBerichtSucheFallsLeer(){
|
|
if(berichtSuche) return;
|
|
berichtSucheOffen = false;
|
|
renderBerichtKopfNeu();
|
|
}
|
|
|
|
// renderBerichtKopfNeu: zeichnet nur die Kopfleiste neu (Gewerk, Fortschritt,
|
|
// Sprungfeld) — eigene Funktion aus demselben Grund wie renderBerichtListe():
|
|
// ein Klick auf den Such-Knopf darf weder die Leiste noch einen gerade
|
|
// offenen Kommentar im Detail antasten. Keine Parameter, keine Rückgabe.
|
|
function renderBerichtKopfNeu(){
|
|
const w = currentWeek();
|
|
const el = document.getElementById('berichtKopf');
|
|
if(!w || !el) return;
|
|
const prevWeek = findPreviousWeek(w);
|
|
const liste = berichtProjekte(w);
|
|
const locked = isWeekLocked(w);
|
|
el.innerHTML = renderBerichtKopf(w, liste, prevWeek, locked);
|
|
}
|
|
|
|
// renderBerichtKopf: Gewerke-Wahl, Fortschrittsring und das Sprungfeld.
|
|
// Parameter: w, liste, prevWeek, locked. Rückgabe: HTML.
|
|
function renderBerichtKopf(w, liste, prevWeek, locked){
|
|
const ab = liste.filter(p => 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
|
|
? `<button type="button" class="bericht-reset-link" onclick="setzeAlleBesprochen(false)">Zurücksetzen</button>`
|
|
: `<button type="button" class="bericht-reset-link" onclick="setzeAlleBesprochen(true)">Alle abhaken</button>`);
|
|
|
|
const sucheAktiv = berichtSucheOffen || !!berichtSuche;
|
|
const sucheHtml = sucheAktiv
|
|
? `<input type="search" id="berichtSuche" class="bericht-suche-input" placeholder="Nummer oder Name…"
|
|
value="${esc(berichtSuche)}" oninput="setzeBerichtSuche(this.value)"
|
|
onblur="schliesseBerichtSucheFallsLeer()" aria-label="Zu einem Projekt springen">`
|
|
: `<button type="button" class="bericht-search-btn" onclick="oeffneBerichtSuche()">Projekt suchen…</button>`;
|
|
|
|
return `<div class="bericht-topbar">
|
|
${renderGewerkDropdown()}
|
|
<span class="bericht-ring" style="background:conic-gradient(var(--green) 0% ${pct}%, var(--line-soft) ${pct}% 100%)"><span></span></span>
|
|
<span class="bericht-fortschritt-text" title="${esc(vergleich)}">${ab} / ${liste.length} besprochen</span>
|
|
${resetBtn}
|
|
${sucheHtml}
|
|
</div>`;
|
|
}
|
|
|
|
// renderBerichtListe: die Tagesordnung als Leiste mit Nummer, Gruppe UND
|
|
// Name direkt sichtbar (Entwurf "Aktenregal", Eriks Wahl aus dem zweiten
|
|
// Darstellungs-Vergleich — löst die vorherige schmale Icon-Leiste ab, die
|
|
// Nummer und Namen nur im Hover-Tooltip zeigte: "so sehe ich die ganze
|
|
// Tagesordnung auf einen Blick"). Gruppiert wie die Projektliste
|
|
// (GROUP_ORDER, js/globals.js), mit einer Zwischenüberschrift bei jedem
|
|
// Gruppenwechsel — die Positionsnummer zählt dabei über alle Gruppen hinweg
|
|
// weiter, sie ist die Position in der VOLLSTÄNDIGEN Tagesordnung. Eigene
|
|
// Funktion, weil das Sprung-Feld sie allein neu zeichnet — das Detail und
|
|
// vor allem ein gerade getippter Kommentar dürfen dabei nicht
|
|
// verlorengehen. Keine Parameter, keine Rückgabe.
|
|
function renderBerichtListe(){
|
|
const el = document.getElementById('berichtListe');
|
|
const w = currentWeek();
|
|
if(!el || !w) return;
|
|
const prevWeek = findPreviousWeek(w);
|
|
const aktiv = aktuellesBerichtProjekt(w);
|
|
// voll bleibt UNGEFILTERT: die Kachel-Nummer ist die Position in der
|
|
// vollständigen Tagesordnung, damit sie beim Tippen im Sprung-Feld nicht
|
|
// ständig neu durchnummeriert wird und mit der Zählung im Detail
|
|
// übereinstimmt.
|
|
const voll = berichtProjekte(w);
|
|
const liste = voll.filter(passtZurSuche);
|
|
|
|
if(liste.length === 0){
|
|
el.innerHTML = `<div class="hint" style="padding:10px 6px">Kein Projekt passt zu „${esc(berichtSuche)}".</div>`;
|
|
return;
|
|
}
|
|
|
|
let gruppeVorher = null;
|
|
el.innerHTML = liste.map(p => {
|
|
const info = classifyProjectChange(p, prevWeek);
|
|
const istAktiv = aktiv && aktiv.id === p.id;
|
|
const position = voll.findIndex(x => x.id === p.id) + 1;
|
|
const gruppe = p.group || 'Ohne Gruppe';
|
|
const gruppenkopf = gruppe !== gruppeVorher ? `<div class="bericht-rail-gruppe">${esc(gruppe)}</div>` : '';
|
|
gruppeVorher = gruppe;
|
|
const name = (p.nr ? p.nr + ' · ' : '') + p.name;
|
|
return `${gruppenkopf}<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${kommentarVon(p, wochenberichtGewerk).besprochen ? ' is-ab' : ''}"
|
|
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}"
|
|
aria-current="${istAktiv ? 'true' : 'false'}">
|
|
<span class="bericht-rail-num">${String(position).padStart(2, '0')}</span>
|
|
${info.bucket === 'unchanged' ? '' : `<span class="bericht-rail-dot is-${esc(info.bucket)}" aria-hidden="true"></span>`}
|
|
<span class="bericht-rail-name">${esc(name)}</span>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
el.querySelectorAll('.bericht-rail-item').forEach(z => {
|
|
const waehlen = () => waehleBerichtProjekt(z.dataset.uid || null);
|
|
z.addEventListener('click', waehlen);
|
|
z.addEventListener('keydown', e => {
|
|
if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); waehlen(); }
|
|
});
|
|
});
|
|
}
|
|
|
|
// renderBerichtDetail: das gewählte Projekt rechts — Kopfzeile, der Kommentar
|
|
// der Vorwoche, die vollständige Bearbeitungsmaske (renderBoardDrawer() aus
|
|
// js/board/board.js, dieselbe wie zuvor) und die Fußzeile mit
|
|
// "Besprochen & weiter". Parameter: p, w, prevWeek, locked. Rückgabe: HTML.
|
|
function renderBerichtDetail(p, w, prevWeek, locked){
|
|
if(!p) return '';
|
|
// 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 `<div class="bericht-head-line">
|
|
<span class="bericht-head-nr">${esc(p.nr || '—')}</span>
|
|
${info.bucket === 'unchanged' ? '' : `<span class="bericht-change-pill is-${esc(info.bucket)}">${esc(info.reason)}</span>`}
|
|
</div>
|
|
<h1 class="bericht-h1">${esc(p.name)}</h1>
|
|
<div class="bericht-sub">${subText}</div>
|
|
${fehlen.length ? `<span class="bericht-abwesend-hinweis">Heute abwesend: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''}
|
|
${renderBoardDrawer(p, allPeople, kLocked, w.label, Object.assign({}, BERICHT_OPTS, { ohneVerlauf: true }))}
|
|
<div class="bericht-detail-fuss">
|
|
${!locked && hatRecht('proj.plan') ? `<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
|
|
Aufgabe aus Kommentar…
|
|
</button>` : ''}
|
|
<span class="bericht-fuss-spacer"></span>
|
|
<button type="button" class="secondary" onclick="berichtWeiter('${esc(p.id)}')"${berichtNaechstes(w, p.id) ? '' : ' disabled title="Letztes Projekt der Liste"'}>Weiter →</button>
|
|
${!locked && hatRecht('wb.besprochen') ? `<button type="button" onclick="besprochenUndWeiter('${esc(p.id)}')">Besprochen & weiter →</button>` : ''}
|
|
</div>
|
|
${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()).
|
|
|
|
// Gezeigt wird, was geladen ist — beim Start die drei Sitzungen vor der
|
|
// aktuellen; „Weitere laden“ holt je fünf ältere (weitereLadenKnopf(),
|
|
// js/store.js) und zeichnet nur diesen Abschnitt neu (berichtVerlaufNeu()).
|
|
|
|
// 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 `<div class="bericht-zuletzt">
|
|
<span class="bericht-zuletzt-kw">Zuletzt ${esc(berichtKurzKw(e.week.label))}</span>
|
|
<div class="bericht-zuletzt-text">${e.html}</div>
|
|
</div>`;
|
|
}
|
|
|
|
// 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 = `<div class="hint" style="margin:0">${sitzungen === 0 && !aeltereSitzungenAnzahl()
|
|
? 'Dieses Projekt erscheint zum ersten Mal, es gibt noch keine frühere Sitzung.'
|
|
: sitzungen === 0 ? 'In den geladenen Sitzungen steht dieses Projekt noch nicht.'
|
|
: 'In den früheren Sitzungen gibt es noch keinen Kommentar für ' + esc(gText) + '.'}</div>`;
|
|
} else {
|
|
const zeigen = eintraege;
|
|
inhalt = `<ol class="bv-liste">${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 ? '<span></span>' :
|
|
`<button type="button" class="bv-uebernehmen"
|
|
onmousedown="event.preventDefault();berichtVerlaufUebernehmen('${esc(e.week.label)}')"
|
|
title="Diesen Kommentar ins Kommentarfeld übernehmen">übernehmen</button>`;
|
|
return `<li class="bv-zeile${i === 0 ? ' ist-juengster' : ''}">
|
|
<span class="bv-kw"><b>${esc(berichtKurzKw(e.week.label))}</b>${wer ? `<span>${esc(wer)}</span>` : ''}</span>
|
|
<span class="bv-punkt" aria-hidden="true"></span>
|
|
<span class="bv-inhalt">
|
|
<span class="bv-text">${e.html}</span>
|
|
${e.ruhe ? `<span class="bv-ruhe">${esc(e.ruhe)}</span>` : ''}
|
|
</span>
|
|
<span class="bv-chips">${e.chips.map(c => `<span class="bv-chip">${esc(c)}</span>`).join('')}</span>
|
|
${knopf}
|
|
</li>`;
|
|
}).join('')}</ol>`;
|
|
}
|
|
const mehr = weitereLadenKnopf('berichtVerlaufNeu');
|
|
const anzahl = eintraege.length === 1 ? '1 Kommentar' : eintraege.length + ' Kommentare';
|
|
return `<section class="bericht-verlauf" id="berichtVerlauf" aria-label="Verlauf der Vorwochen">
|
|
<div class="bv-kopf">
|
|
<h2 class="bv-titel">Verlauf der Vorwochen</h2>
|
|
<span class="bv-anzahl">${esc(gText)}, ${anzahl} in ${sitzungen === 1 ? '1 Sitzung' : sitzungen + ' Sitzungen'}</span>
|
|
<span class="bericht-fuss-spacer"></span>
|
|
${mehr}
|
|
</div>
|
|
${inhalt}
|
|
</section>`;
|
|
}
|
|
|
|
// berichtVerlaufNeu: zeichnet nur den Verlauf neu (nach „Weitere laden“),
|
|
// damit ein angefangener Kommentar unberührt bleibt.
|
|
function berichtVerlaufNeu(){
|
|
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 <div> ohne .value — statusTextMitZeilen() liefert
|
|
// daraus reinen Text mit erhaltenen Zeilenumbrüchen, aus dem unten die
|
|
// erste Zeile als Titelvorschlag gezogen wird.
|
|
const feld = document.querySelector('#wochenbericht .comment-field');
|
|
const text = feld ? statusTextMitZeilen(feld.innerHTML) : statusPlainText(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 =>
|
|
`<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;
|
|
// 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 <div>, kein <textarea> mehr, siehe js/utils.js) —
|
|
// ohne diese Ergänzung griffen ↓/↑ dort als Projekt-Wechsel statt als
|
|
// normale Cursorbewegung im Text.
|
|
const imFeld = e.target && ((e.target.matches && e.target.matches('input, textarea, select')) || e.target.isContentEditable);
|
|
|
|
if((e.key === 'Enter') && (e.ctrlKey || e.metaKey)){
|
|
const feld = e.target && e.target.closest ? e.target.closest('.comment-field') : null;
|
|
// Strg+Enter nur abfangen, wenn der Fokus tatsächlich im Kommentarfeld
|
|
// liegt — sonst OHNE feld-Prüfung, z. B. in einem Leistungsphasen-
|
|
// Eingabefeld (.bericht-lph-input) oder im Titelfeld des "Aufgabe aus
|
|
// Kommentar"-Dialogs getippt, sprang die App sofort zum nächsten Projekt
|
|
// und baute die Liste neu auf — ein gerade eingetippter, noch nicht per
|
|
// onchange/blur gespeicherter Wert ging dabei kommentarlos verloren, und
|
|
// ein offen stehender Dialog bezog sich danach lautlos auf das falsche
|
|
// (inzwischen gewechselte) Projekt. Ohne feld bricht diese Weiche jetzt
|
|
// einfach ab und überlässt Strg+Enter dem normalen Feldverhalten.
|
|
if(!feld) return;
|
|
const aktiv = aktuellesBerichtProjekt(w);
|
|
if(!aktiv) return;
|
|
e.preventDefault();
|
|
// Der Kommentar speichert sich über den bestehenden Weg selbst; hier
|
|
// folgt nur der Sprung zum nächsten offenen Punkt.
|
|
if(typeof saveBoardComment === 'function'){
|
|
const knopf = feld.closest('.comment-block').querySelector('.comment-editor-foot button');
|
|
if(knopf) saveBoardComment(knopf);
|
|
}
|
|
besprochenUndWeiter(aktiv.id);
|
|
return;
|
|
}
|
|
|
|
if(imFeld) return;
|
|
if(e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
|
const liste = berichtProjekte(w).filter(passtZurSuche);
|
|
if(liste.length === 0) return;
|
|
const aktiv = aktuellesBerichtProjekt(w);
|
|
const idx = liste.findIndex(p => aktiv && p.id === aktiv.id);
|
|
const ziel = Math.max(0, Math.min(liste.length - 1, (idx < 0 ? 0 : idx) + (e.key === 'ArrowDown' ? 1 : -1)));
|
|
e.preventDefault();
|
|
waehleBerichtProjekt(liste[ziel].projectUid || null);
|
|
});
|