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:
1 parent
51dfbe8cd8
commit
7d65ff813f
10 files changed
+697
-265
No files matched your search
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user