// ----------------------------- // Projektplan — "TODOs aus Protokoll" (externes PDF per KI) // ----------------------------- // Eigenständige Funktion im Bereich "Aufgaben" (+ GANTT-Toolbar, gleichrangig // neben "+ Vorlage"/"+ Terminplan"): ein EXTERNES Besprechungsprotokoll im // PDF-Format wird per KI eingelesen, und daraus werden — in einem zweiten, // eigenen KI-Schritt — Aufgaben für dieses Projekt abgeleitet. Bewusst // KOMPLETT unabhängig von js/plan/plan-protokoll.js: nichts hier liest aus // oder schreibt in project_protokolle/protokoll_punkte, nichts wird auf dem // Server dauerhaft abgelegt (weder das PDF noch die erkannten Punkte) — ein // reines Ad-hoc-Werkzeug wie der Plan Checker, kein Dokumentenarchiv. Wer das // externe Protokoll dauerhaft als Projektunterlage ablegen will, nutzt den // normalen Protokoll-Import (Bereich "Protokolle"). // // Ein Dialog (#protokollTodosModal), DREI Schritte, feste Fußzeile // (Zurück/Abbrechen/Weiter) — Entwurfsrichtung "A — Schritt-Dialog" aus dem // Entwurfsvergleich (Taste-Skill), von Erik gewählt: ein sichtbarer // Fortschrittsbalken (#protokollTodosSteps) und derselbe Knopf-Platz über // alle Schritte hinweg, statt je Phase ein eigenes Knopf-Set. Nur der // Inhalt zwischen Fortschrittsbalken und Fußzeile (#protokollTodosBody) // wird je Schritt neu gerendert; Titel/Untertitel/Fortschritt/Fußzeile // bleiben derselbe Rahmen. Der rechte Fußzeilen-Knopf ("Weiter") führt // zugleich die KI-Aufrufe aus — ein Klick macht Schritt und Netzwerkaufruf // in einem: // 0. Hochladen — PDF + KI-Anbieter wählen → "Einlesen" ruft POST // /plan-todos-aus-protokoll/einlesen auf (server.js, nutzt dieselbe // Erkennung wie der Protokoll-Import: erkennePunkteMitKI()/ // erkennePunkteAusText()). // 1. Punkte wählen — die erkannten Punkte als Checkliste (alle offenen // standardmäßig angehakt) → "Aufgaben ableiten" ruft POST // /plan-todos-aus-protokoll/aufgaben-vorschlag mit genau diesen // Punkten im Body (kein erneuter Server-Lookup, nichts ist // persistiert). // 2. Aufgaben bestätigen — die Vorschläge als kompakte Zeilenliste // (Entwurfsrichtung "B — Kompakte Zeilen" aus einem zweiten // Taste-Skill-Vergleich, nur für diesen Schritt; siehe // renderVorschlaegeHtml()): Titel/Termin-Abzeichen/Zuständig-Kürzel auf // einen Blick, Beschreibung/Bearbeitung erst nach Antippen der Zeile — // ein echter Testlauf lieferte bereits 19 Vorschläge, als ausführliche // Karten wäre das nur noch Scrollen gewesen. "N Aufgaben einfügen" // erzeugt normale plan_todos MIT Start- UND Enddatum (sonst keine // GANTT-Zeile, siehe CLAUDE.md) und ruft danach fitPlanGanttRange() wie // applyTemplate(), damit die neuen Balken im sichtbaren GANTT-Zeitraum // liegen. // "Zurück" springt ohne erneuten Serveraufruf auf den vorherigen Schritt // zurück (die Daten bleiben im Modul-Zustand); einzige Ausnahme: ein // Datei-Eingabefeld lässt sich aus Sicherheitsgründen nicht per Skript // erneut vorbelegen — wer von Schritt 1 zurück zu Schritt 0 geht, muss die // Datei bei Bedarf erneut auswählen. // // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // (plan-state.js). (function(){ const app = window.PlanApp; // Modul-Zustand — lebt nur, während der Dialog offen ist; ein erneutes // Öffnen startet immer wieder bei Schritt 0. let step = 0; let erkanntePunkte = []; let punkteAuswahl = {}; // Index in erkanntePunkte -> angehakt (Boolean), überlebt Zurück/Weiter let gewaehlterAnbieter = ''; let ausgewaehltePunkte = []; // die Punkte, die tatsächlich an die KI gingen (für die Quelle-Referenz in Schritt 2) let vorschlaege = []; const STEP_TITEL = ['Externes Protokoll einlesen', 'Punkte wählen', 'Aufgaben bestätigen']; // openProtokollTodosModal: öffnet den Dialog bei Schritt 0. Bei gesperrter // Sitzung gar nicht erst geöffnet (wie bei den anderen Bearbeitungs- // Dialogen, siehe plan-modals.js). window.openProtokollTodosModal = function(){ if(app.isLocked()) return; step = 0; erkanntePunkte = []; punkteAuswahl = {}; gewaehlterAnbieter = ''; ausgewaehltePunkte = []; vorschlaege = []; renderStep(); document.getElementById('protokollTodosModal').style.display = 'flex'; }; window.closeProtokollTodosModal = function(){ document.getElementById('protokollTodosModal').style.display = 'none'; }; function zeigeFehler(msg){ const el = document.getElementById('protokollTodosError'); if(el){ el.textContent = msg; el.hidden = false; } } function verbergeFehler(){ const el = document.getElementById('protokollTodosError'); if(el){ el.textContent = ''; el.hidden = true; } } // zeigeFallbackHinweis: eigener, bernsteinfarbener Hinweis für einen // GLÜCKTEN, aber degradierten Durchlauf (KI nicht erreichbar, auf die // einfache Übernahme zurückgefallen) — bewusst NICHT dieselbe rote // Fehlermeldung wie zeigeFehler() oben, dieselbe Unterscheidung wie der // Protokoll-Import (.protokoll-ki-fallback-banner, plan-protokoll.js): // ein Fallback ist ein akzeptiertes Ergebnis, kein Fehlschlag. function zeigeFallbackHinweis(msg){ const el = document.getElementById('protokollTodosFallback'); if(el){ el.textContent = '⚠️ ' + msg; el.hidden = false; } } function verbergeFallbackHinweis(){ const el = document.getElementById('protokollTodosFallback'); if(el){ el.textContent = ''; el.hidden = true; } } // setzeLadezustand: sperrt während eines laufenden KI-Aufrufs den // gesamten Dialog (Fußzeile inklusive, damit kein zweiter Aufruf // überlappt) — ohne das gäbe es während eines länger laufenden Aufrufs // (bis zu KI_TIMEOUT_MS_LOKAL = 30 Minuten bei einem eigenen Server, // server.js) keinerlei Rückmeldung, derselbe Grund wie bei // setzeProtokollImportLadezustand() in plan-protokoll.js. function setzeLadezustand(aktiv, text){ const modal = document.getElementById('protokollTodosModal'); if(modal) modal.querySelectorAll('input, select, button').forEach(el => { el.disabled = aktiv; }); const ladeEl = document.getElementById('protokollTodosLoading'); if(ladeEl) ladeEl.hidden = !aktiv; const textEl = document.getElementById('protokollTodosLoadingText'); if(textEl && text) textEl.textContent = text; // setzeLadezustand(false) würde sonst auch "Zurück" auf Schritt 0 // wieder aktivieren — das macht erst renderStep() richtig. if(!aktiv) renderStep(); } // anbieterOptionenHtml: baut die Options-Liste des KI-Anbieter-Felds aus // GET /my-ai-settings — derselbe Aufbau wie #protokollImportErkennung in // plan-protokoll.js. function anbieterOptionenHtml(){ return fetch(baseUrl() + '/my-ai-settings') .then(r => r.json()) .then(d => { if(!d.ok) throw new Error('nicht ok'); return ` `; }) .catch(() => ''); } // renderStep: zeichnet Titel, Fortschrittsbalken, Fußzeilen-Beschriftung // und den schrittspezifischen Inhalt. Macht keinen Netzwerkaufruf — das // übernimmt protokollTodosWeiter(). Keine Parameter, keine Rückgabe. function renderStep(){ verbergeFehler(); verbergeFallbackHinweis(); document.getElementById('protokollTodosTitle').textContent = STEP_TITEL[step]; document.querySelectorAll('#protokollTodosSteps .seg').forEach(s => { s.classList.toggle('done', +s.dataset.i <= step); }); document.getElementById('protokollTodosBackBtn').disabled = (step === 0); const primaryBtn = document.getElementById('protokollTodosPrimaryBtn'); const subtitleEl = document.getElementById('protokollTodosSubtitle'); const body = document.getElementById('protokollTodosBody'); if(step === 0){ subtitleEl.textContent = 'PDF hochladen, KI-Anbieter wählen.'; primaryBtn.textContent = 'Einlesen'; body.innerHTML = `
`; anbieterOptionenHtml().then(html => { const sel = document.getElementById('protokollTodosAnbieter'); if(sel){ sel.innerHTML = html; if(gewaehlterAnbieter) sel.value = gewaehlterAnbieter; } }); } else if(step === 1){ subtitleEl.textContent = erkanntePunkte.length + ' Punkte erkannt — offene sind vorausgewählt.'; primaryBtn.textContent = 'Aufgaben ableiten'; body.innerHTML = `
${erkanntePunkte.map((p, i) => { const checked = punkteAuswahl[i] !== undefined ? punkteAuswahl[i] : ((p.status || (p.erledigt ? 'erledigt' : 'offen')) === 'offen'); punkteAuswahl[i] = checked; return ``; }).join('')}
`; document.getElementById('protokollTodosPunkteListe').querySelectorAll('input[type=checkbox]').forEach(cb => { cb.addEventListener('change', () => { punkteAuswahl[cb.value] = cb.checked; }); }); } else { subtitleEl.textContent = vorschlaege.length + ' Vorschläge aus den gewählten Punkten.'; body.innerHTML = renderVorschlaegeHtml(); const liste = document.getElementById('protokollTodosVorschlaege'); if(liste){ // Abwahl blendet die Zeile ab (statt sie zu entfernen) — dieselbe // "noch da, aber erkennbar nicht dabei"-Behandlung wie bei den // Punkten in Schritt 1. liste.addEventListener('change', e => { if(!e.target.classList.contains('pt-vorschlag-check')) return; e.target.closest('.protokoll-todo-vorschlag').classList.toggle('off', !e.target.checked); aktualisierePrimaerKnopf(); }); // Klick auf die Zeile klappt Beschreibung/Termin/Zuständige auf — // außer der Klick galt der Checkbox oder dem Titel-Eingabefeld // selbst, die sollen nicht zugleich die Zeile zu-/aufklappen. liste.addEventListener('click', e => { if(e.target.closest('.pt-vorschlag-check, .pt-vorschlag-titel')) return; const main = e.target.closest('.pt-row-main'); if(main) main.closest('.protokoll-todo-vorschlag').classList.toggle('open'); }); } aktualisierePrimaerKnopf(); } } function aktualisierePrimaerKnopf(){ const checks = document.querySelectorAll('.pt-vorschlag-check'); const n = checks.length ? Array.from(checks).filter(c => c.checked).length : vorschlaege.length; const btn = document.getElementById('protokollTodosPrimaryBtn'); btn.textContent = n + ' Aufgabe' + (n === 1 ? '' : 'n') + ' einfügen'; btn.disabled = n === 0; const selCount = document.getElementById('protokollTodosSelCount'); if(selCount) selCount.innerHTML = `${n} von ${vorschlaege.length} ausgewählt`; } // protokollTodosAuswahlAlle: markiert alle Vorschlagszeilen auf einen // Schlag an oder ab ("Alle"/"Keine" neben der Auswahl-Zählung) — bei 19 // Vorschlägen (im echten Testlauf beobachtet) sonst 19 Einzelklicks. // Parameter: alle (Boolean). Keine Rückgabe. window.protokollTodosAuswahlAlle = function(alle){ document.querySelectorAll('#protokollTodosVorschlaege .pt-vorschlag-check').forEach(cb => { cb.checked = alle; cb.closest('.protokoll-todo-vorschlag').classList.toggle('off', !alle); }); aktualisierePrimaerKnopf(); }; // matchePersonenVonName: einfacher Namensabgleich zwischen einem // KI-Textvorschlag ("zustaendig" aus dem externen Protokoll — oft eine // externe Firma, kein Teammitglied) und den diesem Projekt zugeordneten // Personen. Case-insensitive Teilstring auf Name/vollem Namen; kein // Treffer ist der Normalfall und bleibt als reiner Text sichtbar. function matchePersonenVonName(text, kandidaten){ const t = String(text || '').trim().toLowerCase(); if(!t) return []; const treffer = new Set(); kandidaten.forEach(k => { const name = String(k.person.name || '').toLowerCase(); const voll = String(k.person.fullName || '').toLowerCase(); if((name && t.includes(name)) || (voll && t.includes(voll))) treffer.add(k.person.id); }); return Array.from(treffer); } // renderVorschlaegeHtml: die Vorschlagsliste aus Entwurfsrichtung // "B — Kompakte Zeilen" (Taste-Skill-Vergleich) — eine Zeile je Vorschlag // (Titel, Termin-Abzeichen, Zuständig-Kürzel), Beschreibung/Termin-Feld/ // Zuständigen-Auswahl erst nach Antippen der Zeile sichtbar. Gewählt, weil // ein echter Testlauf bereits 19 Vorschläge aus einem Protokoll lieferte — // als ausführliche Karte wäre das ganze Fenster voll Scrollen gewesen, // als Zeilenliste passt die komplette Übersicht auf einen Blick, ohne // Termin/Zuständig ganz zu verstecken (beide stehen als Abzeichen schon // in der eingeklappten Zeile). Eine Auswahl-Zeile oben // ("N von M ausgewählt" + Alle/Keine) erspart bei vielen Vorschlägen das // einzelne Abwählen. function renderVorschlaegeHtml(){ if(vorschlaege.length === 0){ return '

Keine Aufgaben vorgeschlagen — die ausgewählten Punkte waren rein informativ.

'; } // Kandidaten für die Zuständigen-Auswahl: die diesem Projekt bereits // zugeordneten Personen (projectAssignees(), js/gantt.js), einmalig // entdoppelt (eine Person kann in mehreren Rollen zugeordnet sein). const kandidaten = []; const gesehen = new Set(); projectAssignees(app.project, app.week).forEach(k => { if(!gesehen.has(k.person.id)){ gesehen.add(k.person.id); kandidaten.push(k); } }); const zeilen = vorschlaege.map((v, idx) => { const quelle = (v.quellRefs || []).map(r => ausgewaehltePunkte[r] && ausgewaehltePunkte[r].thema).filter(Boolean).join(', '); const vorausgewaehlt = matchePersonenVonName(v.zustaendigVorschlag, kandidaten); const erstePerson = vorausgewaehlt.length ? kandidaten.find(k => k.person.id === vorausgewaehlt[0]) : null; return `
${v.faelligkeit ? `${esc(formatDateDE(parseDateISO(v.faelligkeit)) || v.faelligkeit)}` : ''} ${erstePerson ? `${esc(erstePerson.person.name || initialsForName(erstePerson.person.fullName || '') || '?')}` : ''} ▸
${v.beschreibung ? `
${esc(v.beschreibung)}
` : ''}
${kandidaten.length ? kandidaten.map(k => { const kuerzel = k.person.name || initialsForName(k.person.fullName || '') || '?'; const voll = k.person.fullName || k.person.name || ''; return ``; }).join('') : 'Niemand diesem Projekt zugeordnet.'}
${!vorausgewaehlt.length && v.zustaendigVorschlag ? `KI-Vorschlag „${esc(v.zustaendigVorschlag)}“ — kein passendes Teammitglied gefunden.` : ''} ${quelle ? `aus: ${esc(quelle)}` : ''}
`; }).join(''); return `
·
${zeilen}
`; } // protokollTodosZurueck: ein Schritt zurück, ohne erneuten Serveraufruf — // alle Daten bleiben im Modul-Zustand. Keine Parameter, keine Rückgabe. window.protokollTodosZurueck = function(){ if(step === 0) return; step--; renderStep(); }; // protokollTodosWeiter: der rechte Fußzeilen-Knopf führt Schritt UND // Netzwerkaufruf in einem aus — "Einlesen"/"Aufgaben ableiten" lösen den // jeweils nächsten KI-Schritt aus, der letzte Klick übernimmt die // Aufgaben. Keine Parameter, keine Rückgabe. window.protokollTodosWeiter = function(){ if(step === 0) return schrittEinlesen(); if(step === 1) return schrittAbleiten(); return schrittUebernehmen(); }; function schrittEinlesen(){ verbergeFehler(); const fileInput = document.getElementById('protokollTodosFile'); const file = fileInput && fileInput.files && fileInput.files[0]; if(!file) return zeigeFehler('Bitte eine PDF-Datei auswählen.'); gewaehlterAnbieter = (document.getElementById('protokollTodosAnbieter') || {}).value || ''; setzeLadezustand(true, 'Wird eingelesen … bei KI-Erkennung über einen eigenen Server kann dies bis zu 30 Minuten dauern.'); fetch(baseUrl() + '/plan-todos-aus-protokoll/einlesen?anbieter=' + encodeURIComponent(gewaehlterAnbieter), { method: 'POST', headers: { 'Content-Type': file.type || 'application/pdf' }, body: file }) .then(r => r.json().then(d => ({ ok: r.ok, d }))) .then(({ ok, d }) => { if(!ok || !d.ok) throw new Error((d && d.error) || 'Einlesen fehlgeschlagen.'); erkanntePunkte = Array.isArray(d.punkte) ? d.punkte : []; punkteAuswahl = {}; if(erkanntePunkte.length === 0){ setzeLadezustand(false); zeigeFehler('Es wurden keine Punkte erkannt.'); return; } step = 1; setzeLadezustand(false); if(d.erkennungsart !== 'chatgpt' && d.erkennungsart !== 'claude' && d.erkennungshinweis){ zeigeFallbackHinweis('KI-Erkennung nicht verfügbar (' + d.erkennungshinweis + ') — Punkte per Textmuster erkannt.'); } }) .catch(err => { console.error('TODOs aus Protokoll (Einlesen) fehlgeschlagen:', err); setzeLadezustand(false); zeigeFehler(err.message || String(err)); }); } function schrittAbleiten(){ verbergeFehler(); const listEl = document.getElementById('protokollTodosPunkteListe'); const checkedIdx = listEl ? Array.from(listEl.querySelectorAll('input[type="checkbox"]:checked')).map(cb => parseInt(cb.value, 10)) : []; if(checkedIdx.length === 0) return zeigeFehler('Bitte mindestens einen Punkt auswählen.'); ausgewaehltePunkte = checkedIdx.map(i => erkanntePunkte[i]); setzeLadezustand(true, 'Aufgaben werden abgeleitet …'); fetch(baseUrl() + '/plan-todos-aus-protokoll/aufgaben-vorschlag', { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ anbieter: gewaehlterAnbieter, punkte: ausgewaehltePunkte }) }) .then(r => r.json().then(d => ({ ok: r.ok, d }))) .then(({ ok, d }) => { if(!ok || !d.ok) throw new Error((d && d.error) || 'Ableiten fehlgeschlagen.'); vorschlaege = Array.isArray(d.vorschlaege) ? d.vorschlaege : []; step = 2; setzeLadezustand(false); if(d.erkennungsart === 'direkt' && d.erkennungshinweis){ zeigeFallbackHinweis('KI-Ableitung nicht verfügbar (' + d.erkennungshinweis + ') — Punkte direkt übernommen, ohne Zusammenfassung.'); } }) .catch(err => { console.error('TODOs aus Protokoll (Ableiten) fehlgeschlagen:', err); setzeLadezustand(false); zeigeFehler(err.message || String(err)); }); } // schrittUebernehmen: legt für jede angehakte Vorschlagszeile einen // normalen plan_todo-Eintrag an — IMMER mit gesetztem Start UND Ende, // sonst bleibt die Aufgabe ein reiner Checklisten-Eintrag ohne GANTT-Zeile // (CLAUDE.md: "Ein TODO ist nur dann eine GANTT-Zeile, wenn es BEIDE Daten // hat"). Gleiche Form wie applyTemplate() (plan-templates.js) und // saveTodoAusKommentar() (wochenbericht.js). Kein Netzwerkaufruf hier // selbst — app.savePlan() übernimmt das Speichern wie überall sonst im // Projektbereich. function schrittUebernehmen(){ if(app.isLocked()) return; const rows = document.querySelectorAll('#protokollTodosVorschlaege .protokoll-todo-vorschlag'); const plan = app.ensurePlanLocal(); const heute = formatDateISO(new Date()); let anzahl = 0; rows.forEach(row => { const check = row.querySelector('.pt-vorschlag-check'); if(!check || !check.checked) return; const titel = ((row.querySelector('.pt-vorschlag-titel') || {}).value || '').trim(); if(!titel) return; const faelligkeit = (row.querySelector('.pt-vorschlag-termin') || {}).value || ''; const personen = Array.from(row.querySelectorAll('.pt-vorschlag-person[aria-pressed="true"]')).map(el => el.dataset.personId); plan.todos.push({ id: uid(), title: titel, start: heute, end: faelligkeit || heute, done: false, progress: 0, dependsOn: [], assignees: personen, }); anzahl++; }); closeProtokollTodosModal(); if(anzahl === 0) return; app.savePlan(); // Wie applyTemplate(): der sichtbare GANTT-Zeitraum muss die neuen // Balken einschließen, sonst erscheinen sie zwar in der Aufgabenliste, // aber außerhalb des aktuellen Zooms unsichtbar im Terminplan. fitPlanGanttRange(); app.showToast(anzahl + ' Aufgabe(n) aus Protokoll angelegt.'); } })();