Files
projektuebersicht/public/js/planchecker/planchecker.js
T
erikandClaude Sonnet 5 6ba93b2a92 Plan Checker, Wochenbericht-Fokus-Modus und Wandtafel-Dashboard-Feinschliff
Plan Checker (neues Studenten-Werkzeug, Seitenleiste + Admin → Plan-Checker-
Merkmale): lädt einen Bauplan als PDF hoch und lässt ChatGPT oder Claude ihn
gegen eine gepflegte Merkmalsliste prüfen. PDF-Rendering (pdf.js +
@napi-rs/canvas) berücksichtigt jetzt Ebenen (Optional Content Groups) und
WASM-Decoder korrekt, sendet eine Übersicht plus mehrere hochaufgelöste
Kachel-Ausschnitte statt eines einzelnen, zu groben Bilds, und beide Anbieter
bekommen dasselbe Bildmaterial. Jedes Prüfergebnis kann auf eines der
gesendeten Bilder als Beleg verweisen (bildIndex) — als Screenshot im
Ergebnis anklickbar, öffnet vergrößert in einem Modal statt eines von Chrome
blockierten target=_blank-Links auf eine data:-URL. KI-Modellname ist jetzt
pro Konto überschreibbar (Einstellungen → Mein Konto) für Drittanbieter mit
abweichendem Modell-Katalog.

Wochenbericht: dichte Kartenansicht bekommt einen Fokus-Modus — nur der
Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in
aufklappbaren Abschnitten statt dauerhaft nebeneinander zu stehen.

Projektbereich: Terminplan-Auslastung rechnet jetzt exakt wie der
Auslastungs-Tab/die Wandtafel (statt einer unabhängigen, tageweisen
Schätzung); die LPH-Fortschrittsliste im Dashboard zeigt nur noch die
beauftragten Leistungsphasen.

Wandtafel: Dashboard-Kopf mit Kennzahlen-Reihe und Panel-Reihe neu
strukturiert, in den Blautönen des Firmenlogos; dieselbe Palette wurde
anschließend auf den gesamten App-weiten Dark Mode übernommen.

Dateibereinigung: nicht mehr benötigte Test-Uploads/Backups entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 14:01:41 +02:00

262 lines
13 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.
//
// 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 und die KI-Anbieter-Auswahl neu — beide
// können sich seit dem letzten Besuch geändert haben (Admin-Pflege bzw.
// eigener Schlüssel unter Einstellungen → Mein Konto). Keine Parameter,
// keine Rückgabe.
function renderPlanChecker(){
ladePlanCheckerMerkmale();
ladePlanCheckerAnbieter();
}
// 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'].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() : '';
const url = baseUrl() + '/plan-checker-pruefen'
+ '?provider=' + encodeURIComponent(provider)
+ '&zusatzMerkmal=' + encodeURIComponent(zusatz);
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 = '';
}