Neues Design „Bauplan“, Dashboard, Notizen-Bereich, Seitenleiste

- Gestaltung „Bauplan“: ruhigere Farben, Schriftskala, Abstände im
  4-px-Raster, Kalenderwochen und Nummern in Mono-Ziffern, Glas beruhigt
- Dashboard „Meine Aufgaben“: Kopf mit Kalenderwoche, Band „Diese Woche“
  (Mo–Fr), je Projekt Meine Rolle / Team / Notizen; offene Termine blau
  hervorgehoben; Monatskalender unverändert
- Neuer Bereich „Notizen“ in der Seitenleiste: Notizen aller eigenen
  Projekte und eigene Notizen ohne Projekt, später einem Projekt zuordenbar
- Seitenleiste neu gegliedert (Projekte, Team, Notizen, Studenten,
  Einstellungen/Admin unten), Admin-Punkte in Personen/Vorgaben/System
- Reiter im Projektkopf wie die Seitenleiste hervorgehoben
- Behoben: „&…“ in Kommentaren wuchs bei jedem Speichern;
  bestehende Kommentare werden beim Start repariert
- Behoben: Lösch-Rückfrage bei Notizen unlesbar bzw. blieb stehen,
  Cursor im Notiztitel verdeckt, Dropdown im Dunkelmodus gekachelt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-11-02 15:07:32 +01:00
1 parent e697057b86
commit e291ca8438
18 files changed
+2185 -1250

No files matched your search

