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>
This commit is contained in:
erikandClaude Sonnet 5 committed 2026-10-05 14:01:41 +02:00
1 parent db63a29365
commit 6ba93b2a92
308 files changed
+64441 -743

No files matched your search

+139 -88
View File
@@ -164,11 +164,16 @@ function renderAttendance(){
const locked = isWeekLocked(w);
const att = w.attendance || {};
const people = Array.isArray(w.people) ? w.people : [];
// Fokus-Modus (Eriks Wahl "C"): die Anwesenheit ist ein aufklappbarer
// Abschnitt wie Leistungsphasen/Beteiligte, kein dauerhaft sichtbarer Block
// mehr — Zustand in berichtDisclosureState (js/board/board.js).
const offen = berichtDisclosureState.anwesenheit ? 'open' : '';
if(people.length === 0){
container.innerHTML = `<div class="attendance-block"><div class="attendance-bar">
<span class="attendance-title">Anwesenheit</span></div>
<div class="attendance-grid"><span class="hint">Für diese Sitzung sind keine Projektbeteiligten hinterlegt.</span></div></div>`;
container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}>
<summary><span>Anwesenheit</span><span class="chev">${icon('chevronRight')}</span></summary>
<div class="bericht-disclosure-body"><span class="hint" style="margin:0">Für diese Sitzung sind keine Projektbeteiligten hinterlegt.</span></div>
</details>`;
return;
}
@@ -177,9 +182,10 @@ function renderAttendance(){
// ein offener Kommentar-Editor beim Klick den Fokus, sein Blur-Netz baut den
// ganzen Bericht neu auf, die Schaltfläche verschwindet dabei — und der
// Klick kommt nie an (erst der zweite Versuch wirkte).
const actions = locked ? '' : `
const actions = locked ? '' : `<div class="attendance-actions">
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(true)">Alle anwesend</button>
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(false)">Zurücksetzen</button>`;
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(false)">Zurücksetzen</button>
</div>`;
const tiles = people.map(p => {
const present = !!att[p.id];
@@ -201,15 +207,10 @@ function renderAttendance(){
</button>`;
}).join('');
container.innerHTML = `<div class="attendance-block">
<div class="attendance-bar">
<span class="attendance-title">Anwesenheit</span>
<span class="attendance-count">${anwesend} von ${people.length} anwesend</span>
<span class="attendance-spacer"></span>
${actions}
</div>
<div class="attendance-grid">${tiles}</div>
</div>`;
container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}>
<summary><span>Anwesenheit der Sitzung</span><span class="count">${anwesend} von ${people.length}</span><span class="chev">${icon('chevronRight')}</span></summary>
<div class="bericht-disclosure-body">${actions}<div class="attendance-grid">${tiles}</div></div>
</details>`;
}
// toggleAttendance: schaltet die Anwesenheit einer Person für die aktuelle
@@ -353,18 +354,14 @@ function waehleBerichtProjekt(uid){
// ---------- Besprochen-Haken ----------
// toggleBesprochen: hakt ein Projekt als besprochen ab bzw. nimmt den Haken
// zurück. Der Haken gehört zur Sitzung (Spalte projects.besprochen) und geht
// deshalb durch den normalen Speicherweg. Parameter: pid. Keine Rückgabe.
function toggleBesprochen(pid){
if(guardWeekLocked()) return;
const w = currentWeek();
const p = (w.projects || []).find(x => x.id === pid);
if(!p) return;
p.besprochen = !p.besprochen;
save();
renderWochenbericht();
}
// HINWEIS: toggleBesprochen() ist entfallen — die schmale Nummern-Leiste des
// Fokus-Modus (Eriks Wahl "C") hat keinen Platz mehr für einen Haken je
// Zeile, und die Detail-Kopfzeile zeigt bewusst keinen eigenen mehr (siehe
// freigegebener Entwurf). Einzelne Projekte abhaken läuft über
// besprochenUndWeiter() (einzeln, beim Weitergehen), alle zurücknehmen über
// setzeAlleBesprochen(false) ("Zurücksetzen" in der Kopfleiste) — ein
// bewusstes Zurücknehmen nur EINES bereits abgehakten Projekts ohne die
// übrigen anzutasten ist damit nicht mehr vorgesehen.
// besprochenUndWeiter: hakt das aktuelle Projekt ab und springt zum nächsten,
// das noch offen ist. Der Motor des Meetings — ohne ihn sucht man nach jedem
@@ -435,12 +432,15 @@ function passtZurSuche(p){
// ---------- Rendern ----------
// renderWochenbericht: die Tagesordnung des Teammeetings — links die
// vollständige Projektliste, rechts das gewählte Projekt in voller Größe.
// renderWochenbericht: die Tagesordnung des Teammeetings — links eine
// schmale Nummern-Leiste über die vollständige Projektliste (Fokus-Modus,
// Eriks Wahl "C" aus dem Darstellungs-Vergleich: Nummer und Änderungs-Punkt
// sichtbar, Nummer und Name im Tooltip), rechts das gewählte Projekt in
// voller Größe.
//
// Diese Aufteilung hat die frühere Abfolge gestapelter Karten abgelöst: Bei
// 29 Projekten war der Bericht 18 Bildschirmseiten lang, und man verlor beim
// Durchgehen die Orientierung. Jetzt bleibt die Liste stehen, man sieht
// Durchgehen die Orientierung. Jetzt bleibt die Leiste stehen, man sieht
// jederzeit, wo man steht und was noch kommt, und scrollt nicht mehr.
//
// Die Liste ist nach PROJEKTGRUPPE geordnet (wie im Projektboard), nicht mehr
@@ -478,25 +478,25 @@ function renderWochenbericht(){
</div>`;
}
html += renderBerichtKopf(w, liste, prevWeek, locked);
if(liste.length === 0){
html += renderBerichtKopf(w, liste, prevWeek, locked);
html += `<div class="hint">In dieser Sitzung ist noch kein Projekt angelegt.</div>`;
container.innerHTML = html;
renderAttendance();
return;
}
html += `<div class="bericht-split">
<div class="bericht-liste-spalte">
<div class="bericht-suche">
<input type="search" id="berichtSuche" placeholder="Nummer oder Name…"
value="${esc(berichtSuche)}" oninput="setzeBerichtSuche(this.value)"
aria-label="Zu einem Projekt springen">
</div>
<div class="bericht-liste" id="berichtListe"></div>
// Eine Fläche (.bericht-panel), nicht zwei lose nebeneinanderstehende Karten
// — Kopfleiste und Leiste|Hauptspalte gehören sichtbar zusammen (Entwurf
// "Fokus-Modus"). Die Kopfleiste bekommt einen eigenen Container, damit das
// Öffnen/Schließen des Sprungfelds (oeffneBerichtSuche()) nur sie neu
// zeichnen muss, nicht die ganze Tagesordnung samt offenem Kommentarfeld.
html += `<div class="bericht-panel">
<div id="berichtKopf">${renderBerichtKopf(w, liste, prevWeek, locked)}</div>
<div class="bericht-layout">
<div class="bericht-rail" id="berichtListe"></div>
<div class="bericht-main" id="berichtDetail">${renderBerichtDetail(aktiv, w, prevWeek, locked)}</div>
</div>
<div class="bericht-detail" id="berichtDetail">${renderBerichtDetail(aktiv, w, prevWeek, locked)}</div>
</div>`;
container.innerHTML = html;
@@ -504,72 +504,124 @@ function renderWochenbericht(){
renderAttendance();
}
// renderBerichtKopf: Vergleichs-Hinweis und Fortschrittsanzeige.
// berichtSucheOffen: ob das Sprungfeld gerade als Eingabefeld statt als
// Knopf dargestellt wird (Fokus-Modus, Eriks Wahl "C") — der Entwurf zeigt
// die Suche standardmäßig nur als schmalen Knopf, nicht als dauerhaft
// sichtbares Eingabefeld. Bleibt auch offen, solange tatsächlich gesucht
// wird (berichtSuche nicht leer), sonst verschwände der eigene Suchtext beim
// nächsten Neu-Rendern wieder hinter dem Knopf.
let berichtSucheOffen = false;
// oeffneBerichtSuche: blendet das Eingabefeld ein und fokussiert es.
// Keine Parameter, keine Rückgabe.
function oeffneBerichtSuche(){
berichtSucheOffen = true;
renderBerichtKopfNeu();
const feld = document.getElementById('berichtSuche');
if(feld) feld.focus();
}
// schliesseBerichtSucheFallsLeer: fällt beim Verlassen des leeren Feldes
// zurück auf den Knopf — ein leeres Feld brächte sonst dauerhaft unnötige
// Breite in die Kopfleiste. Keine Parameter, keine Rückgabe.
function schliesseBerichtSucheFallsLeer(){
if(berichtSuche) return;
berichtSucheOffen = false;
renderBerichtKopfNeu();
}
// renderBerichtKopfNeu: zeichnet nur die Kopfleiste neu (Gewerk, Fortschritt,
// Sprungfeld) — eigene Funktion aus demselben Grund wie renderBerichtListe():
// ein Klick auf den Such-Knopf darf weder die Leiste noch einen gerade
// offenen Kommentar im Detail antasten. Keine Parameter, keine Rückgabe.
function renderBerichtKopfNeu(){
const w = currentWeek();
const el = document.getElementById('berichtKopf');
if(!w || !el) return;
const prevWeek = findPreviousWeek(w);
const liste = berichtProjekte(w);
const locked = isWeekLocked(w);
el.innerHTML = renderBerichtKopf(w, liste, prevWeek, locked);
}
// renderBerichtKopf: Gewerke-Wahl, Fortschrittsring und das Sprungfeld.
// Parameter: w, liste, prevWeek, locked. Rückgabe: HTML.
function renderBerichtKopf(w, liste, prevWeek, locked){
const ab = liste.filter(p => p.besprochen).length;
const pct = liste.length ? Math.round(ab / liste.length * 100) : 0;
// Der Vergleichs-Hinweis ("Vergleich mit KW …") steht nicht mehr als eigene
// Zeile da, sondern als Titel-Tooltip am Fortschritt — dieselbe Angabe,
// ohne eine weitere, dauerhaft sichtbare Textzeile im Entwurf zu ergänzen.
const vergleich = prevWeek
? `Vergleich mit ${esc(prevWeek.label)}${prevWeek.date ? ' (' + esc(formatWeekDate(prevWeek.date)) + ')' : ''}.`
: 'Dies ist die früheste vorhandene Sitzung — kein Vergleich zur Vorwoche möglich.';
return `<div class="bericht-kopf">
const resetBtn = locked ? '' : (ab > 0
? `<button type="button" class="bericht-reset-link" onclick="setzeAlleBesprochen(false)">Zurücksetzen</button>`
: `<button type="button" class="bericht-reset-link" onclick="setzeAlleBesprochen(true)">Alle abhaken</button>`);
const sucheAktiv = berichtSucheOffen || !!berichtSuche;
const sucheHtml = sucheAktiv
? `<input type="search" id="berichtSuche" class="bericht-suche-input" placeholder="Nummer oder Name…"
value="${esc(berichtSuche)}" oninput="setzeBerichtSuche(this.value)"
onblur="schliesseBerichtSucheFallsLeer()" aria-label="Zu einem Projekt springen">`
: `<button type="button" class="bericht-search-btn" onclick="oeffneBerichtSuche()">Projekt suchen…</button>`;
return `<div class="bericht-topbar">
${renderGewerkDropdown()}
<span class="hint" style="margin:0">${vergleich}</span>
<span class="bericht-fortschritt">
<span class="bericht-fortschritt-text">${ab} von ${liste.length} ${esc(wochenberichtGewerk)}-Projekten besprochen</span>
<span class="bericht-fortschritt-bar"><i style="width:${pct}%"></i></span>
${locked ? '' : (ab > 0
? `<button type="button" class="secondary small" onclick="setzeAlleBesprochen(false)">Haken zurücksetzen</button>`
: `<button type="button" class="secondary small" onclick="setzeAlleBesprochen(true)">Alle abhaken</button>`)}
</span>
<span class="bericht-ring" style="background:conic-gradient(var(--green) 0% ${pct}%, var(--line-soft) ${pct}% 100%)"><span></span></span>
<span class="bericht-fortschritt-text" title="${esc(vergleich)}">${ab} / ${liste.length} besprochen</span>
${resetBtn}
${sucheHtml}
</div>`;
}
// renderBerichtListe: die Tagesordnung links. Eigene Funktion, weil das
// Sprung-Feld sie allein neu zeichnet — das Detail rechts und vor allem ein
// gerade getippter Kommentar dürfen dabei nicht verlorengehen.
// Keine Parameter, keine Rückgabe.
// renderBerichtListe: die Tagesordnung als schmale Nummern-Leiste (Fokus-
// Modus, Eriks Wahl "C" aus dem Darstellungs-Vergleich) — jede Kachel trägt
// nur die Position in der Liste plus ggf. den Änderungs-Punkt, Nummer und
// Name stehen in einem Tooltip. Eigene Funktion, weil das Sprung-Feld sie
// allein neu zeichnet — das Detail und vor allem ein gerade getippter
// Kommentar dürfen dabei nicht verlorengehen. Keine Parameter, keine
// Rückgabe.
function renderBerichtListe(){
const el = document.getElementById('berichtListe');
const w = currentWeek();
if(!el || !w) return;
const locked = isWeekLocked(w);
const prevWeek = findPreviousWeek(w);
const aktiv = aktuellesBerichtProjekt(w);
const liste = berichtProjekte(w).filter(passtZurSuche);
// voll bleibt UNGEFILTERT: die Kachel-Nummer ist die Position in der
// vollständigen Tagesordnung, damit sie beim Tippen im Sprung-Feld nicht
// ständig neu durchnummeriert wird und mit der Zählung im Detail
// übereinstimmt.
const voll = berichtProjekte(w);
const liste = voll.filter(passtZurSuche);
if(liste.length === 0){
el.innerHTML = `<div class="hint" style="padding:10px">Kein Projekt passt zu „${esc(berichtSuche)}".</div>`;
el.innerHTML = `<div class="hint" style="padding:10px 6px">Kein Projekt passt zu „${esc(berichtSuche)}".</div>`;
return;
}
let letzteGruppe = null;
el.innerHTML = liste.map(p => {
const info = classifyProjectChange(p, prevWeek);
const fehlen = abwesendeBeteiligte(p, w);
const kopf = p.group !== letzteGruppe
? (letzteGruppe = p.group, `<div class="bericht-gruppe">${esc(p.group || 'Ohne Gruppe')}</div>`)
: '';
const istAktiv = aktiv && aktiv.id === p.id;
return kopf + `<div class="bericht-zeile${istAktiv ? ' is-aktiv' : ''}${p.besprochen ? ' is-ab' : ''}"
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}" data-pid="${esc(p.id)}"
aria-current="${istAktiv ? 'true' : 'false'}">
<button type="button" class="bericht-haken${p.besprochen ? ' ab' : ''}"
${locked ? 'disabled' : `onclick="event.stopPropagation();toggleBesprochen('${esc(p.id)}')"`}
title="${p.besprochen ? 'Haken zurücknehmen' : 'Als besprochen markieren'}"
aria-label="${esc(p.name)} als besprochen markieren">✓</button>
<span class="bericht-zeile-nr">${esc(p.nr || '—')}</span>
<span class="bericht-zeile-name">${esc(p.name)}</span>
${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''}
${fehlen.length ? `<span class="bericht-abwesend" title="Fehlt heute: ${esc(fehlen.join(', '))}">${esc(fehlen.length)}✗</span>` : ''}
${info.bucket === 'unchanged' ? '' : `<span class="bericht-punkt is-${esc(info.bucket)}" title="${esc(info.reason)}"></span>`}
const position = voll.findIndex(x => x.id === p.id) + 1;
const tooltip = (p.nr ? p.nr + ' · ' : '') + p.name;
// Eigene, schwebende Tooltip-Box (wie im freigegebenen Entwurf) statt
// des nativen Browser-Tooltips: Seit die Leiste nicht mehr selbst
// scrollt (kein overflow-y:auto mehr, siehe .bericht-rail in styles.css),
// gibt es auch keine abschneidende Overflow-Begrenzung mehr dafür.
return `<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${p.besprochen ? ' is-ab' : ''}"
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}"
aria-current="${istAktiv ? 'true' : 'false'}" aria-label="${esc(tooltip)}">
<span class="bericht-rail-num">${String(position).padStart(2, '0')}</span>
${info.bucket === 'unchanged' ? '' : `<span class="bericht-rail-dot is-${esc(info.bucket)}" aria-hidden="true"></span>`}
<span class="bericht-rail-tooltip">${esc(tooltip)}</span>
</div>`;
}).join('');
el.querySelectorAll('.bericht-zeile').forEach(z => {
el.querySelectorAll('.bericht-rail-item').forEach(z => {
const waehlen = () => waehleBerichtProjekt(z.dataset.uid || null);
z.addEventListener('click', e => { if(!e.target.closest('.bericht-haken')) waehlen(); });
z.addEventListener('click', waehlen);
z.addEventListener('keydown', e => {
if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); waehlen(); }
});
@@ -589,19 +641,18 @@ function renderBerichtDetail(p, w, prevWeek, locked){
const info = classifyProjectChange(p, prevWeek);
const tags = Array.isArray(p.tags) ? p.tags : [];
const fehlen = abwesendeBeteiligte(p, w);
const liste = berichtProjekte(w);
const nr = liste.findIndex(x => x.id === p.id) + 1;
// Reiner Text wie im freigegebenen Entwurf — kein zusätzlicher
// Auftragsstatus-Chip mehr an dieser Stelle (der Status bleibt über die
// Projektliste/-einstellungen erreichbar, "Ein Ort je Angabe").
const subText = esc(p.group || 'Ohne Gruppe') + (tags.length ? ' · ' + tags.map(t => esc(t)).join(' · ') : '');
return `<div class="bericht-detail-kopf">
<span class="bericht-detail-zaehler">${nr} / ${liste.length}</span>
<span class="bericht-zeile-nr">${esc(p.nr || '—')}</span>
<h3>${esc(p.name)}</h3>
${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''}
${tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('')}
${info.bucket === 'unchanged' ? '' : `<span class="bericht-kennz is-${esc(info.bucket)}">${esc(info.reason)}</span>`}
return `<div class="bericht-head-line">
<span class="bericht-head-nr">${esc(p.nr || '—')}</span>
${info.bucket === 'unchanged' ? '' : `<span class="bericht-change-pill is-${esc(info.bucket)}">${esc(info.reason)}</span>`}
</div>
<div class="bericht-detail-meta">${esc(p.group || 'Ohne Gruppe')}</div>
${fehlen.length ? `<div class="bericht-abwesend-hinweis">Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.</div>` : ''}
<h1 class="bericht-h1">${esc(p.name)}</h1>
<div class="bericht-sub">${subText}</div>
${fehlen.length ? `<span class="bericht-abwesend-hinweis">Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''}
${renderVorwochenzeile(p, prevWeek)}
${renderBoardDrawer(p, allPeople, locked, w.label, BERICHT_OPTS)}
${locked ? '' : `<div class="bericht-detail-fuss">
@@ -624,7 +675,7 @@ function renderVorwochenzeile(p, prevWeek){
const text = statusPlainText(vor.status);
if(!text) return '';
return `<div class="bericht-vorwoche">
<span class="bericht-vorwoche-label">${esc(prevWeek.label)}</span>
<span class="bericht-vorwoche-label">Vorwoche · ${esc(prevWeek.label)}</span>
<span class="bericht-vorwoche-text">${esc(text)}</span>
</div>`;
}