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

+162 -8
View File
@@ -1670,11 +1670,11 @@ const IMPORT_TABLES = ['weeks','week_people','week_people_roles','attendance','p
'project_plan','plan_people','plan_todos','plan_todo_assignees','plan_todo_deps',
'plan_milestones','plan_milestone_deps',
'project_protokolle','protokoll_teilnehmer','protokoll_punkte',
'project_kommentare'];
'project_kommentare', 'projekt_notizen'];
// IMPORT_OPTIONAL_TABLES: Tabellen, die ältere Backups noch nicht kennen.
// Fehlen sie, bleiben sie nach dem Import leer; migrateKommentareJeGewerk()
// verteilt die alten Kommentare danach aus projects.status.
const IMPORT_OPTIONAL_TABLES = ['project_kommentare'];
const IMPORT_OPTIONAL_TABLES = ['project_kommentare', 'projekt_notizen'];
// dbTableColumns: liefert die Spaltennamen einer Tabelle (funktioniert gegen
// jede DatabaseSync-Instanz, auch read-only Backups) via pragma_table_info.
@@ -7084,12 +7084,17 @@ function mailTeilLesen(roh, tiefe){
if(/attachment/i.test(kopf['content-disposition'] || '')) return { plain: '', html: '' };
if(!typ.startsWith('text/')) return { plain: '', html: '' };
const enc = String(kopf['content-transfer-encoding'] || '').toLowerCase();
let buf;
if(enc === 'base64') buf = Buffer.from(rumpf.replace(/\s+/g, ''), 'base64');
else if(enc === 'quoted-printable'){
buf = Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
} else buf = Buffer.from(rumpf, 'latin1');
const text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8');
let text;
if(enc === 'base64' || enc === 'quoted-printable'){
const buf = enc === 'base64'
? Buffer.from(rumpf.replace(/\s+/g, ''), 'base64')
: Buffer.from(rumpf.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
text = mailZeichensatz(buf, mailKopfParam(kopf['content-type'], 'charset') || 'utf-8');
} else {
// 7bit/8bit: Der Browser hat die Datei bereits als Text gelesen (UTF-8) —
// ein Umweg über Bytes zerstörte hier die Umlaute.
text = rumpf;
}
return typ.startsWith('text/html') ? { plain: '', html: text } : { plain: text, html: '' };
}
@@ -7305,6 +7310,155 @@ app.post('/plan-eintrag-aus-mail', requireAuth, requireRecht('proj.plan'), async
}
});
// ---------- Projekt-Notizen (Reiter „Notizen“, js/plan/plan-notizen.js) ----------
//
// Eriks Wunsch (Entwurf A „Liste + Editor“): gemeinsame Notizen je Projekt,
// formatiert wie der Sitzungskommentar, anheftbar, Mail per Drag & Drop.
// Die Notizen hängen am PROJEKT (project_uid), nicht an einer Sitzung —
// sie überstehen den Wochenwechsel, ohne kopiert zu werden, und gelten auch
// für Planung-Terminpläne (Sandbox). Gespeichert wird je Notiz, nicht über
// /save-week. Lesen: jedes Konto mit Grundzugang; Schreiben: Recht
// proj.plan; Löschen: Verfasser oder Administrator.
db.exec(`CREATE TABLE IF NOT EXISTS projekt_notizen (
id TEXT PRIMARY KEY,
project_uid TEXT NOT NULL,
titel TEXT NOT NULL DEFAULT '',
html TEXT NOT NULL DEFAULT '',
angeheftet INTEGER NOT NULL DEFAULT 0,
quelle TEXT NOT NULL DEFAULT '',
erstellt_von INTEGER NOT NULL DEFAULT 0,
erstellt_am TEXT NOT NULL DEFAULT '',
geaendert_von INTEGER NOT NULL DEFAULT 0,
geaendert_am TEXT NOT NULL DEFAULT ''
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_projekt_notizen_uid ON projekt_notizen(project_uid)');
const NOTIZ_MAX_HTML = 200000;
function notizZeile(r){
return {
id: r.id, uid: r.project_uid, titel: r.titel, html: r.html, angeheftet: !!r.angeheftet, quelle: r.quelle,
erstelltVon: r.erstellt_name || '', erstelltVonId: r.erstellt_von, erstelltAm: r.erstellt_am,
geaendertVon: r.geaendert_name || '', geaendertAm: r.geaendert_am
};
}
function notizLesen(id){
const r = db.prepare(`SELECT n.*, COALESCE(u1.display_name, 'Gelöschter Benutzer') AS erstellt_name,
COALESCE(u2.display_name, 'Gelöschter Benutzer') AS geaendert_name
FROM projekt_notizen n LEFT JOIN users u1 ON u1.id = n.erstellt_von LEFT JOIN users u2 ON u2.id = n.geaendert_von
WHERE n.id = ?`).get(String(id));
return r ? notizZeile(r) : null;
}
function istAdministrator(user){ return !!(user && (rollenCache.get(user.role) || {}).fest); }
function notizAnlegen(req, uid, titel, html, quelle){
const id = crypto.randomUUID();
const jetzt = new Date().toISOString();
db.prepare(`INSERT INTO projekt_notizen (id, project_uid, titel, html, angeheftet, quelle, erstellt_von, erstellt_am, geaendert_von, geaendert_am)
VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`)
.run(id, uid, String(titel || '').slice(0, 300), String(html || '').slice(0, NOTIZ_MAX_HTML), quelle || '', req.user.id, jetzt, req.user.id, jetzt);
ereignisSenden({ typ: 'notizen', uid }, req);
return notizLesen(id);
}
app.get('/projekt-notizen', requireAuth, (req, res) => {
try {
const uid = String(req.query.uid || '');
if(!uid) return res.status(400).json({ ok: false, error: 'uid fehlt.' });
const rows = db.prepare(`SELECT n.*, COALESCE(u1.display_name, 'Gelöschter Benutzer') AS erstellt_name,
COALESCE(u2.display_name, 'Gelöschter Benutzer') AS geaendert_name
FROM projekt_notizen n LEFT JOIN users u1 ON u1.id = n.erstellt_von LEFT JOIN users u2 ON u2.id = n.geaendert_von
WHERE n.project_uid = ? ORDER BY n.angeheftet DESC, n.geaendert_am DESC`).all(uid);
res.json({ ok: true, notizen: rows.map(notizZeile) });
} catch(err){
console.error('Notizen laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
app.post('/projekt-notizen', requireAuth, requireRecht('proj.plan'), (req, res) => {
try {
const b = req.body || {};
const uid = String(b.uid || '');
if(!uid) return res.status(400).json({ ok: false, error: 'uid fehlt.' });
res.json({ ok: true, notiz: notizAnlegen(req, uid, b.titel, b.html, '') });
} catch(err){
console.error('Notiz anlegen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// PUT {titel?, html?, angeheftet?, basis?} — basis = geaendertAm des Stands,
// auf dem die Änderung beruht; weicht der gespeicherte ab, 409 mit dem
// aktuellen Stand (jemand anderes hat inzwischen gespeichert).
app.put('/projekt-notizen/:id', requireAuth, requireRecht('proj.plan'), (req, res) => {
try {
const alt = notizLesen(req.params.id);
if(!alt) return res.status(404).json({ ok: false, error: 'Die Notiz gibt es nicht mehr.' });
const b = req.body || {};
if(typeof b.basis === 'string' && b.basis && b.basis !== alt.geaendertAm){
return res.status(409).json({ ok: false, konflikt: true, notiz: alt,
error: alt.geaendertVon + ' hat diese Notiz inzwischen geändert.' });
}
const titel = typeof b.titel === 'string' ? b.titel.slice(0, 300) : alt.titel;
const html = typeof b.html === 'string' ? b.html.slice(0, NOTIZ_MAX_HTML) : alt.html;
const angeheftet = typeof b.angeheftet === 'boolean' ? (b.angeheftet ? 1 : 0) : (alt.angeheftet ? 1 : 0);
// Anheften allein ist keine inhaltliche Änderung: Zeitstempel und
// Bearbeiter bleiben, damit die Sortierung „zuletzt geändert“ stimmt.
const nurPin = typeof b.titel !== 'string' && typeof b.html !== 'string';
const jetzt = nurPin ? alt.geaendertAm : new Date().toISOString();
db.prepare(`UPDATE projekt_notizen SET titel = ?, html = ?, angeheftet = ?, geaendert_am = ?
${nurPin ? '' : ', geaendert_von = ' + Number(req.user.id)} WHERE id = ?`)
.run(titel, html, angeheftet, jetzt, alt.id);
ereignisSenden({ typ: 'notizen', uid: alt.uid }, req);
res.json({ ok: true, notiz: notizLesen(alt.id) });
} catch(err){
console.error('Notiz speichern fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
app.delete('/projekt-notizen/:id', requireAuth, requireRecht('proj.plan'), (req, res) => {
try {
const alt = notizLesen(req.params.id);
if(!alt) return res.json({ ok: true });
if(alt.erstelltVonId !== req.user.id && !istAdministrator(req.user)){
return res.status(403).json({ ok: false, error: 'Löschen darf nur, wer die Notiz angelegt hat, oder ein Administrator.' });
}
db.prepare('DELETE FROM projekt_notizen WHERE id = ?').run(alt.id);
ereignisSenden({ typ: 'notizen', uid: alt.uid }, req);
res.json({ ok: true });
} catch(err){
console.error('Notiz löschen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// POST /projekt-notizen/aus-mail {uid, text|eml} — Mail als neue Notiz, OHNE
// KI: Titel = Betreff (sonst erste Zeile), oben Absender und Datum, darunter
// der Text (zitierte Vorgänger-Mails abgeschnitten, wie bei „Termin aus Mail“).
app.post('/projekt-notizen/aus-mail', requireAuth, requireRecht('proj.plan'), (req, res) => {
try {
const b = req.body || {};
const uid = String(b.uid || '');
if(!uid) return res.status(400).json({ ok: false, error: 'uid fehlt.' });
const mail = (typeof b.eml === 'string' && b.eml.trim()) ? leseEml(b.eml)
: { betreff: '', absender: '', datum: '', text: String(b.text || '') };
const text = mailZitateAbschneiden(mail.text).slice(0, 50000);
if(!text && !mail.betreff) return res.status(400).json({ ok: false, error: 'In der Mail wurde kein Text gefunden.' });
const ersteZeile = (text.split('\n').map(z => z.trim()).find(Boolean) || 'Mail').slice(0, 120);
const escHtml = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const kopf = [mail.absender ? '<b>Von:</b> ' + escHtml(mail.absender) : '',
mail.datum ? '<b>Datum:</b> ' + escHtml(teamsDatum(mail.datum)) : ''].filter(Boolean).join('<br>');
const html = (kopf ? kopf + '<br><br>' : '') + escHtml(text).replace(/\n/g, '<br>');
res.json({ ok: true, notiz: notizAnlegen(req, uid, mail.betreff || ersteZeile, html, 'mail') });
} catch(err){
console.error('Notiz aus Mail fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Plan Checker (plan_checker_merkmale) ----------
//
// Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt