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:
1 parent
a2266662ad
commit
4c2e04e78b
19 files changed
+1100
-139
No files matched your search
@@ -265,9 +265,9 @@ function abwTeamHtml(){
|
||||
<div class="section-title" style="margin:0">Team im ${esc(abwMonat.toLocaleDateString('de-DE', { month: 'long' }))}</div>
|
||||
<span class="abw-team-steuer">
|
||||
<span class="abw-monat">
|
||||
<button type="button" class="abw-rund" onclick="abwMonatWechseln(-1)" aria-label="Voriger Monat">‹</button>
|
||||
<button type="button" class="abw-rund" onclick="abwMonatWechseln(-1)" aria-label="Voriger Monat" title="Voriger Monat">${icon('chevronLeft')}</button>
|
||||
<strong>${esc(monatName)}</strong>
|
||||
<button type="button" class="abw-rund" onclick="abwMonatWechseln(1)" aria-label="Nächster Monat">›</button>
|
||||
<button type="button" class="abw-rund" onclick="abwMonatWechseln(1)" aria-label="Nächster Monat" title="Nächster Monat">${icon('chevronRight')}</button>
|
||||
<button type="button" class="secondary small" onclick="abwMonatWechseln(0)">Heute</button>
|
||||
</span>
|
||||
<span class="abw-reiter" role="group" aria-label="Gewerk">${['Alle'].concat(gewerke).map(g => `<button type="button" class="${g === abwFilter ? 'ist-an' : ''}" onclick="abwFilterSetzen('${g}')">${g}</button>`).join('')}</span>
|
||||
|
||||
+94
-14
@@ -64,6 +64,7 @@ const ADMIN_SECTIONS = [
|
||||
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen', recht: 'a.vorlagen' },
|
||||
{ key: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' },
|
||||
{ key: 'betrieb', title: 'Betrieb & Sicherheit', recht: 'a.betrieb' },
|
||||
{ key: 'teams', title: 'Teams', recht: 'a.teams' },
|
||||
{ key: 'papierkorb', title: 'Papierkorb', recht: 'a.papierkorb' },
|
||||
{ key: 'datenbank', title: 'Datenbank & Sitzungen', recht: ['a.backup', 'a.import'] },
|
||||
];
|
||||
@@ -159,6 +160,7 @@ function showAdminSection(key){
|
||||
// Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso
|
||||
// frisch holen.
|
||||
if(key === 'papierkorb') loadAdminPapierkorb();
|
||||
if(key === 'teams') loadAdminTeams();
|
||||
if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte();
|
||||
|
||||
try {
|
||||
@@ -287,6 +289,14 @@ function juengsteWoche(){
|
||||
return [...db.weeks].sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))).pop();
|
||||
}
|
||||
|
||||
// adminSitzungen: ALLE Sitzungen für die Liste — geladene als volles Objekt
|
||||
// (aktueller Sperrstand), nicht geladene aus db.wochenListe (schlank). Seit
|
||||
// beim Start nur die jüngsten Sitzungen geladen werden (js/store.js).
|
||||
function adminSitzungen(){
|
||||
const liste = (db.wochenListe && db.wochenListe.length) ? db.wochenListe : db.weeks;
|
||||
return liste.map(k => db.weeks.find(w => w.label === k.label) || k);
|
||||
}
|
||||
|
||||
// renderAdminSitzungen: listet alle Sitzungen (js/weeks.js: db.weeks),
|
||||
// neueste zuerst, mit Datum, Zustand und den passenden Aktionen.
|
||||
//
|
||||
@@ -321,7 +331,7 @@ function renderAdminSitzungen(){
|
||||
if(!el || !Array.isArray(db.weeks)) return;
|
||||
|
||||
const juengste = juengsteWoche();
|
||||
const sortiert = [...db.weeks].sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label)));
|
||||
const sortiert = adminSitzungen().sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label)));
|
||||
el.innerHTML = sortiert.map(w => {
|
||||
const aktuell = w.id === db.currentWeek;
|
||||
const istJuengste = juengste && w.id === juengste.id;
|
||||
@@ -337,20 +347,20 @@ function renderAdminSitzungen(){
|
||||
// jede Aktion und hätte keinen Weg zurück außer über eine andere,
|
||||
// zufällige applyAutoLock()-auslösende Handlung.
|
||||
chip = '<span class="sitzung-chip ist-geoeffnet">Geöffnet (Korrektur)</span>';
|
||||
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.id)}')">Fertig – wieder sperren</button>`;
|
||||
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.label)}')">Fertig – wieder sperren</button>`;
|
||||
} else if(w.locked){
|
||||
chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>';
|
||||
aktionen = hatRecht('s.entsperren') ? `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.id)}')">Entsperren</button>` : '';
|
||||
aktionen = hatRecht('s.entsperren') ? `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.label)}')">Entsperren</button>` : '';
|
||||
} else {
|
||||
chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>';
|
||||
aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.id)}')">Öffnen</button>`;
|
||||
aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.label)}')">Öffnen</button>`;
|
||||
}
|
||||
|
||||
return `<div class="template-admin-row">
|
||||
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
|
||||
${chip}
|
||||
${aktionen}
|
||||
${hatRecht('s.loeschen') ? `<button class="danger small" onclick="adminDeleteWeek('${esc(w.id)}')" title="Sitzung löschen">×</button>` : ''}
|
||||
${hatRecht('s.loeschen') ? `<button class="danger small" onclick="adminDeleteWeek('${esc(w.label)}')" title="Sitzung löschen">×</button>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -363,15 +373,22 @@ function renderAdminSitzungen(){
|
||||
// nicht. "Öffnen" macht dagegen ausdrücklich sichtbar, was sich geändert
|
||||
// hat: Es wechselt zur Sitzung UND zum Board, statt sie nur im Hintergrund
|
||||
// zur aktuellen zu machen.
|
||||
function adminUnlockWeek(weekId){
|
||||
unlockWeek(db.weeks.find(w => w.id === weekId), renderAdminSitzungen);
|
||||
// Alle vier nehmen das Label: Die Sitzung ist evtl. nicht geladen und wird
|
||||
// dafür erst nachgeholt (sitzungSicherLaden(), js/store.js).
|
||||
function adminMitSitzung(label, fn){
|
||||
sitzungSicherLaden(label).then(w => {
|
||||
if(!w){ alert('Die Sitzung ' + label + ' wurde am Server nicht gefunden.'); renderAdminSitzungen(); return; }
|
||||
fn(w);
|
||||
}).catch(() => alert('Die Sitzung ' + label + ' konnte nicht geladen werden.'));
|
||||
}
|
||||
function adminDeleteWeek(weekId){
|
||||
deleteWeekById(weekId, renderAdminSitzungen);
|
||||
function adminUnlockWeek(label){
|
||||
adminMitSitzung(label, w => unlockWeek(w, () => { sitzungKurzAbgleichen(w); renderAdminSitzungen(); }));
|
||||
}
|
||||
function adminOpenWeek(weekId){
|
||||
switchWeek(weekId);
|
||||
showTab('board');
|
||||
function adminDeleteWeek(label){
|
||||
adminMitSitzung(label, w => deleteWeekById(w.id, renderAdminSitzungen));
|
||||
}
|
||||
function adminOpenWeek(label){
|
||||
adminMitSitzung(label, w => { switchWeek(w.id); showTab('board'); });
|
||||
}
|
||||
|
||||
// adminRelockWeek: schließt eine Korrektur ab — sperrt die übergebene
|
||||
@@ -383,10 +400,11 @@ function adminOpenWeek(weekId){
|
||||
// markieren würde — und das ist zu diesem Zeitpunkt schon die jüngste, NICHT
|
||||
// die gerade gesperrte Sitzung (gleiches Muster wie savePlan() in
|
||||
// js/plan/plan-state.js). Parameter: weekId. Keine Rückgabe.
|
||||
function adminRelockWeek(weekId){
|
||||
const w = db.weeks.find(x => x.id === weekId);
|
||||
function adminRelockWeek(label){
|
||||
const w = db.weeks.find(x => x.label === label);
|
||||
if(!w || w.locked) return;
|
||||
w.locked = true;
|
||||
sitzungKurzAbgleichen(w);
|
||||
markWeekDirty(w.label);
|
||||
save({ week: false });
|
||||
const juengste = juengsteWoche();
|
||||
@@ -1083,3 +1101,65 @@ async function adminProjektEndgueltigLoeschen(id){
|
||||
alert('Löschen fehlgeschlagen: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Teams (Admin → Teams, Server: /admin/teams*) ----------
|
||||
// Die Webhook-Adresse ist ein Geheimnis und kommt nie zurück in den Browser;
|
||||
// angezeigt wird nur, ob eine eingetragen ist (und an welchen Host).
|
||||
async function loadAdminTeams(){
|
||||
const el = document.getElementById('adminTeamsInhalt');
|
||||
if(!el) return;
|
||||
let d;
|
||||
try { d = await fetch(baseUrl() + '/admin/teams').then(r => r.json()); }
|
||||
catch(e){ el.innerHTML = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
|
||||
if(!d || !d.ok){ el.innerHTML = '<span class="settings-warn">Keine Berechtigung.</span>'; return; }
|
||||
el.innerHTML = `
|
||||
<div class="settings-kv-row"><span class="settings-kv-key">Status</span>
|
||||
<span>${d.konfiguriert ? '<span class="sitzung-chip ist-geoeffnet">Verbunden</span> <span class="hint" style="margin:0">' + esc(d.host) + '</span>'
|
||||
: '<span class="sitzung-chip ist-gesperrt">Nicht eingerichtet</span>'}</span></div>
|
||||
<label class="settings-field"><span>${d.konfiguriert ? 'Neue Adresse (ersetzt die bisherige)' : 'Adresse aus dem Teams-Workflow'}</span>
|
||||
<input type="password" id="teamsWebhook" placeholder="https://…" autocomplete="off"></label>
|
||||
<label class="settings-glas-schalter"><input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''}
|
||||
onchange="teamsSpeichern({ neueAufgaben: this.checked })"> Neue Aufgaben melden</label>
|
||||
<div class="kalender-aktionen" style="margin-top:10px">
|
||||
<button type="button" onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Adresse speichern</button>
|
||||
<button type="button" class="secondary" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht senden</button>
|
||||
${d.konfiguriert ? '<button type="button" class="secondary" onclick="teamsEntfernen()">Entfernen</button>' : ''}
|
||||
</div>
|
||||
<p class="field-error" id="teamsFehler" hidden></p>`;
|
||||
}
|
||||
|
||||
async function teamsSpeichern(patch){
|
||||
if('webhook' in patch && !String(patch.webhook || '').trim()){ alert('Bitte die Adresse aus dem Teams-Workflow einfügen.'); return; }
|
||||
const r = await fetch(baseUrl() + '/admin/teams', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(patch)
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if(!r.ok || !d.ok){ alert(d.error || 'Speichern fehlgeschlagen.'); return; }
|
||||
// Nach dem ersten Eintragen der Adresse die Meldungen gleich einschalten.
|
||||
if('webhook' in patch && !document.getElementById('teamsNeueAufgaben').checked){
|
||||
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ neueAufgaben: true }) });
|
||||
}
|
||||
loadAdminTeams();
|
||||
}
|
||||
|
||||
async function teamsEntfernen(){
|
||||
if(!confirm('Teams-Anbindung entfernen? Es werden keine Nachrichten mehr gesendet.')) return;
|
||||
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ webhook: '', neueAufgaben: false }) });
|
||||
loadAdminTeams();
|
||||
}
|
||||
|
||||
async function teamsTesten(){
|
||||
const knopf = document.getElementById('teamsTestKnopf');
|
||||
const fehler = document.getElementById('teamsFehler');
|
||||
if(knopf){ knopf.disabled = true; knopf.textContent = 'Wird gesendet…'; }
|
||||
try {
|
||||
const d = await fetch(baseUrl() + '/admin/teams/test', { method: 'POST' }).then(r => r.json());
|
||||
if(fehler){ fehler.hidden = d.ok; fehler.textContent = d.ok ? '' : 'Nicht gesendet: ' + (d.error || 'unbekannter Fehler'); }
|
||||
if(d.ok) alert('Testnachricht gesendet — bitte im Teams-Kanal nachsehen.');
|
||||
} catch(e){
|
||||
if(fehler){ fehler.hidden = false; fehler.textContent = 'Der Server ist nicht erreichbar.'; }
|
||||
} finally {
|
||||
if(knopf){ knopf.disabled = false; knopf.textContent = 'Testnachricht senden'; }
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,8 @@ function afterAuthenticated(onAuthenticated){
|
||||
hideLoginOverlay();
|
||||
renderUserBar();
|
||||
onAuthenticated();
|
||||
// "Termin aus Mail" (js/plan/plan-mail-eintrag.js) nur mit eingerichteter KI.
|
||||
if(typeof mailKiStatusLaden === 'function') mailKiStatusLaden();
|
||||
if(currentUser && currentUser.mustChangePassword) openPasswordModal(true);
|
||||
// Rundgang (js/einfuehrung.js): wartet selbst, bis die Daten geladen sind
|
||||
// und kein Dialog offen ist — nach einem Pflicht-Passwortwechsel also erst
|
||||
|
||||
@@ -40,7 +40,7 @@ function openDatePicker(opts){
|
||||
document.body.appendChild(pop);
|
||||
|
||||
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
|
||||
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px';
|
||||
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 332)) + 'px';
|
||||
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
|
||||
|
||||
renderRangePicker();
|
||||
@@ -80,7 +80,7 @@ function openRangePicker(opts){
|
||||
|
||||
// Position: unterhalb des Ankers, innerhalb des Viewports halten
|
||||
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
|
||||
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px';
|
||||
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 332)) + 'px';
|
||||
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
|
||||
|
||||
renderRangePicker();
|
||||
@@ -160,6 +160,14 @@ function applyRangePicker(){
|
||||
const selected = start || end;
|
||||
if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (start ? formatDateISO(start) : '');
|
||||
if(endInput) endInput.value = end ? formatDateISO(end) : (start ? formatDateISO(start) : '');
|
||||
// Für umgeleitete native Datumsfelder (siehe unten): deren onchange/
|
||||
// oninput-Handler sollen wie bei einer Eingabe von Hand laufen.
|
||||
[startInput, endInput].forEach(el => {
|
||||
if(el && el.type === 'date'){
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
if(opts.displayId){
|
||||
const disp = document.getElementById(opts.displayId);
|
||||
if(disp){
|
||||
@@ -199,10 +207,15 @@ function renderRangePicker(){
|
||||
</div>
|
||||
<div class="range-picker-hint">${hint}</div>
|
||||
<div class="range-picker-grid">`;
|
||||
// Erste Spalte: Kalenderwoche (Eriks Wunsch — im Büro wird in KW gedacht).
|
||||
html += '<div class="range-picker-weekday range-picker-kw-kopf">KW</div>';
|
||||
['Mo','Di','Mi','Do','Fr','Sa','So'].forEach(w => html += `<div class="range-picker-weekday">${w}</div>`);
|
||||
const kwZelle = d => `<div class="range-picker-kw">${getWeekNumber(d)}</div>`;
|
||||
if(firstWeekday > 0) html += kwZelle(first);
|
||||
for(let i = 0; i < firstWeekday; i++) html += '<div></div>';
|
||||
for(let day = 1; day <= daysInMonth; day++){
|
||||
const d = new Date(st.viewYear, st.viewMonth, day);
|
||||
if(d.getDay() === 1) html += kwZelle(d);
|
||||
const iso = formatDateISO(d);
|
||||
const classes = ['range-picker-day'];
|
||||
if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend');
|
||||
@@ -218,4 +231,15 @@ function renderRangePicker(){
|
||||
${st.mode !== 'single' && st.start && !st.end ? '<button type="button" class="small" onclick="rangePickerState.end = rangePickerState.start; applyRangePicker()">Nur dieser Tag</button>' : ''}
|
||||
</div>`;
|
||||
pop.innerHTML = html;
|
||||
}
|
||||
}
|
||||
// Native Datumsfelder (<input type="date">) öffnen statt des Browser-
|
||||
// Kalenders (kennt keine KW) denselben Kalender wie der Rest der App. Das
|
||||
// Feld selbst bleibt — Tippen funktioniert weiter, der Picker schreibt den
|
||||
// ISO-Wert hinein und löst input/change aus. Ausnahme: data-nativ.
|
||||
document.addEventListener('click', e => {
|
||||
const input = e.target && e.target.closest ? e.target.closest('input[type="date"]') : null;
|
||||
if(!input || input.disabled || input.readOnly || input.hasAttribute('data-nativ')) return;
|
||||
e.preventDefault();
|
||||
if(!input.id) input.id = 'datum_' + Math.random().toString(36).slice(2, 9);
|
||||
openDatePicker({ anchor: input, inputId: input.id });
|
||||
}, true);
|
||||
@@ -108,6 +108,9 @@ let saveSyncTimer = null;
|
||||
// In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung.
|
||||
// globalPeople ist ein flaches Array (keine Abteilungstrennung mehr).
|
||||
let db = { weeks: [], currentWeek: null, globalPeople: [],
|
||||
// wochenListe: ALLE Sitzungen schlank (id, label, date, locked, updatedAt) —
|
||||
// db.weeks hält nur die geladenen (siehe SITZUNGEN_START, js/store.js).
|
||||
wochenListe: [],
|
||||
// sandboxWeek: Platzhalter bis zum ersten erfolgreichen load() (js/store.js)
|
||||
// befüllt ihn — leer statt undefined, damit ein Zugriff vor dem Laden nicht
|
||||
// crasht, sondern nur eine leere Planung-Liste zeigt.
|
||||
|
||||
@@ -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(){
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,9 +24,82 @@
|
||||
// gerufen. Keine Parameter, keine Rueckgabe.
|
||||
function renderSettings(){
|
||||
renderSettingsAccount();
|
||||
renderSettingsKalender();
|
||||
renderSettingsAiKeys();
|
||||
}
|
||||
|
||||
// ---------- Kalender in Outlook ----------
|
||||
// Geheime Abo-Adresse je Konto (GET/POST /mein-kalender). Ohne Adresse ein
|
||||
// Knopf zum Einrichten; mit Adresse: Kopieren, „In Outlook öffnen“ (webcal:),
|
||||
// Anleitung, neue Adresse (alte wird ungültig) und Abschalten.
|
||||
let kalenderUrlAktuell = null;
|
||||
async function renderSettingsKalender(){
|
||||
const el = document.getElementById('settingsKalender');
|
||||
if(!el) return;
|
||||
if(kalenderUrlAktuell === null){
|
||||
try {
|
||||
const d = await fetch(baseUrl() + '/mein-kalender').then(r => r.json());
|
||||
kalenderUrlAktuell = (d && d.url) || '';
|
||||
} catch(e){ el.innerHTML = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
|
||||
}
|
||||
const url = kalenderUrlAktuell;
|
||||
if(!currentUser || !currentUser.personId){
|
||||
el.innerHTML = '<p class="settings-warn">Ohne Personen-Zuordnung enthält der Kalender nur Ihre Abwesenheiten.</p>';
|
||||
} else el.innerHTML = '';
|
||||
if(!url){
|
||||
el.innerHTML += '<button type="button" onclick="kalenderAdresse(false)">Kalender-Adresse erzeugen</button>';
|
||||
return;
|
||||
}
|
||||
const webcal = url.replace(/^https?:/, 'webcal:');
|
||||
el.innerHTML += `
|
||||
<div class="kalender-adresse">
|
||||
<input type="text" id="kalenderUrlFeld" readonly value="${esc(url)}" onclick="this.select()" aria-label="Kalender-Adresse">
|
||||
<button type="button" class="secondary" onclick="kalenderKopieren()">Kopieren</button>
|
||||
<a class="button-link" href="${esc(webcal)}">In Outlook öffnen</a>
|
||||
</div>
|
||||
<details class="kalender-anleitung">
|
||||
<summary>So abonnieren Sie den Kalender</summary>
|
||||
<ol>
|
||||
<li><b>Outlook am PC (klassisch):</b> „In Outlook öffnen“ klicken und bestätigen — oder
|
||||
Kalender → <i>Kalender hinzufügen</i> → <i>Aus dem Internet</i> → Adresse einfügen.</li>
|
||||
<li><b>Neues Outlook / Outlook im Browser:</b> Kalender → <i>Kalender hinzufügen</i> →
|
||||
<i>Aus dem Internet abonnieren</i> → Adresse einfügen. Das funktioniert nur, wenn der
|
||||
Server aus dem Internet erreichbar ist — Microsoft holt den Kalender von außen ab.</li>
|
||||
</ol>
|
||||
<p class="hint">Die Adresse ist Ihr persönlicher Schlüssel: Wer sie kennt, sieht Ihre Termine.
|
||||
Nicht weitergeben. Bei Verdacht „Neue Adresse“ wählen — die alte gilt dann nicht mehr.</p>
|
||||
</details>
|
||||
<div class="kalender-aktionen">
|
||||
<button type="button" class="secondary small" onclick="kalenderAdresse(false, true)">Neue Adresse</button>
|
||||
<button type="button" class="secondary small" onclick="kalenderAdresse(true)">Abschalten</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function kalenderAdresse(aus, nachfragen){
|
||||
if(aus && !confirm('Kalender-Abo abschalten? Outlook zeigt danach keine neuen Termine mehr.')) return;
|
||||
if(nachfragen && !confirm('Neue Adresse erzeugen? Die bisherige gilt dann nicht mehr — in Outlook muss der Kalender neu abonniert werden.')) return;
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/mein-kalender', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ aus: !!aus })
|
||||
});
|
||||
const d = await r.json();
|
||||
if(!r.ok || !d.ok) throw new Error(d.error || 'Fehler');
|
||||
kalenderUrlAktuell = d.url || '';
|
||||
renderSettingsKalender();
|
||||
showSettingsNote(aus ? 'Kalender-Abo abgeschaltet.' : 'Kalender-Adresse erzeugt.');
|
||||
} catch(e){ alert('Das hat nicht geklappt: ' + e.message); }
|
||||
}
|
||||
|
||||
function kalenderKopieren(){
|
||||
const feld = document.getElementById('kalenderUrlFeld');
|
||||
if(!feld) return;
|
||||
const fertig = () => showSettingsNote('Adresse kopiert.');
|
||||
if(navigator.clipboard && window.isSecureContext){
|
||||
navigator.clipboard.writeText(feld.value).then(fertig).catch(() => { feld.select(); document.execCommand('copy'); fertig(); });
|
||||
} else { feld.select(); document.execCommand('copy'); fertig(); }
|
||||
}
|
||||
|
||||
// ---------- Mein Konto ----------
|
||||
|
||||
// renderSettingsAccount: zeigt die Stammdaten des angemeldeten Kontos an.
|
||||
@@ -234,6 +307,8 @@ function putMyAiKey(provider, patch){
|
||||
body: JSON.stringify(body)
|
||||
}).then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.');
|
||||
// "Termin aus Mail" ist nur mit eingerichteter KI aktiv — Stand neu holen.
|
||||
if(typeof mailKiStatusLaden === 'function') mailKiStatusLaden();
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
+109
-9
@@ -596,7 +596,13 @@ function liveWocheNachladen(label, geloescht){
|
||||
// Während eines eigenen Speicherns nicht dazwischenfunken; kurz danach.
|
||||
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
|
||||
const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === label);
|
||||
if(!w) return liveAllesNeu();
|
||||
if(!w){
|
||||
// Eine ältere, hier nicht geladene Sitzung (z. B. entsperrt): nur ihren
|
||||
// Eintrag in der Sitzungsliste nachziehen, nicht alles neu laden.
|
||||
const kurz = (db.wochenListe || []).find(x => x.label === label);
|
||||
if(kurz) return wocheVomServer(label).then(f => { if(f) Object.assign(kurz, sitzungKurz(f)); ansichtNachBearbeitung(); }).catch(() => {});
|
||||
return liveAllesNeu();
|
||||
}
|
||||
wocheVomServer(label).then(frisch => {
|
||||
if(!frisch) return liveAllesNeu();
|
||||
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
|
||||
@@ -648,6 +654,102 @@ function handleWeekConflict(week, err){
|
||||
else load();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sitzungen nach Bedarf (Eriks Wunsch: beim Start nur die aktuelle und drei
|
||||
// davor; im Verlauf „Weitere laden“ holt je fünf ältere nach)
|
||||
// ---------------------------------------------------------------------------
|
||||
// db.weeks hält nur die GELADENEN Sitzungen, db.wochenListe ALLE schlank
|
||||
// ({id, label, date, locked, updatedAt}, älteste zuerst). Wer eine nicht
|
||||
// geladene Sitzung braucht (Admin → Sitzungen, Deep-Link auf ein Projekt
|
||||
// einer älteren Sitzung), holt sie über sitzungSicherLaden().
|
||||
const SITZUNGEN_START = 4;
|
||||
const SITZUNGEN_NACHLADEN = 5;
|
||||
|
||||
function wocheAbsichern(w){
|
||||
// Felder absichern, damit alte/kaputte Dokumente nicht crashen
|
||||
if(!Array.isArray(w.people)) w.people = [];
|
||||
if(!Array.isArray(w.projects)) w.projects = [];
|
||||
if(!w.attendance) w.attendance = {};
|
||||
return w;
|
||||
}
|
||||
function sitzungKurz(w){
|
||||
return { id: w.id, label: w.label, date: w.date || '', locked: !!w.locked, updatedAt: w.updatedAt || '' };
|
||||
}
|
||||
|
||||
// sitzungKurzAbgleichen: den schlanken Eintrag einer Sitzung in
|
||||
// db.wochenListe auf den Stand des vollen Objekts bringen (Sperre, Revision).
|
||||
function sitzungKurzAbgleichen(w){
|
||||
const k = (db.wochenListe || []).find(x => x.label === w.label);
|
||||
if(k) Object.assign(k, sitzungKurz(w));
|
||||
}
|
||||
|
||||
// wochenEinfuegen: geladene Sitzungen in db.weeks aufnehmen (ohne Doppel),
|
||||
// sortieren und als Basis fürs Zusammenführen merken.
|
||||
function wochenEinfuegen(liste){
|
||||
(liste || []).forEach(w => {
|
||||
if(!w || typeof w.label !== 'string' || db.weeks.some(x => x.label === w.label)) return;
|
||||
db.weeks.push(wocheAbsichern(w));
|
||||
basisMerken(w);
|
||||
});
|
||||
db.weeks.sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||||
}
|
||||
|
||||
// aeltereSitzungenAnzahl: wie viele Sitzungen älter als die älteste geladene
|
||||
// noch nicht geladen sind.
|
||||
function aeltereSitzungenAnzahl(){
|
||||
const liste = db.wochenListe || [];
|
||||
if(!db.weeks.length) return 0;
|
||||
const aelteste = db.weeks[0].label;
|
||||
const idx = liste.findIndex(w => w.label === aelteste);
|
||||
return idx < 0 ? 0 : idx;
|
||||
}
|
||||
|
||||
// weitereSitzungenLaden: holt die nächsten älteren Sitzungen und zeichnet
|
||||
// danach neu — die ganze Ansicht, oder nur, was nachher (Name einer globalen
|
||||
// Funktion) zeichnet. Der Wochenbericht nutzt das, damit ein angefangener
|
||||
// Kommentar nicht durch ein Neuzeichnen verloren geht. Rückgabe: Promise.
|
||||
let weitereLaufen = null;
|
||||
function weitereSitzungenLaden(anzahl, nachher){
|
||||
if(weitereLaufen) return weitereLaufen;
|
||||
if(!db.weeks.length || aeltereSitzungenAnzahl() === 0) return Promise.resolve();
|
||||
const vor = db.weeks[0].label;
|
||||
weitereLaufen = fetch(baseUrl() + '/weeks-aelter?vor=' + encodeURIComponent(vor) + '&anzahl=' + (anzahl || SITZUNGEN_NACHLADEN))
|
||||
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(d => {
|
||||
wochenEinfuegen(d.weeks);
|
||||
if(nachher && typeof window[nachher] === 'function'){ window[nachher](); return; }
|
||||
ansichtAuffrischen();
|
||||
// Kommentar-Verlauf im Projekt-Dashboard hängt an renderPlanInfo().
|
||||
if(typeof currentTab !== 'undefined' && currentTab === 'plan' && typeof renderPlanInfo === 'function') renderPlanInfo();
|
||||
})
|
||||
.catch(err => { console.error('Ältere Sitzungen laden fehlgeschlagen:', err); alert('Ältere Sitzungen konnten nicht geladen werden.'); })
|
||||
.finally(() => { weitereLaufen = null; });
|
||||
return weitereLaufen;
|
||||
}
|
||||
|
||||
// weitereLadenKnopf: Knopf „Weitere laden“ für einen Verlauf — nur, solange
|
||||
// es ältere, nicht geladene Sitzungen gibt. Parameter: nachher (optional,
|
||||
// siehe weitereSitzungenLaden()). Rückgabe: HTML-String ('' sonst).
|
||||
function weitereLadenKnopf(nachher){
|
||||
const n = aeltereSitzungenAnzahl();
|
||||
if(!n) return '';
|
||||
const k = Math.min(n, SITZUNGEN_NACHLADEN);
|
||||
return `<button type="button" class="secondary small verlauf-weitere" onmousedown="event.preventDefault()" onclick="event.stopPropagation(); weitereSitzungenLaden(0${nachher ? ", '" + nachher + "'" : ''})"
|
||||
title="Noch ${n} ältere Sitzung${n === 1 ? '' : 'en'} am Server">Weitere laden (${k} ${k === 1 ? 'Sitzung' : 'Sitzungen'})</button>`;
|
||||
}
|
||||
|
||||
// sitzungSicherLaden: liefert eine Sitzung, lädt sie bei Bedarf einzeln
|
||||
// nach. Rückgabe: Promise<Woche|null>.
|
||||
function sitzungSicherLaden(label){
|
||||
const da = db.weeks.find(w => w.label === label);
|
||||
if(da) return Promise.resolve(da);
|
||||
return wocheVomServer(label).then(w => {
|
||||
if(!w) return null;
|
||||
wochenEinfuegen([w]);
|
||||
return db.weeks.find(x => x.label === label) || null;
|
||||
});
|
||||
}
|
||||
|
||||
// reloadWeekFromServer: lädt alle Sitzungen neu und ersetzt NUR die
|
||||
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
|
||||
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
|
||||
@@ -745,7 +847,9 @@ function load(silent){
|
||||
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
|
||||
return;
|
||||
}
|
||||
const url = baseUrl() + '/load-all';
|
||||
// Nur die jüngsten SITZUNGEN_START Sitzungen (aktuelle + drei davor);
|
||||
// ältere holt weitereSitzungenLaden() bei Bedarf (Eriks Wunsch).
|
||||
const url = baseUrl() + '/load-all?wochen=' + SITZUNGEN_START;
|
||||
console.log('Loading from', url);
|
||||
// Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen:
|
||||
// renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala,
|
||||
@@ -775,13 +879,9 @@ function load(silent){
|
||||
|
||||
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
|
||||
.filter(w => w && typeof w.label === 'string')
|
||||
.map(w => {
|
||||
// Felder absichern, damit alte/kaputte Dokumente nicht crashen
|
||||
if(!Array.isArray(w.people)) w.people = [];
|
||||
if(!Array.isArray(w.projects)) w.projects = [];
|
||||
if(!w.attendance) w.attendance = {};
|
||||
return w;
|
||||
});
|
||||
.map(wocheAbsichern);
|
||||
// Alle Sitzungen schlank (Name, Datum, Sperre) — auch die nicht geladenen.
|
||||
db.wochenListe = Array.isArray(data.wochenListe) ? data.wochenListe : db.weeks.map(sitzungKurz);
|
||||
|
||||
// Server-Stand als Basis für späteres Zusammenführen merken.
|
||||
wochenBasis.clear();
|
||||
|
||||
+17
-3
@@ -1187,8 +1187,19 @@ function verlaufBeteiligte(version){
|
||||
// die eine geänderte Zahl sonst nicht.
|
||||
// Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell"
|
||||
// gilt und deshalb NICHT als Kachel erscheint), locked. Rückgabe: HTML.
|
||||
// verlaufOffen: project_uids, deren Verlauf aufgeklappt ist — übersteht das
|
||||
// Neuzeichnen nach „Weitere laden“ (sonst klappte er dabei wieder zu).
|
||||
const verlaufOffen = new Set();
|
||||
function verlaufUmschalten(uid, offen){ if(offen) verlaufOffen.add(uid); else verlaufOffen.delete(uid); }
|
||||
|
||||
function renderProjektVerlauf(p, weekLabel, locked){
|
||||
const versionen = projectVersionsForUid(p.projectUid);
|
||||
// Gezeigt wird, was geladen ist (anfangs die drei Sitzungen vor der
|
||||
// aktuellen); „Weitere laden“ holt je fünf ältere (js/store.js).
|
||||
const weitere = typeof weitereLadenKnopf === 'function' ? weitereLadenKnopf() : '';
|
||||
const uidAttr = esc(String(p.projectUid || '')).replace(/'/g, ''');
|
||||
const offen = verlaufOffen.has(p.projectUid) ? ' open' : '';
|
||||
const umschalten = ` ontoggle="verlaufUmschalten('${uidAttr}', this.open)"`;
|
||||
// Neueste zuerst, damit links die jüngste Vergangenheit steht.
|
||||
//
|
||||
// Ohne weekLabel werden ALLE Sitzungen gezeigt: Der Verlaufs-Dialog im
|
||||
@@ -1199,13 +1210,15 @@ function renderProjektVerlauf(p, weekLabel, locked){
|
||||
const frueher = (weekLabel ? (idx > 0 ? versionen.slice(0, idx) : []) : versionen.slice()).reverse();
|
||||
|
||||
if(frueher.length === 0){
|
||||
return `<details class="timeline-block">
|
||||
return `<details class="timeline-block"${offen}${umschalten}>
|
||||
<summary onmousedown="event.preventDefault()">
|
||||
<span class="comment-history-caret">▶</span>
|
||||
<span>Verlauf</span>
|
||||
<span class="comment-history-count zero">0</span>
|
||||
</summary>
|
||||
<div class="timeline-body"><span class="hint" style="margin:0">Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.</span></div>
|
||||
<div class="timeline-body"><span class="hint" style="margin:0">${weitere
|
||||
? 'In den geladenen Sitzungen steht dieses Projekt noch nicht.'
|
||||
: 'Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.'}</span>${weitere}</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
@@ -1276,7 +1289,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
|
||||
</article>`;
|
||||
}).join('');
|
||||
|
||||
return `<details class="timeline-block">
|
||||
return `<details class="timeline-block"${offen}${umschalten}>
|
||||
<summary onmousedown="event.preventDefault()">
|
||||
<span class="comment-history-caret">▶</span>
|
||||
<span>Verlauf</span>
|
||||
@@ -1284,6 +1297,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
|
||||
</summary>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-rail">${kacheln}</div>
|
||||
${weitere ? `<div class="verlauf-weitere-zeile">${weitere}</div>` : ''}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
+4
-1
@@ -138,7 +138,9 @@ function unlockCurrentWeek(){ unlockWeek(currentWeek()); }
|
||||
// üblichen renderAll() zusätzlich läuft — die Admin-Liste zeichnet sich
|
||||
// darüber selbst neu, weil renderAll() sie nicht kennt). Keine Rückgabe.
|
||||
function deleteWeekById(weekId, nachDelete){
|
||||
if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
|
||||
// Gezählt werden ALLE Sitzungen, nicht nur die geladenen (db.wochenListe).
|
||||
const gesamt = (db.wochenListe && db.wochenListe.length) || db.weeks.length;
|
||||
if(gesamt<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
|
||||
const w = db.weeks.find(x => x.id === weekId);
|
||||
if(!w) return;
|
||||
if(!confirm('Sitzung "' + w.label + '" wirklich löschen?')) return;
|
||||
@@ -151,6 +153,7 @@ function deleteWeekById(weekId, nachDelete){
|
||||
console.log('Woche gelöscht:', w.label);
|
||||
const warAktuell = (weekId === db.currentWeek);
|
||||
db.weeks = db.weeks.filter(x=>x.id!==weekId);
|
||||
if(Array.isArray(db.wochenListe)) db.wochenListe = db.wochenListe.filter(x => x.label !== w.label);
|
||||
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||||
// Zeigte die Oberfläche gerade die gelöschte Sitzung (oder zeigt sie aus
|
||||
// irgendeinem Grund gar keine mehr existierende), auf die jüngste
|
||||
|
||||
@@ -361,7 +361,6 @@ function aktuellesBerichtProjekt(w){
|
||||
// Parameter: uid (project_uid). Keine Rückgabe.
|
||||
function waehleBerichtProjekt(uid){
|
||||
berichtAuswahl = uid;
|
||||
berichtVerlaufAlle = false; // neues Projekt zeigt wieder nur die jüngsten Einträge
|
||||
renderWochenbericht();
|
||||
const detail = document.getElementById('berichtDetail');
|
||||
if(detail) detail.scrollIntoView({ block: 'nearest' });
|
||||
@@ -742,10 +741,9 @@ function renderBerichtDetail(p, w, prevWeek, locked){
|
||||
// jedem Eintrag alle Werte zu wiederholen. Keine neue Datenhaltung — jede
|
||||
// Sitzung hält ihre eigene Kopie des Projekts (projectVersionsForUid()).
|
||||
|
||||
const BERICHT_VERLAUF_KURZ = 3;
|
||||
// berichtVerlaufAlle: "Alle N anzeigen" aufgeklappt; waehleBerichtProjekt()
|
||||
// setzt es beim Projektwechsel zurück.
|
||||
let berichtVerlaufAlle = false;
|
||||
// Gezeigt wird, was geladen ist — beim Start die drei Sitzungen vor der
|
||||
// aktuellen; „Weitere laden“ holt je fünf ältere (weitereLadenKnopf(),
|
||||
// js/store.js) und zeichnet nur diesen Abschnitt neu (berichtVerlaufNeu()).
|
||||
|
||||
// berichtKurzKw: "KW 43 / 2026" -> "KW 43" (Jahr nur, wenn nicht das laufende)
|
||||
function berichtKurzKw(label){
|
||||
@@ -828,11 +826,12 @@ function renderBerichtVerlauf(p, w, locked){
|
||||
const gText = g || 'alle Gewerke';
|
||||
let inhalt;
|
||||
if(eintraege.length === 0){
|
||||
inhalt = `<div class="hint" style="margin:0">${sitzungen === 0
|
||||
inhalt = `<div class="hint" style="margin:0">${sitzungen === 0 && !aeltereSitzungenAnzahl()
|
||||
? 'Dieses Projekt erscheint zum ersten Mal, es gibt noch keine frühere Sitzung.'
|
||||
: sitzungen === 0 ? 'In den geladenen Sitzungen steht dieses Projekt noch nicht.'
|
||||
: 'In den früheren Sitzungen gibt es noch keinen Kommentar für ' + esc(gText) + '.'}</div>`;
|
||||
} else {
|
||||
const zeigen = berichtVerlaufAlle ? eintraege : eintraege.slice(0, BERICHT_VERLAUF_KURZ);
|
||||
const zeigen = eintraege;
|
||||
inhalt = `<ol class="bv-liste">${zeigen.map((e, i) => {
|
||||
const datum = berichtVerlaufDatum(e.at);
|
||||
const wer = [e.autor, datum].filter(Boolean).join(', ');
|
||||
@@ -854,9 +853,7 @@ function renderBerichtVerlauf(p, w, locked){
|
||||
</li>`;
|
||||
}).join('')}</ol>`;
|
||||
}
|
||||
const mehr = eintraege.length > BERICHT_VERLAUF_KURZ
|
||||
? `<button type="button" class="bv-mehr" onclick="berichtVerlaufUmschalten()">${berichtVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + eintraege.length + ' anzeigen'}</button>`
|
||||
: '';
|
||||
const mehr = weitereLadenKnopf('berichtVerlaufNeu');
|
||||
const anzahl = eintraege.length === 1 ? '1 Kommentar' : eintraege.length + ' Kommentare';
|
||||
return `<section class="bericht-verlauf" id="berichtVerlauf" aria-label="Verlauf der Vorwochen">
|
||||
<div class="bv-kopf">
|
||||
@@ -869,10 +866,9 @@ function renderBerichtVerlauf(p, w, locked){
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// berichtVerlaufUmschalten: "Alle N anzeigen" / "Weniger anzeigen". Zeichnet
|
||||
// nur den Verlauf neu, damit ein angefangener Kommentar unberührt bleibt.
|
||||
function berichtVerlaufUmschalten(){
|
||||
berichtVerlaufAlle = !berichtVerlaufAlle;
|
||||
// berichtVerlaufNeu: zeichnet nur den Verlauf neu (nach „Weitere laden“),
|
||||
// damit ein angefangener Kommentar unberührt bleibt.
|
||||
function berichtVerlaufNeu(){
|
||||
const w = currentWeek();
|
||||
const p = w && aktuellesBerichtProjekt(w);
|
||||
const el = document.getElementById('berichtVerlauf');
|
||||
|
||||
Reference in new issue
Block a user