alle new
This commit is contained in:
erik committed 2026-10-01 06:46:51 +02:00
1 parent da64472251
commit 5b20adee48
1605 files changed
+396517 -1674

No files matched your search

+764
View File
@@ -0,0 +1,764 @@
// -----------------------------
// 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 = '<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 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 = '<span class="hint">Keine Projektnummer.</span>';
return;
}
if(protokolleCache.length === 0){
el.innerHTML = '<span class="hint">Noch keine Protokolle.</span>';
return;
}
const letztes = protokolleCache[0]; // Server liefert nach nummer DESC
const chip = letztes.offenCount > 0
? `<span class="plan-mini-chip warn">${letztes.offenCount} offen</span>`
: '<span class="plan-mini-chip">Alle erledigt</span>';
el.innerHTML = `<div class="plan-mini-card-preview">Nr. ${esc(letztes.nummer)} · ${esc(letztes.betreff || 'ohne Betreff')}</div>
<div class="plan-mini-chip-row">${chip}</div>`;
}
// 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 ? '' : `<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.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 = `<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 =
// 5 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.isLocked();
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 (in Abschnitten je
// Gruppe) — eigene Funktion, weil er nach einer Gruppenänderung isoliert
// neu gerendert wird, ohne den Rest der Detailansicht (und damit den Fokus
// in anderen Feldern) anzufassen.
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 => {
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' : '';
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>
</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>'}
</div>`;
}).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>`;
return `<div class="protokoll-block-head"><h4>Punkte</h4></div>
${gruppenHtml || '<span class="hint">Noch keine Punkte.</span>'}
${datalist}
${addOhneGruppe}
<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>
</div>`;
}
function renderPunktZeileHtml(punkt, idx, nummer, istErste, istLetzte){
const locked = app.isLocked();
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>`}
</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
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>`;
}
// ---------- 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;
};
window.currentProtokollSetzePunktFeld = function(idx, feld, wert){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
currentProtokoll.punkte[idx][feld] = wert;
};
window.syncProtokollPunktTermin = function(idx){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
const el = document.getElementById('protokollPunktTermin_' + idx);
if(el) currentProtokoll.punkte[idx].termin = el.value;
};
window.currentProtokollSetzePunktErledigt = function(idx, checked){
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);
};
// 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;
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
};
// ---------- Strukturelle Änderungen (Zeile hinzufügen/entfernen) ----------
window.fuegeTeilnehmerHinzu = function(){
if(!currentProtokoll || app.isLocked()) return;
currentProtokoll.teilnehmer.push({ name: '', firma: '', anwesend: true });
renderProtokollDetail();
};
window.entferneTeilnehmer = function(idx){
if(!currentProtokoll || app.isLocked()) return;
currentProtokoll.teilnehmer.splice(idx, 1);
renderProtokollDetail();
};
window.fuegePunktHinzu = function(gruppe){
if(!currentProtokoll || app.isLocked()) 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).
currentProtokoll.punkte.push({
id: null, nummer: '', gruppe: gruppe || '', neu: true,
thema: '', text: '', zustaendig: '', termin: '', erledigt: false,
erstelltAm: currentProtokoll.datum || ''
});
const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml();
};
window.entfernePunkt = function(idx){
if(!currentProtokoll || app.isLocked()) return;
currentProtokoll.punkte.splice(idx, 1);
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.isLocked()) 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);
currentProtokoll.punkte.forEach((p, idx) => { p.nummer = nummern[idx]; });
const body = {
datum: currentProtokoll.datum,
betreff: currentProtokoll.betreff,
ort: currentProtokoll.ort,
teilnehmer: currentProtokoll.teilnehmer.filter(t => String(t.name || '').trim()),
punkte: currentProtokoll.punkte
};
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;
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.isLocked()) 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));
});
};
// 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();
};
})();