// -----------------------------
// Projektplan — Protokolle (Besprechungsprotokolle je Projekt)
// -----------------------------
// Bereich "Protokolle" des Projektbereichs (siehe js/plan/plan-sections.js)
// plus eine Kompakt-Karte im Dashboard (#planProtokolleSummary, 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 = 'Lade Protokolle…';
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 = 'Protokolle konnten nicht geladen werden.';
console.error('Protokollliste laden fehlgeschlagen:', err);
});
}
// renderProtokolleSummary: füllt die Kompakt-Vorschau der Dashboard-Karte
// (#planProtokolleSummary) — letztes Protokoll + Anzahl offener Punkte,
// gleiches Muster wie renderFilesSummary() in plan-files.js.
function renderProtokolleSummary(){
const el = document.getElementById('planProtokolleSummary');
if(!el) return;
if(!app.project || !app.project.nr){
el.innerHTML = 'Keine Projektnummer.';
return;
}
if(protokolleCache.length === 0){
el.innerHTML = 'Noch keine Protokolle.';
return;
}
const letztes = protokolleCache[0]; // Server liefert nach nummer DESC
const chip = letztes.offenCount > 0
? `${letztes.offenCount} offen`
: 'Alle erledigt';
el.innerHTML = `
Nr. ${esc(letztes.nummer)} · ${esc(letztes.betreff || 'ohne Betreff')}
${chip}
`;
}
// 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) —
// "Ohne Gruppe" (leerer String) immer zuerst, wenn vorhanden.
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);
}
// ---------- 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 (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.
// verschiebePunkt: vertauscht einen Punkt mit seinem Vorgänger/Nachfolger
// INNERHALB derselben Gruppe (bzw. "Ohne 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.isLocked()) 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 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.
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);
});
const pos = namen.indexOf(gruppe);
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());
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.isLocked();
const anlegeBtns = locked ? '' : `
`;
if(protokolleCache.length === 0){
container.innerHTML = `
Protokolle
${anlegeBtns}
${locked ? 'Für diese Sitzung sind keine Protokolle hinterlegt.' : 'Noch keine Protokolle. Über „+ Neues Protokoll" anlegen oder ein bestehendes importieren.'}
`;
}
// ---------- 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.isLocked()) 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.isLocked()) 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 = ``;
fetch(baseUrl() + '/my-ai-settings')
.then(r => r.json())
.then(d => {
if(!d.ok) return;
erkennungSel.innerHTML = `
`;
})
.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 = 'Lade Protokoll…';
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 = 'Protokoll konnte nicht geladen werden.';
});
};
// 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.isLocked();
const datumDisplay = formatDateDE(parseDateISO(p.datum)) || '';
const quelleHinweis = p.quelleDateiname
? ` · importiert aus ${esc(p.quelleDateiname)}`
: '';
// 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
? `
🔎 Punkte automatisch aus „${esc(p.quelleDateiname)}“ erkannt — bitte vor dem Speichern prüfen und bei Bedarf korrigieren.
`
: '';
// 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
? `
⚠️ Die KI-Erkennung ist fehlgeschlagen (${esc(p._kiFallbackHinweis)}) — die Punkte wurden stattdessen per Textmuster erkannt.