+133 -10
View File
@@ -1020,6 +1020,30 @@ function htmlEscape(s){
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// textMaskieren: wie htmlEscape(), aber ein '&', das schon eine fertige
// Entity beginnt (&amp; &nbsp; &#39; …), bleibt stehen. Der Text zwischen den
// Tags kommt aus gespeichertem HTML, in dem solche Entities ganz normal
// stehen. htmlEscape() machte daraus bei JEDEM Speichern eine Stufe mehr
// (&nbsp; → &amp;nbsp; → &amp;amp;nbsp; …) — Eriks Meldung nach dem
// Sitzungswechsel KW 45. So bleibt die Bereinigung beliebig oft wiederholbar.
function textMaskieren(s){
return String(s)
.replace(/&(?!(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});)/g, '&amp;')
.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// entitiesEntstapeln: macht gestapelte Maskierung rückgängig
// (&amp;amp;nbsp; → &nbsp;, &amp;amp; → &amp;). Ein „&amp;“ vor einer
// weiteren Entity ist immer das Ergebnis des Fehlers oben, nie Absicht.
function entitiesEntstapeln(html){
let alt, neu = String(html || '');
do {
alt = neu;
neu = neu.replace(/&amp;((?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});)/g, '&$1');
} while(neu !== alt);
return neu;
}
// sicheresStyleAttribut: lässt aus einem style-Attribut nur color und
// font-weight durch — identisch zur Client-Regel. Rückgabe: bereinigter
// Wert oder '' (dann wird das Attribut ganz weggelassen).
@@ -1108,7 +1132,7 @@ function sanitizeFormatHtml(html){
if(c !== '<'){
const naechstes = quelle.indexOf('<', i);
const text = quelle.slice(i, naechstes === -1 ? quelle.length : naechstes);
if(!verwerfenBis) aus += htmlEscape(text);
if(!verwerfenBis) aus += textMaskieren(text);
i += text.length;
continue;
}
@@ -2611,6 +2635,30 @@ function kommentareVorsatzUmstellen(){
if(n) console.log('Gewerke-Vorsatz aus ' + n + ' Kommentaren entfernt.');
}
// entitiesReparieren: führt gestapelte Maskierung (&amp;amp;nbsp; …) in
// allen Sitzungskommentaren und Protokoll-Punkten auf das ursprüngliche
// Zeichen zurück — Folge des Fehlers in sanitizeFormatHtml() vor
// textMaskieren(). Läuft bei jedem Start und nach /import, idempotent.
function entitiesReparieren(){
const teile = [
['project_kommentare', 'status', 'week_label, project_id, gewerk', 'week_label = ? AND project_id = ? AND gewerk = ?', r => [r.week_label, r.project_id, r.gewerk]],
['protokoll_punkte', 'text', 'id', 'id = ?', r => [r.id]]
];
let n = 0;
db.exec('BEGIN');
try {
teile.forEach(([tab, feld, schluessel, wo, werte]) => {
const upd = db.prepare('UPDATE ' + tab + ' SET ' + feld + ' = ? WHERE ' + wo);
db.prepare('SELECT ' + schluessel + ', ' + feld + " AS wert FROM " + tab + " WHERE " + feld + " LIKE '%&amp;%'").all().forEach(r => {
const neu = entitiesEntstapeln(r.wert);
if(neu !== r.wert){ upd.run(neu, ...werte(r)); n++; }
});
});
db.exec('COMMIT');
} catch(e){ db.exec('ROLLBACK'); throw e; }
if(n) console.log('Doppelt maskierte Zeichen in ' + n + ' Kommentaren/Protokoll-Punkten repariert.');
}
function kommentareSpiegelstricheUmstellen(){
const zeilen = db.prepare("SELECT week_label, project_id, gewerk, status FROM project_kommentare WHERE status != ''").all();
const upd = db.prepare('UPDATE project_kommentare SET status = ? WHERE week_label = ? AND project_id = ? AND gewerk = ?');
@@ -3558,6 +3606,7 @@ einmaligeUmstellung('siemon-anteile-2021', siemon2021Umstellen);
// Idempotent (ein bereits entfernter Vorsatz ist weg) — läuft deshalb bei
// jedem Start und nach /import, damit ältere Backups ihn nicht zurückbringen.
kommentareVorsatzUmstellen();
entitiesReparieren();
ensureSandboxWeek();
ensureUserPersonIdUniqueIndex();
migrateRetirePeopleTablesIntoUsers();
@@ -7362,6 +7411,68 @@ function notizAnlegen(req, uid, titel, html, quelle){
return notizLesen(id);
}
// GET /projekt-notizen/anzahl — je Projekt (project_uid) Anzahl und zuletzt
// geändert, für die Projektblöcke auf „Meine Aufgaben“. Nur Zählung, kein Inhalt.
app.get('/projekt-notizen/anzahl', requireAuth, (req, res) => {
try {
const anzahl = {};
db.prepare("SELECT project_uid AS uid, COUNT(*) AS n, MAX(geaendert_am) AS zuletzt FROM projekt_notizen WHERE project_uid != '' GROUP BY project_uid")
.all().forEach(r => { anzahl[r.uid] = { anzahl: r.n, zuletzt: r.zuletzt || '' }; });
res.json({ ok: true, anzahl });
} catch(err){
console.error('Notizen zählen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Seitenleiste „Notizen“: alle Notizen meiner Projekte ----------
//
// Eriks Wunsch: ein eigener Bereich, in dem jedes Konto die Notizen ALLER
// seiner Projekte sieht und auch projektunabhängige Notizen anlegt, die sich
// später einem Projekt zuordnen lassen. Eine projektunabhängige Notiz ist eine
// Zeile mit project_uid = '' und gehört allein ihrem Verfasser (sonst sieht
// und ändert sie niemand). „Meine Projekte“ = in der jüngsten echten Sitzung
// zugeordnet (assign > 0) oder mit eigener Aufgabe — dieselbe Regel wie das
// Dashboard (dashDaten()).
function meineProjektUids(user){
const ich = user && user.personId;
const uids = new Set();
if(!ich) return uids;
const label = echteSitzungenSortiert().map(w => w.label).pop();
const w = label ? loadWeeks(label)[0] : null;
((w && w.projects) || []).forEach(p => {
const zugeordnet = Object.keys(p.assign || {}).some(k => k.split('|')[0] === ich && Number(p.assign[k]) > 0);
const eigene = ((p.plan && p.plan.todos) || []).some(t => Array.isArray(t.assignees) && t.assignees.includes(ich));
if((zugeordnet || eigene) && p.projectUid) uids.add(String(p.projectUid));
});
return uids;
}
const NOTIZ_SELECT = `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`;
// GET /meine-notizen — {notizen, meineProjekte:[uid]}: die Notizen meiner
// Projekte plus meine projektunabhängigen.
app.get('/meine-notizen', requireAuth, (req, res) => {
try {
const uids = Array.from(meineProjektUids(req.user));
const rows = db.prepare(NOTIZ_SELECT + ` WHERE (n.project_uid = '' AND n.erstellt_von = ?)`
+ (uids.length ? ' OR n.project_uid IN (' + uids.map(() => '?').join(',') + ')' : '')
+ ' ORDER BY n.angeheftet DESC, n.geaendert_am DESC').all(req.user.id, ...uids);
res.json({ ok: true, notizen: rows.map(notizZeile), meineProjekte: uids });
} catch(err){
console.error('Meine Notizen laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// notizDarf: Schreibrecht auf eine Notiz bzw. ein Ziel. Projektunabhängig
// (uid '') nur der Verfasser; an einem Projekt das Recht proj.plan.
function notizDarf(user, uid, erstelltVonId){
if(!uid) return erstelltVonId === undefined || erstelltVonId === user.id;
return hatRecht(user, 'proj.plan');
}
app.get('/projekt-notizen', requireAuth, (req, res) => {
try {
const uid = String(req.query.uid || '');
@@ -7377,11 +7488,11 @@ app.get('/projekt-notizen', requireAuth, (req, res) => {
}
});
app.post('/projekt-notizen', requireAuth, requireRecht('proj.plan'), (req, res) => {
app.post('/projekt-notizen', requireAuth, (req, res) => {
try {
const b = req.body || {};
const uid = String(b.uid || '');
if(!uid) return res.status(400).json({ ok: false, error: 'uid fehlt.' });
if(!notizDarf(req.user, uid)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen in Projekten anzulegen.' });
res.json({ ok: true, notiz: notizAnlegen(req, uid, b.titel, b.html, '') });
} catch(err){
console.error('Notiz anlegen fehlgeschlagen:', err);
@@ -7392,11 +7503,20 @@ app.post('/projekt-notizen', requireAuth, requireRecht('proj.plan'), (req, res)
// 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) => {
// uid (optional): einem Projekt zuordnen bzw. mit '' wieder projektunabhängig
// machen (das nur der Verfasser) — aus der Seitenleiste „Notizen“.
app.put('/projekt-notizen/:id', requireAuth, (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.' });
if(!alt.uid && alt.erstelltVonId !== req.user.id) return res.status(404).json({ ok: false, error: 'Die Notiz gibt es nicht mehr.' });
if(!notizDarf(req.user, alt.uid, alt.erstelltVonId)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen zu ändern.' });
const b = req.body || {};
const neueUid = typeof b.uid === 'string' ? b.uid : alt.uid;
if(neueUid !== alt.uid){
if(!neueUid && alt.erstelltVonId !== req.user.id) return res.status(403).json({ ok: false, error: 'Nur wer die Notiz angelegt hat, kann sie vom Projekt lösen.' });
if(!notizDarf(req.user, neueUid, alt.erstelltVonId)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen in Projekten anzulegen.' });
}
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.' });
@@ -7408,10 +7528,11 @@ app.put('/projekt-notizen/:id', requireAuth, requireRecht('proj.plan'), (req, re
// 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 = ?
db.prepare(`UPDATE projekt_notizen SET titel = ?, html = ?, angeheftet = ?, geaendert_am = ?, project_uid = ?
${nurPin ? '' : ', geaendert_von = ' + Number(req.user.id)} WHERE id = ?`)
.run(titel, html, angeheftet, jetzt, alt.id);
.run(titel, html, angeheftet, jetzt, neueUid, alt.id);
ereignisSenden({ typ: 'notizen', uid: alt.uid }, req);
if(neueUid !== alt.uid) ereignisSenden({ typ: 'notizen', uid: neueUid }, req);
res.json({ ok: true, notiz: notizLesen(alt.id) });
} catch(err){
console.error('Notiz speichern fehlgeschlagen:', err);
@@ -7419,10 +7540,11 @@ app.put('/projekt-notizen/:id', requireAuth, requireRecht('proj.plan'), (req, re
}
});
app.delete('/projekt-notizen/:id', requireAuth, requireRecht('proj.plan'), (req, res) => {
app.delete('/projekt-notizen/:id', requireAuth, (req, res) => {
try {
const alt = notizLesen(req.params.id);
if(!alt) return res.json({ ok: true });
if(!notizDarf(req.user, alt.uid, alt.erstelltVonId)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen zu löschen.' });
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.' });
}
@@ -7438,11 +7560,11 @@ app.delete('/projekt-notizen/:id', requireAuth, requireRecht('proj.plan'), (req,
// 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) => {
app.post('/projekt-notizen/aus-mail', requireAuth, (req, res) => {
try {
const b = req.body || {};
const uid = String(b.uid || '');
if(!uid) return res.status(400).json({ ok: false, error: 'uid fehlt.' });
if(!notizDarf(req.user, uid)) return res.status(403).json({ ok: false, recht: 'proj.plan', error: 'Keine Berechtigung, Notizen in Projekten anzulegen.' });
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);
@@ -9262,6 +9384,7 @@ app.post('/import', requireRecht('a.import'), express.raw({ type: '*/*', limit:
// Ältere Backups tragen Kommentare noch gewerkübergreifend in projects.
migrateKommentareJeGewerk();
kommentareVorsatzUmstellen();
entitiesReparieren();
// Ältere Backups kennen das Feld leistungsphase noch nicht.
phasenbalkenAusTitel();
console.log('Datenbank aus Backup wiederhergestellt (' + buf.length + ' Bytes)');
@@ -9292,7 +9415,7 @@ app.get('/', (req,res)=>{
// Projektliste aufgegangen ist: Weitergegebene Links sollen nicht mit 404
// enden. Die Oberfläche leitet den Abschnitt auf die Projektliste um
// (TAB_BY_SLUG in public/js/globals.js) und erzeugt ihn selbst nicht mehr.
const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Abwesenheiten','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']);
const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Abwesenheiten','Notizen','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']);
// Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen)
const PLAN_SLUG = 'Projektplan';