Files
projektuebersicht/public/js/planchecker/planchecker.js
T
erikandClaude Opus 5 93505e0e48 Beteiligte in die Projekteinstellungen, TODOs aus Protokoll, Bewegung
Beteiligten-Zuordnung verlagert: p.assign ist jetzt ein Stammdatum und
nur noch in den Projekteinstellungen editierbar (planCycleAssign()).
Wochenbericht und Projektboard zeigen sie nur noch an, mit Verweis
dorthin (springZuEinstellungen()). cycleAssign() ist damit entfallen.

Neu "TODOs aus Protokoll": ein externes Besprechungsprotokoll als PDF
wird per KI eingelesen und daraus in einem zweiten KI-Schritt Aufgaben
abgeleitet. Eigener Bereich in der Aufgaben-/GANTT-Leiste, bewusst
unabhaengig von den gespeicherten Protokollen - nichts davon wird
abgelegt. Drei Schritte in einem Dialog mit fester Fusszeile; die
Vorschlagsliste als kompakte, aufklappbare Zeilen, weil ein echter
Import leicht 20 Vorschlaege liefert.

Sichtbarkeit je Aufgabe im GANTT: neue Spalte plan_todos.show_in_gantt
(Vorgabe 1, idempotente Migration) und eine Checkbox im Aufgaben-Dialog.
Eine terminierte Aufgabe kann damit gezielt aus dem Terminplan
herausgehalten werden, bleibt sonst aber eine vollwertige Aufgabe.
plan-build-gantt.js und plan-drag.js pruefen dieselbe Bedingung, damit
Zeilenzahl und Umsortierung nicht auseinanderlaufen.

KI-Timeout dritte Stufe: ein abweichender, aber entfernter Endpunkt
(Drittanbieter-Proxy) bekommt 2 Minuten statt der 45 Sekunden der
echten Anbieter-API. Gemessen: die Punkte-Erkennung eines realen
Protokolls brauchte dort 39 Sekunden, lag also stets knapp unter dem
alten Limit und brach bei jeder Schwankung ab.

Bewegung: Hover- und Klick-Rueckmeldung als Token-Satz in :root,
angewandt auf die anklickbaren Zeilen, Schalter und Dialoge. Die
Akzentleiste ist ein inset-Schatten statt eines positionierten
Elements, damit Drag-and-Drop unberuehrt bleibt; prefers-reduced-motion
war bisher gar nicht vorhanden und kippt jetzt einfach die Tokens.

Plan Checker: optionaler Projektabgleich des Plankopfs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-17 15:14:26 +02:00

306 lines
15 KiB
JavaScript

