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>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-30 08:23:02 +01:00
1 parent a2266662ad
commit 4c2e04e78b
19 files changed
+1100 -139

No files matched your search

+9 -16
View File
@@ -132,18 +132,17 @@
return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2];
}
const PLAN_VERLAUF_KURZ = 3;
let planVerlaufAlle = false;
let planVerlaufProjekt = null;
function renderPlanKommentarVerlauf(verlauf){
const el = document.getElementById('planStatusVerlauf');
if(!el) return;
const uid = app.project && (app.project.projectUid || app.project.id);
if(uid !== planVerlaufProjekt){ planVerlaufProjekt = uid; planVerlaufAlle = false; }
if(!verlauf.length){ el.hidden = true; el.innerHTML = ''; return; }
// Gezeigt wird, was geladen ist — beim Start die drei Sitzungen vor der
// aktuellen; „Weitere laden“ holt je fünf ältere (weitereLadenKnopf(),
// js/store.js). Die Sandbox hat keine früheren Fassungen.
const weitere = app.isSandboxPlan() ? '' : weitereLadenKnopf();
if(!verlauf.length && !weitere){ el.hidden = true; el.innerHTML = ''; return; }
el.hidden = false;
const zeigen = planVerlaufAlle ? verlauf : verlauf.slice(0, PLAN_VERLAUF_KURZ);
const zeigen = verlauf;
const zeilen = zeigen.map((e, i) => `
<li class="plan-verlauf-eintrag${i === 0 ? ' ist-juengster' : ''}">
<span class="plan-verlauf-kw">${esc(kurzKw(e.week.label))}</span>
@@ -153,21 +152,15 @@
<div class="plan-verlauf-meta">${esc(e.gewerk)}${e.autor ? ' · ' + esc(e.autor) : ''}</div>
</div>
</li>`).join('');
const mehr = verlauf.length > PLAN_VERLAUF_KURZ
? `<button type="button" class="plan-verlauf-mehr" onclick="planVerlaufUmschalten()">${planVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + verlauf.length + ' anzeigen'}</button>`
: '';
const mehr = weitere ? `<div class="plan-verlauf-mehr-zeile">${weitere}</div>` : '';
el.innerHTML = `
<div class="plan-verlauf-kopf">
<span class="plan-verlauf-titel">Verlauf</span>
<span class="plan-verlauf-anzahl">${verlauf.length === 1 ? '1 früherer Kommentar' : verlauf.length + ' frühere Kommentare'}</span>
<span class="plan-verlauf-anzahl">${verlauf.length === 0 ? 'in den geladenen Sitzungen kein früherer Kommentar' : verlauf.length === 1 ? '1 früherer Kommentar' : verlauf.length + ' frühere Kommentare'}</span>
</div>
<ol class="plan-verlauf-liste">${zeilen}</ol>${mehr}`;
${zeilen ? `<ol class="plan-verlauf-liste">${zeilen}</ol>` : ''}${mehr}`;
}
window.planVerlaufUmschalten = function(){
planVerlaufAlle = !planVerlaufAlle;
window.renderPlanInfo();
};
window.renderPlanInfo = function(){
+56 -23
View File
@@ -1,21 +1,31 @@
// 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.
// Einträge.
//
// 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).
// 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 '';
@@ -24,7 +34,7 @@
}
function ablageErlaubt(){
return ZIEL_ABSCHNITTE.includes(aktiverAbschnitt()) && app.project && !app.isLocked();
return kiAnbieter.length > 0 && ZIEL_ABSCHNITTE.includes(aktiverAbschnitt()) && app.project && !app.isLocked();
}
function bringtMail(dt){
@@ -44,7 +54,7 @@
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>`;
<span id="mailAblageText"></span></div>`;
document.body.appendChild(el);
}
return el;
@@ -139,12 +149,9 @@
// ---------- 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(() => '');
let zuletzt = '';
try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
return Promise.resolve(kiAnbieter.includes(zuletzt) ? zuletzt : (kiAnbieter[0] || ''));
}
function projektPersonen(){
@@ -229,7 +236,8 @@
app.showToast('In der Mail steht nichts für den Terminplan.');
return;
}
if(eintraege.length === 1){ nacheinanderOeffnen(d, eintraege); return; }
// Auch bei nur einem Vorschlag erst die Liste — dort wird gewählt, ob es
// eine Aufgabe oder ein Meilenstein wird.
auswahlZeigen(d, eintraege);
}
@@ -240,6 +248,16 @@
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,
@@ -271,20 +289,35 @@
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} Einträge in der Mail</h2>
<p class="hint">${esc(hinweisText(d))} Die ausgewählten öffnen sich nacheinander im Aufgaben-Panel.</p>
<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) => `
<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="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 => eintraege[Number(cb.dataset.i)]);
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);
};
+25 -4
View File
@@ -33,8 +33,15 @@
// (Index des Meilensteins oder null für einen neuen). Befüllt Name, Datum,
// Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und
// TODOs) aus dem Plan.
window.openMilestoneModal = function(index){
// msNachSchliessen: wie todoNachSchliessen — "Termin aus Mail" öffnet
// damit nach dem Schließen den nächsten Vorschlag.
let msNachSchliessen = null;
// openMilestoneModal: Parameter index (null = neu), prefill (optional, nur
// bei neu: name, date, notiz, mailHinweis, nachSchliessen).
window.openMilestoneModal = function(index, prefill){
if(app.isLocked()) return;
const pre = prefill || {};
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = [];
// Migration alter Meilensteine
@@ -43,10 +50,18 @@
const isNew = index === null || plan.milestones[index] === undefined;
const currentMs = isNew ? null : plan.milestones[index];
document.getElementById('milestoneEditName').value = isNew ? '' : (currentMs.name || '');
document.getElementById('milestoneEditName').value = isNew ? (pre.name || '') : (currentMs.name || '');
const msNotiz = document.getElementById('milestoneEditNotiz');
if(msNotiz) msNotiz.value = isNew ? (pre.notiz || '') : (currentMs.notiz || '');
const msMailHinweis = document.getElementById('milestoneEditMailHinweis');
if(msMailHinweis){
msMailHinweis.hidden = !(isNew && pre.mailHinweis);
msMailHinweis.textContent = (isNew && pre.mailHinweis) || '';
}
msNachSchliessen = isNew && typeof pre.nachSchliessen === 'function' ? pre.nachSchliessen : null;
// Verstecktes ISO-Feld + sichtbares Anzeigefeld (deutsches Format) —
// dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite.
const msDateIso = isNew ? '' : (currentMs.date || '');
const msDateIso = isNew ? (pre.date || '') : (currentMs.date || '');
document.getElementById('milestoneEditDate').value = msDateIso;
const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
@@ -152,6 +167,9 @@
window.closeMilestoneModal = function(){
document.getElementById('milestoneModal').style.display = 'none';
app.currentMilestoneIndex = null;
const danach = msNachSchliessen;
msNachSchliessen = null;
if(danach) setTimeout(danach, 0);
};
// saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position,
@@ -169,18 +187,21 @@
const positionSelect = document.getElementById('milestoneEditPosition');
const position = positionSelect ? positionSelect.value : '';
const msNotizEl = document.getElementById('milestoneEditNotiz');
const notiz = msNotizEl ? msNotizEl.value.trim() : '';
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = [];
if(app.currentMilestoneIndex === null){
plan.milestones.push({ id: uid(), name, date, dependsOn, position });
plan.milestones.push({ id: uid(), name, date, dependsOn, position, notiz });
} else if(plan.milestones[app.currentMilestoneIndex]){
const m = plan.milestones[app.currentMilestoneIndex];
m.name = name;
m.date = date;
m.dependsOn = dependsOn;
m.position = position;
m.notiz = notiz;
}
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
app.savePlan();
+42
View File
@@ -99,8 +99,21 @@ window.PlanApp = {
// showTab('plan') gerufen (und von dort aus auch nach jedem erneuten
// Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt
// gefunden und gerendert wurde.
// startAufgabe: Link mit ?aufgabe=<id> (z. B. aus einer Teams-Nachricht):
// diese Aufgabe nach dem Öffnen des Projekts gleich im Aufgaben-Panel
// zeigen — einmalig, beim ersten passenden Öffnen.
let startAufgabe = '';
try { startAufgabe = new URLSearchParams(window.location.search).get('aufgabe') || ''; } catch(e){ /* egal */ }
window.openPlanView = function(){
app.showPlanNav();
// Ein Aufgaben-Link stammt aus der Sitzung, in der zugewiesen wurde. Ist
// inzwischen eine neuere da, dort öffnen — Aufgaben behalten über den
// Sitzungswechsel ihre id (ensureCurrentWeekRollover()).
if(startAufgabe && app.weekLabel !== SANDBOX_WEEK_LABEL && Array.isArray(db.wochenListe) && db.wochenListe.length){
const juengste = db.wochenListe[db.wochenListe.length - 1].label;
if(juengste && juengste !== app.weekLabel) app.weekLabel = juengste;
}
// Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte
// "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js)
@@ -143,6 +156,19 @@ window.PlanApp = {
}
app.week = db.weeks.find(w => w.label === app.weekLabel) || null;
if(!app.week){
// Eine ältere Sitzung, die beim Start nicht mitgeladen wurde (nur die
// jüngsten, js/store.js): einzeln nachholen und dann neu öffnen.
const bekannt = (db.wochenListe || []).some(w => w.label === app.weekLabel);
if(bekannt && typeof sitzungSicherLaden === 'function'){
const t = document.getElementById('planSubtitle');
if(t) t.textContent = 'Sitzung ' + app.weekLabel + ' wird geladen…';
const label = app.weekLabel;
sitzungSicherLaden(label).then(w => {
if(w && app.weekLabel === label && currentTab === 'plan') showTab('plan');
else if(!w) planFail('Nicht gefunden', 'Sitzung "' + label + '" konnte nicht geladen werden.');
}).catch(() => planFail('Nicht gefunden', 'Sitzung "' + label + '" konnte nicht geladen werden.'));
return false;
}
planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.');
return false;
}
@@ -286,6 +312,22 @@ window.PlanApp = {
// js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle
// Bereiche befüllt sind, wird einer davon eingeblendet.
app.initPlanSections();
if(startAufgabe){
const id = startAufgabe;
startAufgabe = '';
// Adresszeile auf die tatsächlich geöffnete Sitzung bringen (der Link
// nannte evtl. eine ältere, siehe openPlanView()).
if(typeof updateRouteUrl === 'function') updateRouteUrl('plan');
const idx = (plan.todos || []).findIndex(t => String(t.id) === id);
if(idx < 0){ showToast('Die Aufgabe aus dem Link gibt es in diesem Projekt nicht mehr.'); return; }
if(location.hash !== '#aufgaben') showPlanSection('aufgaben');
// Panel erst nach dem Zeichnen öffnen (das Gantt misst seine Breite).
setTimeout(() => {
if(isLocked()){ showToast('Aufgabe: ' + (plan.todos[idx].title || '') + ' — diese Sitzung ist gesperrt.'); return; }
openTodoModal(idx);
}, 50);
}
}
// Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine)