Files
projektuebersicht/public/js/plan/plan-protokoll.js
erikandClaude Opus 5.5 b7e0bd0d92 Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb
- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan
  oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues
  Notizfeld je Aufgabe (plan_todos.notiz)
- Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile,
  "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen
- Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang
- Glas-Darstellung, Logo als Favicon
- Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und
  Prüfskript inkl. WLAN-Wächter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:16:52 +01:00

1115 lines
59 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// Projektplan — Protokolle (Besprechungsprotokolle je Projekt)
// -----------------------------
// Bereich "Protokolle" des Projektbereichs (siehe js/plan/plan-sections.js)
// plus eine Kachel im Dashboard (#planDashProtokolle, springt per
// Klick über openProtokollePanel() in diesen Bereich — gleiches Muster wie
// #planFilesSummary/openFilesPanel() in plan-files.js).
//
// Protokolle hängen wie Dateien NUR an project_nr (nicht an week_label/
// project_id, das sich bei jedem automatischen Sitzungswechsel ändert), sind
// also bei einem Projekt ohne Projektnummer nicht möglich — der Bereich zeigt
// dann nur einen Hinweis, wie bei Dateien.
//
// Das "Fortschreiben" (offene Punkte wandern automatisch mit gleicher Nummer
// ins nächste Protokoll) rechnet ausschließlich der Server beim Anlegen
// (POST /project-protokolle, server.js) — hier wird nur angezeigt/bearbeitet.
// Ob ein Punkt "neu" ist (gelb markiert), steht serverseitig fest (Feld
// `neu`, unabhängig von `nummer`/`gruppe`, die frei editierbare Anzeige-Texte
// sind, siehe CLAUDE.md "### Protokolle").
//
// Die Detailansicht ist bewusst KEIN Modal (ein Protokoll mit vielen Punkten
// ist zu umfangreich dafür), sondern ersetzt inline die Listenansicht
// innerhalb desselben Bereichs-Containers. Um dabei den Eingabefokus beim
// Tippen nicht zu verlieren, wird bei einfachen Textfeldern NICHT bei jedem
// Tastendruck neu gerendert — nur strukturelle Änderungen (Zeile hinzufügen/
// entfernen, Gruppe ändern) lösen ein Neu-Rendern aus.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// protokolleCache: die zuletzt geladene Listenübersicht (Zusammenfassungen,
// keine vollen Punkte/Teilnehmer — die liefert erst das Öffnen eines
// einzelnen Protokolls). currentProtokoll: das volle, gerade in der
// Detailansicht bearbeitete Protokoll, oder null in der Listenansicht.
let protokolleCache = [];
let currentProtokoll = null;
// ---------- Laden ----------
// initPlanProtokoll: wird unbedingt aus renderPlanPage() aufgerufen (wie
// initPlanFiles()), nicht erst beim Öffnen des Bereichs — sonst zeigte die
// Dashboard-Kompaktkarte veraltete Zahlen, bis man den Bereich einmal
// betreten hat.
window.initPlanProtokoll = function(){
const hasNr = !!(app.project && app.project.nr);
const hint = document.getElementById('protokolleNoProjectHint');
const container = document.getElementById('protokolleContainer');
if(hint) hint.style.display = hasNr ? 'none' : '';
if(container) container.style.display = hasNr ? '' : 'none';
currentProtokoll = null;
if(!hasNr){
protokolleCache = [];
renderProtokolleSummary();
return;
}
ladeProtokollListe();
};
// ladeProtokollListe: GET /project-protokolle?projectNr= — füllt
// protokolleCache und rendert Listenansicht + Dashboard-Kompaktkarte neu.
function ladeProtokollListe(){
const container = document.getElementById('protokolleContainer');
if(container && !currentProtokoll) container.innerHTML = '<span class="hint">Lade Protokolle…</span>';
fetch(baseUrl() + '/project-protokolle?projectNr=' + encodeURIComponent(app.project.nr))
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
protokolleCache = Array.isArray(data.protokolle) ? data.protokolle : [];
window.renderPlanProtokolle();
renderProtokolleSummary();
})
.catch(err => {
if(container) container.innerHTML = '<span class="hint">Protokolle konnten nicht geladen werden.</span>';
console.error('Protokollliste laden fehlgeschlagen:', err);
});
}
// renderProtokolleSummary: füllt die Dashboard-Kachel "Protokolle"
// (#planDashProtokolle, Eriks Wunsch — vorher nur eine Zeile in
// "Unterlagen"). Oben das letzte Protokoll, darunter dessen OFFENE Punkte
// (die Frage nach einer Besprechung ist "was ist noch offen?"), unten die
// früheren Protokolle als Kurzliste. Die Punkte kommen aus einem zweiten
// Abruf (GET /project-protokolle/:id) — die Liste liefert nur Zahlen.
const DASH_PROTOKOLL_PUNKTE = 5;
const DASH_PROTOKOLL_FRUEHERE = 3;
let dashProtokollAbruf = 0;
function datumDe(iso){
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ''));
return m ? `${m[3]}.${m[2]}.${m[1]}` : String(iso || '');
}
function renderProtokolleSummary(){
const el = document.getElementById('planDashProtokolle');
const panel = document.getElementById('planDashProtokollePanel');
if(!el) return;
// Ohne Projektnummer (Planung-Terminplan) gibt es keine Protokolle —
// die Kachel verschwindet wie der Bereich selbst.
if(panel) panel.hidden = !(app.project && app.project.nr);
if(!app.project || !app.project.nr){ el.innerHTML = ''; return; }
if(protokolleCache.length === 0){
el.innerHTML = `<div class="plan-dash-leer">Noch keine Protokolle.
<button type="button" class="plan-panel-link" onclick="dashNeuesProtokoll()">+ Neues Protokoll</button></div>`;
return;
}
const letztes = protokolleCache[0]; // Server liefert nach nummer DESC
const frueher = protokolleCache.slice(1, 1 + DASH_PROTOKOLL_FRUEHERE);
const zahlen = [
letztes.offenCount ? `<span class="plan-mini-chip warn">${letztes.offenCount} offen</span>` : '<span class="plan-mini-chip">nichts offen</span>',
letztes.erledigtCount ? `<span class="plan-mini-chip">${letztes.erledigtCount} erledigt</span>` : '',
letztes.infoCount ? `<span class="plan-mini-chip">${letztes.infoCount} Info</span>` : ''
].join('');
el.innerHTML = `
<button type="button" class="plan-prot-kopf" onclick="dashOeffneProtokoll('${esc(letztes.id)}')">
<span class="plan-prot-nr">Nr. ${esc(letztes.nummer)}</span>
<span class="plan-prot-titel"><b>${esc(letztes.betreff || 'ohne Betreff')}</b>
<span>${esc(datumDe(letztes.datum))}${letztes.ort ? ' · ' + esc(letztes.ort) : ''}${letztes.teilnehmerCount ? ' · ' + letztes.teilnehmerCount + ' Teilnehmer' : ''}</span></span>
</button>
<div class="plan-mini-chip-row">${zahlen}</div>
<div class="plan-prot-punkte" id="planDashProtokollPunkte">${letztes.offenCount ? '<span class="hint">Lade offene Punkte…</span>' : ''}</div>
${frueher.length ? `<div class="plan-termin-gruppe">Frühere Protokolle</div>
${frueher.map(pr => `<button type="button" class="plan-prot-frueher" onclick="dashOeffneProtokoll('${esc(pr.id)}')">
<span>Nr. ${esc(pr.nummer)} · ${esc(datumDe(pr.datum))}</span>
<span class="plan-prot-frueher-titel">${esc(pr.betreff || 'ohne Betreff')}</span>
<span class="plan-prot-frueher-zahl">${pr.offenCount ? pr.offenCount + ' offen' : ''}</span>
</button>`).join('')}` : ''}`;
if(letztes.offenCount) ladeOffenePunkte(letztes.id);
}
function ladeOffenePunkte(id){
const nr = ++dashProtokollAbruf;
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(id))
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(d => {
if(nr !== dashProtokollAbruf) return; // inzwischen anderes Projekt
const ziel = document.getElementById('planDashProtokollPunkte');
if(!ziel || !d.protokoll) return;
const punkte = d.protokoll.punkte || [];
const nummern = window.berechneProtokollNummern(punkte);
const heute = typeof heuteIso === 'function' ? heuteIso() : new Date().toISOString().slice(0, 10);
const offen = punkte.map((p, i) => Object.assign({}, p, { anzeigeNr: nummern[i] }))
.filter(p => p.status === 'offen')
// Überfälliges zuerst, dann nach Termin, ohne Termin zuletzt
.sort((a, b) => (a.termin || '9999').localeCompare(b.termin || '9999'));
const zeigen = offen.slice(0, DASH_PROTOKOLL_PUNKTE);
const rest = offen.length - zeigen.length;
ziel.innerHTML = zeigen.map(p => {
const ueber = p.termin && p.termin < heute;
const titel = p.thema || statusPlainText(p.text).slice(0, 80) || 'ohne Thema';
return `<div class="plan-termin ist-klickbar" onclick="dashOeffneProtokoll('${esc(d.protokoll.id)}')">
<span class="plan-termin-art">${esc(p.anzeigeNr)}</span>
<span class="plan-termin-text"><b>${esc(titel)}</b><span>${p.gruppe ? esc(p.gruppe) + ' · ' : ''}${p.zustaendig ? esc(p.zustaendig) : 'ohne Zuständigen'}</span></span>
<span class="plan-termin-datum${ueber ? ' ist-rot' : ''}">${p.termin ? esc(datumDe(p.termin)) + (typeof relativerTag === 'function' ? '<em>' + esc(relativerTag(p.termin)) + '</em>' : '') : '—'}</span>
</div>`;
}).join('') + (rest > 0 ? `<button type="button" class="plan-panel-link plan-prot-mehr" onclick="dashOeffneProtokoll('${esc(d.protokoll.id)}')">… ${rest} weitere offene Punkte</button>` : '');
})
.catch(err => {
if(nr !== dashProtokollAbruf) return;
const ziel = document.getElementById('planDashProtokollPunkte');
if(ziel) ziel.innerHTML = '<span class="hint">Offene Punkte konnten nicht geladen werden.</span>';
console.error('Offene Protokollpunkte laden fehlgeschlagen:', err);
});
}
// dashOeffneProtokoll: aus der Kachel direkt in DIESES Protokoll.
window.dashOeffneProtokoll = function(id){
window.openProtokollePanel();
window.oeffneProtokoll(id);
};
window.dashNeuesProtokoll = function(){
window.openProtokollePanel();
window.neuesProtokoll();
};
// openProtokollePanel: springt in den Bereich "Protokolle" — Gegenstück zu
// openFilesPanel()/openResourceModal().
window.openProtokollePanel = function(){
if(typeof showPlanSection === 'function') showPlanSection('protokolle');
};
// ---------- Nummer-/Gruppen-Hilfsfunktionen ----------
// berechneProtokollNummern: errechnet die Anzeige-Nummer JEDES Punkts rein
// aus seiner Position — "1.1"/"1.2"/"1.3" für Punkte innerhalb einer Gruppe
// (die erste Ziffer ist die Gruppennummer, die zweite die laufende Position
// INNERHALB dieser Gruppe), schlichte "1"/"2"/"3" für Punkte ohne Gruppe.
// Gruppennummer UND die Nummer eines Punkts ohne Gruppe kommen aus EINEM
// gemeinsamen, fortlaufenden Zähler (nicht zwei getrennten) — sonst gäbe es
// z. B. sowohl einen Punkt "1" ohne Gruppe als auch eine Gruppe mit der
// Nummer "1" (Punkte "1.1", "1.2" …), zwei verschiedene Dinge mit
// derselben führenden Ziffer. Jede oberste Ebene (ob Einzelpunkt oder
// Gruppe) bekommt dadurch eine über das ganze Protokoll hinweg einmalige
// Zahl. Nie an einen gespeicherten Wert gebunden — ändert sich die
// Reihenfolge oder die Gruppe eines Punkts, verschiebt sich die Nummer
// automatisch mit, exakt wie bei einem klassischen Baubesprechungsprotokoll
// mit durchnummerierten Abschnitten. Global verfügbar, weil sowohl die
// Detailansicht hier als auch exportProtokollPDF() (js/pdf.js) dieselbe
// Nummerierung zeigen müssen — dasselbe Prinzip wie classifyProjectChange()
// aus wochenbericht.js, das pdf.js ebenfalls direkt aufruft.
// Parameter: punkte (Array in Anzeige-/array_order-Reihenfolge). Rückgabe:
// Array von Strings, indexgleich zu punkte.
window.berechneProtokollNummern = function(punkte){
const gruppenNr = new Map();
const positionInGruppe = new Map();
let naechsteTopLevelNr = 1;
return (punkte || []).map(p => {
const g = String(p.gruppe || '').trim();
if(!g) return String(naechsteTopLevelNr++);
if(!gruppenNr.has(g)){
gruppenNr.set(g, naechsteTopLevelNr++);
positionInGruppe.set(g, 0);
}
const pos = positionInGruppe.get(g) + 1;
positionInGruppe.set(g, pos);
return gruppenNr.get(g) + '.' + pos;
});
};
// gruppenReihenfolge: liefert die vorkommenden Gruppennamen in der
// 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 = [];
(currentProtokoll.punkte || []).forEach(p => {
const g = String(p.gruppe || '').trim();
if(!namen.includes(g)) namen.push(g);
});
return namen;
}
// ---------- Nachträgliches Umordnen ----------
//
// 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 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. 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){
if(!currentProtokoll || app.protokolleGesperrt()) return;
const punkte = currentProtokoll.punkte;
const gruppe = String(punkte[idx].gruppe || '').trim();
const gleicheGruppeIdx = punkte.map((p, i) => i).filter(i => String(punkte[i].gruppe || '').trim() === gruppe);
const posInGruppe = gleicheGruppeIdx.indexOf(idx);
const zielPos = posInGruppe + richtung;
if(zielPos < 0 || zielPos >= gleicheGruppeIdx.length) return;
const zielIdx = gleicheGruppeIdx[zielPos];
const tmp = punkte[idx];
punkte[idx] = punkte[zielIdx];
punkte[zielIdx] = tmp;
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
};
// 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 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.protokolleGesperrt()) return;
const punkte = currentProtokoll.punkte;
const namen = [];
punkte.forEach(p => {
const g = String(p.gruppe || '').trim();
if(!namen.includes(g)) namen.push(g);
});
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 = [];
namen.forEach(g => {
punkte.filter(p => String(p.gruppe || '').trim() === g).forEach(p => neu.push(p));
});
currentProtokoll.punkte = neu;
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
};
// ---------- Listenansicht ----------
function todoStatusZeile(offen, erledigt){
if(offen === 0 && erledigt === 0) return 'keine Punkte';
if(offen === 0) return 'alle erledigt';
return offen + ' offen · ' + erledigt + ' erledigt';
}
function renderProtokolleListe(){
const container = document.getElementById('protokolleContainer');
if(!container) return;
const locked = app.protokolleGesperrt();
const anlegeBtns = locked ? '' : `<button onclick="neuesProtokoll()">+ Neues Protokoll</button>
<button class="secondary" onclick="oeffneProtokollImportDialog()">Protokoll importieren</button>`;
if(protokolleCache.length === 0){
container.innerHTML = `<div class="plan-panel">
<div class="protokoll-list-head"><h3 class="plan-panel-title" style="margin:0">Protokolle</h3><span class="toolbar">${anlegeBtns}</span></div>
<span class="hint">${locked ? 'Für diese Sitzung sind keine Protokolle hinterlegt.' : 'Noch keine Protokolle. Über „+ Neues Protokoll" anlegen oder ein bestehendes importieren.'}</span>
</div>`;
return;
}
const zeilen = protokolleCache.map(p => `
<div class="protokoll-row" onclick="oeffneProtokoll('${esc(p.id)}')">
<span class="protokoll-nr">Nr. ${esc(p.nummer)}</span>
<span class="protokoll-betreff">${esc(p.betreff || 'ohne Betreff')}<span class="protokoll-datum">${esc(formatDateDE(parseDateISO(p.datum)) || '')}${p.ort ? ' · ' + esc(p.ort) : ''}</span></span>
<span class="protokoll-verfasser">${esc(p.verfasserName || '')}</span>
<span class="protokoll-punkte-status${p.offenCount === 0 ? ' ist-leer' : ''}"><span class="protokoll-status-dot"></span>${esc(todoStatusZeile(p.offenCount, p.erledigtCount))}</span>
<span class="protokoll-teilnehmer-count">${p.teilnehmerCount} Teilnehmer</span>
</div>`).join('');
container.innerHTML = `<div class="plan-panel">
<div class="protokoll-list-head"><h3 class="plan-panel-title" style="margin:0">Protokolle</h3><span class="toolbar">${anlegeBtns}</span></div>
${zeilen}
</div>`;
}
// ---------- Anlegen ----------
// neuesProtokoll: legt ein neues Protokoll an. Vorbelegung der Teilnehmer
// (rein clientseitige Komfort-Logik vor dem POST, siehe CLAUDE.md): Namen
// aus dem LETZTEN vorherigen Protokoll übernehmen (Anwesenheit auf
// "anwesend" zurückgesetzt), sonst Fallback auf projectAssignees(), sonst
// leer. Das eigentliche Fortschreiben der Punkte übernimmt der Server.
window.neuesProtokoll = async function(){
if(app.protokolleGesperrt()) return;
let teilnehmer = [];
try {
if(protokolleCache.length > 0){
const r = await fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(protokolleCache[0].id));
const d = await r.json();
if(r.ok && d.ok && d.protokoll){
teilnehmer = d.protokoll.teilnehmer.map(t => ({ name: t.name, firma: t.firma, anwesend: true }));
}
}
if(teilnehmer.length === 0){
const zugeordnet = projectAssignees(app.project, app.week);
const gesehen = new Set();
zugeordnet.forEach(a => {
if(gesehen.has(a.person.id)) return;
gesehen.add(a.person.id);
teilnehmer.push({ name: a.person.fullName || a.person.name, firma: '', anwesend: true });
});
}
} catch(err){
console.error('Teilnehmer-Vorbelegung fehlgeschlagen:', err);
}
try {
const url = baseUrl() + '/project-protokolle'
+ '?projectNr=' + encodeURIComponent(app.project.nr)
+ '&projectName=' + encodeURIComponent(app.project.name || '')
+ '&weekLabel=' + encodeURIComponent(app.week ? app.week.label : '');
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ datum: heuteIso(), betreff: '', ort: '', teilnehmer })
});
const d = await r.json();
if(!r.ok || !d.ok){ alert('Protokoll konnte nicht angelegt werden: ' + (d && d.error ? d.error : 'unbekannter Fehler')); return; }
currentProtokoll = d.protokoll;
renderProtokollDetail();
ladeProtokollListe();
} catch(err){
console.error('Protokoll anlegen fehlgeschlagen:', err);
alert('Protokoll konnte nicht angelegt werden: ' + (err.message || err));
}
};
// ---------- Import (externes Protokoll hochladen) ----------
// oeffneProtokollImportDialog: zeigt den Import-Dialog, Datum vorbelegt
// mit heute (dieselbe Vorbelegung wie neuesProtokoll()). Das Erkennungs-
// Auswahlfeld wird hier aus GET /my-ai-settings aufgebaut statt fest im
// Markup zu stehen — der Hinweis "(kein Schlüssel hinterlegt)" bezieht
// sich auf den EIGENEN Schlüssel des angemeldeten Kontos (siehe
// Einstellungen → Mein Konto → KI-Zugangsdaten), nicht mehr auf einen
// firmenweiten Stand, und kann sich seit dem letzten Öffnen geändert
// haben.
window.oeffneProtokollImportDialog = function(){
if(app.protokolleGesperrt()) return;
document.getElementById('protokollImportFile').value = '';
document.getElementById('protokollImportBetreff').value = '';
document.getElementById('protokollImportOrt').value = '';
document.getElementById('protokollImportDatum').value = heuteIso();
document.getElementById('protokollImportDatumDisplay').value = formatDateDE(parseDateISO(heuteIso())) || '';
const erkennungSel = document.getElementById('protokollImportErkennung');
if(erkennungSel){
erkennungSel.innerHTML = `<option value="chatgpt">ChatGPT</option><option value="claude">Claude</option>`;
fetch(baseUrl() + '/my-ai-settings')
.then(r => r.json())
.then(d => {
if(!d.ok) return;
erkennungSel.innerHTML = `
<option value="chatgpt">ChatGPT${d.chatgpt && d.chatgpt.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>
<option value="claude">Claude${d.claude && d.claude.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>`;
})
.catch(err => console.error('KI-Einstellungen laden fehlgeschlagen:', err));
}
const errEl = document.getElementById('protokollImportError');
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
setzeProtokollImportLadezustand(false);
document.getElementById('protokollImportModal').style.display = 'flex';
};
window.schliesseProtokollImportDialog = function(){
document.getElementById('protokollImportModal').style.display = 'none';
};
// 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 =
// 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){
['protokollImportFile', 'protokollImportErkennung', 'protokollImportDatumDisplay',
'protokollImportBetreff', 'protokollImportOrt'].forEach(id => {
const el = document.getElementById(id);
if(el) el.disabled = aktiv;
});
const modal = document.getElementById('protokollImportModal');
if(modal) modal.querySelectorAll('.toolbar button').forEach(btn => { btn.disabled = aktiv; });
const ladeEl = document.getElementById('protokollImportLoading');
if(ladeEl) ladeEl.hidden = !aktiv;
}
// importiereProtokoll: liest die gewählte Datei + Kopfdaten, schickt die
// Datei als rohen Binär-Body an POST /project-protokolle/import (gleiches
// Vorgehen wie submitFileUpload() in js/plan/plan-files.js, andere
// Zielroute). Bei Erfolg landet der Nutzer direkt in der ganz normalen,
// voll editierbaren Detailansicht — die erkannten Punkte sind darin
// bereits sichtbar (Hinweisbanner in renderProtokollDetail(), siehe dort)
// und lassen sich dort wie jeder andere Punkt prüfen/korrigieren, bevor
// gespeichert wird.
window.importiereProtokoll = function(){
const errEl = document.getElementById('protokollImportError');
const zeigeFehler = msg => { if(errEl){ errEl.textContent = msg; errEl.hidden = false; } };
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
const fileInput = document.getElementById('protokollImportFile');
const file = fileInput && fileInput.files && fileInput.files[0];
if(!file) return zeigeFehler('Bitte eine Datei auswählen.');
const datum = document.getElementById('protokollImportDatum').value;
if(!datum) return zeigeFehler('Bitte ein Datum wählen.');
const betreff = document.getElementById('protokollImportBetreff').value;
const ort = document.getElementById('protokollImportOrt').value;
const erkennungSel = document.getElementById('protokollImportErkennung');
const erkennung = erkennungSel ? erkennungSel.value : '';
const url = baseUrl() + '/project-protokolle/import'
+ '?projectNr=' + encodeURIComponent(app.project.nr)
+ '&projectName=' + encodeURIComponent(app.project.name || '')
+ '&weekLabel=' + encodeURIComponent(app.week ? app.week.label : '')
+ '&datum=' + encodeURIComponent(datum)
+ '&betreff=' + encodeURIComponent(betreff)
+ '&ort=' + encodeURIComponent(ort)
+ '&erkennung=' + encodeURIComponent(erkennung)
+ '&filename=' + encodeURIComponent(file.name);
setzeProtokollImportLadezustand(true);
fetch(url, {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file
})
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
if(!ok || !d.ok) throw new Error((d && d.error) || 'Import fehlgeschlagen.');
schliesseProtokollImportDialog();
currentProtokoll = d.protokoll;
// Angeforderte KI nicht verfügbar gewesen -> automatisch auf
// Textmuster zurückgefallen (server.js, POST .../import) — rein
// clientseitiges, EINMALIGES Merkmal (nie vom Server geliefert,
// nie gespeichert), das renderProtokollDetail() in einen deutlich
// sichtbaren, bestehen bleibenden Hinweis übersetzt (nicht nur
// einen nach 2,5s verschwindenden Toast, der beim gleichzeitigen
// Wechsel in die Detailansicht leicht übersehen wird — genau das
// war Eriks Rückmeldung, nachdem die Erkennung bei ihm an LM
// Studio scheiterte und er davon nichts in der Oberfläche sah).
if(erkennung && d.erkennungsart === 'textmuster' && d.erkennungshinweis){
currentProtokoll._kiFallbackHinweis = d.erkennungshinweis;
}
renderProtokollDetail();
ladeProtokollListe();
})
.catch(err => {
console.error('Protokoll-Import fehlgeschlagen:', err);
zeigeFehler(err.message || String(err));
})
.finally(() => setzeProtokollImportLadezustand(false));
};
// oeffneProtokoll: lädt das volle Protokoll (Teilnehmer + Punkte) und
// wechselt in die Detailansicht.
window.oeffneProtokoll = function(id){
const container = document.getElementById('protokolleContainer');
if(container) container.innerHTML = '<span class="hint">Lade Protokoll…</span>';
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(id))
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(d => {
if(!d.ok || !d.protokoll) throw new Error(d.error || 'nicht gefunden');
currentProtokoll = d.protokoll;
renderProtokollDetail();
})
.catch(err => {
console.error('Protokoll laden fehlgeschlagen:', err);
if(container) container.innerHTML = '<span class="hint">Protokoll konnte nicht geladen werden.</span>';
});
};
// schliesseProtokollDetail: zurück zur Listenansicht.
window.schliesseProtokollDetail = function(){
currentProtokoll = null;
renderProtokolleListe();
};
// ---------- Detailansicht ----------
function renderProtokollDetail(){
const container = document.getElementById('protokolleContainer');
if(!container || !currentProtokoll) return;
const p = currentProtokoll;
const locked = app.protokolleGesperrt();
const datumDisplay = formatDateDE(parseDateISO(p.datum)) || '';
const quelleHinweis = p.quelleDateiname
? ` · importiert aus <a href="${baseUrl()}/project-protokolle/${esc(p.id)}/quelldatei" target="_blank" rel="noopener">${esc(p.quelleDateiname)}</a>`
: '';
// Hinweisbanner: nur bei einem importierten Protokoll (quelleDateiname
// gesetzt) — die automatisch erkannten Punkte sind ein Entwurf, siehe
// erkennePunkteAusText() (server.js) und CLAUDE.md "### Protokolle".
// Kein eigener Zustand: leitet sich allein aus dem geladenen Protokoll
// ab, verschwindet also nie von selbst, sondern nur wenn man das
// Protokoll erneut öffnet — bewusst so, die Herkunft bleibt sichtbar.
const importBanner = p.quelleDateiname
? `<div class="protokoll-import-banner">🔎 Punkte automatisch aus „${esc(p.quelleDateiname)}“ erkannt — bitte vor dem Speichern prüfen und bei Bedarf korrigieren.</div>`
: '';
// Rein clientseitiges Merkmal (siehe importiereProtokoll() oben) — nur
// direkt nach GENAU dem Import gesetzt, der die KI angefordert, aber
// nicht erreicht hat. Bewusst deutlich wärmer als importBanner oben
// (amber statt dem neutralen Gelb-Ton), damit ein echter Fehlschlag
// nicht im selben Ton wie die übliche "bitte prüfen"-Erinnerung
// untergeht, und bewusst kein Toast (verschwindet von selbst) — genau
// das war Eriks Rückmeldung, nachdem er von einem KI-Fehlschlag nichts
// in der Oberfläche sah.
const kiFallbackBanner = p._kiFallbackHinweis
? `<div class="protokoll-ki-fallback-banner">⚠️ Die KI-Erkennung ist fehlgeschlagen (${esc(p._kiFallbackHinweis)}) — die Punkte wurden stattdessen per Textmuster erkannt.</div>`
: '';
const kopf = `
${kiFallbackBanner}
${importBanner}
<div class="protokoll-detail-head">
<div>
<h3 style="margin:0 0 4px">${esc(p.betreff || 'ohne Betreff')}</h3>
<div class="protokoll-detail-meta">Protokoll Nr. ${esc(p.nummer)} · verfasst von ${esc(p.verfasserName || '—')}${quelleHinweis}</div>
</div>
<div class="toolbar">
${locked ? '' : `<button class="secondary" onclick="exportProtokollPDF('${esc(p.id)}')">Als PDF exportieren</button>`}
${locked ? '' : `<button class="secondary" onclick="loescheProtokoll()">Löschen</button>`}
${locked ? '' : `<button onclick="speichereProtokoll()">Speichern</button>`}
</div>
</div>
<div class="protokoll-field-grid">
<div class="protokoll-field">
<label>Datum</label>
<input type="text" id="protokollDatumDisplay" class="range-picker-display" value="${esc(datumDisplay)}" placeholder="Datum wählen…" readonly
onclick="openDatePicker({ anchor: this, inputId: 'protokollDatum', displayId: 'protokollDatumDisplay', onApply: syncProtokollDatum })">
<input type="hidden" id="protokollDatum" value="${esc(p.datum)}">
</div>
<div class="protokoll-field">
<label>Betreff</label>
<input type="text" value="${esc(p.betreff)}" oninput="currentProtokollSetzeFeld('betreff', this.value)">
</div>
<div class="protokoll-field">
<label>Ort</label>
<input type="text" value="${esc(p.ort)}" oninput="currentProtokollSetzeFeld('ort', this.value)">
</div>
</div>`;
const teilnehmerZeilen = p.teilnehmer.map((t, idx) => `
<div class="protokoll-teilnehmer-row">
<input type="text" value="${esc(t.name)}" placeholder="Name" oninput="currentProtokollSetzeTeilnehmerFeld(${idx}, 'name', this.value)">
<input type="text" value="${esc(t.firma)}" placeholder="Firma / Rolle" oninput="currentProtokollSetzeTeilnehmerFeld(${idx}, 'firma', this.value)">
<label class="protokoll-anwesend-toggle"><input type="checkbox" ${t.anwesend ? 'checked' : ''} onchange="currentProtokollSetzeTeilnehmerFeld(${idx}, 'anwesend', this.checked)"> anwesend</label>
<span class="protokoll-row-remove" onclick="entferneTeilnehmer(${idx})" title="Teilnehmer entfernen">✕</span>
</div>`).join('');
const teilnehmerBlock = `
<div class="protokoll-block">
<div class="protokoll-block-head"><h4>Teilnehmer</h4></div>
${teilnehmerZeilen || '<span class="hint">Noch keine Teilnehmer.</span>'}
${locked ? '' : '<div class="add-row-btn" onclick="fuegeTeilnehmerHinzu()">+ Teilnehmer</div>'}
</div>`;
container.innerHTML = `<div class="plan-panel">
<div class="protokoll-back-link" onclick="schliesseProtokollDetail()">← Zurück zur Liste</div>
${kopf}
${teilnehmerBlock}
<div class="protokoll-block" id="protokollPunkteBlock">${renderPunkteBlockHtml()}</div>
</div>`;
}
// 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.protokolleGesperrt();
const gruppen = gruppenReihenfolge();
const nummern = window.berechneProtokollNummern(p.punkte);
// 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);
let moveBtns = '';
if(!locked){
const gPos = gruppen.indexOf(gruppe);
moveBtns = `<span class="protokoll-gruppe-move">
<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 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 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>
<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 info"></span>Info (läuft nicht weiter mit)</span>
</div>`;
}
// 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.protokolleGesperrt();
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>' : '';
// 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>
<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)}">
</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>`;
}
// ---------- Feld-Mutationen (ohne Neu-Rendern, siehe Datei-Kopfkommentar) ----------
window.currentProtokollSetzeFeld = function(feld, wert){
if(!currentProtokoll) return;
currentProtokoll[feld] = wert;
};
window.syncProtokollDatum = function(){
if(!currentProtokoll) return;
currentProtokoll.datum = document.getElementById('protokollDatum').value;
};
window.currentProtokollSetzeTeilnehmerFeld = function(idx, feld, wert){
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.currentProtokollSetzePunktStatus = function(idx, status){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
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);
};
// 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();
};
// ---------- Strukturelle Änderungen (Zeile hinzufügen/entfernen) ----------
window.fuegeTeilnehmerHinzu = function(){
if(!currentProtokoll || app.protokolleGesperrt()) return;
currentProtokoll.teilnehmer.push({ name: '', firma: '', anwesend: true });
renderProtokollDetail();
};
window.entferneTeilnehmer = function(idx){
if(!currentProtokoll || app.protokolleGesperrt()) return;
currentProtokoll.teilnehmer.splice(idx, 1);
renderProtokollDetail();
};
window.fuegePunktHinzu = function(gruppe){
if(!currentProtokoll || app.protokolleGesperrt()) return;
// nummer wird nicht gesetzt — sie ergibt sich beim Rendern automatisch
// 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).
const neuerPunkt = {
id: null, nummer: '', gruppe: gruppe || '', neu: true,
thema: '', text: '', zustaendig: '', termin: '', status: 'offen',
erstelltAm: currentProtokoll.datum || '', entfaelltNaechstes: false
};
// Hinter dem letzten Punkt DERSELBEN Gruppe einfügen statt ans Ende: Die
// Nummern folgen der Reihenfolge im Array (berechneProtokollNummern()),
// die Anzeige aber gruppiert. Ein ans Ende gehängter Punkt "ohne Gruppe"
// bekam sonst eine Nummer hinter allen Gruppen (1–5, dann 7, darunter 6.1).
// Gibt es noch keinen Punkt ohne Gruppe, kommt er an den Anfang — dieser
// Abschnitt steht immer zuerst.
const liste = currentProtokoll.punkte;
const g = gruppe || '';
let letzter = -1;
liste.forEach((x, i) => { if((x.gruppe || '') === g) letzter = i; });
if(letzter >= 0) liste.splice(letzter + 1, 0, neuerPunkt);
else if(!g) liste.unshift(neuerPunkt);
else liste.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.protokolleGesperrt()) 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.protokolleGesperrt()) 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();
};
// ---------- Speichern / Löschen ----------
// speichereProtokoll: PUT /project-protokolle/:id — ersetzt Teilnehmer und
// Punkte komplett (siehe server.js). Ausdrückliches Speichern, kein
// Auto-Save, wie die Board-Kommentare.
window.speichereProtokoll = function(){
if(!currentProtokoll || app.protokolleGesperrt()) return;
// Errechnete Nummern vor dem Senden in die Punkte schreiben, damit die
// gespeicherten Werte mit der Anzeige übereinstimmen (maßgeblich ist beim
// Anzeigen aber immer die Neuberechnung, nie dieser gespeicherte Wert —
// siehe berechneProtokollNummern()).
const nummern = window.berechneProtokollNummern(currentProtokoll.punkte);
// 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,
ort: currentProtokoll.ort,
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' },
body: JSON.stringify(body)
})
.then(r => r.json().then(d => ({ status: r.status, ok: r.ok, d })))
.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();
})
.catch(err => {
console.error('Protokoll speichern fehlgeschlagen:', err);
alert('Protokoll konnte nicht gespeichert werden: ' + (err.message || err));
});
};
window.loescheProtokoll = function(){
if(!currentProtokoll || app.protokolleGesperrt()) return;
if(!confirm('Protokoll Nr. ' + currentProtokoll.nummer + ' wirklich löschen?')) return;
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), { method: 'DELETE' })
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(d => {
if(!d.ok){ alert('Protokoll konnte nicht gelöscht werden.'); return; }
currentProtokoll = null;
ladeProtokollListe();
})
.catch(err => {
console.error('Protokoll löschen fehlgeschlagen:', err);
alert('Protokoll konnte nicht gelöscht werden: ' + (err.message || err));
});
};
// 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
// vorhandenen protokolleCache/currentProtokoll).
window.renderPlanProtokolle = function(){
if(currentProtokoll) renderProtokollDetail();
else renderProtokolleListe();
};
})();