- 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>
294 lines
12 KiB
JavaScript
294 lines
12 KiB
JavaScript
// 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';
|
||
}
|
||
})();
|