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:
1 parent
f4fb3c550b
commit
b7e0bd0d92
56 files changed
+12265
-2974
No files matched your search
@@ -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';
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user