Notizen je Projekt statt Reiter Leistungsphasen

- Neuer Reiter „Notizen“ im Projektbereich: Liste mit Suche und
  angehefteten Notizen, Editor mit Formatierung, automatisches Speichern,
  Mail per Drag & Drop oder Strg+V als neue Notiz, live bei allen
- Reiter „Leistungsphasen“ entfernt (Phasen weiter im Terminplan)
- Behoben: Umlaute in .eml-Dateien ohne Transfer-Kodierung gingen
  verloren (betraf auch „Termin aus Mail“)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-30 10:00:20 +01:00
1 parent 51dfbe8cd8
commit 7d65ff813f
10 files changed
+697 -265

No files matched your search

+410
View File
@@ -0,0 +1,410 @@
// -----------------------------
// Projektbereich — Notizen (Eriks Wahl „A – Liste + Editor“)
// -----------------------------
// Gemeinsame Notizen je Projekt: links die Liste (angeheftete oben, danach
// zuletzt geänderte zuerst, Suche über Titel und Text), rechts die gewählte
// Notiz direkt bearbeitbar — Titel, dieselbe Werkzeugleiste wie beim
// Sitzungskommentar (richtextToolbarHtml(), js/utils.js) und der Text.
// Gespeichert wird automatisch kurz nach dem Tippen und beim Verlassen des
// Feldes; „Gespeichert ✓“ zeigt den Stand. Mail per Drag & Drop (Text oder
// .eml) bzw. Strg+V außerhalb von Eingabefeldern legt eine neue Notiz an,
// ohne KI. Live: andere Browser erfahren Änderungen über das Ereignis
// {typ:'notizen'} (notizenLiveAenderung(), aus js/store.js).
//
// Daten am Server: Tabelle projekt_notizen, Routen /projekt-notizen*
// (server.js). Die Notizen hängen am Projekt (projectUid), nicht an der
// Sitzung. Schreiben: Recht proj.plan; Löschen: Verfasser oder Admin.
(function(){
const app = window.PlanApp;
const SPEICHER_PAUSE_MS = 1200;
let liste = []; // Notizen des geladenen Projekts
let uidGeladen = null; // projectUid, zu dem `liste` gehört
let aktivId = null; // gewählte Notiz
let suche = '';
let basis = ''; // geaendertAm, auf dem die Bearbeitung beruht
let ungespeichert = false;
let speicherTimer = null;
let speichertGerade = false;
let beobachter = null; // MutationObserver auf dem Textfeld
let loeschenFragt = null; // Timer der zweistufigen Löschen-Abfrage
let ladeFehler = '';
const kannSchreiben = () => typeof hatRecht === 'function' && hatRecht('proj.plan');
const projektUid = () => app.project ? String(app.project.projectUid || app.project.id || '') : '';
const wrap = () => document.getElementById('planNotizenWrap');
const aktiv = () => liste.find(n => n.id === aktivId) || null;
function zeit(iso){
if(!iso) return '';
const d = new Date(iso);
if(isNaN(d)) return '';
const heute = new Date();
const gleicherTag = d.toDateString() === heute.toDateString();
const gestern = new Date(heute); gestern.setDate(heute.getDate() - 1);
const uhr = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
if(gleicherTag) return 'heute ' + uhr;
if(d.toDateString() === gestern.toDateString()) return 'gestern ' + uhr;
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: d.getFullYear() === heute.getFullYear() ? undefined : 'numeric' });
}
function kurzname(name){
const teile = String(name || '').trim().split(/\s+/);
return teile.length > 1 ? teile[0][0] + '. ' + teile.slice(1).join(' ') : (name || '');
}
function klartext(html){
return typeof statusPlainText === 'function' ? statusPlainText(html) : String(html || '').replace(/<[^>]+>/g, ' ');
}
function sortieren(){
liste.sort((a, b) => (b.angeheftet - a.angeheftet) || String(b.geaendertAm).localeCompare(String(a.geaendertAm)));
}
// ---------- Laden ----------
function laden(uid){
uidGeladen = uid;
ladeFehler = '';
return fetch(baseUrl() + '/projekt-notizen?uid=' + encodeURIComponent(uid))
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
if(uid !== uidGeladen) return;
if(!ok || !d.ok) throw new Error(d.error || 'Laden fehlgeschlagen');
liste = d.notizen || [];
sortieren();
})
.catch(e => { ladeFehler = e.message || 'Die Notizen konnten nicht geladen werden.'; liste = []; });
}
// ---------- Darstellung ----------
window.renderPlanNotizen = function(){
const el = wrap();
if(!el || !app.project) return;
const uid = projektUid();
if(uid !== uidGeladen){
jetztSpeichern();
aktivId = null; liste = []; suche = '';
el.innerHTML = '<div class="notizen-laedt">Notizen werden geladen…</div>';
laden(uid).then(() => { if(projektUid() === uid) aufbauen(); });
return;
}
aufbauen();
};
function aufbauen(){
const el = wrap();
if(!el) return;
if(!aktiv() && liste.length) aktivId = liste[0].id;
el.innerHTML = `
<div class="notizen" id="notizenBereich">
<aside class="notizen-liste">
<div class="notizen-suchzeile">
<input type="search" id="notizenSuche" class="notizen-suche" placeholder="Notizen durchsuchen…"
aria-label="Notizen durchsuchen" value="${esc(suche)}" oninput="notizenSuchen(this.value)">
${kannSchreiben() ? '<button type="button" class="notizen-neu" onclick="notizNeu()">+ Neu</button>' : ''}
</div>
<div id="notizenEintraege" class="notizen-eintraege"></div>
${kannSchreiben() ? '<div class="notizen-ablage">Mail hierher ziehen = neue Notiz</div>' : ''}
</aside>
<div class="notizen-editor" id="notizenEditor"></div>
</div>`;
listeZeichnen();
editorZeichnen();
}
function listeZeichnen(){
const box = document.getElementById('notizenEintraege');
if(!box) return;
if(ladeFehler){ box.innerHTML = '<div class="notizen-leer ist-fehler">' + esc(ladeFehler) + '</div>'; return; }
const s = suche.trim().toLowerCase();
const treffer = s ? liste.filter(n => (n.titel + ' ' + klartext(n.html)).toLowerCase().includes(s)) : liste;
if(!liste.length){ box.innerHTML = '<div class="notizen-leer">Noch keine Notizen.</div>'; return; }
if(!treffer.length){ box.innerHTML = '<div class="notizen-leer">Keine Notiz enthält „' + esc(suche) + '“.</div>'; return; }
const zeile = n => {
const vorschau = klartext(n.html).replace(/\s+/g, ' ').trim().slice(0, 90);
return `<button type="button" class="notizen-eintrag${n.id === aktivId ? ' ist-aktiv' : ''}" onclick="notizWaehlen('${esc(n.id)}')">
<span class="notizen-eintrag-titel">${esc(n.titel || 'Ohne Titel')}${n.quelle === 'mail' ? ' <span class="notizen-chip">Mail</span>' : ''}</span>
<span class="notizen-eintrag-text">${esc(vorschau || 'Kein Text')}</span>
<span class="notizen-eintrag-meta">${esc(kurzname(n.geaendertVon))} · ${esc(zeit(n.geaendertAm))}</span>
</button>`;
};
const oben = treffer.filter(n => n.angeheftet), rest = treffer.filter(n => !n.angeheftet);
box.innerHTML = (oben.length ? '<div class="notizen-gruppe"><span class="notizen-pin-punkt"></span>Angeheftet</div>' + oben.map(zeile).join('') : '')
+ (rest.length ? (oben.length ? '<div class="notizen-gruppe">Weitere</div>' : '') + rest.map(zeile).join('') : '');
}
function editorZeichnen(){
const el = document.getElementById('notizenEditor');
if(!el) return;
if(beobachter){ beobachter.disconnect(); beobachter = null; }
const n = aktiv();
if(!n){
el.innerHTML = `<div class="notizen-editor-leer">
<b>${liste.length ? 'Notiz links auswählen' : 'Noch keine Notizen in diesem Projekt'}</b>
<span>${kannSchreiben() ? 'Mit „+ Neu“ anlegen oder eine Mail auf diesen Bereich ziehen.' : 'Zum Anlegen fehlt das Recht, den Terminplan zu bearbeiten.'}</span>
</div>`;
return;
}
const schreiben = kannSchreiben();
basis = n.geaendertAm;
ungespeichert = false;
el.innerHTML = `
<div class="notizen-editor-kopf">
<input type="text" id="notizTitel" class="notizen-titel" value="${esc(n.titel)}" placeholder="Titel der Notiz"
aria-label="Titel der Notiz" ${schreiben ? '' : 'readonly'} oninput="notizGeaendert()" onblur="notizJetztSpeichern()">
${schreiben ? `<button type="button" class="notizen-symbol${n.angeheftet ? ' ist-an' : ''}" onclick="notizAnheften()"
title="${n.angeheftet ? 'Nicht mehr anheften' : 'Oben anheften'}" aria-pressed="${n.angeheftet}">${n.angeheftet ? 'Angeheftet' : 'Anheften'}</button>
<button type="button" class="notizen-symbol ist-gefahr" id="notizLoeschen" onclick="notizLoeschen()">Löschen</button>` : ''}
</div>
<div class="richtext-wrap notizen-richtext">
${schreiben ? richtextToolbarHtml('.notizen-text') : ''}
${richtextFeldHtml('notizen-text', sanitizeStatusHtml(n.html || ''), 'Notiz schreiben…', 'id="notizText" onblur="notizJetztSpeichern()"', schreiben)}
</div>
<div class="notizen-fuss">
<span>Angelegt von ${esc(n.erstelltVon)} ${esc(zeit(n.erstelltAm))}${n.geaendertAm !== n.erstelltAm ? ' · zuletzt geändert von ' + esc(n.geaendertVon) + ' ' + esc(zeit(n.geaendertAm)) : ''}</span>
<span id="notizStatus" class="notizen-status"></span>
</div>`;
const text = document.getElementById('notizText');
// Auch Fett/Farbe aus der Werkzeugleiste ändern den Inhalt, ohne ein
// input-Ereignis auszulösen — daher ein MutationObserver.
if(text && schreiben){
beobachter = new MutationObserver(() => notizGeaendert());
beobachter.observe(text, { childList: true, subtree: true, characterData: true, attributes: true });
}
}
function status(text, art){
const el = document.getElementById('notizStatus');
if(!el) return;
el.className = 'notizen-status' + (art ? ' ist-' + art : '');
el.innerHTML = text;
}
// ---------- Bearbeiten und Speichern ----------
window.notizenSuchen = function(wert){ suche = wert || ''; listeZeichnen(); };
window.notizWaehlen = function(id){
if(id === aktivId) return;
jetztSpeichern();
aktivId = id;
listeZeichnen();
editorZeichnen();
};
window.notizGeaendert = function(){
if(!kannSchreiben() || !aktiv()) return;
ungespeichert = true;
status('Ungespeichert');
clearTimeout(speicherTimer);
speicherTimer = setTimeout(jetztSpeichern, SPEICHER_PAUSE_MS);
};
window.notizJetztSpeichern = function(){ jetztSpeichern(); };
// jetztSpeichern: schickt die gewählte Notiz, falls geändert. ohneBasis =
// bewusst überschreiben (nach einem Konflikt „Meine Fassung behalten“).
function jetztSpeichern(ohneBasis){
clearTimeout(speicherTimer);
const n = aktiv();
if(!n || !ungespeichert || !kannSchreiben()) return Promise.resolve();
if(speichertGerade){ speicherTimer = setTimeout(jetztSpeichern, 400); return Promise.resolve(); }
const titelEl = document.getElementById('notizTitel');
const textEl = document.getElementById('notizText');
const titel = titelEl ? titelEl.value : n.titel;
const html = textEl ? textEl.innerHTML : n.html;
ungespeichert = false;
speichertGerade = true;
status('Speichert…');
return fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), {
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ titel, html, basis: ohneBasis ? '' : basis })
}).then(r => r.json().then(d => ({ r, d }))).then(({ r, d }) => {
if(r.status === 409 && d.konflikt){
ungespeichert = true;
status(esc(d.error) + ' <button type="button" class="notizen-link" onclick="notizKonflikt(true)">Meine Fassung speichern</button>'
+ ' <button type="button" class="notizen-link" onclick="notizKonflikt(false)">Neuen Stand laden</button>', 'warnung');
n._fremd = d.notiz;
return;
}
if(!r.ok || !d.ok) throw new Error(d.error || 'Speichern fehlgeschlagen');
Object.assign(n, d.notiz, { titel, html });
if(n.id === aktivId) basis = d.notiz.geaendertAm;
sortieren();
listeZeichnen();
if(!ungespeichert) status('Gespeichert ✓', 'ok');
}).catch(e => {
ungespeichert = true;
status('Nicht gespeichert: ' + esc(e.message) + ' — wird erneut versucht.', 'fehler');
speicherTimer = setTimeout(jetztSpeichern, 5000);
}).finally(() => { speichertGerade = false; });
}
window.notizKonflikt = function(meine){
const n = aktiv();
if(!n) return;
if(meine){ ungespeichert = true; jetztSpeichern(true); return; }
if(n._fremd) Object.assign(n, n._fremd);
delete n._fremd;
ungespeichert = false;
listeZeichnen();
editorZeichnen();
};
window.notizNeu = function(){
const uid = projektUid();
if(!uid || !kannSchreiben()) return;
jetztSpeichern();
fetch(baseUrl() + '/projekt-notizen', {
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ uid, titel: '', html: '' })
}).then(r => r.json()).then(d => {
if(!d.ok) throw new Error(d.error || 'Anlegen fehlgeschlagen');
neueNotizZeigen(d.notiz);
const t = document.getElementById('notizTitel');
if(t) t.focus();
}).catch(e => app.showToast('Notiz konnte nicht angelegt werden: ' + e.message));
};
function neueNotizZeigen(notiz){
if(!notiz || notiz.uid !== uidGeladen) return;
liste = liste.filter(x => x.id !== notiz.id);
liste.push(notiz);
sortieren();
aktivId = notiz.id;
suche = '';
const s = document.getElementById('notizenSuche');
if(s) s.value = '';
listeZeichnen();
editorZeichnen();
}
window.notizAnheften = function(){
const n = aktiv();
if(!n) return;
jetztSpeichern().then(() => fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), {
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ angeheftet: !n.angeheftet })
})).then(r => r.json()).then(d => {
if(!d.ok) throw new Error(d.error || 'Fehler');
n.angeheftet = d.notiz.angeheftet;
sortieren();
listeZeichnen();
const knopf = document.querySelector('.notizen-editor-kopf .notizen-symbol:not(.ist-gefahr)');
if(knopf){
knopf.classList.toggle('ist-an', n.angeheftet);
knopf.textContent = n.angeheftet ? 'Angeheftet' : 'Anheften';
knopf.title = n.angeheftet ? 'Nicht mehr anheften' : 'Oben anheften';
knopf.setAttribute('aria-pressed', String(n.angeheftet));
}
}).catch(e => app.showToast('Anheften fehlgeschlagen: ' + e.message));
};
// notizLoeschen: zweistufig im Knopf selbst (kein confirm()).
window.notizLoeschen = function(){
const n = aktiv();
const knopf = document.getElementById('notizLoeschen');
if(!n || !knopf) return;
if(!loeschenFragt){
knopf.textContent = 'Wirklich löschen?';
knopf.classList.add('ist-fragend');
loeschenFragt = setTimeout(() => {
loeschenFragt = null;
const k = document.getElementById('notizLoeschen');
if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); }
}, 4000);
return;
}
clearTimeout(loeschenFragt); loeschenFragt = null;
clearTimeout(speicherTimer); ungespeichert = false;
fetch(baseUrl() + '/projekt-notizen/' + encodeURIComponent(n.id), { method: 'DELETE' })
.then(r => r.json().then(d => ({ r, d }))).then(({ r, d }) => {
if(!r.ok || !d.ok) throw new Error(d.error || 'Löschen fehlgeschlagen');
liste = liste.filter(x => x.id !== n.id);
aktivId = liste.length ? liste[0].id : null;
listeZeichnen();
editorZeichnen();
}).catch(e => { status(esc(e.message), 'fehler'); const k = document.getElementById('notizLoeschen'); if(k){ k.textContent = 'Löschen'; k.classList.remove('ist-fragend'); } });
};
// ---------- Live: Änderungen aus anderen Browsern ----------
// Liste neu holen; die gewählte Notiz nur ersetzen, wenn hier gerade
// nichts ungespeichert ist und niemand in ihr tippt.
window.notizenLiveAenderung = function(uid){
if(!uid || uid !== uidGeladen) return;
const n = aktiv();
laden(uid).then(() => {
if(!wrap()) return;
if(aktivId && !liste.some(x => x.id === aktivId)){ aktivId = liste.length ? liste[0].id : null; editorZeichnen(); }
listeZeichnen();
const frisch = aktiv();
const tippt = document.activeElement && document.activeElement.closest && document.activeElement.closest('#notizenEditor');
if(frisch && n && frisch.id === n.id && !ungespeichert && !tippt && frisch.geaendertAm !== basis) editorZeichnen();
});
};
// ---------- Mail ablegen (Drag & Drop, Strg+V) ----------
function imNotizenBereich(){
const s = document.querySelector('.plan-section.is-active');
return typeof currentTab !== 'undefined' && currentTab === 'plan' && s && s.dataset.section === 'notizen' && !!app.project;
}
function ausMail(nutzlast){
const uid = projektUid();
if(!uid) return;
jetztSpeichern();
fetch(baseUrl() + '/projekt-notizen/aus-mail', {
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(Object.assign({ uid }, nutzlast))
}).then(r => r.json()).then(d => {
if(!d.ok) throw new Error(d.error || 'Fehlgeschlagen');
neueNotizZeigen(d.notiz);
app.showToast('Notiz aus der Mail angelegt.');
}).catch(e => app.showToast('Mail konnte nicht übernommen werden: ' + e.message));
}
function ausDaten(dt){
const dateien = Array.from(dt.files || []);
const eml = dateien.find(f => /\.eml$/i.test(f.name || '') || f.type === 'message/rfc822');
if(eml){
if(eml.size > 5 * 1024 * 1024){ app.showToast('Die Mail ist zu groß (mehr als 5 MB).'); return; }
eml.text().then(t => ausMail({ eml: t }));
return;
}
if(dateien.some(f => /\.msg$/i.test(f.name || ''))){
app.showToast('Outlook-Dateien (.msg) lassen sich nicht lesen. Bitte den Mailtext markieren und hineinziehen.');
return;
}
let text = (dt.getData('text/plain') || '').trim();
if(!text){
const div = document.createElement('div');
div.innerHTML = (dt.getData('text/html') || '').replace(/<(script|style)[\s\S]*?<\/\1>/gi, '');
text = (div.innerText || div.textContent || '').trim();
}
if(text) ausMail({ text });
}
const bereich = () => document.getElementById('planSection-notizen');
document.addEventListener('dragover', e => {
if(!imNotizenBereich() || !kannSchreiben() || !bereich().contains(e.target)) return;
if(e.target.closest && e.target.closest('#notizenEditor [contenteditable="true"], #notizTitel')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
bereich().classList.add('notizen-zieht');
});
document.addEventListener('dragleave', e => {
const b = bereich();
if(b && (!e.relatedTarget || !b.contains(e.relatedTarget))) b.classList.remove('notizen-zieht');
});
document.addEventListener('drop', e => {
const b = bereich();
if(b) b.classList.remove('notizen-zieht');
if(!imNotizenBereich() || !kannSchreiben() || !b.contains(e.target)) return;
// In das Textfeld gezogen: normal einfügen lassen, keine neue Notiz.
if(e.target.closest && e.target.closest('#notizenEditor [contenteditable="true"], #notizTitel')) return;
e.preventDefault();
ausDaten(e.dataTransfer);
});
document.addEventListener('paste', e => {
if(!imNotizenBereich() || !kannSchreiben()) return;
const ziel = e.target;
if(ziel && ziel.closest && ziel.closest('input, textarea, select, [contenteditable="true"], .modal-bg')) return;
if(!e.clipboardData) return;
e.preventDefault();
ausDaten(e.clipboardData);
});
// Beim Verlassen der Seite noch speichern, was offen ist.
window.addEventListener('pagehide', () => { if(ungespeichert) jetztSpeichern(); });
})();