Files
projektuebersicht/public/js/plan/plan-mail-eintrag.js
T
erikandClaude Opus 5.5 4c2e04e78b Kleine Verbesserungen und Teams, Outlook Integration
- Outlook: Kalender-Abo je Konto (eigene Aufgaben, Meilensteine,
  Abwesenheiten als .ics), einzurichten unter Einstellungen
- Teams: Nachricht in einen Kanal bei neuen Aufgaben, mit Link direkt
  auf die Aufgabe; einzurichten unter Admin → Teams
- Sitzungen nach Bedarf laden: beim Start nur die aktuelle und drei
  davor, im Verlauf „Weitere laden“ in Fünferschritten
- Termin aus Mail: Wahl Aufgabe/Meilenstein, Notiz auch bei
  Meilensteinen, nur aktiv mit eingerichteter KI
- Kalender zeigt die KW, auch statt der Browser-Datumsfelder
- Pfeile bei den Abwesenheiten, kleinere Server-Korrekturen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:23:02 +01:00

327 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
// Einträge.
//
// Nichts wird ohne Prüfung angelegt: Die Vorschläge stehen erst in einer
// Liste — je Zeile abhaken und „Aufgabe | Meilenstein“ wählen (Eriks
// Wunsch; die KI schlägt die Art vor) —, dann öffnet sich je Eintrag das
// gewohnte Panel vorausgefüllt (openTodoModal/openMilestoneModal mit
// prefill), die Mail steht als Notiz darin (t.notiz bzw. m.notiz).
// 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;
// kiAnbieter: die Anbieter mit hinterlegtem Schlüssel. Ohne eingerichtete
// KI ist die Funktion ganz aus (Eriks Wunsch) — keine Ablagefläche, kein
// Abfangen von Strg+V. Geladen nach der Anmeldung und nach jeder Änderung
// unter Einstellungen → KI-Zugangsdaten (mailKiStatusLaden()).
let kiAnbieter = [];
window.mailKiStatusLaden = function(){
return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => {
kiAnbieter = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured);
}).catch(() => { kiAnbieter = []; });
};
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 kiAnbieter.length > 0 && 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"></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(){
let zuletzt = '';
try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
return Promise.resolve(kiAnbieter.includes(zuletzt) ? zuletzt : (kiAnbieter[0] || ''));
}
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;
}
// Auch bei nur einem Vorschlag erst die Liste — dort wird gewählt, ob es
// eine Aufgabe oder ein Meilenstein wird.
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;
if(e.art === 'meilenstein'){
openMilestoneModal(null, {
name: e.titel,
date: e.ende || e.start,
notiz: notizText(d.mail || {}, e),
mailHinweis: (gesamt > 1 ? `Vorschlag ${nr} von ${gesamt}. ` : '') + hinweisText(d),
nachSchliessen: naechster
});
return;
}
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;
};
const arten = eintraege.map(e => e.art === 'meilenstein' ? 'meilenstein' : 'aufgabe');
bg.innerHTML = `<div class="modal mail-auswahl" role="dialog" aria-label="Einträge aus der Mail">
<h2>${eintraege.length === 1 ? '1 Eintrag' : eintraege.length + ' Einträge'} in der Mail</h2>
<p class="hint">${esc(hinweisText(d))} Wähle je Eintrag, ob er eine Aufgabe oder ein Meilenstein wird. Die ausgewählten öffnen sich danach nacheinander zum Prüfen.</p>
<div class="mail-auswahl-liste">${eintraege.map((e, i) => `
<div class="mail-auswahl-zeile">
<input type="checkbox" checked data-i="${i}" aria-label="${esc(e.titel)} übernehmen">
<span class="mail-auswahl-text"><span class="mail-auswahl-titel">${esc(e.titel)}</span>
<span class="mail-auswahl-datum">${esc(zeitraum(e))}</span></span>
<span class="segmented mail-auswahl-art" role="group" aria-label="Art" data-i="${i}">
<button type="button" class="mail-art-knopf" data-art="aufgabe" aria-pressed="${arten[i] === 'aufgabe'}">Aufgabe</button>
<button type="button" class="mail-art-knopf" data-art="meilenstein" aria-pressed="${arten[i] === 'meilenstein'}">Meilenstein</button>
</span>
</div>`).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.querySelectorAll('.mail-auswahl-art button').forEach(b => {
b.onclick = () => {
const i = Number(b.parentElement.dataset.i);
arten[i] = b.dataset.art;
b.parentElement.querySelectorAll('button').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
};
});
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 => { const i = Number(cb.dataset.i); return Object.assign({}, eintraege[i], { art: arten[i] }); });
bg.style.display = 'none';
if(gewaehlt.length) nacheinanderOeffnen(d, gewaehlt);
};
bg.style.display = 'flex';
}
})();