Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb

- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan
  oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues
  Notizfeld je Aufgabe (plan_todos.notiz)
- Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile,
  "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen
- Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang
- Glas-Darstellung, Logo als Favicon
- Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und
  Prüfskript inkl. WLAN-Wächter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-29 14:16:52 +01:00
1 parent f4fb3c550b
commit b7e0bd0d92
56 files changed
+12265 -2974

No files matched your search

+293
View File
@@ -0,0 +1,293 @@
// plan-mail-eintrag.js — "Termin aus Mail" (Eriks Wunsch): Mailtext per
// Drag & Drop (markierter Text oder eine ganze .eml-Datei) oder Strg+V auf
// den Terminplan bzw. die Aufgabenliste eines Projekts; eine KI macht daraus
// Aufgaben. Der Terminplan ist nur die grafische Darstellung der
// Aufgabenliste — deshalb entstehen hier ausschließlich Aufgaben
// (plan.todos), keine Meilensteine.
//
// Nichts wird ohne Prüfung angelegt: Jeder Vorschlag öffnet das gewohnte
// Aufgaben-Panel vorausgefüllt (openTodoModal(null, prefill)), die Mail
// steht als Notiz darin (t.notiz). Mehrere Vorschläge erst als Liste zum
// Abhaken, dann nacheinander im Panel. Server: POST /plan-eintrag-aus-mail
// (speichert nichts).
(function(){
const app = window.PlanApp;
const ANBIETER_KEY = 'projuebersicht-mail-ki';
const ZIEL_ABSCHNITTE = ['terminplan', 'aufgaben'];
let tiefe = 0; // dragenter/-leave-Zähler (Kindelemente feuern mit)
let laeuft = false;
function aktiverAbschnitt(){
if(typeof currentTab === 'undefined' || currentTab !== 'plan') return '';
const s = document.querySelector('.plan-section.is-active');
return s ? s.dataset.section : '';
}
function ablageErlaubt(){
return ZIEL_ABSCHNITTE.includes(aktiverAbschnitt()) && app.project && !app.isLocked();
}
function bringtMail(dt){
if(!dt) return false;
const typen = Array.from(dt.types || []);
return typen.includes('Files') || typen.includes('text/plain') || typen.includes('text/html');
}
// ---------- Ablagefläche ----------
function flaeche(){
let el = document.getElementById('mailAblage');
if(!el){
el = document.createElement('div');
el.id = 'mailAblage';
el.className = 'mail-ablage';
el.hidden = true;
el.innerHTML = `<div class="mail-ablage-karte">${icon('mail')}
<b id="mailAblageTitel">Mail hier ablegen</b>
<span id="mailAblageText">Die KI macht daraus einen Eintrag im Terminplan. Der Mailtext geht dafür an den KI-Anbieter aus Einstellungen → KI-Zugangsdaten.</span></div>`;
document.body.appendChild(el);
}
return el;
}
function zeigeFlaeche(titel, text){
const el = flaeche();
document.getElementById('mailAblageTitel').textContent = titel;
if(text !== undefined) document.getElementById('mailAblageText').textContent = text;
el.classList.toggle('ist-beschaeftigt', laeuft);
el.hidden = false;
}
function versteckeFlaeche(){
const el = document.getElementById('mailAblage');
if(el) el.hidden = true;
}
document.addEventListener('dragenter', e => {
if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)) return;
tiefe++;
zeigeFlaeche('Mail hier ablegen', 'Die KI macht daraus einen Eintrag im Terminplan. Der Mailtext geht dafür an den KI-Anbieter aus Einstellungen → KI-Zugangsdaten.');
});
document.addEventListener('dragover', e => {
if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
document.addEventListener('dragleave', () => {
if(tiefe === 0) return;
tiefe--;
if(tiefe === 0 && !laeuft) versteckeFlaeche();
});
document.addEventListener('drop', e => {
if(tiefe === 0 && !ablageErlaubt()) return;
tiefe = 0;
if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)){ if(!laeuft) versteckeFlaeche(); return; }
e.preventDefault();
ausDataTransfer(e.dataTransfer);
});
// Strg+V als Ausweg, wenn ein Mailprogramm das Ziehen nicht unterstützt —
// nur außerhalb von Eingabefeldern, damit normales Einfügen unberührt bleibt.
document.addEventListener('paste', e => {
if(laeuft || !ablageErlaubt()) return;
const ziel = e.target;
if(ziel && (ziel.closest('input, textarea, select, [contenteditable="true"], .modal-bg'))) return;
const dt = e.clipboardData;
if(!dt) return;
const datei = Array.from(dt.files || []).find(istEml);
const text = (dt.getData('text/plain') || '').trim();
if(!datei && !text) return;
e.preventDefault();
ausDataTransfer(dt);
});
function istEml(f){ return f && /\.eml$/i.test(f.name || '') || (f && f.type === 'message/rfc822'); }
function ausDataTransfer(dt){
const dateien = Array.from(dt.files || []);
const eml = dateien.find(istEml);
if(eml){
if(eml.size > 5 * 1024 * 1024){ fehler('Die Mail ist zu groß (mehr als 5 MB).'); return; }
eml.text().then(t => auswerten({ eml: t })).catch(() => fehler('Die Mail-Datei ließ sich nicht lesen.'));
return;
}
if(dateien.some(f => /\.msg$/i.test(f.name || ''))){
fehler('Outlook-Dateien (.msg) lassen sich nicht direkt lesen. Bitte den Mailtext markieren und hineinziehen oder mit Strg+V einfügen.');
return;
}
let text = (dt.getData('text/plain') || '').trim();
if(!text){
const html = dt.getData('text/html') || '';
const div = document.createElement('div');
div.innerHTML = html.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '');
text = (div.innerText || div.textContent || '').trim();
}
if(!text){
if(dateien.length) fehler('Diese Datei ist keine Mail. Abgelegt werden können markierter Mailtext oder .eml-Dateien.');
else versteckeFlaeche();
return;
}
auswerten({ text });
}
function fehler(meldung){
laeuft = false;
versteckeFlaeche();
app.showToast(meldung, 'error');
}
// ---------- Auswerten ----------
function anbieterWaehlen(){
return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => {
const bereit = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured);
let zuletzt = '';
try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
return bereit.includes(zuletzt) ? zuletzt : (bereit[0] || '');
}).catch(() => '');
}
function projektPersonen(){
const gesehen = new Set();
const liste = [];
projectAssignees(app.project, app.week).forEach(a => {
if(gesehen.has(a.person.id)) return;
gesehen.add(a.person.id);
liste.push(a.person);
});
return liste;
}
function kontext(){
const todos = (app.project.plan && app.project.plan.todos) || [];
return {
heute: heuteIso(),
projekt: [app.project.nr, app.project.name].filter(Boolean).join(' '),
personen: projektPersonen().map(p => p.fullName || p.name).filter(Boolean),
phasen: todos.filter(t => t.lph && t.start && t.end).map(t => ({ lph: t.lph, start: t.start, ende: t.end }))
};
}
function auswerten(nutzlast){
laeuft = true;
zeigeFlaeche('Mail wird ausgewertet …', 'Die KI liest die Mail. Das dauert einige Sekunden.');
anbieterWaehlen().then(anbieter => fetch(baseUrl() + '/plan-eintrag-aus-mail', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(Object.assign({ anbieter, kontext: kontext() }, nutzlast))
}))
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
laeuft = false;
versteckeFlaeche();
if(!ok || !d.ok){ app.showToast(d.error || 'Die Mail konnte nicht ausgewertet werden.', 'error'); return; }
if(d.erkennungsart === 'chatgpt' || d.erkennungsart === 'claude'){
try { localStorage.setItem(ANBIETER_KEY, d.erkennungsart); } catch(e){ /* egal */ }
}
verarbeiten(d);
})
.catch(() => fehler('Der Server ist nicht erreichbar.'));
}
// ---------- Vorschläge ins Panel ----------
// personenZuordnen: Namen aus der KI-Antwort → Personen-IDs des Projekts
// (voller Name, sonst Nachname, sonst Kürzel; Groß-/Kleinschreibung egal).
function personenZuordnen(namen){
const leute = projektPersonen();
const ids = [];
(namen || []).forEach(n => {
const s = String(n || '').trim().toLowerCase();
if(!s) return;
const treffer = leute.find(p => String(p.fullName || '').toLowerCase() === s)
|| leute.find(p => { const nn = String(p.fullName || '').toLowerCase().split(/\s+/).pop(); return nn && (s === nn || s.split(/\s+/).pop() === nn); })
|| leute.find(p => String(p.name || '').toLowerCase() === s);
if(treffer && !ids.includes(treffer.id)) ids.push(treffer.id);
});
return ids;
}
function notizText(mail, eintrag){
const kopf = [];
const datum = mail.datum ? formatDateDE(parseDateISO(mail.datum)) : '';
kopf.push('Aus Mail' + (mail.absender ? ' von ' + mail.absender : '') + (datum ? ' vom ' + datum : ''));
if(mail.betreff) kopf.push('Betreff: ' + mail.betreff);
if(eintrag.zitat) kopf.push('Bezug: „' + eintrag.zitat + '“');
return kopf.join('\n') + '\n\n' + String(mail.text || '').slice(0, 8000);
}
function hinweisText(d){
if(d.erkennungsart === 'chatgpt' || d.erkennungsart === 'claude'){
return 'Vorschlag der KI aus der Mail — bitte prüfen und speichern.';
}
return 'Die KI war nicht verfügbar (' + (d.erkennungshinweis || 'unbekannter Fehler') + '). Termine bitte selbst eintragen.';
}
function verarbeiten(d){
const eintraege = Array.isArray(d.eintraege) ? d.eintraege : [];
if(eintraege.length === 0){
app.showToast('In der Mail steht nichts für den Terminplan.');
return;
}
if(eintraege.length === 1){ nacheinanderOeffnen(d, eintraege); return; }
auswahlZeigen(d, eintraege);
}
function nacheinanderOeffnen(d, liste){
const rest = liste.slice();
const gesamt = rest.length;
const naechster = () => {
const e = rest.shift();
if(!e){ renderPlanGantt(); return; }
const nr = gesamt - rest.length;
openTodoModal(null, {
title: e.titel,
start: e.start,
end: e.ende,
assignees: personenZuordnen(e.zustaendig),
aufwand: e.aufwandStunden,
notiz: notizText(d.mail || {}, e),
mailHinweis: (gesamt > 1 ? `Vorschlag ${nr} von ${gesamt}. ` : '') + hinweisText(d),
nachSchliessen: naechster
});
};
naechster();
}
// ---------- Mehrere Vorschläge: Auswahl ----------
function auswahlZeigen(d, eintraege){
let bg = document.getElementById('mailAuswahlModal');
if(!bg){
bg = document.createElement('div');
bg.id = 'mailAuswahlModal';
bg.className = 'modal-bg';
bg.style.display = 'none';
bg.addEventListener('click', e => { if(e.target === bg) bg.style.display = 'none'; });
document.body.appendChild(bg);
}
const zeitraum = e => {
if(!e.start) return 'ohne Termin';
const a = formatDateDE(parseDateISO(e.start)), b = formatDateDE(parseDateISO(e.ende));
return a === b ? a : a + ' – ' + b;
};
bg.innerHTML = `<div class="modal mail-auswahl" role="dialog" aria-label="Einträge aus der Mail">
<h2>${eintraege.length} Einträge in der Mail</h2>
<p class="hint">${esc(hinweisText(d))} Die ausgewählten öffnen sich nacheinander im Aufgaben-Panel.</p>
<div class="mail-auswahl-liste">${eintraege.map((e, i) => `
<label class="mail-auswahl-zeile"><input type="checkbox" checked data-i="${i}">
<span class="mail-auswahl-titel">${esc(e.titel)}</span>
<span class="mail-auswahl-datum">${esc(zeitraum(e))}</span></label>`).join('')}</div>
<div class="modal-actions">
<button type="button" class="secondary" data-aktion="abbrechen">Abbrechen</button>
<button type="button" data-aktion="weiter">Prüfen und anlegen</button>
</div></div>`;
bg.querySelector('[data-aktion="abbrechen"]').onclick = () => { bg.style.display = 'none'; };
bg.querySelector('[data-aktion="weiter"]').onclick = () => {
const gewaehlt = Array.from(bg.querySelectorAll('input[type="checkbox"]:checked')).map(cb => eintraege[Number(cb.dataset.i)]);
bg.style.display = 'none';
if(gewaehlt.length) nacheinanderOeffnen(d, gewaehlt);
};
bg.style.display = 'flex';
}
})();