// -----------------------------
// Projektübersicht — Plan Checker (Bereich "Studenten")
// -----------------------------
// Eigenständiges Prüf-Werkzeug: lädt einen Bauplan als PDF hoch und lässt
// eine KI (ChatGPT oder Claude, Anbieter wählbar — exakt wie beim
// bestehenden Protokoll-Import, js/plan/plan-protokoll.js) ihn gegen eine
// admin-gepflegte Liste von Merkmalen prüfen (z. B. "Maßketten vorhanden",
// siehe Admin → Plan-Checker-Merkmale, js/admin/admin.js). Zusätzlich lässt
// sich je Prüfung EIN weiteres Merkmal ergänzen, das nur für diese eine
// Prüfung gilt und nicht gespeichert wird - und optional ein Projekt der
// aktuellen Sitzung wählen: Dann kommt serverseitig automatisch ein
// weiteres, ebenso nicht gespeichertes Merkmal dazu, das den Plankopf gegen
// Projektnummer/Bezeichnung dieses Projekts abgleicht (ladePlanCheckerProjekte()/
// planCheckerPruefen() unten, server.js POST /plan-checker-pruefen).
//
// Darstellung "Arbeitsfläche" (Eriks Wahl "B" aus dem Design-Vergleich):
// zwei Spalten dauerhaft nebeneinander — links Upload/Konfiguration, rechts
// das Ergebnis. Nach einer Korrektur am Plan lässt sich erneut hochladen und
// prüfen, ohne das vorherige Ergebnis aus den Augen zu verlieren.
//
// Server-Gegenstück: POST /plan-checker-pruefen (server.js) — Roh-Binärkörper
// wie beim Protokoll-Import, rendert die erste PDF-Seite serverseitig zu
// mehreren Bildern (Übersicht + hochaufgelöste Kachel-Ausschnitte,
// renderPdfAlsBildsatz()) und schickt die an die KI (Maßketten/Plankopf sind
// optische Merkmale, kein Fließtext). Die KI nennt zu jedem Merkmal
// zusätzlich den Index DESJENIGEN der gesendeten Bilder, auf dem der
// Nachweis am besten zu sehen ist (bildIndex) — genau dieses, bereits
// gerenderte Bild kommt unverändert als "screenshot"-Feld je Ergebnis
// zurück, s. renderPlanCheckerErgebnis() unten. (Ein erster Versuch mit
// einem Beschriftungsraster + nachträglichem Zuschnitt einer genannten
// Rasterzelle erwies sich als nicht gut genug und wurde durch diese
// einfachere Variante ersetzt.) Weder die PDF- noch die Bilddateien werden
// gespeichert — kein Dokumentenarchiv, ein Ad-hoc-Werkzeug.
//
// Läuft wie js/planung/planung.js als klassisches globales Skript (kein
// Modul/IIFE), geladen nach js/store.js/js/weeks.js, vor js/tabs-auth.js
// (das renderPlanChecker() beim Tab-Wechsel aufruft) — siehe App.html.
// planCheckerDatei: die aktuell gewählte PDF-Datei (File-Objekt) oder null.
let planCheckerDatei = null;
// planCheckerMerkmale: die zuletzt geladene Grundliste der Merkmale
// ({id, titel, prompt}), für die Vorschau links und als Grundlage der
// Ergebnisliste rechts (die Reihenfolge der Antwort folgt derselben Liste).
let planCheckerMerkmale = [];
// renderPlanChecker: Einstiegspunkt, von showTab('planchecker') gerufen.
// Lädt die Merkmale-Grundliste, die KI-Anbieter-Auswahl und die Projektliste
// neu — alle drei können sich seit dem letzten Besuch geändert haben
// (Admin-Pflege, eigener Schlüssel unter Einstellungen → Mein Konto, bzw.
// ein inzwischen angelegtes/umbenanntes Projekt). Keine Parameter, keine
// Rückgabe.
function renderPlanChecker(){
ladePlanCheckerMerkmale();
ladePlanCheckerAnbieter();
ladePlanCheckerProjekte();
}
// ladePlanCheckerProjekte: füllt die optionale Projekt-Auswahl aus der
// bereits geladenen aktuellen Sitzung (currentWeek().projects, js/weeks.js)
// — kein eigener Server-Aufruf nötig, die Liste liegt dem Client ohnehin
// schon vor. Gruppiert wie die Projektliste selbst (GROUP_ORDER/sortGroups(),
// js/globals.js bzw. js/utils.js). Der Options-Wert ist die Projekt-ID
// (stabil innerhalb der Sitzung) statt Nummer/Name direkt zu kodieren — bei
// der Prüfung wird das Projekt darüber erneut in currentWeek().projects
// nachgeschlagen (planCheckerPruefen() unten). Keine Parameter, keine
// Rückgabe.
function ladePlanCheckerProjekte(){
const sel = document.getElementById('planCheckerProjekt');
if(!sel) return;
const w = typeof currentWeek === 'function' ? currentWeek() : null;
const projekte = (w && Array.isArray(w.projects)) ? w.projects : [];
const bisher = sel.value;
const gruppenNamen = sortGroups([...new Set(projekte.map(p => p.group || 'Ohne Gruppe'))]);
sel.innerHTML = '<option value="">Kein Projekt wählen</option>' + gruppenNamen.map(g => {
const opts = projekte.filter(p => (p.group || 'Ohne Gruppe') === g)
.slice().sort((a, b) => a.name.localeCompare(b.name))
.map(p => `<option value="${esc(p.id)}">${esc(p.nr ? p.nr + ' · ' : '')}${esc(p.name)}</option>`).join('');
return opts ? `<optgroup label="${esc(g)}">${opts}</optgroup>` : '';
}).join('');
// Auswahl erhalten, falls sie nach dem Neuladen noch existiert (z. B. nach
// erneutem Tab-Wechsel innerhalb derselben Sitzung).
if(bisher && [...sel.options].some(o => o.value === bisher)) sel.value = bisher;
}
// ladePlanCheckerMerkmale: holt die admin-gepflegte Grundliste (GET
// /plan-checker-merkmale, jeder angemeldete Nutzer darf lesen) und zeigt sie
// read-only als Vorschau, damit vor dem Prüfen klar ist, was geprüft wird.
// Keine Parameter, keine Rückgabe.
function ladePlanCheckerMerkmale(){
const el = document.getElementById('planCheckerMerkmaleListe');
if(el) el.innerHTML = '<span class="hint" style="margin:0">Lade Merkmale…</span>';
fetch(baseUrl() + '/plan-checker-merkmale')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
planCheckerMerkmale = Array.isArray(data.merkmale) ? data.merkmale : [];
if(!el) return;
el.innerHTML = planCheckerMerkmale.length === 0
? '<span class="hint" style="margin:0">Noch kein Merkmal hinterlegt (siehe Admin → Plan-Checker-Merkmale).</span>'
: planCheckerMerkmale.map(m => `<div class="pc-merkmal-chip">${esc(m.titel)}</div>`).join('');
})
.catch(err => {
console.error('Plan-Checker-Merkmale laden fehlgeschlagen:', err);
if(el) el.innerHTML = '<span class="hint" style="margin:0">Merkmale konnten nicht geladen werden.</span>';
});
}
// ladePlanCheckerAnbieter: baut die Anbieter-Auswahl aus GET /my-ai-settings
// auf — derselbe Weg wie beim Protokoll-Import-Dialog
// (oeffneProtokollImportDialog(), js/plan/plan-protokoll.js): der Hinweis
// "(kein Schlüssel hinterlegt)" bezieht sich auf den EIGENEN Schlüssel des
// angemeldeten Kontos. Keine Parameter, keine Rückgabe.
function ladePlanCheckerAnbieter(){
const sel = document.getElementById('planCheckerAnbieter');
if(!sel) return;
sel.innerHTML = `<option value="chatgpt">ChatGPT</option><option value="claude">Claude</option>`;
fetch(baseUrl() + '/my-ai-settings')
.then(r => r.json())
.then(d => {
if(!d.ok) return;
sel.innerHTML = `
<option value="chatgpt">ChatGPT${d.chatgpt && d.chatgpt.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>
<option value="claude">Claude${d.claude && d.claude.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>`;
})
.catch(err => console.error('KI-Einstellungen laden fehlgeschlagen:', err));
}
// ---------- Datei-Auswahl (Klick oder Ziehen-und-Ablegen) ----------
// planCheckerDateiGewaehlt: übernimmt eine gewählte/abgelegte Datei, prüft
// grob die Endung (die eigentliche Formatprüfung — PDF-Magic-Bytes — läuft
// serverseitig) und zeigt den Dateinamen in der Ablagefläche an.
// Parameter: file (File oder undefined). Keine Rückgabe.
function planCheckerDateiGewaehlt(file){
const fehlerEl = document.getElementById('planCheckerError');
if(fehlerEl){ fehlerEl.hidden = true; fehlerEl.textContent = ''; }
if(!file) return;
if(!/\.pdf$/i.test(file.name)){
if(fehlerEl){ fehlerEl.textContent = 'Bitte eine PDF-Datei auswählen.'; fehlerEl.hidden = false; }
planCheckerDatei = null;
return;
}
planCheckerDatei = file;
const nameEl = document.getElementById('planCheckerDateiname');
const textEl = document.getElementById('planCheckerDropText');
if(nameEl){ nameEl.textContent = file.name; nameEl.hidden = false; }
if(textEl) textEl.textContent = 'Andere Datei wählen';
}
function planCheckerDragOver(e){
e.preventDefault();
const drop = document.getElementById('planCheckerDrop');
if(drop) drop.classList.add('is-dragover');
}
function planCheckerDragLeave(e){
const drop = document.getElementById('planCheckerDrop');
if(drop) drop.classList.remove('is-dragover');
}
function planCheckerDrop(e){
e.preventDefault();
const drop = document.getElementById('planCheckerDrop');
if(drop) drop.classList.remove('is-dragover');
const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
planCheckerDateiGewaehlt(file);
}
// ---------- Prüfen ----------
// planCheckerLadezustand: sperrt Eingaben/Knopf während der KI-Aufruf läuft
// — kann bei einem lokal laufenden Modell mehrere Minuten dauern (siehe
// KI_TIMEOUT_MS_LOKAL, server.js); ohne Rückmeldung wirkte der Bildschirm
// sonst hängengeblieben, und ein zweiter Klick hätte dieselbe Prüfung
// doppelt losgeschickt. Parameter: aktiv (Boolean). Keine Rückgabe.
function planCheckerLadezustand(aktiv){
const btn = document.getElementById('planCheckerPruefenBtn');
if(btn){ btn.disabled = aktiv; btn.textContent = aktiv ? 'Wird geprüft…' : 'Plan prüfen'; }
['planCheckerFile', 'planCheckerZusatz', 'planCheckerAnbieter', 'planCheckerProjekt'].forEach(id => {
const el = document.getElementById(id);
if(el) el.disabled = aktiv;
});
}
// planCheckerPruefen: schickt die gewählte Datei als rohen Binär-Body an
// POST /plan-checker-pruefen (gleiches Vorgehen wie importiereProtokoll(),
// js/plan/plan-protokoll.js) und zeigt das Ergebnis rechts an. Keine
// Parameter, keine Rückgabe.
function planCheckerPruefen(){
const fehlerEl = document.getElementById('planCheckerError');
const zeigeFehler = msg => { if(fehlerEl){ fehlerEl.textContent = msg; fehlerEl.hidden = false; } };
if(fehlerEl){ fehlerEl.hidden = true; fehlerEl.textContent = ''; }
if(!planCheckerDatei) return zeigeFehler('Bitte zuerst eine PDF-Datei wählen.');
const anbieterEl = document.getElementById('planCheckerAnbieter');
const provider = anbieterEl ? anbieterEl.value : '';
const zusatzEl = document.getElementById('planCheckerZusatz');
const zusatz = zusatzEl ? zusatzEl.value.trim() : '';
// Projekt-Abgleich: die Auswahl trägt nur die Projekt-ID (s.
// ladePlanCheckerProjekte() oben) - Nummer/Bezeichnung werden hier erneut
// aus currentWeek().projects nachgeschlagen, statt sie schon beim Aufbau
// der Auswahl im value zu kodieren.
const projektEl = document.getElementById('planCheckerProjekt');
const projektId = projektEl ? projektEl.value : '';
const w = typeof currentWeek === 'function' ? currentWeek() : null;
const projekt = projektId && w ? (w.projects || []).find(p => p.id === projektId) : null;
const url = baseUrl() + '/plan-checker-pruefen'
+ '?provider=' + encodeURIComponent(provider)
+ '&zusatzMerkmal=' + encodeURIComponent(zusatz)
+ '&projektNr=' + encodeURIComponent(projekt ? (projekt.nr || '') : '')
+ '&projektName=' + encodeURIComponent(projekt ? projekt.name : '');
const datei = planCheckerDatei;
planCheckerLadezustand(true);
fetch(url, {
method: 'POST',
headers: { 'Content-Type': datei.type || 'application/pdf' },
body: datei
})
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
if(!ok || !d.ok) throw new Error((d && d.error) || 'Prüfung fehlgeschlagen.');
renderPlanCheckerErgebnis(datei.name, provider, d.ergebnisse || []);
})
.catch(err => {
console.error('Plan-Prüfung fehlgeschlagen:', err);
zeigeFehler(err.message || String(err));
})
.finally(() => planCheckerLadezustand(false));
}
// planCheckerLetzteErgebnisse: die zuletzt gerenderten Ergebniszeilen,
// gemerkt für planCheckerZeigeScreenshot() unten — ein Klick auf eine Zeile
// übergibt nur den Index, nicht die (potenziell sehr lange) Data-URL selbst
// als Inline-onclick-Parameter.
let planCheckerLetzteErgebnisse = [];
// renderPlanCheckerErgebnis: zeigt das Prüfergebnis in der rechten Spalte —
// Dateiname/Anbieter/Zeitpunkt als Kopfzeile, danach je Merkmal eine Zeile
// mit grünem/rotem Kreis, der Begründung der KI und (falls vorhanden) einem
// Screenshot — demjenigen der an die KI gesendeten Bilder (Übersicht oder
// eine Kachel), das die KI über bildIndex als besten Beleg für dieses
// Merkmal benannt hat (server.js, pruefePlanMitKI()). Parameter: dateiname
// (String), provider ('chatgpt'|'claude'), ergebnisse (Array {merkmal,
// erfuellt, begruendung, screenshot}, screenshot eine Data-URL oder null).
// Keine Rückgabe.
function renderPlanCheckerErgebnis(dateiname, provider, ergebnisse){
const leerEl = document.getElementById('planCheckerLeer');
const zielEl = document.getElementById('planCheckerErgebnis');
if(!zielEl) return;
if(leerEl) leerEl.hidden = true;
planCheckerLetzteErgebnisse = ergebnisse;
const anbieterLabel = provider === 'claude' ? 'Claude' : 'ChatGPT';
const zeit = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
// Der Screenshot öffnet NICHT als <a target="_blank"> auf die data:-URL —
// Chrome blockiert das Öffnen einer data:-URL als Top-Level-Navigation in
// einem neuen Tab, der Klick blieb dadurch wirkungslos (Eriks Rückmeldung:
// "wenn ich auf das Bild im Protokoll öffne, wird es nicht in groß
// angezeigt"). Stattdessen ein Knopf, der #pcShotModal füllt und einblendet
// (planCheckerZeigeScreenshot(), App.html) — bleibt auf derselben Seite,
// keine Navigation nötig.
const zeilen = ergebnisse.map((e, i) => `<div class="pc-result-row">
<span class="pc-verdict ${e.erfuellt ? 'ok' : 'fail'}">${e.erfuellt ? '✓' : '✕'}</span>
<div class="pc-result-text"><div class="titel">${esc(e.merkmal)}</div><div class="begruendung">${esc(e.begruendung)}</div></div>
${e.screenshot ? `<button type="button" class="pc-result-shot" onclick="planCheckerZeigeScreenshot(${i})" title="Planausschnitt vergrößern">
<img src="${e.screenshot}" alt="Planausschnitt zu ${esc(e.merkmal)}">
</button>` : ''}
</div>`).join('');
zielEl.innerHTML = `
<div class="pc-result-head">
<span class="filename">${esc(dateiname)}</span>
<span class="meta">geprüft mit ${esc(anbieterLabel)} · ${esc(zeit)}</span>
</div>
${zeilen || '<span class="hint">Keine Merkmale geprüft.</span>'}`;
}
// planCheckerZeigeScreenshot: füllt #pcShotModal (App.html) mit dem
// Screenshot der Ergebniszeile index (aus planCheckerLetzteErgebnisse) und
// blendet es ein. Parameter: index (Number). Keine Rückgabe.
function planCheckerZeigeScreenshot(index){
const e = planCheckerLetzteErgebnisse[index];
if(!e || !e.screenshot) return;
const img = document.getElementById('pcShotModalImg');
const titel = document.getElementById('pcShotModalTitel');
const modal = document.getElementById('pcShotModal');
if(img) img.src = e.screenshot;
if(titel) titel.textContent = e.merkmal;
if(modal) modal.style.display = 'flex';
}
// planCheckerSchliesseScreenshot: blendet #pcShotModal wieder aus. Leert
// zusätzlich img.src (statt die Data-URL im DOM zu belassen) - unnötig, das
// Bild erneut im Speicher zu halten, solange der Dialog geschlossen ist.
// Keine Parameter, keine Rückgabe.
function planCheckerSchliesseScreenshot(){
const modal = document.getElementById('pcShotModal');
const img = document.getElementById('pcShotModalImg');
if(modal) modal.style.display = 'none';
if(img) img.src = '';
}