Wandtafel-Dashboard, Dateibereinigung und Auslastungs-Vereinfachung

Neue Wandtafel (Touch-Dashboard für Kiosk-Anzeige): Auslastung/Besprochen je
Gewerk, Aufgaben-Kennzahlen, Ereignis-Feed nach Art gruppiert, vollständige
Projektliste zum Scrollen, automatischer Rücksprung nach Untätigkeit mit
sichtbarem Countdown. Eigene Kiosk-Rolle ohne Fachrolle und ohne Schreibrechte.
Auslastung: Möglichkeit zum Wechsel der ausgewerteten Sitzung entfernt.
Aufräumen nicht mehr benötigter Dateien, README auf aktuellen Stand gebracht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Sonnet 5 committed 2026-10-02 15:14:47 +02:00
1 parent bc30f2900b
commit db63a29365
47 files changed
+16486 -3768

No files matched your search

+352 -89
View File
@@ -144,17 +144,20 @@
};
// gruppenReihenfolge: liefert die vorkommenden Gruppennamen in der
// Reihenfolge ihres ersten Auftretens unter den Punkten (array_order) —
// "Ohne Gruppe" (leerer String) immer zuerst, wenn vorhanden.
// Reihenfolge ihres ersten Auftretens unter den Punkten (array_order).
// Jeder Punkt gehört jetzt zu einer Gruppe (siehe fuegeNeueGruppeHinzu()/
// "+ Neue Gruppe" unten) — eine frühere Sonderrolle "Ohne Gruppe" (leerer
// String, immer zuerst) gibt es nicht mehr; ein einzelner Punkt mit leerer
// Gruppe (nur noch aus Altbeständen von vor dieser Umstellung möglich)
// bekommt ganz normal eine eigene, leer betitelte Gruppenzeile wie jede
// andere — kein Sonderfall im Code nötig.
function gruppenReihenfolge(){
const namen = [];
let hatOhneGruppe = false;
(currentProtokoll.punkte || []).forEach(p => {
const g = String(p.gruppe || '').trim();
if(!g){ hatOhneGruppe = true; return; }
if(!namen.includes(g)) namen.push(g);
});
return (hatOhneGruppe ? [''] : []).concat(namen);
return namen;
}
// ---------- Nachträgliches Umordnen ----------
@@ -162,15 +165,13 @@
// Die Reihenfolge in `currentProtokoll.punkte` (= array_order) bestimmt
// sowohl die Anzeige-Reihenfolge als auch — über berechneProtokollNummern()
// — die Nummern. Zwei getrennte, aufeinander abgestimmte Stellschrauben:
// ein Punkt lässt sich nur INNERHALB seiner eigenen Gruppe (bzw. innerhalb
// "Ohne Gruppe") verschieben (die Zeile steht dort ohnehin nur im passenden
// Abschnitt), eine ganze Gruppe lässt sich als Block gegenüber den anderen
// Gruppen verschieben — das ändert, welche Gruppe die Nummer "1" bekommt.
// "Ohne Gruppe" bleibt bewusst immer der erste Abschnitt (siehe
// gruppenReihenfolge()) und ist deshalb hier nicht mitverschiebbar.
// ein Punkt lässt sich nur INNERHALB seiner eigenen Gruppe verschieben (die
// Zeile steht dort ohnehin nur im passenden Abschnitt), eine ganze Gruppe
// lässt sich als Block gegenüber den anderen Gruppen verschieben — das
// ändert, welche Gruppe die Nummer "1" bekommt.
// verschiebePunkt: vertauscht einen Punkt mit seinem Vorgänger/Nachfolger
// INNERHALB derselben Gruppe (bzw. "Ohne Gruppe"). Parameter: idx (Index in
// INNERHALB derselben Gruppe. Parameter: idx (Index in
// currentProtokoll.punkte), richtung (-1 = nach oben, +1 = nach unten).
// Keine Rückgabe; rendert nur den Punkte-Block neu.
window.verschiebePunkt = function(idx, richtung){
@@ -192,24 +193,24 @@
// verschiebeGruppe: verschiebt eine ganze Gruppe (alle ihre Punkte als
// Block, ihre eigene Reihenfolge bleibt untereinander erhalten) gegenüber
// den anderen Gruppen — ändert damit auch die Gruppennummer. Baut die
// Punkteliste dafür komplett neu auf: "Ohne Gruppe" zuerst (unverändert),
// danach jede Gruppe in der neuen Reihenfolge. Parameter: gruppe (Name),
// richtung (-1 = nach vorn, +1 = nach hinten). Keine Rückgabe.
// Punkteliste dafür komplett in der neuen Gruppenreihenfolge neu auf.
// Parameter: gruppe (Name), richtung (-1 = nach vorn, +1 = nach hinten).
// Keine Rückgabe.
window.verschiebeGruppe = function(gruppe, richtung){
if(!currentProtokoll || app.isLocked()) return;
const punkte = currentProtokoll.punkte;
const namen = [];
punkte.forEach(p => {
const g = String(p.gruppe || '').trim();
if(g && !namen.includes(g)) namen.push(g);
if(!namen.includes(g)) namen.push(g);
});
const pos = namen.indexOf(gruppe);
const pos = namen.indexOf(String(gruppe || '').trim());
const zielPos = pos + richtung;
if(pos === -1 || zielPos < 0 || zielPos >= namen.length) return;
const tmpName = namen[pos];
namen[pos] = namen[zielPos];
namen[zielPos] = tmpName;
const neu = punkte.filter(p => !String(p.gruppe || '').trim());
const neu = [];
namen.forEach(g => {
punkte.filter(p => String(p.gruppe || '').trim() === g).forEach(p => neu.push(p));
});
@@ -348,7 +349,7 @@
// setzeProtokollImportLadezustand: blendet den Ladehinweis ein/aus und
// sperrt alle Felder + Knöpfe des Dialogs währenddessen — ohne das gab es
// während eines länger laufenden KI-Aufrufs (bis zu KI_TIMEOUT_MS_LOKAL =
// 5 Minuten bei einem eigenen Server, server.js) keinerlei Rückmeldung,
// 30 Minuten bei einem eigenen Server, server.js) keinerlei Rückmeldung,
// der Dialog wirkte hängengeblieben, und ein zweiter Klick auf
// "Importieren" hätte denselben Import doppelt losgeschickt.
function setzeProtokollImportLadezustand(aktiv){
@@ -538,89 +539,275 @@
</div>`;
}
// renderPunkteBlockHtml: baut nur den Punkte-Block (in Abschnitten je
// Gruppe) — eigene Funktion, weil er nach einer Gruppenänderung isoliert
// renderPunkteBlockHtml: baut nur den Punkte-Block — EINE durchgehende
// Tabelle (dieselben Spalten wie der PDF-Export, js/pdf.js), mit einer
// vollbreiten Gruppen-Kopfzeile je Abschnitt statt eigener Tabellen je
// Gruppe — eigene Funktion, weil sie nach einer Gruppenänderung isoliert
// neu gerendert wird, ohne den Rest der Detailansicht (und damit den Fokus
// in anderen Feldern) anzufassen.
// PROTOKOLL_GRUPPEN_FARBEN/protokollGruppeFarbe: der Farbbalken der
// Gruppen-Kopfzeile wird — wie projektFarbe() in js/load/load.js für die
// Auslastung — aus dem Gruppennamen gehasht statt fest zugeordnet, damit
// eine Gruppe ihre Farbe behält, egal an welcher Position sie steht.
// Eigene, von den Status-Farben (Amber/Grün/Teal/Rot) getrennte Palette,
// damit eine Gruppenfarbe nie wie ein Status aussieht.
const PROTOKOLL_GRUPPEN_FARBEN = ['#5b63d6', '#8b5cf6', '#c2419a', '#4f8ef7', '#7a52c7'];
function protokollGruppeFarbe(name){
let hash = 0;
const s = String(name || '');
for(let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) >>> 0;
return PROTOKOLL_GRUPPEN_FARBEN[hash % PROTOKOLL_GRUPPEN_FARBEN.length];
}
// ausgewaehlterPunkt: der gerade im Schreibbereich geöffnete Punkt, als
// OBJEKT-Referenz (nicht als Index). Die Punkte wandern beim Verschieben
// und beim Umsortieren einer ganzen Gruppe durch das Array; eine gemerkte
// Zahl zeigte danach auf einen anderen Punkt, die Referenz überlebt jede
// dieser Umstellungen unverändert. Nur ein Speichern tauscht die Objekte
// aus (der Server liefert frische) — dort wird die Auswahl über ihre
// Position wiederhergestellt, siehe speichereProtokoll().
let ausgewaehlterPunkt = null;
// waehlePunkt: öffnet einen Punkt im Schreibbereich. Parameter: idx (Index
// in currentProtokoll.punkte). Keine Rückgabe.
window.waehlePunkt = function(idx){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
ausgewaehlterPunkt = currentProtokoll.punkte[idx];
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
};
// listenUnterzeile: die kleine zweite Zeile eines Listeneintrags —
// Zuständig und Termin, so weit vorhanden. Parameter: punkt. Rückgabe:
// String.
function listenUnterzeile(punkt){
const teile = [];
const zust = String(punkt.zustaendig || '').trim();
if(zust) teile.push(zust);
const termin = formatDateDE(parseDateISO(punkt.termin));
if(termin) teile.push(termin);
return teile.join(' · ') || 'noch nichts erfasst';
}
// aktualisiereListenZeile: schreibt Thema, Zuständig/Termin und den
// Status-Punkt eines Listeneintrags neu — gezielt am vorhandenen Element,
// OHNE den Block neu zu rendern. Nötig, weil im Schreibbereich getippter
// Text sofort links in der Liste erscheinen soll, ein Neu-Rendern dabei
// aber den Eingabefokus (und die Schreibmarke) zerstören würde — dieselbe
// Begründung wie im Datei-Kopfkommentar. Parameter: idx. Keine Rückgabe.
function aktualisiereListenZeile(idx){
const punkt = currentProtokoll && currentProtokoll.punkte[idx];
const zeile = document.getElementById('protokollListeZeile_' + idx);
if(!punkt || !zeile) return;
const titel = zeile.querySelector('.titel');
if(titel){
const thema = String(punkt.thema || '').trim();
titel.textContent = thema || 'Ohne Thema';
titel.classList.toggle('ist-leer', !thema);
}
const sub = zeile.querySelector('.sub');
if(sub) sub.textContent = listenUnterzeile(punkt);
const dot = zeile.querySelector('.protokoll-punkt-dot');
if(dot) dot.setAttribute('data-status', punkt.status || 'offen');
}
// renderPunkteBlockHtml: baut den Punkte-Block als ZWEI nebeneinander
// liegende Bereiche (Eriks Auswahl „Liste + Schreibbereich" aus dem
// Protokoll-schreiben-Mockup) statt der früheren durchgehenden Tabelle:
// links eine schmale, überfliegbare Liste aller Punkte (nach Gruppen
// gegliedert, je Zeile Nummer, Thema, Status-Punkt und darunter
// Zuständig/Termin), rechts der Schreibbereich für den EINEN gerade
// ausgewählten Punkt mit großem Thema-Feld, mehrzeiligem Textfeld und
// Zuständig/Termin als ordentlichen Formularfeldern.
//
// Warum weg von der Tabelle: ein Protokollpunkt ist ein kleiner Fließtext,
// keine Tabellenzelle. In der Spalte blieben für den Text rund 60 % der
// Breite bei drei, vier sichtbaren Zeilen; Thema und Text klebten
// übereinander, und die Formatierungsleiste saß eingeklemmt dazwischen.
// Der Schreibbereich gibt dem Text eine lesbare Zeilenlänge und jedem Feld
// eine eigene Beschriftung.
//
// Eigene Funktion, weil sie nach jeder strukturellen Änderung (Punkt/Gruppe
// anlegen, löschen, verschieben, auswählen) isoliert neu gerendert wird,
// ohne den Rest der Detailansicht anzufassen. Reine Feldänderungen rendern
// dagegen NICHT neu, sondern aktualisieren die Liste gezielt über
// aktualisiereListenZeile() oben.
function renderPunkteBlockHtml(){
const p = currentProtokoll;
const locked = app.isLocked();
const gruppen = gruppenReihenfolge();
const gruppenNamen = gruppen.filter(g => g); // ohne '' für die Datalist-Vorschläge
const nummern = window.berechneProtokollNummern(p.punkte);
const gruppenHtml = gruppen.map(gruppe => {
// Auswahl nachziehen: fehlt sie oder zeigt sie auf einen inzwischen
// gelöschten Punkt, rückt der erste vorhandene nach.
let aktivIdx = p.punkte.indexOf(ausgewaehlterPunkt);
if(aktivIdx === -1){
aktivIdx = p.punkte.length ? 0 : -1;
ausgewaehlterPunkt = aktivIdx >= 0 ? p.punkte[aktivIdx] : null;
}
const offeneAnzahl = p.punkte.filter(x => (x.status || 'offen') === 'offen').length;
const kopfText = p.punkte.length
? `${p.punkte.length} ${p.punkte.length === 1 ? 'Punkt' : 'Punkte'} · ${offeneAnzahl} offen`
: 'Noch keine Punkte';
const listeHtml = gruppen.map(gruppe => {
const punkteDerGruppe = p.punkte
.map((punkt, idx) => ({ punkt, idx }))
.filter(x => String(x.punkt.gruppe || '').trim() === gruppe);
const titel = gruppe ? esc(gruppe) : 'Ohne Gruppe';
const addBtn = locked ? '' : `<span class="protokoll-gruppe-add" onclick="fuegePunktHinzu('${esc(gruppe)}')">+ Punkt</span>`;
// "Ohne Gruppe" bleibt fest der erste Abschnitt (siehe gruppenReihenfolge()) —
// nur echte Gruppen lassen sich gegeneinander verschieben.
let moveBtns = '';
if(gruppe && !locked){
const gPos = gruppenNamen.indexOf(gruppe);
const hochDisabled = gPos <= 0 ? 'disabled' : '';
const runterDisabled = gPos >= gruppenNamen.length - 1 ? 'disabled' : '';
if(!locked){
const gPos = gruppen.indexOf(gruppe);
moveBtns = `<span class="protokoll-gruppe-move">
<button type="button" class="secondary small" ${hochDisabled} onclick="verschiebeGruppe('${esc(gruppe)}', -1)" title="Gruppe nach vorn">↑</button>
<button type="button" class="secondary small" ${runterDisabled} onclick="verschiebeGruppe('${esc(gruppe)}', 1)" title="Gruppe nach hinten">↓</button>
<button type="button" class="secondary small" ${gPos <= 0 ? 'disabled' : ''} onclick="verschiebeGruppe('${esc(gruppe)}', -1)" title="Gruppe nach vorn">↑</button>
<button type="button" class="secondary small" ${gPos >= gruppen.length - 1 ? 'disabled' : ''} onclick="verschiebeGruppe('${esc(gruppe)}', 1)" title="Gruppe nach hinten">↓</button>
</span>`;
}
const zeilen = punkteDerGruppe.map(({ punkt, idx }, pos) => renderPunktZeileHtml(punkt, idx, nummern[idx], pos === 0, pos === punkteDerGruppe.length - 1)).join('');
return `<div class="protokoll-gruppe">
<div class="protokoll-gruppe-head"><span class="protokoll-gruppe-name${gruppe ? '' : ' is-leer'}">${titel}</span>${moveBtns}${addBtn}</div>
${zeilen || '<span class="hint">Keine Punkte in dieser Gruppe.</span>'}
const kopf = `<div class="protokoll-liste-gruppe" style="--protokoll-gruppe-farbe: ${protokollGruppeFarbe(gruppe)}">
<input class="protokoll-gruppe-heading" value="${esc(gruppe)}" placeholder="Gruppenname …" ${locked ? 'disabled' : ''}
onchange="currentProtokollUmbenenneGruppe('${esc(gruppe)}', this.value)">
<span class="count">${punkteDerGruppe.length}</span>${moveBtns}
</div>`;
const zeilen = punkteDerGruppe.map(({ punkt, idx }) =>
renderListenZeileHtml(punkt, idx, nummern[idx], idx === aktivIdx)).join('');
const addZeile = locked ? '' : `<div class="protokoll-liste-neu" onclick="fuegePunktHinzu('${esc(gruppe)}')">+ Punkt</div>`;
return kopf + zeilen + addZeile;
}).join('');
const datalist = `<datalist id="protokollGruppenListe">${gruppenNamen.map(g => `<option value="${esc(g)}">`).join('')}</datalist>`;
const addOhneGruppe = locked ? '' : `<div class="add-row-btn" onclick="fuegePunktHinzu('')">+ Punkt (ohne Gruppe)</div>`;
const leerHinweis = listeHtml ? '' : '<div class="protokoll-liste-leer">Noch keine Punkte erfasst.</div>';
const neueGruppe = locked ? '' : `<div class="protokoll-liste-neu ist-gruppe" onclick="fuegeNeueGruppeHinzu()">+ Neue Gruppe</div>`;
return `<div class="protokoll-block-head"><h4>Punkte</h4></div>
${gruppenHtml || '<span class="hint">Noch keine Punkte.</span>'}
${datalist}
${addOhneGruppe}
<div class="protokoll-split">
<div class="protokoll-liste">
<div class="protokoll-liste-kopf">${esc(kopfText)}</div>
${listeHtml}${leerHinweis}${neueGruppe}
</div>
${renderSchreibbereichHtml(aktivIdx, nummern)}
</div>
<div class="legend">
<span class="legend-item"><span class="legend-swatch neu"></span>Neu in diesem Protokoll</span>
<span class="legend-item"><span class="legend-swatch fortgeschrieben"></span>Aus vorigem Protokoll fortgeschrieben</span>
<span class="legend-item"><span class="legend-swatch erledigt"></span>Erledigt (läuft nicht weiter mit)</span>
<span class="legend-item"><span class="legend-swatch info"></span>Info (läuft nicht weiter mit)</span>
</div>`;
}
function renderPunktZeileHtml(punkt, idx, nummer, istErste, istLetzte){
// renderListenZeileHtml: ein Eintrag der linken Liste. Bewusst knapp —
// Nummer, Thema, Status-Punkt und darunter Zuständig/Termin; alles
// Weitere steht im Schreibbereich. Parameter: punkt, idx, nummer,
// istAktiv. Rückgabe: HTML-String.
function renderListenZeileHtml(punkt, idx, nummer, istAktiv){
const thema = String(punkt.thema || '').trim();
const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : '';
return `<div class="protokoll-liste-zeile${istAktiv ? ' ist-aktiv' : ''}" id="protokollListeZeile_${idx}" onclick="waehlePunkt(${idx})">
<span class="nr">${esc(nummer)}</span>
<span class="titel${thema ? '' : ' ist-leer'}">${esc(thema || 'Ohne Thema')}</span>
<span class="protokoll-punkt-dot" data-status="${esc(punkt.status || 'offen')}" title="${esc(PROTOKOLL_PUNKT_STATUS_LABEL[punkt.status || 'offen'] || 'Offen')}"></span>
<span class="sub">${esc(listenUnterzeile(punkt))}</span>
${neuChip}
</div>`;
}
// PROTOKOLL_PUNKT_STATUS_LABEL: Anzeigetext je Status im Status-Umschalter
// unten — einzige Stelle, die die drei Werte kennt, damit ein vierter
// Status (sollte es den je geben) nur hier ergänzt werden müsste.
const PROTOKOLL_PUNKT_STATUS_LABEL = { offen: 'Offen', erledigt: 'Erledigt', info: 'Info' };
// renderSchreibbereichHtml: die rechte Hälfte — der EINE gerade
// ausgewählte Punkt, mit echtem Platz zum Schreiben. Parameter: idx (Index
// in currentProtokoll.punkte, -1 = kein Punkt vorhanden), nummern (das
// Ergebnis von berechneProtokollNummern()). Rückgabe: HTML-String.
function renderSchreibbereichHtml(idx, nummern){
const locked = app.isLocked();
if(idx < 0){
return `<div class="protokoll-schreiben ist-leer">
<p>Noch kein Punkt angelegt.</p>
${locked ? '' : '<p class="hint">Links über „+ Neue Gruppe" beginnen — jeder Punkt gehört zu einer Gruppe.</p>'}
</div>`;
}
const punkt = currentProtokoll.punkte[idx];
const terminDisplay = formatDateDE(parseDateISO(punkt.termin)) || '';
const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : '';
const moveBtns = locked ? '' : `<span class="protokoll-punkt-move">
<button type="button" class="secondary small" ${istErste ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, -1)" title="Innerhalb der Gruppe nach oben">↑</button>
<button type="button" class="secondary small" ${istLetzte ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, 1)" title="Innerhalb der Gruppe nach unten">↓</button>
</span>`;
return `<div class="protokoll-punkt${punkt.neu ? ' is-neu' : ''}${punkt.erledigt ? ' is-erledigt' : ''}" id="protokollPunkt_${idx}">
<div class="protokoll-punkt-top-row">
<span class="protokoll-punkt-nr" title="Errechnet sich automatisch aus Gruppe und Reihenfolge">${esc(nummer)}</span>
${moveBtns}
<input type="text" class="protokoll-punkt-gruppe" list="protokollGruppenListe" value="${esc(punkt.gruppe)}"
placeholder="Gruppe (z. B. ELT, HLS) …" ${locked ? 'disabled' : ''}
onchange="currentProtokollSetzePunktGruppe(${idx}, this.value)">
${neuChip}
<input type="text" class="protokoll-punkt-thema" value="${esc(punkt.thema)}" placeholder="Thema" ${locked ? 'disabled' : ''}
oninput="currentProtokollSetzePunktFeld(${idx}, 'thema', this.value)">
<label class="protokoll-punkt-erledigt-toggle"><input type="checkbox" ${punkt.erledigt ? 'checked' : ''} ${locked ? 'disabled' : ''}
onchange="currentProtokollSetzePunktErledigt(${idx}, this.checked)"> erledigt</label>
${locked ? '' : `<span class="protokoll-row-remove" onclick="entfernePunkt(${idx})" title="Punkt entfernen">✕</span>`}
// entfaelltNaechstes: vom Server gesetzt, wenn dieser Punkt beim
// Fortschreiben aus einem erledigten Punkt des vorigen Protokolls
// entstand — seine Gnadenrunde (Eriks Wunsch: ein erledigter Punkt
// taucht noch EIN Protokoll lang auf, bevor er entfällt). Nie vom
// Client gesetzt (siehe PUT .../:id, server.js).
const entfaelltChip = punkt.entfaelltNaechstes
? '<span class="protokoll-chip-entfaellt" title="Dieser Punkt wurde bereits als erledigt fortgeschrieben — im übernächsten Protokoll taucht er nicht mehr auf.">Entfällt im nächsten Protokoll</span>'
: '';
// themaGeaendert/textGeaendert: vom Server gesetzt (siehe
// loadProtokollDetail(), server.js) für einen fortgeschriebenen Punkt
// (neu=0), dessen Thema bzw. Text sich gegenüber der Fassung im
// UNMITTELBAR vorangegangenen Protokoll geändert hat — markiert NUR das
// jeweils geänderte Feld, der Punkt selbst ist ja derselbe geblieben.
const themaGeaendertClass = punkt.themaGeaendert ? ' ist-geaendert' : '';
const textGeaendertClass = punkt.textGeaendert ? ' ist-geaendert' : '';
const status = punkt.status || 'offen';
// Verschieben gilt wie bisher nur INNERHALB der eigenen Gruppe — die
// Knöpfe sind an den Rändern dieser Gruppe gesperrt, nicht an denen der
// Gesamtliste.
const gruppe = String(punkt.gruppe || '').trim();
const idxDerGruppe = currentProtokoll.punkte
.map((x, i) => ({ x, i }))
.filter(e => String(e.x.gruppe || '').trim() === gruppe)
.map(e => e.i);
const posInGruppe = idxDerGruppe.indexOf(idx);
const kopfAktionen = locked ? '' : `<span class="protokoll-punkt-move">
<button type="button" class="secondary small" ${posInGruppe <= 0 ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, -1)" title="Innerhalb der Gruppe nach oben">↑</button>
<button type="button" class="secondary small" ${posInGruppe >= idxDerGruppe.length - 1 ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, 1)" title="Innerhalb der Gruppe nach unten">↓</button>
</span>
<span class="protokoll-row-remove" onclick="entfernePunkt(${idx})" title="Punkt entfernen">✕</span>`;
const statusButtons = Object.keys(PROTOKOLL_PUNKT_STATUS_LABEL).map(s =>
`<button type="button" class="${s}${s === status ? ' is-active' : ''}" ${locked ? 'disabled' : ''}
onclick="currentProtokollSetzePunktStatus(${idx}, '${s}')">${PROTOKOLL_PUNKT_STATUS_LABEL[s]}</button>`).join('');
return `<div class="protokoll-schreiben">
<div class="protokoll-schreiben-kopf">
<span class="nr" title="Errechnet sich automatisch aus Gruppe und Reihenfolge">${esc(nummern[idx])}</span>
<span class="gruppe">${esc(gruppe)}</span>
${neuChip}${entfaelltChip}
<span class="rechts">
<div class="protokoll-status-switch ist-waagerecht">${statusButtons}</div>
${kopfAktionen}
</span>
</div>
<textarea class="protokoll-punkt-text" rows="2" placeholder="Text / Vereinbarung" ${locked ? 'disabled' : ''}
oninput="currentProtokollSetzePunktFeld(${idx}, 'text', this.value)">${esc(punkt.text)}</textarea>
<div class="protokoll-punkt-foot">
<label>Zuständig <input type="text" value="${esc(punkt.zustaendig)}" ${locked ? 'disabled' : ''}
oninput="currentProtokollSetzePunktFeld(${idx}, 'zustaendig', this.value)"></label>
<label>Termin
<input type="text" id="protokollPunktTerminDisplay_${idx}" class="range-picker-display" value="${esc(terminDisplay)}" placeholder="—" readonly
<div class="protokoll-schreiben-feld">
<span class="protokoll-feld-label">Thema</span>
<input type="text" class="protokoll-punkt-thema${themaGeaendertClass}" value="${esc(punkt.thema)}"
placeholder="Worum geht es?" ${locked ? 'disabled' : ''}
oninput="currentProtokollSetzePunktFeld(${idx}, 'thema', this.value)">
</div>
<div class="richtext-wrap">
${locked ? '' : richtextToolbarHtml('.protokoll-punkt-text')}
<div class="protokoll-schreiben-feld">
<span class="protokoll-feld-label">Vereinbarung · Text</span>
${richtextFeldHtml('protokoll-punkt-text' + textGeaendertClass, sanitizeStatusHtml(punkt.text),
'Was wurde besprochen oder vereinbart?',
`oninput="currentProtokollSetzePunktFeld(${idx}, 'text', this.innerHTML)"`, !locked)}
</div>
</div>
<div class="protokoll-schreiben-raster">
<div>
<span class="protokoll-feld-label">Zuständig</span>
<input type="text" value="${esc(punkt.zustaendig)}" placeholder="Name oder Firma" ${locked ? 'disabled' : ''}
oninput="currentProtokollSetzePunktFeld(${idx}, 'zustaendig', this.value)">
</div>
<div>
<span class="protokoll-feld-label">Termin</span>
<input type="text" id="protokollPunktTerminDisplay_${idx}" class="range-picker-display" value="${esc(terminDisplay)}" placeholder="— kein Termin" readonly
onclick="${locked ? '' : `openDatePicker({ anchor: this, inputId: 'protokollPunktTermin_${idx}', displayId: 'protokollPunktTerminDisplay_${idx}', onApply: function(){ syncProtokollPunktTermin(${idx}); } })`}">
<input type="hidden" id="protokollPunktTermin_${idx}" value="${esc(punkt.termin)}">
</label>
<span class="protokoll-punkt-erstellt" title="Datum, an dem dieser Punkt zum ersten Mal erfasst wurde — bleibt beim Fortschreiben unverändert">Erstellt ${esc(formatDateDE(parseDateISO(punkt.erstelltAm)) || '—')}</span>
</div>
</div>
<div class="protokoll-schreiben-fuss">
Erstellt am ${esc(formatDateDE(parseDateISO(punkt.erstelltAm)) || '—')}
<span class="hint">— bleibt beim Fortschreiben unverändert</span>
</div>
</div>`;
}
@@ -639,29 +826,47 @@
if(!currentProtokoll || !currentProtokoll.teilnehmer[idx]) return;
currentProtokoll.teilnehmer[idx][feld] = wert;
};
// Thema/Zuständig schlagen sofort auf den Listeneintrag links durch, ohne
// den Block neu zu rendern (sonst wäre der Fokus beim Tippen weg, siehe
// aktualisiereListenZeile() oben).
window.currentProtokollSetzePunktFeld = function(idx, feld, wert){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
currentProtokoll.punkte[idx][feld] = wert;
if(feld === 'thema' || feld === 'zustaendig') aktualisiereListenZeile(idx);
};
window.syncProtokollPunktTermin = function(idx){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
const el = document.getElementById('protokollPunktTermin_' + idx);
if(el) currentProtokoll.punkte[idx].termin = el.value;
aktualisiereListenZeile(idx);
};
window.currentProtokollSetzePunktErledigt = function(idx, checked){
window.currentProtokollSetzePunktStatus = function(idx, status){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
currentProtokoll.punkte[idx].erledigt = checked;
const row = document.getElementById('protokollPunkt_' + idx);
if(row) row.classList.toggle('is-erledigt', checked);
currentProtokoll.punkte[idx].status = status;
// Gezielt die beiden betroffenen Stellen nachziehen statt neu zu rendern:
// der aktive Knopf im Umschalter und der farbige Punkt im Listeneintrag.
document.querySelectorAll('.protokoll-schreiben .protokoll-status-switch button').forEach(btn => {
btn.classList.toggle('is-active', btn.classList.contains(status));
});
aktualisiereListenZeile(idx);
};
// currentProtokollSetzePunktGruppe: einzige Punkt-Feldänderung, die neu
// rendert (nur den Punkte-Block, siehe renderPunkteBlockHtml) — ändert
// sich die Gruppe, muss der Punkt sichtbar in den passenden Abschnitt
// wandern. Läuft über onchange (Blur/Enter), nicht oninput, damit während
// des Tippens nicht bei jedem Zeichen neu gerendert wird.
window.currentProtokollSetzePunktGruppe = function(idx, wert){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
currentProtokoll.punkte[idx].gruppe = wert;
// currentProtokollUmbenenneGruppe: ersetzt currentProtokollSetzePunktGruppe
// (bis eben: ein Feld PRO Punkt, das genau diesen einen Punkt umhängte).
// Jetzt ist die Gruppen-Kopfzeile selbst das Eingabefeld (Eriks Wunsch) —
// ein Umbenennen wirkt auf ALLE Punkte dieser Gruppe gleichzeitig. Trägt
// der neue Name den einer bereits bestehenden anderen Gruppe, verschmelzen
// beide Gruppen schlicht dadurch, dass ab jetzt beide Punktmengen denselben
// gruppe-Wert tragen — kein Sonderfall nötig. Läuft über onchange
// (Blur/Enter), nicht oninput, damit während des Tippens nicht bei jedem
// Zeichen neu gerendert wird.
window.currentProtokollUmbenenneGruppe = function(alterName, neuerName){
if(!currentProtokoll) return;
const alt = String(alterName || '').trim();
const neu = String(neuerName || '').trim();
if(alt === neu) return;
currentProtokoll.punkte.forEach(punkt => {
if(String(punkt.gruppe || '').trim() === alt) punkt.gruppe = neu;
});
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
};
@@ -684,17 +889,54 @@
// aus Gruppe und Position (siehe berechneProtokollNummern() oben).
// erstelltAm nur als Vorschau bis zum nächsten Speichern — maßgeblich ist
// immer, was der Server beim Speichern setzt (server.js, PUT .../:id).
currentProtokoll.punkte.push({
const neuerPunkt = {
id: null, nummer: '', gruppe: gruppe || '', neu: true,
thema: '', text: '', zustaendig: '', termin: '', erledigt: false,
erstelltAm: currentProtokoll.datum || ''
});
thema: '', text: '', zustaendig: '', termin: '', status: 'offen',
erstelltAm: currentProtokoll.datum || '', entfaelltNaechstes: false
};
currentProtokoll.punkte.push(neuerPunkt);
// Den neuen Punkt gleich im Schreibbereich öffnen und ins Thema-Feld
// springen — sonst stünde er zwar links in der Liste, rechts bliebe
// aber der vorige Punkt stehen und man müsste ihn erst anklicken.
ausgewaehlterPunkt = neuerPunkt;
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
const themaFeld = block && block.querySelector('.protokoll-schreiben .protokoll-punkt-thema');
if(themaFeld) themaFeld.focus();
};
// fuegeNeueGruppeHinzu: eigener, ausdrücklicher Weg, eine neue Gruppe
// anzulegen (Eriks Wunsch). Gruppe bleibt weiterhin freier Text ohne eigene
// Tabelle (siehe CLAUDE.md "### Protokolle") — eine Gruppe ohne jeden
// Punkt hätte in diesem Modell nirgends eine Repräsentation, deshalb legt
// dieser Knopf die Gruppe zusammen mit ihrem ersten (leeren) Punkt an. Ein
// nicht kollidierender Platzhaltername ("Neue Gruppe", bei Bedarf
// "Neue Gruppe 2" …) wird direkt danach in der großen Gruppen-Überschrift
// (nicht mehr einem Feld am Punkt, siehe currentProtokollUmbenenneGruppe()
// oben) fokussiert und markiert, damit er sich sofort überschreiben lässt.
window.fuegeNeueGruppeHinzu = function(){
if(!currentProtokoll || app.isLocked()) return;
const bestehende = new Set(gruppenReihenfolge());
let name = 'Neue Gruppe';
let n = 2;
while(bestehende.has(name)){ name = 'Neue Gruppe ' + n; n++; }
fuegePunktHinzu(name);
const block = document.getElementById('protokollPunkteBlock');
const input = block
? Array.from(block.querySelectorAll('.protokoll-gruppe-heading')).find(el => el.value === name)
: null;
if(input){ input.focus(); input.select(); }
};
window.entfernePunkt = function(idx){
if(!currentProtokoll || app.isLocked()) return;
// War der gelöschte Punkt der geöffnete, rückt der an seine Stelle
// getretene nach (bzw. der letzte, wenn es der letzte war) — sonst
// stünde der Schreibbereich leer da, obwohl noch Punkte vorhanden sind.
const warAusgewaehlt = currentProtokoll.punkte[idx] === ausgewaehlterPunkt;
currentProtokoll.punkte.splice(idx, 1);
if(warAusgewaehlt){
const ersatz = Math.min(idx, currentProtokoll.punkte.length - 1);
ausgewaehlterPunkt = ersatz >= 0 ? currentProtokoll.punkte[ersatz] : null;
}
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
};
@@ -711,7 +953,16 @@
// Anzeigen aber immer die Neuberechnung, nie dieser gespeicherte Wert —
// siehe berechneProtokollNummern()).
const nummern = window.berechneProtokollNummern(currentProtokoll.punkte);
currentProtokoll.punkte.forEach((p, idx) => { p.nummer = nummern[idx]; });
// text kommt live aus dem contenteditable-Feld (this.innerHTML,
// currentProtokollSetzePunktFeld()) — ein Einfügen von außerhalb könnte
// dort ungefiltertes HTML hinterlassen (contenteditable übernimmt
// eingefügte Formatierung standardmäßig); vor dem Senden deshalb
// dieselbe Bereinigung wie beim Board-Kommentar (saveBoardComment(),
// js/board/board.js).
currentProtokoll.punkte.forEach((p, idx) => {
p.nummer = nummern[idx];
p.text = sanitizeStatusHtml(p.text || '');
});
const body = {
datum: currentProtokoll.datum,
betreff: currentProtokoll.betreff,
@@ -719,6 +970,11 @@
teilnehmer: currentProtokoll.teilnehmer.filter(t => String(t.name || '').trim()),
punkte: currentProtokoll.punkte
};
// Die Auswahl hängt an der OBJEKT-Referenz des Punkts (siehe dort) — der
// Server liefert gleich frische Objekte zurück, die Referenz zeigt dann
// ins Leere. Die Reihenfolge bleibt beim Speichern erhalten (array_order),
// also lässt sich die Auswahl über ihre Position wiederherstellen.
const auswahlPos = currentProtokoll.punkte.indexOf(ausgewaehlterPunkt);
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), {
method: 'PUT',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
@@ -728,6 +984,7 @@
.then(({ ok, d }) => {
if(!ok || !d.ok){ alert('Protokoll konnte nicht gespeichert werden: ' + (d && d.error ? d.error : 'unbekannter Fehler')); return; }
currentProtokoll = d.protokoll;
ausgewaehlterPunkt = (auswahlPos >= 0 && currentProtokoll.punkte[auswahlPos]) || null;
renderProtokollDetail();
ladeProtokollListe();
})
@@ -753,6 +1010,12 @@
});
};
// Die Rückmeldung der Formatierungsleiste (hervorgehoben + "✓ Markiert"
// sobald Text ausgewählt ist, sonst blass + "Erst Text markieren") ist ein
// gemeinsamer, dokumentweiter Lauscher in js/utils.js geworden (gilt jetzt
// auch für den Board-Kommentar) — hier war er ursprünglich fest auf den
// Protokoll-Schreibbereich zugeschnitten.
// renderPlanProtokolle: zentrale Render-Funktion, von showPlanSection()
// (plan-sections.js) beim Wechsel in den Bereich aufgerufen — zeigt je
// nach Zustand Liste oder Detail (rendert dabei nichts neu, nur aus dem