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

+113 -4
View File
@@ -34,8 +34,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Projektübersicht</title>
<link rel="stylesheet" href="/styles.css?v=62">
<link rel="stylesheet" href="/styles.css?v=64">
<link rel="stylesheet" href="/plan-styles.css?v=19">
<!-- Fredoka/Nunito Sans: nur für die Wandtafel (js/wand/wand.js), siehe ihren
Dateikopf — fällt ohne Internetzugang automatisch auf die System-Schrift
zurück (font-family-Stack in styles.css), bricht also nichts offline. -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600;700&family=Nunito+Sans:wght@400;600;700;800&display=swap">
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/pdfmake.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/vfs_fonts.js"></script>
</head>
@@ -139,6 +143,16 @@
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4" width="17" height="17" rx="2.5"/><line x1="3.5" y1="9.5" x2="20.5" y2="9.5"/><line x1="8" y1="2.5" x2="8" y2="6.5"/><line x1="16" y1="2.5" x2="16" y2="6.5"/><line x1="7" y1="14" x2="12" y2="14"/><line x1="7" y1="17.5" x2="15" y2="17.5"/></svg></span></span><span class="nav-label">Planung</span>
</button>
<!-- Studenten: eigener, dritter Abschnitt (wie "Planung" nicht Teil
von "Navigation") — bislang nur ein Werkzeug, der Plan Checker:
lädt einen Bauplan als PDF hoch und lässt ihn von einer KI gegen
eine Liste von Merkmalen prüfen (Maßketten, Plankopf, ...),
siehe js/planchecker/planchecker.js. -->
<div class="sidebar-section-label">Studenten</div>
<button class="nav-item tab" data-tab="planchecker" onclick="showTab('planchecker')" title="Plan Checker">
<span class="nav-icon"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="m9 12 2 2 4-4"/></svg></span></span><span class="nav-label">Plan Checker</span>
</button>
<hr class="sidebar-nav-divider">
<!-- Einstellungen: seit die firmenweiten Masken in den Admin-Bereich
@@ -196,6 +210,7 @@
<button class="menu-item sidebar-subitem" data-admin-section="termine" onclick="showAdminSection('termine')">Termine &amp; Sitzungen</button>
<button class="menu-item sidebar-subitem" data-admin-section="feiertage" onclick="showAdminSection('feiertage')">Feiertage</button>
<button class="menu-item sidebar-subitem" data-admin-section="vorlagen" onclick="showAdminSection('vorlagen')">Aufgaben-Vorlagen</button>
<button class="menu-item sidebar-subitem" data-admin-section="planchecker" onclick="showAdminSection('planchecker')">Plan-Checker-Merkmale</button>
<button class="menu-item sidebar-subitem" data-admin-section="betrieb" onclick="showAdminSection('betrieb')">Betrieb &amp; Sicherheit</button>
<button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank &amp; Sitzungen</button>
</div>
@@ -282,6 +297,69 @@
<div id="planungList"></div>
</div>
<!-- PLAN CHECKER (js/planchecker/planchecker.js) — Studenten-Werkzeug: lädt
einen Bauplan als PDF hoch und lässt eine KI ihn gegen die unter
Admin → Plan-Checker-Merkmale gepflegten Merkmale prüfen. Weder die
Datei noch das Ergebnis werden gespeichert — ein Ad-hoc-Werkzeug, kein
Dokumentenarchiv (siehe POST /plan-checker-pruefen, server.js). -->
<div id="planchecker" style="display:none">
<div class="section-title">Plan Checker</div>
<p class="hint">Lädt einen Bauplan als PDF hoch und prüft ihn mit einer KI gegen die hinterlegten Merkmale.</p>
<div class="pc-layout">
<div class="pc-panel">
<label class="pc-drop" id="planCheckerDrop" for="planCheckerFile"
ondragover="planCheckerDragOver(event)" ondragleave="planCheckerDragLeave(event)" ondrop="planCheckerDrop(event)">
<span id="planCheckerDropText">PDF hierher ziehen oder klicken</span>
<div class="pc-drop-name" id="planCheckerDateiname" hidden></div>
</label>
<input type="file" id="planCheckerFile" accept="application/pdf" hidden onchange="planCheckerDateiGewaehlt(this.files[0])">
<div class="pc-section">
<label class="pc-label">Geprüfte Merkmale</label>
<div id="planCheckerMerkmaleListe"><span class="hint" style="margin:0">Lade Merkmale…</span></div>
<div class="pc-zusatz">
<input type="text" class="pc-input" id="planCheckerZusatz" placeholder="Weiteres Merkmal für diese Prüfung…">
</div>
</div>
<div class="pc-section">
<label class="pc-label">KI-Anbieter</label>
<select id="planCheckerAnbieter" class="pc-select" style="width:100%"></select>
</div>
<div class="pc-section">
<button type="button" class="pc-btn-primary pc-btn" id="planCheckerPruefenBtn" onclick="planCheckerPruefen()" style="width:100%">Plan prüfen</button>
<p class="hint pc-error" id="planCheckerError" hidden></p>
</div>
</div>
<div class="pc-panel pc-results">
<div class="pc-results-empty" id="planCheckerLeer">
<span class="big">Noch kein Plan geprüft</span>
PDF auswählen und auf „Plan prüfen" klicken.
</div>
<div id="planCheckerErgebnis"></div>
</div>
</div>
</div>
<!-- Plan Checker: Planausschnitt-Vorschau (js/planchecker/planchecker.js,
planCheckerZeigeScreenshot()) — zeigt den Screenshot vergrößert, den
die KI für ein Merkmal als Beleg gewählt hat. Ein einfaches In-Page-
Modal statt eines <a target="_blank"> auf die data:-URL: Chrome
blockiert das Öffnen einer data:-URL als Top-Level-Navigation in einem
neuen Tab, ein Link blieb deshalb wirkungslos. -->
<div class="modal-bg" id="pcShotModal" style="display:none">
<div class="modal modal-wide">
<h2 id="pcShotModalTitel">Planausschnitt</h2>
<img id="pcShotModalImg" src="" alt="" style="width:100%;height:auto;border-radius:var(--r-md);display:block">
<div class="toolbar" style="margin-top:16px">
<button class="secondary" onclick="planCheckerSchliesseScreenshot()">Schließen</button>
</div>
</div>
</div>
<!-- DASHBOARD (Begrüßung/Uhr, Meine Aufgaben, Freigaben, Kollegen-Abwesenheiten) -->
<div id="dashboard" style="display:none">
<div id="dashboardContent"></div>
@@ -381,6 +459,18 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 13 10 18 19 7"/></svg>
</button>
</div>
<!-- Modellname: nur nötig, wenn der eigene Server/Anbieter einen
bestimmten Namen erwartet (z. B. ein eigenständiger Anbieter
wie Mammouth.ai mit eigenem Modell-Katalog, bei dem der feste
OpenAI-Name "gpt-4o"/"gpt-4o-mini" nicht existiert) - leer
bedeutet weiterhin die hier hinterlegte Vorgabe. -->
<label>Modellname (optional)</label>
<div class="ai-provider-row">
<input type="text" id="setAiOpenaiModel" placeholder="leer = Vorgabe (gpt-4o-mini / gpt-4o)">
<button class="ai-icon-btn" onclick="saveMyAiModell('chatgpt')" title="Modellname speichern">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 13 10 18 19 7"/></svg>
</button>
</div>
</div>
</div>
@@ -416,6 +506,13 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 13 10 18 19 7"/></svg>
</button>
</div>
<label>Modellname (optional)</label>
<div class="ai-provider-row">
<input type="text" id="setAiClaudeModel" placeholder="leer = Vorgabe (claude-haiku-4-5 / claude-sonnet-5)">
<button class="ai-icon-btn" onclick="saveMyAiModell('claude')" title="Modellname speichern">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 13 10 18 19 7"/></svg>
</button>
</div>
</div>
</div>
@@ -582,6 +679,17 @@
<button class="secondary small" onclick="addTemplateEntry()">+ Schritt</button>
</div>
</section>
<section class="settings-card settings-card-wide" id="adminPlanCheckerCard" data-admin-section="planchecker" style="display:none">
<h3 class="settings-card-title">Plan-Checker-Merkmale <span class="settings-scope">firmenweit · nur Administratoren</span></h3>
<p class="hint">Die Grundliste, gegen die der Plan Checker (Bereich „Studenten") jeden hochgeladenen Plan prüft.
„Prüfanweisung" geht wortwörtlich an die KI — „Titel" ist zugleich die Anzeige-Bezeichnung.</p>
<div id="planCheckerMerkmaleAdminListe" class="template-admin-list"></div>
<div class="template-add-row">
<input type="text" id="merkmalNeuTitel" placeholder="Titel, z. B. „Maßketten vorhanden“">
<input type="text" id="merkmalNeuPrompt" placeholder="Prüfanweisung für die KI" style="flex:2">
<button class="secondary small" onclick="addPlanCheckerMerkmal()">+ Merkmal</button>
</div>
</section>
<section class="settings-card settings-card-wide" id="adminUsersCard" data-admin-section="benutzer" style="display:none">
<h3 class="settings-card-title">Benutzerverwaltung <span class="settings-scope">nur Administratoren</span></h3>
<p class="hint">Konten anlegen, bearbeiten, deaktivieren, Passwoerter zuruecksetzen und
@@ -1618,6 +1726,7 @@
<script src="/js/wochenbericht/wochenbericht.js?v=20"></script>
<script src="/js/people/people.js?v=7"></script>
<script src="/js/planung/planung.js?v=1"></script>
<script src="/js/planchecker/planchecker.js?v=1"></script>
<script src="/js/projects/projects.js?v=7"></script>
<script src="/js/load/load.js?v=18"></script>
<!-- auftrag.js: Auftragsstatus und Dialog "Beauftragung". Nach load.js, weil
@@ -1633,7 +1742,7 @@
relativerTag()/tageBis()/initialsFor() (dashboard.js) — alle laden
bereits vorher, die Reihenfolge hier ist also nur Zuordnung zum
Tab-Ordner, keine neue Abhängigkeit nach vorn. -->
<script src="/js/wand/wand.js?v=11"></script>
<script src="/js/wand/wand.js?v=12"></script>
<script src="/js/settings/settings.js?v=9"></script>
<script src="/js/admin/admin.js?v=11"></script>
<script src="/js/admin-users.js?v=4"></script>
@@ -1647,10 +1756,10 @@
umhuellt. -->
<script src="/js/plan/plan-state.js?v=12"></script>
<script src="/js/plan/plan-sections.js?v=5"></script>
<script src="/js/plan/plan-info.js?v=7"></script>
<script src="/js/plan/plan-info.js?v=8"></script>
<script src="/js/plan/plan-history.js?v=3"></script>
<script src="/js/plan/plan-build-gantt.js?v=7"></script>
<script src="/js/plan/plan-gantt.js?v=9"></script>
<script src="/js/plan/plan-gantt.js?v=10"></script>
<script src="/js/plan/plan-drag.js?v=6"></script>
<script src="/js/plan/plan-modals.js?v=7"></script>
<script src="/js/plan/plan-tasks.js?v=2"></script>
+91
View File
@@ -59,6 +59,7 @@ const ADMIN_SECTIONS = [
{ key: 'termine', title: 'Termine & Sitzungen' },
{ key: 'feiertage', title: 'Feiertage' },
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen' },
{ key: 'planchecker', title: 'Plan-Checker-Merkmale' },
{ key: 'betrieb', title: 'Betrieb & Sicherheit' },
{ key: 'datenbank', title: 'Datenbank & Sitzungen' },
];
@@ -66,6 +67,10 @@ const ADMIN_SECTIONS = [
// adminSection: der gerade sichtbare Abschnitt.
let adminSection = 'benutzer';
// adminPlanCheckerMerkmale: zuletzt geladene Plan-Checker-Merkmale-Liste
// (siehe Abschnitt "Plan-Checker-Merkmale" weiter unten).
let adminPlanCheckerMerkmale = [];
// renderAdminArea: Einstiegspunkt des Bereichs, von showTab('admin')
// gerufen. Befüllt alle Masken und wählt danach den sichtbaren Abschnitt.
// Keine Parameter, keine Rückgabe.
@@ -74,6 +79,7 @@ function renderAdminArea(){
loadAdminHonorar();
loadAdminTemplates();
loadAdminPlanCheckerMerkmale();
// Die firmenweiten Zahlen und Feiertage stehen bereits in APP_SETTINGS bzw.
// HOLIDAYS (beim Seitenstart geladen, js/store.js) — die Masken füllen sich
// also ohne einen weiteren Serveraufruf.
@@ -694,6 +700,91 @@ function deleteTemplateEntry(id){
});
}
// ---------- Abschnitt Plan-Checker-Merkmale ----------
//
// Dieselbe add/delete-only Liste wie die Aufgaben-Vorlagen oben, nur ohne
// Gruppierung (die Merkmale gelten unterschiedslos für jede Prüfung) — siehe
// plan_checker_merkmale/GET+POST+DELETE /plan-checker-merkmale, server.js.
// Die Liste selbst nutzt auch der Plan Checker (js/planchecker/planchecker.js),
// um zu zeigen, was geprüft wird — beide Stellen lesen denselben Endpunkt.
// loadAdminPlanCheckerMerkmale: holt alle Merkmale und rendert die Liste.
// Keine Parameter, keine Rückgabe.
function loadAdminPlanCheckerMerkmale(){
const listEl = document.getElementById('planCheckerMerkmaleAdminListe');
if(listEl) listEl.innerHTML = '<span class="hint">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 => {
adminPlanCheckerMerkmale = Array.isArray(data.merkmale) ? data.merkmale : [];
renderAdminPlanCheckerMerkmale();
})
.catch(err => {
console.error('Plan-Checker-Merkmale laden fehlgeschlagen:', err);
if(listEl) listEl.innerHTML = '<span class="hint">Merkmale konnten nicht geladen werden.</span>';
});
}
// renderAdminPlanCheckerMerkmale: zeigt die Merkmale in gepflegter Reihenfolge,
// je Zeile mit Prüfanweisung und Lösch-Knopf. Keine Parameter, keine Rückgabe.
function renderAdminPlanCheckerMerkmale(){
const listEl = document.getElementById('planCheckerMerkmaleAdminListe');
if(!listEl) return;
listEl.innerHTML = adminPlanCheckerMerkmale.length === 0
? '<span class="hint">Noch kein Merkmal hinterlegt.</span>'
: adminPlanCheckerMerkmale.map(m => `<div class="template-admin-row">
<span class="template-admin-title">${esc(m.titel)}</span>
<span class="hint" style="margin:0">${esc(m.prompt)}</span>
<button class="danger small" onclick="deletePlanCheckerMerkmal('${esc(m.id)}')" title="Merkmal löschen">×</button>
</div>`).join('');
}
// addPlanCheckerMerkmal: legt ein neues Merkmal an (POST, Admin-only).
// Keine Parameter, keine Rückgabe.
function addPlanCheckerMerkmal(){
const titelEl = document.getElementById('merkmalNeuTitel');
const promptEl = document.getElementById('merkmalNeuPrompt');
const titel = titelEl ? titelEl.value.trim() : '';
const prompt = promptEl ? promptEl.value.trim() : '';
if(!titel){ alert('Bitte einen Titel eingeben.'); return; }
if(!prompt){ alert('Bitte eine Prüfanweisung eingeben.'); return; }
fetch(baseUrl() + '/plan-checker-merkmale', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ titel, prompt })
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.');
if(titelEl) titelEl.value = '';
if(promptEl) promptEl.value = '';
loadAdminPlanCheckerMerkmale();
showAdminNote('Merkmal angelegt.');
})
.catch(err => {
console.error('Merkmal anlegen fehlgeschlagen:', err);
alert('Merkmal konnte nicht angelegt werden: ' + (err.message || err));
});
}
// deletePlanCheckerMerkmal: löscht ein Merkmal (DELETE, Admin-only).
// Parameter: id (String). Keine Rückgabe.
function deletePlanCheckerMerkmal(id){
if(!confirm('Dieses Merkmal löschen?')) return;
fetch(baseUrl() + '/plan-checker-merkmale/' + encodeURIComponent(id), { method: 'DELETE' })
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Löschen fehlgeschlagen.');
loadAdminPlanCheckerMerkmale();
})
.catch(err => {
console.error('Merkmal löschen fehlgeschlagen:', err);
alert('Merkmal konnte nicht gelöscht werden: ' + (err.message || err));
});
}
// ---------- Rückmeldung ----------
+52 -21
View File
@@ -28,6 +28,22 @@
// persistiert — jede Sitzung startet mit eingeklappten Zeilen.
let boardRowOpen = {};
// berichtDisclosureState: merkt sich je Abschnitt (Leistungsphasen, Beteiligte,
// Anwesenheit), ob er im Wochenbericht (Fokus-Modus — Eriks Wahl "C" aus dem
// Darstellungs-Vergleich: renderBoardDrawer()s dichte Fassung weiter unten +
// renderAttendance() in js/wochenbericht/wochenbericht.js) gerade auf- oder
// zugeklappt ist. Ohne dieses Gedächtnis klappte jeder Abschnitt bei jedem
// Neu-Rendern wieder auf seinen Vorgabewert zurück — und JEDE Änderung in der
// Schublade (eine Zuordnung, ein LPH-Wert) baut den ganzen Bericht neu auf.
// 'toggle' bubbelt nicht, ein Lauscher in der Capture-Phase sieht das
// Ereignis trotzdem auf dem Weg nach unten (gleiches Muster wie der
// scroll-Lauscher der Wandtafel).
const berichtDisclosureState = { lph: true, beteiligte: false, anwesenheit: false };
document.addEventListener('toggle', function(e){
const key = e.target && e.target.dataset && e.target.dataset.berichtAbschnitt;
if(key) berichtDisclosureState[key] = e.target.open;
}, true);
// toggleBoardRow: klappt das Detailpanel einer Projekt-Zeile auf bzw. zu.
// Parameter: pid (Projekt-ID). Keine Rückgabe.
function toggleBoardRow(pid){
@@ -500,34 +516,49 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
return `<div class="board-role-row"><span class="board-role-label ${roleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`;
}).join('');
// Dichte Fassung: zwei Spalten — links der Kommentar, rechts die
// Leistungsphasen-Leiste und darunter die Beteiligten.
//
// Zwischenzeitlich waren es drei Spalten, weil die gestapelten Beteiligten
// die Karte höher machten und damals 29 Karten untereinander standen. Seit
// der Wochenbericht eine Tagesordnung ist, ist immer nur EIN Projekt
// geöffnet — die Höhe der Karte kostet nichts mehr, während die Breite
// knapp ist: Neben der Projektliste blieben für drei Spalten nur noch
// 137 px für die Beteiligten, zu wenig für Rollenname und Kürzel.
// Dichte Fassung (Wochenbericht): erst zwei, dann drei Spalten nebeneinander
// — bei 29 Projekten mit allem gleichzeitig sichtbar kam von Erik die
// Rückmeldung "zu überladen". Jetzt (Fokus-Modus, Eriks Wahl "C" aus dem
// Darstellungs-Vergleich) liegt nur der Kommentar offen, Leistungsphasen
// und Beteiligte stecken in aufklappbaren Abschnitten untereinander.
//
// Die Beteiligten behalten ihre Zeile je Rolle — anders als im Entwurf, wo
// sie zu einer flachen Reihe zusammengefasst waren: Ein Kürzel kann in
// mehreren Rollen vorkommen, und cycleAssign() braucht die Rolle. Ohne sie
// wäre beim Klicken nicht erkennbar, welche Zuordnung man durchschaltet.
if(opts && opts.dicht){
// Fokus-Modus (Eriks Wahl "C" aus dem Darstellungs-Vergleich): nur der
// Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in
// aufklappbaren Abschnitten (<details>, Zustand in berichtDisclosureState)
// statt dauerhaft nebeneinander zu stehen. Die Leistungsphasen stehen hier
// als Balkenzeilen (bericht-lph-row) statt als Kästchen-Leiste
// (board-lph-leiste bleibt die Darstellung des Projektboards, unverändert).
const lphBarRows = sichtbareLphs.map(l=>{
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
const aktiv = l === aktLp;
const inputAttr = starr ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
const pct = v !== "" ? Math.min(100, parseFloat(v)||0) : 0;
return `<div class="bericht-lph-row${aktiv ? ' is-aktiv' : ''}">
<span class="bericht-lph-label">${esc(l.replace('LPH ', 'LP '))}</span>
<span class="bericht-lph-track"><i style="width:${pct}%"></i></span>
<span class="bericht-lph-pct"><input type="text" class="bericht-lph-input${locked ? ' locked-cell' : ''}" value="${esc(v)}" ${inputAttr} placeholder="-">%</span>
</div>`;
}).join('');
const lphInfo = aktLp
? esc(aktLp.replace('LPH ', 'LP ')) + ' aktiv' + (p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? ' · ' + esc(p.lph[aktLp]) + '%' : '')
: 'Keine aktive Leistungsphase';
const zugeordnetAnzahl = allPeople.filter(e => (p.assign[assignKey(e.person.id, e.role)] || 0) > 0).length;
return `<div class="board-drawer board-drawer-dicht">
<div class="board-drawer-grid">
<div class="drawer-col drawer-col-comment">
<h4>Kommentar${weekLabel ? ' zur Sitzung ' + esc(weekLabel) : ''}</h4>
${renderCommentEditor(p, weekLabel, locked, true)}
</div>
<div class="drawer-col drawer-col-kompakt">
<h4>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</h4>
<div class="board-lph-leiste">${lphLeiste}</div>
<h4 class="drawer-h4-zweit">Beteiligte</h4>
<div class="board-role-mini">${roleRows}</div>
</div>
</div>
${renderCommentEditor(p, weekLabel, locked, true)}
<details class="bericht-disclosure" data-bericht-abschnitt="lph" ${berichtDisclosureState.lph ? 'open' : ''}>
<summary><span>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</span><span class="count">${lphInfo}</span><span class="chev">${icon('chevronRight')}</span></summary>
<div class="bericht-disclosure-body">${lphBarRows}</div>
</details>
<details class="bericht-disclosure" data-bericht-abschnitt="beteiligte" ${berichtDisclosureState.beteiligte ? 'open' : ''}>
<summary><span>Beteiligte</span><span class="count">${zugeordnetAnzahl} zugeordnet</span><span class="chev">${icon('chevronRight')}</span></summary>
<div class="bericht-disclosure-body"><div class="board-role-mini">${roleRows}</div></div>
</details>
${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''}
</div>`;
}
+1
View File
@@ -145,6 +145,7 @@ const TAB_SLUGS = {
load: 'Auslastung',
people: 'Projektbeteiligte',
planung: 'Planung',
planchecker: 'PlanChecker',
dashboard:'MeineAufgaben',
wand: 'Wandtafel',
settings: 'Einstellungen',
+82 -62
View File
@@ -9,9 +9,12 @@
// (computeResourceLoads/renderResourceLoad/openResourceModal, Cockpit-Layout:
// Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter)
// und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt,
// offene Aufgaben, nächster Meilenstein, Team-Auslastung, alle aus denselben
// Plan-Daten abgeleitet). renderPlanGantt ist die zentrale Neu-Render-
// Funktion (rendert am Ende auch Auslastung und KPIs neu) und wird nach
// offene Aufgaben und nächster Meilenstein aus denselben Plan-Daten;
// Team-Auslastung dagegen über berechneAuslastung() aus js/load/load.js,
// siehe computeResourceLoads() weiter unten — dieselbe Rechnung wie im
// Auslastungs-Tab, nicht aus den Plan-Daten selbst). renderPlanGantt ist die
// zentrale Neu-Render-Funktion (rendert am Ende auch Auslastung und KPIs
// neu) und wird nach
// Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js erneut
// aufgerufen.
//
@@ -171,15 +174,15 @@
milestoneTile = kpiTile('Nächster Meilenstein', '–', milestones.length > 0 ? 'Keine anstehenden Meilensteine' : 'Keine Meilensteine angelegt');
}
const { loads, maxDays } = computeResourceLoads();
const { loads } = computeResourceLoads();
const loadValues = Object.values(loads);
let loadTile;
if(loadValues.length === 0){
loadTile = kpiTile('Team-Auslastung', '–', 'Keine Beteiligten zugeordnet');
} else {
const avgPct = Math.round(loadValues.reduce((s, l) => s + (l.days / maxDays * 100), 0) / loadValues.length);
const top = loadValues.slice().sort((a, b) => b.days - a.days)[0];
loadTile = kpiTile('Team-Auslastung', avgPct + '%', top.days > 0 ? 'Höchste Auslastung: ' + top.person.name : 'Noch keine Aufgaben zugewiesen');
const avgPct = Math.round(loadValues.reduce((s, l) => s + l.quote * 100, 0) / loadValues.length);
const top = loadValues.slice().sort((a, b) => b.quote - a.quote)[0];
loadTile = kpiTile('Team-Auslastung', avgPct + '%', top.stunden > 0 ? 'Höchste Auslastung: ' + top.person.name : 'Noch nicht bewertbar');
}
row.innerHTML = progressTile + openTile + milestoneTile + loadTile;
@@ -304,66 +307,83 @@
// ---------- Ressourcen-Auslastung ----------
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten in
// Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs.
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten
// NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel
// (berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor(),
// js/load/load.js) — Wochenstunden aus Honorar × Phasenanteil ×
// Restfortschritt, bezogen auf die Kapazität der Person.
//
// Vorher zählte diese Stelle etwas ganz anderes: Aufgabentage aus den
// TODO-Zeiträumen DIESES Terminplans, ohne jeden Bezug zu Honorar oder
// Kapazität — ein Blick auf dieselbe Person zeigte hier eine andere Zahl
// als im Auslastungs-Tab. Erik: "Die Auslastung der Projektbeteiligten im
// Terminplan ist eine andere als die in der sonstigen Anwendung... gleich-
// setzen mit der in der sonstigen Anwendung." Jetzt wird für JEDES Projekt
// der Sitzung (dieses UND alle übrigen) genau derselbe
// berechneAuslastung()-Aufruf gemacht wie in renderLoad() — dieselbe
// Person zeigt hier also exakt dieselbe Gesamtzahl wie im Auslastungs-Tab,
// nur aufgeteilt in den Anteil DIESES Projekts und den Anteil der übrigen
// Projekte der Sitzung (sonst wäre eine Überbuchung hier unsichtbar:
// jemand kann in diesem Projekt entspannt aussehen und trotzdem in vier
// weiteren ausgebucht sein — dieselbe Überlegung wie zuvor, nur jetzt in
// Stunden statt Tagen).
//
// Gemeinsam genutzt von renderResourceLoad() (Detailkarten im
// Auslastungs-Modal) und renderPlanKpis() (Team-Auslastung-Kachel), damit
// beide dieselbe Zahl zeigen.
//
// Neben der Auslastung IN DIESEM Projekt (days/todos) wird zusätzlich
// erfasst, wie stark dieselbe Person in den ÜBRIGEN Projekten derselben
// Sitzung gebunden ist (otherDays/otherProjects). Ohne diesen Blick über den
// Projektrand ist eine Überbuchung strukturell unsichtbar: jemand kann hier
// entspannt aussehen und trotzdem in vier weiteren Projekten verplant sein.
// Die Daten dafür sind bereits vorhanden — app.week.projects enthält alle
// Projekte der Sitzung (GET /load-all lädt sie ohnehin komplett).
//
// Rückgabe: { loads, maxDays } — loads ist ein Objekt Person-ID ->
// { person, roles, days, todos, otherDays, otherProjects }, maxDays das
// Maximum über alle Personen (mindestens 1, für Prozent-Berechnungen; bezieht
// sich bewusst nur auf die projekteigenen Tage, damit die Balken im Modal
// untereinander vergleichbar bleiben).
// Terminplan-Bereich) und renderPlanKpis() (Team-Auslastung-Kachel), damit
// beide dieselbe Zahl zeigen. Rückgabe: { loads } — loads ist ein Objekt
// Person-ID -> { person, roles, kapazitaet, stundenProjekt, stundenAndere,
// stunden (Summe), quote (stunden/kapazitaet), andereProjekte, todos
// (Titel der eigenen TODOs in DIESEM Projekt, rein informativ — zählt
// nicht mehr in die Stundenrechnung mit ein).
function computeResourceLoads(){
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos.filter(t => t.start && t.end) : [];
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const assignees = projectAssignees(app.project, app.week);
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
const loads = {};
assignees.forEach(a => {
if(!loads[a.person.id]){
loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [], otherDays: 0, otherProjects: [] };
loads[a.person.id] = {
person: a.person, roles: [], todos: [],
kapazitaet: kapazitaetsFaktor(a.person) * vollzeit,
stundenProjekt: 0, stundenAndere: 0, andereProjekte: [],
};
}
if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role);
});
todos.forEach(t => {
if(!Array.isArray(t.assignees) || t.assignees.length === 0) return;
const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end));
if(!Array.isArray(t.assignees)) return;
t.assignees.forEach(pid => {
if(!loads[pid]) return;
loads[pid].days += days;
loads[pid].todos.push(t.title);
if(loads[pid]) loads[pid].todos.push(t.title);
});
});
// Auslastung in den anderen Projekten derselben Sitzung
((app.week && app.week.projects) || []).forEach(other => {
if(other.id === app.project.id) return;
const otherTodos = (other.plan && Array.isArray(other.plan.todos)) ? other.plan.todos : [];
otherTodos.forEach(t => {
if(!t.start || !t.end || !Array.isArray(t.assignees)) return;
const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end));
t.assignees.forEach(pid => {
if(!loads[pid]) return; // nur Personen, die auch hier beteiligt sind
loads[pid].otherDays += days;
const label = other.nr ? (other.nr + ' ' + (other.name || '')) : (other.name || 'Projekt');
if(!loads[pid].otherProjects.includes(label)) loads[pid].otherProjects.push(label);
});
((app.week && app.week.projects) || []).forEach(pr => {
const aktivePhase = lpActiveList(pr)[0];
Object.keys(pr.assign || {}).forEach(aKey => {
const pid = aKey.slice(0, aKey.indexOf('|'));
if(!loads[pid]) return; // nur Personen, die auch in DIESEM Projekt beteiligt sind
const rolle = aKey.slice(aKey.indexOf('|') + 1);
const al = berechneAuslastung(null, pr, aktivePhase, rolle, pr.assign[aKey], pr.assign);
if(al.stunden <= 0) return;
if(pr.id === app.project.id){
loads[pid].stundenProjekt += al.stunden;
} else {
loads[pid].stundenAndere += al.stunden;
const label = pr.nr ? (pr.nr + ' ' + (pr.name || '')) : (pr.name || 'Projekt');
if(!loads[pid].andereProjekte.includes(label)) loads[pid].andereProjekte.push(label);
}
});
});
const maxDays = Math.max(1, Object.values(loads).reduce((m, l) => Math.max(m, l.days), 0));
return { loads, maxDays };
Object.values(loads).forEach(l => {
l.stunden = l.stundenProjekt + l.stundenAndere;
l.quote = l.kapazitaet > 0 ? l.stunden / l.kapazitaet : 0;
});
return { loads };
}
// openResourceModal: springt in den Bereich "Terminplan", in dem die
@@ -387,7 +407,7 @@
const container = document.getElementById('resourceLoadContainer');
const summaryEl = document.getElementById('planResourceSummary');
if(!container || !app.project) return;
const { loads, maxDays } = computeResourceLoads();
const { loads } = computeResourceLoads();
const loadValues = Object.values(loads);
if(loadValues.length === 0){
@@ -398,11 +418,10 @@
if(summaryEl){
const cells = loadValues.map(l => {
const pct = Math.round(l.days / maxDays * 100);
const otherHint = l.otherDays > 0 ? ` (+${l.otherDays} anderswo)` : '';
return `<div class="plan-heat-cell" style="opacity:${Math.max(.15, pct / 100)}" title="${esc(l.person.name)}: ${l.days} Tage${otherHint}"></div>`;
const otherHint = l.stundenAndere > 0 ? ` (+${stundenFormat(l.stundenAndere)} h anderswo)` : '';
return `<div class="plan-heat-cell" style="opacity:${Math.max(.15, Math.min(1, l.quote))}" title="${esc(l.person.name)}: ${stundenFormat(l.stunden)} h${otherHint}"></div>`;
}).join('');
const alsoBooked = loadValues.filter(l => l.otherDays > 0).length;
const alsoBooked = loadValues.filter(l => l.stundenAndere > 0).length;
const chip = alsoBooked > 0
? `<span class="plan-mini-chip warn">${alsoBooked} auch in anderen Projekten</span>`
: `<span class="plan-mini-chip">nur in diesem Projekt</span>`;
@@ -410,10 +429,11 @@
<div class="plan-mini-chip-row">${chip}</div>`;
}
let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)</div>';
let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Wochenstunden, wie im Auslastungs-Tab)</div>';
html += '<div class="load-grid plan-resource-grid">';
Object.values(loads).forEach(l => {
const pct = Math.round(l.days / maxDays * 100);
const ampel = loadAmpelColor(l.quote);
const breite = Math.min(100, l.quote * 100);
const absCount = app.countPersonAbsences(l.person.id);
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">${absCount} Abwesenheit(en)</div>` : '';
@@ -427,19 +447,19 @@
}
}
// Gegenüberstellung: Tage in diesem Projekt vs. in den übrigen Projekten
// derselben Sitzung (siehe computeResourceLoads) — macht sichtbar, wenn
// jemand hier entspannt aussieht, aber anderswo ausgebucht ist.
const otherBadge = l.otherDays > 0
? `<div class="plan-resource-other" title="${esc(l.otherProjects.join('\n'))}">
+${l.otherDays} Tage in ${l.otherProjects.length} weiteren Projekt${l.otherProjects.length === 1 ? '' : 'en'}
// Gegenüberstellung: Stunden aus diesem Projekt vs. aus den übrigen
// Projekten der Sitzung (siehe computeResourceLoads) — macht sichtbar,
// wenn jemand hier entspannt aussieht, aber anderswo ausgebucht ist.
const otherBadge = l.stundenAndere > 0
? `<div class="plan-resource-other" title="${esc(l.andereProjekte.join('\n'))}">
+${stundenFormat(l.stundenAndere)} h in ${l.andereProjekte.length} weiteren Projekt${l.andereProjekte.length === 1 ? '' : 'en'}
</div>`
: `<div class="plan-resource-other muted">keine weiteren Projekte</div>`;
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3>
<div class="load-num" style="font-size:18px">${l.days}</div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${pct}%;background:${pct>75?'#c53030':pct>45?'#d69e2e':'#2f855a'}"></div></div>
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h</span></div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
${otherBadge}
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
${absBadge}
+13 -2
View File
@@ -101,11 +101,22 @@
lpSel.disabled = locked;
}
// Fortschritt je Leistungsphase — nur Anzeige, ohne Eingabefeld
// Fortschritt je Leistungsphase — nur Anzeige, ohne Eingabefeld.
// Zeigt nur die BEAUFTRAGTEN Leistungsphasen (p.beauftragteLP, siehe
// "Einstellungen"), genau wie die Wochenbericht-/Board-Karte
// (BOARD_OPTS.nurBeauftragteLph, js/board/board.js) — dieselbe Regel,
// hier nur fest statt als opt-in, da das Dashboard keine zweite Fassung
// mit allen Phasen kennt. Ist nichts beauftragt (ältere Bestands-
// projekte), bleibt es bei der vollständigen Liste, sonst stünde dort
// gar nichts. Vorher zeigte diese Liste IMMER alle Leistungsphasen,
// unabhängig von den Einstellungen — von Erik gemeldet.
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
const sichtbareLphs = beauftragt.length > 0 ? LPHS.filter(l => beauftragt.includes(l)) : LPHS;
const lphList = document.getElementById('planLphList');
if(lphList){
if(!p.lph) p.lph = {};
lphList.innerHTML = LPHS.map(l => {
lphList.innerHTML = sichtbareLphs.map(l => {
const v = (p.lph[l] !== undefined && p.lph[l] !== '') ? p.lph[l] : '';
const aktiv = l === aktLp;
const pct = v !== '' ? Math.min(100, parseFloat(v)||0) : 0;
+261
View File
@@ -0,0 +1,261 @@
// -----------------------------
// 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 = '';
}
+28
View File
@@ -139,6 +139,7 @@ function setAiMode(provider, modus){
async function renderSettingsAiKeys(){
const statusEl = { chatgpt: document.getElementById('aiOpenaiStatus'), claude: document.getElementById('aiClaudeStatus') };
const urlEl = { chatgpt: document.getElementById('setAiOpenaiUrl'), claude: document.getElementById('setAiClaudeUrl') };
const modelEl = { chatgpt: document.getElementById('setAiOpenaiModel'), claude: document.getElementById('setAiClaudeModel') };
const clearBtn = { chatgpt: document.getElementById('aiOpenaiClearBtn'), claude: document.getElementById('aiClaudeClearBtn') };
const reconnectBtn = { chatgpt: document.getElementById('aiOpenaiReconnectBtn'), claude: document.getElementById('aiClaudeReconnectBtn') };
const testErrorEl = { chatgpt: document.getElementById('aiOpenaiTestError'), claude: document.getElementById('aiClaudeTestError') };
@@ -156,6 +157,7 @@ async function renderSettingsAiKeys(){
chip.innerHTML = '<span class="dot"></span>' + (info.configured ? 'Verbunden' : 'Nicht eingerichtet');
}
if(urlEl[provider] && document.activeElement !== urlEl[provider]) urlEl[provider].value = info.apiUrl || '';
if(modelEl[provider] && document.activeElement !== modelEl[provider]) modelEl[provider].value = info.model || '';
if(clearBtn[provider]) clearBtn[provider].disabled = !info.configured;
if(reconnectBtn[provider]) reconnectBtn[provider].disabled = !info.configured;
// Ein neuer Seitenaufruf ist ein neuer Anlauf — ein Fehler aus einem
@@ -270,6 +272,32 @@ async function saveMyAiUrl(provider){
}
}
// saveMyAiModell: übernimmt den eingegebenen Modellnamen — leer löscht eine
// zuvor gesetzte Abweichung und fällt auf die im Code hinterlegte Vorgabe
// zurück (gpt-4o-mini/gpt-4o bzw. claude-haiku/claude-sonnet-5, je nach
// Aufrufer). Nötig für einen eigenständigen Anbieter mit abweichendem
// Modell-Katalog (z. B. Mammouth.ai), bei dem die festen OpenAI-/
// Anthropic-Modellnamen nicht existieren. Eigenständig von saveMyAiUrl() aus
// demselben Grund, aus dem URL und Schlüssel schon getrennte Speichern-
// Knöpfe haben. Parameter: provider. Keine Rückgabe.
async function saveMyAiModell(provider){
const errEl = document.getElementById('settingsAiError');
const show = msg => { if(errEl){ errEl.textContent = msg; errEl.hidden = false; } };
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
const modelEl = document.getElementById(provider === 'chatgpt' ? 'setAiOpenaiModel' : 'setAiClaudeModel');
const modelValue = modelEl ? modelEl.value.trim() : '';
try {
await putMyAiKey(provider, { model: modelValue });
renderSettingsAiKeys();
showSettingsNote(modelValue ? 'Modellname gespeichert.' : 'Modellname entfernt — Vorgabe wird wieder verwendet.');
} catch(err){
console.error('Modellname speichern fehlgeschlagen:', err);
show(err.message || String(err));
}
}
// clearMyAiKey: löscht nur den Schlüssel, lässt eine hinterlegte API-URL
// unangetastet (patch enthält absichtlich kein apiUrl-Feld). Parameter:
// provider. Keine Rückgabe.
+5 -4
View File
@@ -3,9 +3,9 @@
// -----------------------------
// Modul für die Tab-Navigation der Hauptseite (nur von App.html geladen, nach
// js/wand/wand.js, vor js/pdf.js). showTab() wechselt zwischen Board,
// Wochenbericht, Auslastung, Projekte, Projektbeteiligte, Meine Aufgaben,
// Wandtafel, Einstellungen, Admin und dem Projektbereich eines einzelnen
// Projekts ('plan', siehe TAB_PANELS).
// Wochenbericht, Auslastung, Projekte, Projektbeteiligte, Planung, Plan
// Checker, Meine Aufgaben, Wandtafel, Einstellungen, Admin und dem
// Projektbereich eines einzelnen Projekts ('plan', siehe TAB_PANELS).
// Einstellungen und Admin sind keine eigenen Tabs mehr, sondern aufklappbare
// Bereiche direkt in der Seitenleiste (siehe toggleSidebarAccordion() in
// js/ui.js). Gerendert werden beim Tab-Wechsel renderBoard()/
@@ -23,7 +23,7 @@
// Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der
// Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom
// Dashboard ins Projektboard.
const TAB_PANELS = ['board','wochenbericht','load','people','planung','dashboard','wand','settings','admin','plan'];
const TAB_PANELS = ['board','wochenbericht','load','people','planung','planchecker','dashboard','wand','settings','admin','plan'];
function showTab(t){
// Der Bereich „Admin" steht nur Administratoren offen. Ohne diese Weiche
@@ -60,6 +60,7 @@ function showTab(t){
if(t==='load') renderLoad();
if(t==='people') renderPeople();
if(t==='planung') renderPlanung();
if(t==='planchecker') renderPlanChecker();
if(t==='dashboard') renderDashboard();
if(t==='wand') renderWandboard();
if(t==='settings') renderSettings();
+163 -133
View File
@@ -9,19 +9,29 @@
// Touch-Display mit einem eigens dafür angelegten Kiosk-Konto — siehe
// CLAUDE.md "Wandtafel".
//
// Zweispaltig (Eriks Wahl "Variante C" aus dem Layout-Vergleich): links drei
// kompakte Kennzahl-Kacheln, rechts der Ereignis-Feed — seit einem zweiten,
// eigenen Vergleich (Feed-Vergleich) nach Art in drei Spalten gruppiert
// (Neu/Überfällig/Termine) statt chronologisch gemischt, siehe
// wandFeedHtml(). Die Kacheln selbst sind NICHT antippbar (kein Auf-/Zuklappen
// mehr, auf Eriks Wunsch) — stattdessen ist jedes einzelne darin dargestellte
// Element für sich antippbar (eine Gewerk-Zeile, eine Kennzahl, ein Kürzel,
// ein Feed-Eintrag) und führt in eine Vollbild-Übersicht bzw. direkt ins
// Aufbau: aus einem Vergleich mehrerer komplett unterschiedlicher
// Gesamtentwürfe wählte Erik die "Leitstand"-Struktur (Kopf → eine
// Kennzahlen-Reihe aus vier Kacheln → eine Reihe aus drei Panels), dann in
// einem zweiten Durchgang die konkrete Gestaltung ("Pastell Freundlich"),
// zuletzt umgefärbt in die zwei Farben des Firmenlogos (aus public/logo.ico
// ausgelesen) statt einer generischen Pastellmischung — siehe die
// --dash-*-Eigenschaften in styles.css. Kopf: Marke "Dashboard" + Sitzung/
// Uhrzeit + wer heute abwesend ist (renderWandDashKopfHtml()). Kennzahlen-
// Reihe: Auslastung gesamt, Besprochen gesamt, offene Aufgaben, überfällig
// (wandKpiReiheHtml()/wandGesamtKennzahlen()). Panel-Reihe: Auslastung je
// Gewerk, Besprochen je Gewerk, Aktuell — ein chronologisch sortierter
// Ereignis-Feed (wandAuslastungInhalt()/wandBesprochenInhalt()/
// wandAktuellInhalt(), gemeinsam gerahmt von wandPanel()). Die Kacheln/
// Panels selbst sind NICHT antippbar (kein Auf-/Zuklappen, auf Eriks
// Wunsch) — stattdessen ist jedes einzelne darin dargestellte Element für
// sich antippbar (eine Gewerk-Zeile, eine Kennzahl, ein Kürzel, ein
// Feed-Eintrag) und führt in eine Vollbild-Übersicht bzw. direkt ins
// betroffene Projekt. Jede Kachel/jedes Ereignis ist eine reine Projektion
// bereits geladener Daten (dasselbe db aus GET /load-all, das jeder andere
// Tab auch nutzt) — keine eigene Server-Route, keine schreibenden Aktionen.
// Wiederverwendet werden berechneAuslastung()/loadAmpelColor()/
// kapazitaetsFaktor()/stundenFormat()/lpActiveList() aus js/load/load.js,
// kapazitaetsFaktor()/stundenFormat()/lpActiveList()/schwelleGelb()/
// schwelleRot() aus js/load/load.js,
// classifyProjectChange()/findPreviousWeek()/springZumWochenbericht() aus
// js/wochenbericht/wochenbericht.js bzw. js/plan/plan-info.js,
// statusPreviewText()/renderBoardRow()/renderBoardDrawer()/BOARD_OPTS aus
@@ -35,8 +45,8 @@
// Projektbereich, Seitenleiste und Kopfzeile kommen dabei zurück — wie bei
// jedem anderen Tab-Wechsel auch).
//
// Die Normalansicht ist höher als ein Bildschirm: unterhalb der zwei
// Kennzahl-Spalten folgt die vollständige Projektliste
// Die Normalansicht ist höher als ein Bildschirm: unterhalb von Kopf,
// Kennzahlen- und Panel-Reihe folgt die vollständige Projektliste
// (wandProjektlisteHtml()) — auf Eriks ausdrücklichen Wunsch optisch
// identisch mit der echten Projektliste (Tab "Projekte"), nicht eine eigene,
// vereinfachte Zeilenform: Jede Gruppe/Zeile entsteht über denselben
@@ -68,7 +78,7 @@
const WAND_GEWERKE = ['ELT', 'HLS', 'ARCH'];
const WAND_REFRESH_MS = 60000; // Hintergrund-Aktualisierung
const WAND_FEED_MAX = 5; // Höchstzahl Einträge JE SPALTE im Ereignis-Feed (Neu/Überfällig/Termine)
const WAND_FEED_MAX = 6; // Höchstzahl Einträge im "Aktuell"-Panel
// WAND_IDLE_MS: Erik hat einen echten Touch-Monitor — er darf davortreten und
// in der Projektliste unterhalb der Kacheln scrollen ("ich kann mich vor die
// Tafel stellen und scrollen"). Nach dieser Zeit ohne jede Berührung kehrt
@@ -260,10 +270,10 @@ async function wandRefreshTick(){
load(true); // silent — ruft am Ende renderAll()+showTab(currentTab) auf
}
// renderWandboard: baut entweder die zweispaltige Normalansicht (mit der
// vollständigen Projektliste darunter) oder eine offene Vollbild-Übersicht
// auf. Wird von showTab('wand') sowie nach jedem load() (über
// showTab(currentTab)) aufgerufen.
// renderWandboard: baut entweder die Normalansicht (Kopf + Kennzahlen-Reihe +
// Panel-Reihe, mit der vollständigen Projektliste darunter) oder eine offene
// Vollbild-Übersicht auf. Wird von showTab('wand') sowie nach jedem load()
// (über showTab(currentTab)) aufgerufen.
//
// Die Normalansicht ist höher als ein Bildschirm — Erik hat einen echten
// Touch-Monitor und wollte "unterhalb der Wandtafel... weitere Informationen
@@ -295,18 +305,98 @@ function renderWandboard(opts){
}
container.classList.add('wand-scrollable');
container.innerHTML = '<div class="wand-ambient"><div class="wand-c-layout">'
+ '<div class="wand-c-links">' + wandLinkeSpalteHtml(w) + '</div>'
+ '<div class="wand-c-rechts">'
+ '<h3 class="wand-kachel-titel">Aktuell diese Woche</h3>'
+ '<div class="wand-feed-wrap">' + wandFeedHtml(w) + '</div>'
+ renderWandStand()
container.innerHTML = '<div class="wand-ambient">'
+ wandDashKopfHtml(w)
+ wandKpiReiheHtml(w)
+ '<div class="dash-grid">'
+ wandPanel('Auslastung / Gewerk', wandAuslastungInhalt(w))
+ wandPanel('Besprochen / Gewerk', wandBesprochenInhalt(w))
+ wandPanel('Aktuell', wandAktuellInhalt(w))
+ '</div>'
+ '</div>'
+ '</div></div>'
+ wandProjektlisteHtml(w);
container.scrollTop = vorherigerScroll;
}
// wandGesamtKennzahlen: zwei über alle Gewerke hinweg zusammengefasste
// Kennzahlen für die Kennzahlen-Reihe oben — dieselbe Rechnung wie die
// Gewerk-Aufschlüsselung darunter (wandAuslastungJeGewerk()/
// wandBesprochenDaten()), nur aufsummiert statt aufgeschlüsselt. Die
// bewusste Mehrfach-Gewerke-Dopplung (ein Projekt mit mehreren Gewerken
// zählt seinen vollen Beitrag bei jedem Gewerk) gilt hier automatisch mit,
// da beide Quellfunktionen sie bereits je Gewerk anwenden. Parameter: w.
// Rückgabe: {auslastung: Zahl|null, besprochen: Zahl|null} — null, wenn sich
// die Quote mangels Kapazität bzw. Projekten nicht bilden lässt.
function wandGesamtKennzahlen(w){
const ausl = wandAuslastungJeGewerk(w);
let stunden = 0, kapazitaet = 0;
WAND_GEWERKE.forEach(g => { stunden += ausl[g].stunden; kapazitaet += ausl[g].kapazitaet; });
const bespr = wandBesprochenDaten(w);
let besprochen = 0, gesamt = 0;
WAND_GEWERKE.forEach(g => { besprochen += bespr[g].besprochen; gesamt += bespr[g].gesamt; });
return {
auslastung: kapazitaet > 0 ? stunden / kapazitaet : null,
besprochen: gesamt > 0 ? besprochen / gesamt : null,
};
}
// wandDashKopfHtml: Kopfzeile der Normalansicht — Marke "Dashboard" links,
// rechts wer heute abwesend ist (antippbar, öffnet dieselbe Abwesenheiten-
// Übersicht wie zuvor die Heute-Kachel) und Sitzung/Uhrzeit als laufendes
// Lebenszeichen (ersetzt das frühere separate "Stand: HH:MM Uhr" am Fuß der
// rechten Spalte — ein Uhrzeit-Hinweis genügt, jetzt prominent oben statt
// versteckt unten). Parameter: w. Rückgabe: HTML-String.
function wandDashKopfHtml(w){
const d = wandHeuteDaten(w);
const uhrzeit = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const abwesendHtml = d.abwesend.length
? '<div class="dash-abwesend-mini ist-klickbar" onclick="wandZeigeUebersicht(\'abwesend\')" title="Abwesend heute">'
+ d.abwesend.map(p => '<span class="dash-avatar-mini">' + esc(initialsFor(p.name)) + '</span>').join('')
+ '</div>'
: '';
return '<div class="dash-kopf"><span class="dash-marke">Dashboard</span>'
+ '<div class="dash-kopf-rechts">' + abwesendHtml
+ '<span class="dash-datum">' + esc(w.label) + ' — ' + esc(uhrzeit) + ' Uhr</span>'
+ '</div></div>';
}
// wandKpiReiheHtml: die vier Kennzahlen-Kacheln oben — Auslastung gesamt,
// Besprochen gesamt (wandGesamtKennzahlen()), offene Aufgaben und
// überfällig (wandHeuteDaten(), dieselben Zahlen, die zuvor die Heute-Kachel
// zeigte). Die beiden letzten bleiben wie zuvor antippbar und öffnen
// dieselbe Übersicht. Parameter: w. Rückgabe: HTML-String.
function wandKpiReiheHtml(w){
const g = wandGesamtKennzahlen(w);
const heute = wandHeuteDaten(w);
const auslastungTxt = g.auslastung === null ? '—' : Math.round(g.auslastung * 100) + '%';
const auslastungKlasse = g.auslastung === null ? ''
: (g.auslastung >= schwelleRot() ? 'crit' : g.auslastung >= schwelleGelb() ? 'warn' : 'ok');
const besprochenTxt = g.besprochen === null ? '—' : Math.round(g.besprochen * 100) + '%';
const ueberfaelligKlasse = heute.ueberfaelligeListe.length > 0 ? 'crit' : 'ok';
return '<div class="dash-kpi-reihe">'
+ '<div class="dash-kpi"><div class="dash-kpi-label">Auslastung ges.</div>'
+ '<div class="dash-kpi-wert ' + auslastungKlasse + '">' + auslastungTxt + '</div></div>'
+ '<div class="dash-kpi"><div class="dash-kpi-label">Besprochen</div>'
+ '<div class="dash-kpi-wert info">' + besprochenTxt + '</div></div>'
+ '<div class="dash-kpi ist-klickbar" onclick="wandZeigeUebersicht(\'offen\')" title="Alle offenen Aufgaben anzeigen">'
+ '<div class="dash-kpi-label">Offene Aufgaben</div><div class="dash-kpi-wert ok">' + heute.offen + '</div></div>'
+ '<div class="dash-kpi ist-klickbar" onclick="wandZeigeUebersicht(\'ueberfaellig\')" title="Alle überfälligen Aufgaben anzeigen">'
+ '<div class="dash-kpi-label">Überfällig</div><div class="dash-kpi-wert ' + ueberfaelligKlasse + '">' + heute.ueberfaelligeListe.length + '</div></div>'
+ '</div>';
}
// wandPanel: die gemeinsame, nicht-interaktive Panel-Umrahmung der
// Drei-Panel-Reihe (Auslastung/Besprochen/Aktuell). Parameter: titel,
// inhalt (HTML). Rückgabe: HTML-String.
function wandPanel(titel, inhalt){
return '<div class="dash-panel"><div class="dash-panel-titel">' + esc(titel) + '</div>'
+ '<div class="dash-panel-body">' + inhalt + '</div></div>';
}
// wandProjektlisteHtml: die vollständige Projektliste unterhalb der
// Kennzahl-Kacheln — auf Eriks ausdrücklichen Wunsch ("Die Projektliste soll
// so aussehen wie die Originale") keine eigene, vereinfachte Darstellung,
@@ -350,26 +440,11 @@ function wandProjektlisteHtml(w){
}).join('');
return '<div class="wand-projektliste">'
+ '<h3 class="wand-kachel-titel">Alle Projekte</h3>'
+ '<h3 class="dash-panel-titel">Alle Projekte</h3>'
+ abschnitte
+ '</div>';
}
// wandLinkeSpalteHtml: die drei Kennzahl-Kacheln (nicht mehr antippbar —
// nur die Elemente darin sind es). Parameter: w. Rückgabe: HTML-String.
function wandLinkeSpalteHtml(w){
return wandTileAuslastung(w) + wandTileBesprochen(w) + wandTileHeute(w);
}
// wandKachel: die gemeinsame, nicht-interaktive Kachel-Umrahmung. Parameter:
// titel, inhalt (HTML). Rückgabe: HTML-String.
function wandKachel(titel, inhalt){
return '<section class="wand-kachel">'
+ '<h3 class="wand-kachel-titel">' + esc(titel) + '</h3>'
+ '<div class="wand-kachel-body">' + inhalt + '</div>'
+ '</section>';
}
// wandSpringeZuProjekt: verlässt die Wandtafel und öffnet den Projektbereich
// eines Projekts, optional direkt in einem bestimmten Abschnitt — derselbe
// Weg (Hash VOR dem Tab-Wechsel setzen, showTab('plan') übernimmt ihn über
@@ -496,14 +571,16 @@ function wandAuslastungJeGewerk(w){
return ergebnis;
}
// wandTileAuslastung: ein Meter je Gewerk, Ampelfarbe wie im Auslastungs-Tab.
// Jede Zeile ist einzeln antippbar und öffnet die Projektliste dieses
// Gewerks (wandZeigeUebersicht('auslastung', g)). Eine Null bekommt KEINE
// grüne Farbe (dieselbe Regel wie in renderLoad()) — "keine Daten" statt
// eines irreführend guten Balkens. Parameter: w. Rückgabe: HTML-String.
function wandTileAuslastung(w){
// wandAuslastungInhalt: ein Meter je Gewerk, Ampelfarbe wie im
// Auslastungs-Tab — der Inhalt des Panels "Auslastung / Gewerk"
// (wandPanel() rahmt ihn). Jede Zeile ist einzeln antippbar und öffnet die
// Projektliste dieses Gewerks (wandZeigeUebersicht('auslastung', g)). Eine
// Null bekommt KEINE grüne Farbe (dieselbe Regel wie in renderLoad()) —
// "keine Daten" statt eines irreführend guten Balkens. Parameter: w.
// Rückgabe: HTML-String.
function wandAuslastungInhalt(w){
const daten = wandAuslastungJeGewerk(w);
const inhalt = WAND_GEWERKE.map(g => {
return WAND_GEWERKE.map(g => {
const d = daten[g];
const label = (DEPARTMENTS[g] && DEPARTMENTS[g].label) || g;
const klick = ' onclick="wandZeigeUebersicht(\'auslastung\',\'' + g + '\')"';
@@ -521,7 +598,6 @@ function wandTileAuslastung(w){
+ '<div class="wand-meter"><div class="wand-meter-fill" style="width:' + breite + '%;background:' + ampel + '"></div></div>'
+ '<span class="wand-meter-wert" style="color:' + ampel + '">' + stundenFormat(d.stunden) + '<span class="wand-einheit">h</span></span></div>';
}).join('');
return wandKachel('Auslastung je Gewerk', inhalt);
}
// wandUebersichtAuslastung: alle Projekte, die zu einem Gewerk beitragen,
@@ -565,13 +641,16 @@ function wandBesprochenDaten(w){
return ergebnis;
}
// wandTileBesprochen: ein Meter je Gewerk ("X von Y"). Jede Zeile ist
// wandBesprochenInhalt: ein Meter je Gewerk ("X von Y") — der Inhalt des
// Panels "Besprochen / Gewerk" (wandPanel() rahmt ihn). Jede Zeile ist
// einzeln antippbar und öffnet die Liste der noch offenen Projekte dieses
// Gewerks (wandZeigeUebersicht('besprochen', g)). Parameter: w. Rückgabe:
// HTML-String.
function wandTileBesprochen(w){
// Gewerks (wandZeigeUebersicht('besprochen', g)). Die Füllfarbe nutzt das
// dunklere Logo-Blau (--dash-ink-soft) statt des früheren generischen
// App-Akzents, damit sie zur Wandtafel-eigenen Farbwelt passt (styles.css).
// Parameter: w. Rückgabe: HTML-String.
function wandBesprochenInhalt(w){
const daten = wandBesprochenDaten(w);
const inhalt = WAND_GEWERKE.map(g => {
return WAND_GEWERKE.map(g => {
const d = daten[g];
const label = (DEPARTMENTS[g] && DEPARTMENTS[g].label) || g;
if(d.gesamt === 0){
@@ -582,10 +661,9 @@ function wandTileBesprochen(w){
const klick = ' onclick="wandZeigeUebersicht(\'besprochen\',\'' + g + '\')"';
return '<div class="wand-meter-zeile ist-klickbar"' + klick + '>'
+ '<span class="wand-meter-label">' + esc(label) + '</span>'
+ '<div class="wand-meter"><div class="wand-meter-fill" style="width:' + (quote * 100) + '%;background:var(--accent)"></div></div>'
+ '<div class="wand-meter"><div class="wand-meter-fill" style="width:' + (quote * 100) + '%;background:var(--dash-ink-soft)"></div></div>'
+ '<span class="wand-meter-wert">' + d.besprochen + ' von ' + d.gesamt + '</span></div>';
}).join('');
return wandKachel('Wochenbericht besprochen', inhalt);
}
// wandUebersichtBesprochen: die noch nicht besprochenen Projekte eines
@@ -653,27 +731,9 @@ function wandHeuteDaten(w){
return { offen: offeneListe.length, offeneListe, ueberfaelligeListe, abwesend };
}
// wandTileHeute: zwei Kennzahlen (offen/überfällig) plus Abwesenheits-Kürzel
// — jede einzeln antippbar, kein Auf-/Zuklappen der Kachel mehr. Parameter:
// w. Rückgabe: HTML-String.
function wandTileHeute(w){
const d = wandHeuteDaten(w);
const offenStat = '<div class="wand-stat ist-klickbar" onclick="wandZeigeUebersicht(\'offen\')" title="Alle offenen Aufgaben anzeigen">'
+ '<b>' + d.offen + '</b><span>offene Aufgaben</span></div>';
const ueberfaelligStat = '<div class="wand-stat' + (d.ueberfaelligeListe.length > 0 ? ' ist-rot' : '') + ' ist-klickbar"'
+ ' onclick="wandZeigeUebersicht(\'ueberfaellig\')" title="Alle überfälligen Aufgaben anzeigen">'
+ '<b>' + d.ueberfaelligeListe.length + '</b><span>überfällig</span></div>';
const namenHtml = d.abwesend.length
? '<div class="wand-namen">' + d.abwesend.map(p =>
'<span class="wand-avatar ist-klickbar" title="' + esc(p.name) + ' — Alle Abwesenheiten anzeigen" onclick="wandZeigeUebersicht(\'abwesend\')">'
+ esc(initialsFor(p.name)) + '</span>').join('') + '</div>'
: '<div class="wand-leer-klein">niemand abwesend</div>';
const inhalt = '<div class="wand-stat-reihe">' + offenStat + ueberfaelligStat + '</div>' + namenHtml;
return wandKachel('Heute', inhalt);
}
// wandTileHeute ist entfallen — "offene Aufgaben"/"überfällig" stehen jetzt
// in der Kennzahlen-Reihe (wandKpiReiheHtml()), das Abwesenheits-Kürzel im
// Kopf (wandDashKopfHtml()); beide lesen weiterhin aus wandHeuteDaten().
// wandUebersichtOffeneAufgaben: ALLE offenen Aufgaben (nicht nur
// überfällige), nächste Frist zuerst. Parameter: w. Rückgabe: HTML-String.
@@ -718,20 +778,18 @@ function wandUebersichtAbwesend(w){
return wandUebersichtRahmen('Abwesend heute (' + d.abwesend.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
}
// ---------- Ereignis-Feed (rechte Spalte) ----------
// ---------- Ereignis-Feed (Panel "Aktuell") ----------
// wandAktuelleEreignisse: sammelt drei Ereignisarten aus allen Projekten der
// Sitzung — neue/aktualisierte Kommentare (classifyProjectChange()), frisch
// überfällige Aufgaben und anstehende Meilensteine — als EINE Liste, nach
// zeitlicher Nähe zu heute sortiert (|Tage bis Datum| aufsteigend, nicht nach
// Datum selbst: Ein Ereignis von gestern und eines von morgen sind beide
// "gerade jetzt relevant", eines von vor zwei Wochen oder in zwei Monaten ist
// es nicht — unabhängig davon, ob es in der Vergangenheit oder Zukunft
// liegt). wandFeedHtml() gruppiert diese EINE Liste anschließend nach `art`
// in drei Spalten (Variante C aus dem Feed-Vergleich) — die Sortierung
// bleibt deshalb unverändert, sie gilt nur jetzt INNERHALB jeder Spalte statt
// über alle Arten hinweg. Jeder Eintrag trägt projekt/beschreibung getrennt
// (statt einem fertigen Text) sowie pid/abschnitt für den Sprung ins
// überfällige Aufgaben und anstehende Meilensteine — als EINE, nach
// zeitlicher Nähe zu heute sortierte Liste (|Tage bis Datum| aufsteigend,
// nicht nach Datum selbst: Ein Ereignis von gestern und eines von morgen
// sind beide "gerade jetzt relevant", eines von vor zwei Wochen oder in zwei
// Monaten ist es nicht — unabhängig davon, ob es in der Vergangenheit oder
// Zukunft liegt). wandAktuellInhalt() zeigt daraus die ersten WAND_FEED_MAX.
// Jeder Eintrag trägt projekt/beschreibung getrennt (statt einem fertigen
// Text) sowie pid/abschnitt für den Sprung ins
// Projekt. Parameter: w. Rückgabe: Array aus {art, projekt (HTML, bereits
// escaped), beschreibung (HTML, bereits escaped), zeit, pid, abschnitt}.
function wandAktuelleEreignisse(w){
@@ -786,51 +844,23 @@ function wandAktuelleEreignisse(w){
return ereignisse;
}
// WAND_FEED_SPALTEN: Reihenfolge und Beschriftung der drei Feed-Spalten.
const WAND_FEED_SPALTEN = [
{ art: 'neu', label: 'Neu' },
{ art: 'ueberfaellig', label: 'Überfällig' },
{ art: 'termin', label: 'Termine' },
];
// wandFeedHtml: gruppiert wandAktuelleEreignisse() nach Art in drei Spalten
// (Eriks Wahl "Variante C" aus dem Feed-Vergleich) — zeigt auf einen Blick,
// wie viele Ereignisse welcher Art anstehen, statt sie chronologisch zu
// mischen. Die Spaltenüberschrift nennt die TATSÄCHLICHE Gesamtzahl dieser
// Art, auch wenn nur WAND_FEED_MAX Einträge angezeigt werden. Jeder Eintrag
// bleibt für sich antippbar und springt direkt ins zugehörige Projekt.
// Parameter: w. Rückgabe: HTML-String.
function wandFeedHtml(w){
const ereignisse = wandAktuelleEreignisse(w);
const spalten = WAND_FEED_SPALTEN.map(s => {
const alle = ereignisse.filter(e => e.art === s.art);
const gezeigt = alle.slice(0, WAND_FEED_MAX);
const eintraegeHtml = gezeigt.length
? gezeigt.map(e =>
'<div class="wand-feed-eintrag" onclick="wandSpringeZuProjekt(\'' + esc(e.pid) + '\',\'' + esc(e.abschnitt) + '\')">'
+ '<b>' + e.projekt + '</b><span>' + e.beschreibung + '</span>'
+ '<span class="wand-feed-zeit">' + esc(e.zeit) + '</span>'
+ '</div>'
).join('')
: '<div class="wand-leer-klein">keine</div>';
return '<div class="wand-feed-gruppe">'
+ '<div class="wand-feed-gruppe-kopf">'
+ '<span class="wand-feed-gruppe-punkt ' + s.art + '"></span>'
+ '<span class="wand-feed-gruppe-titel">' + esc(s.label) + '</span>'
+ '<span class="wand-feed-gruppe-anzahl">' + alle.length + '</span>'
+ '</div>'
+ eintraegeHtml
+ '</div>';
}).join('');
return '<div class="wand-feed-gruppen">' + spalten + '</div>';
}
// ---------- Stand ----------
// renderWandStand: reiner Zeitstempel der letzten erfolgreichen
// Aktualisierung — das einzige Lebenszeichen, dass das unbeaufsichtigte
// Display nicht eingefroren ist. Keine Parameter. Rückgabe: HTML-String.
function renderWandStand(){
const jetzt = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
return '<div class="wand-stand">Stand: ' + jetzt + ' Uhr</div>';
// wandAktuellInhalt: der Inhalt des Panels "Aktuell" (wandPanel() rahmt
// ihn) — eine flache, chronologisch sortierte Liste (wandAktuelleEreignisse()
// ist bereits nach zeitlicher Nähe zu heute sortiert), gedeckelt auf
// WAND_FEED_MAX Einträge. Ein farbiger Punkt zeigt die Art
// (Neu/Überfällig/Termin). Jeder Eintrag bleibt für sich antippbar und
// springt direkt ins zugehörige Projekt. Parameter: w. Rückgabe:
// HTML-String.
function wandAktuellInhalt(w){
const ereignisse = wandAktuelleEreignisse(w).slice(0, WAND_FEED_MAX);
if(ereignisse.length === 0){
return '<div class="wand-leer-klein">Nichts Neues diese Woche.</div>';
}
return ereignisse.map(e =>
'<div class="dash-feed-eintrag" onclick="wandSpringeZuProjekt(\'' + esc(e.pid) + '\',\'' + esc(e.abschnitt) + '\')">'
+ '<span class="dash-feed-punkt ' + e.art + '"></span>'
+ '<div class="dash-feed-text">' + e.projekt + ' — ' + e.beschreibung
+ '<span class="dash-feed-zeit">' + esc(e.zeit) + '</span></div>'
+ '</div>'
).join('');
}
+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>`;
}
+505 -353
View File
@@ -309,35 +309,91 @@ button.menu-item:hover { background: var(--surface-sunk); }
#sidebarCollapseIcon { transition: transform .2s var(--ease); }
/* === Wandtafel (js/wand/wand.js) ===
Zweispaltiges Touch-Dashboard für ein dauerhaft laufendes Display,
gewerke- und nutzerübergreifend — ersetzt den früheren Show Mode, der nur
die gerade angemeldete Sitzung großflächig klonte (keine Aggregation über
Gewerke oder Nutzer hinweg, siehe CLAUDE.md). Seitenleiste und Kopfzeile
blendet showTab() über body.wandtafel-aktiv aus: Das Kiosk-Konto ist ein
/* === Wandtafel / Dashboard (js/wand/wand.js) ===
Touch-Dashboard für ein dauerhaft laufendes Display, gewerke- und
nutzerübergreifend — ersetzt den früheren Show Mode, der nur die gerade
angemeldete Sitzung großflächig klonte (keine Aggregation über Gewerke
oder Nutzer hinweg, siehe CLAUDE.md). Seitenleiste und Kopfzeile blendet
showTab() über body.wandtafel-aktiv aus: Das Kiosk-Konto ist ein
gewöhnliches Mitgliedskonto mit echten Schreibrechten — die Kachelfläche
soll die einzige Berührfläche sein, damit eine zufällige Berührung nicht
in einen bearbeitbaren Bereich wie "Projekte" führt.
Verhält sich zusätzlich wie ein fester Bildschirmausschnitt statt einer
normal scrollenden Seite (height:100vh + overflow:hidden auf dem Weg
.app-main → .container → #wand) — ein unbeaufsichtigtes Display soll von
selbst nie scrollen müssen; die zwei Spalten strecken sich über die volle
restliche Höhe.
Aufbau (Eriks Wahl aus dem Vergleich mehrerer Gesamtentwürfe, "Leitstand"-
Struktur, später in Eriks eigener Logo-Farbwelt umgesetzt): Kopf
(Marke "Dashboard" + Sitzung/Uhrzeit + Abwesend-Kürzel) → eine
Kennzahlen-Reihe aus vier Kacheln (Auslastung gesamt, Besprochen gesamt,
offene Aufgaben, überfällig) → eine Reihe aus drei Panels (Auslastung je
Gewerk, Besprochen je Gewerk, Aktuell). Verhält sich zusätzlich wie ein
fester Bildschirmausschnitt statt einer normal scrollenden Seite
(height:100vh + overflow:hidden auf dem Weg .app-main → .container →
#wand) — ein unbeaufsichtigtes Display soll von selbst nie scrollen
müssen; Kopf, Kennzahlen-Reihe und Panel-Reihe strecken sich über die
volle restliche Höhe.
AUSNAHME, auf Eriks Wunsch (echter Touch-Monitor, "ich kann mich vor die
Tafel stellen und scrollen"): unterhalb der zwei Spalten folgt die
vollständige Projektliste (wandProjektlisteHtml(), js/wand/wand.js), und
NUR dieser Bereich (#wandContent, Klasse .wand-scrollable, von
renderWandboard() gesetzt) scrollt — eine offene Übersicht bleibt weiter
exakt einen Bildschirm hoch, siehe .wand-uebersicht unten. Ohne Berührung
kehrt die Ansicht nach WAND_IDLE_MS von selbst an den Anfang zurück
Tafel stellen und scrollen"): unterhalb davon folgt die vollständige
Projektliste (wandProjektlisteHtml(), js/wand/wand.js), und NUR dieser
Bereich (#wandContent, Klasse .wand-scrollable, von renderWandboard()
gesetzt) scrollt — eine offene Übersicht bleibt weiter exakt einen
Bildschirm hoch, siehe .wand-uebersicht unten. Ohne Berührung kehrt die
Ansicht nach WAND_IDLE_MS von selbst an den Anfang zurück
(wandAktivitaetErkannt()/wandBeiUntaetigkeitZuruecksetzen()). */
body.wandtafel-aktiv .app-topbar,
body.wandtafel-aktiv .side-menu,
body.wandtafel-aktiv .side-menu-backdrop { display: none !important; }
body.wandtafel-aktiv .app-main { height: 100vh; overflow: hidden; }
body.wandtafel-aktiv .container { height: 100%; overflow: hidden; display: flex; flex-direction: column; padding: 14px 20px; }
body.wandtafel-aktiv .container {
height: 100%; overflow: hidden; display: flex; flex-direction: column; padding: 14px 20px;
background: var(--dash-bg);
}
body.wandtafel-aktiv #wand { display: flex !important; flex-direction: column; flex: 1; min-height: 0; }
/* Eigene Farbwelt der Wandtafel — bewusst GETRENNT von --accent/--surface der
übrigen App: Eriks Wahl ("Pastell Freundlich", aus dem Design-Vergleich der
Gesamtentwürfe) nutzt die zwei Farben des Firmenlogos (aus public/logo.ico
ausgelesen: #94b0cc helles, #116896 dunkles Blau) statt des generischen
App-Akzents. Jede Fläche (Kennzahlen, Panels) trägt eine eigene, davon
abgeleitete Tönung. Rot/Gelb bleiben als Warn-/Kritisch-Farben eigenständig
(müssen unabhängig von der Markenfarbe sofort erkennbar bleiben), nur
gedeckter gewählt, damit sie zum Blau passen statt dagegen zu stehen.
--dash-hover ist eine reine Hervorhebung beim Antippen (kein Ersatz einer
bestehenden Flächenfarbe), deshalb für beide Modi eigens abgestimmt statt
eines einzigen hartkodierten Werts. */
body.wandtafel-aktiv {
--dash-bg: #f7fafc;
--dash-surface: #ffffff;
--dash-ink: #116896;
--dash-ink-soft: #94b0cc;
--dash-text: #20282e;
--dash-text-muted: #5d7488;
--dash-text-subtle: #8a9aa6;
--dash-panel-1: #dde9f1;
--dash-panel-2: #cfe0ea;
--dash-panel-3: #e7eff5;
--dash-kpi-4: #f6e3e0;
--dash-warn: #9a5a1f;
--dash-crit: #b8463f;
--dash-line: #e7eef3;
--dash-hover: rgba(17, 104, 150, .07);
}
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) body.wandtafel-aktiv {
--dash-bg: #10181f; --dash-surface: #141c23;
--dash-ink: #7fc3dd; --dash-ink-soft: #4a7691;
--dash-text: #e7eef3; --dash-text-muted: #9fb3c0; --dash-text-subtle: #70838f;
--dash-panel-1: #16222c; --dash-panel-2: #141e27; --dash-panel-3: #131b22; --dash-kpi-4: #2a1f1c;
--dash-warn: #e8a15c; --dash-crit: #ef6f63;
--dash-line: #223039; --dash-hover: rgba(255, 255, 255, .06);
}
}
html[data-theme="dark"] body.wandtafel-aktiv {
--dash-bg: #10181f; --dash-surface: #141c23;
--dash-ink: #7fc3dd; --dash-ink-soft: #4a7691;
--dash-text: #e7eef3; --dash-text-muted: #9fb3c0; --dash-text-subtle: #70838f;
--dash-panel-1: #16222c; --dash-panel-2: #141e27; --dash-panel-3: #131b22; --dash-kpi-4: #2a1f1c;
--dash-warn: #e8a15c; --dash-crit: #ef6f63;
--dash-line: #223039; --dash-hover: rgba(255, 255, 255, .06);
}
.wand-kopfzeile {
flex-shrink: 0;
display: flex;
@@ -371,168 +427,124 @@ body.wandtafel-aktiv #wand { display: flex !important; flex-direction: column; f
Block-Scrollcontainer um, dessen erstes Kind (.wand-ambient) weiterhin
genau einen Bildschirm hoch ist — die Projektliste danach verlängert den
Inhalt darüber hinaus und erzeugt dadurch den Scrollbalken. */
.wand-grid-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.wand-grid-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; background: var(--dash-bg); }
.wand-grid-wrap.wand-scrollable { display: block; overflow-y: auto; }
.wand-ambient { min-height: 100%; display: flex; flex-direction: column; }
.wand-c-layout { flex: 1; min-height: 0; display: grid; grid-template-columns: 320px 1fr; gap: 16px; }
.wand-c-links { display: flex; flex-direction: column; gap: 16px; min-height: 0; }
.wand-c-links .wand-kachel { flex: 1; min-height: 0; }
.wand-c-rechts {
background: var(--surface);
border: 1px solid var(--line-soft);
border-radius: var(--r-xl);
box-shadow: var(--sh-2);
padding: 18px 22px;
display: flex;
flex-direction: column;
min-height: 0;
/* ===== Kopf: Marke "Dashboard" + Sitzung/Uhrzeit + Abwesend-Mini ===== */
.dash-kopf { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.dash-marke { font-family: 'Fredoka', sans-serif; font-weight: 600; font-size: 19px; color: var(--dash-ink); }
.dash-kopf-rechts { display: flex; align-items: center; gap: 14px; }
.dash-datum { font-size: 12.5px; color: var(--dash-text-muted); background: var(--dash-panel-3); padding: 4px 12px; border-radius: var(--r-pill); }
.dash-abwesend-mini { display: flex; }
.dash-abwesend-mini.ist-klickbar { cursor: pointer; border-radius: var(--r-pill); }
.dash-abwesend-mini.ist-klickbar:hover { filter: brightness(.96); }
.dash-avatar-mini {
width: 25px; height: 25px; border-radius: 50%; background: var(--dash-panel-2); color: var(--dash-ink);
font-size: 9.5px; font-weight: 800; display: flex; align-items: center; justify-content: center;
margin-left: -7px; border: 2px solid var(--dash-bg);
}
.dash-avatar-mini:first-child { margin-left: 0; }
.wand-kachel {
background: var(--surface);
border: 1px solid var(--line-soft);
border-radius: var(--r-xl);
box-shadow: var(--sh-2);
padding: 18px 20px;
display: flex;
flex-direction: column;
min-height: 0;
}
.wand-kachel-titel {
margin: 0 0 12px;
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--text-muted);
flex-shrink: 0;
}
/* Der Inhalt verteilt sich GLEICHMÄSSIG über die volle, gestreckte
Kachelhöhe (space-evenly) statt oben zu kleben und unten Leerraum zu
lassen — genau das ließ vorher dünn befüllte Kacheln neben vollen leer
wirken. */
.wand-kachel-body { flex: 1; display: flex; flex-direction: column; justify-content: space-evenly; min-height: 0; overflow: hidden; }
/* ===== Kennzahlen-Reihe (4 Kacheln) ===== */
.dash-kpi-reihe { flex-shrink: 0; display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 18px; }
.dash-kpi { border-radius: var(--r-xl); padding: 16px 18px; }
.dash-kpi:nth-child(1) { background: var(--dash-panel-1); }
.dash-kpi:nth-child(2) { background: var(--dash-panel-2); }
.dash-kpi:nth-child(3) { background: var(--dash-panel-3); }
.dash-kpi:nth-child(4) { background: var(--dash-kpi-4); }
.dash-kpi.ist-klickbar { cursor: pointer; }
.dash-kpi.ist-klickbar:hover { filter: brightness(.96); }
.dash-kpi-label { font-size: 11px; font-weight: 700; color: var(--dash-text-muted); margin-bottom: 8px; }
.dash-kpi-wert { font-family: 'Fredoka', sans-serif; font-weight: 600; font-size: 32px; color: var(--dash-ink); font-variant-numeric: tabular-nums; }
.dash-kpi-wert.ok { color: var(--dash-ink); }
.dash-kpi-wert.info { color: var(--dash-ink-soft); }
.dash-kpi-wert.warn { color: var(--dash-warn); }
.dash-kpi-wert.crit { color: var(--dash-crit); }
/* Meter-Zeilen: Auslastung je Gewerk, Wochenbericht-Fortschritt je Gewerk.
Nicht mehr die Kachel als Ganzes ist antippbar, sondern jede einzelne
Gewerk-Zeile für sich (wandZeigeUebersicht('auslastung'|'besprochen', g)) —
deshalb trägt .ist-klickbar hier die Zeile, nicht mehr .wand-kachel. */
/* ===== Drei-Panel-Reihe: Auslastung / Besprochen / Aktuell =====
Dieselbe Kacheltechnik wie zuvor (space-evenly auf .dash-panel-body) —
der Inhalt verteilt sich gleichmäßig über die volle Höhe statt oben zu
kleben, damit ein dünn befülltes Panel neben einem vollen nicht leer
wirkt. */
.dash-grid { flex: 1; min-height: 0; display: grid; grid-template-columns: 1fr 1fr 1.3fr; gap: 14px; }
.dash-panel { border-radius: var(--r-xl); padding: 18px 20px; display: flex; flex-direction: column; min-height: 0; }
.dash-grid > .dash-panel:nth-child(1) { background: var(--dash-panel-1); }
.dash-grid > .dash-panel:nth-child(2) { background: var(--dash-panel-2); }
.dash-grid > .dash-panel:nth-child(3) { background: var(--dash-panel-3); }
.dash-panel-titel { flex-shrink: 0; font-size: 12px; font-weight: 700; color: var(--dash-ink); margin-bottom: 12px; }
.dash-panel-body { flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: space-evenly; overflow: hidden; }
/* Meter-Zeilen: Auslastung je Gewerk, Wochenbericht-Fortschritt je Gewerk —
jede Zeile einzeln antippbar (wandZeigeUebersicht('auslastung'|'besprochen', g)). */
.wand-meter-zeile { display: flex; align-items: center; gap: 12px; }
.wand-meter-zeile.ist-klickbar { cursor: pointer; border-radius: var(--r-md); margin: 0 -6px; padding: 3px 6px; transition: background .15s var(--ease); }
.wand-meter-zeile.ist-klickbar:hover { background: var(--surface-sunk); }
.wand-meter-label { flex: 0 0 90px; font-size: 13px; font-weight: 600; color: var(--text); }
.wand-meter-zeile.ist-klickbar:hover { background: var(--dash-hover); }
.wand-meter-label { flex: 0 0 90px; font-size: 13px; font-weight: 600; color: var(--dash-text); }
.wand-meter {
flex: 1;
height: 18px;
background: var(--surface-sunk);
border-radius: var(--r-pill);
overflow: hidden;
display: flex;
align-items: center;
flex: 1; height: 14px; background: rgba(127, 127, 127, .18); border-radius: var(--r-pill);
overflow: hidden; display: flex; align-items: center;
}
.wand-meter-fill { height: 100%; border-radius: var(--r-pill); transition: width .3s var(--ease); }
.wand-meter-leer { font-size: 12px; color: var(--text-subtle); padding-left: 4px; }
.wand-meter-leer { font-size: 12px; color: var(--dash-text-subtle); padding-left: 4px; }
.wand-meter-wert {
flex: 0 0 76px;
text-align: right;
font-size: 15px;
font-weight: 600;
font-variant-numeric: tabular-nums;
flex: 0 0 76px; text-align: right; font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums;
font-family: 'Fredoka', sans-serif; color: var(--dash-ink);
}
.wand-einheit { font-size: 11px; font-weight: 400; color: var(--text-subtle); margin-left: 2px; }
.wand-leer-klein { font-size: 11.5px; color: var(--text-subtle); }
.wand-einheit { font-size: 11px; font-weight: 400; color: var(--dash-text-subtle); margin-left: 2px; }
.wand-leer-klein { font-size: 11.5px; color: var(--dash-text-subtle); }
/* Kennzahlen: Kachel "Heute" (offene/überfällige Aufgaben, Abwesend) — jede
Kennzahl und jedes Kürzel ist für sich antippbar (.ist-klickbar), öffnet
die passende Vollbild-Übersicht. */
.wand-stat-reihe { display: flex; gap: 28px; }
.wand-stat { display: flex; flex-direction: column; gap: 2px; }
.wand-stat b { font-size: 30px; font-weight: 700; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.wand-stat span { font-size: 12px; color: var(--text-subtle); }
.wand-stat.ist-rot b { color: var(--red); }
.wand-stat.ist-klickbar { cursor: pointer; }
.wand-stat.ist-klickbar:hover b { text-decoration: underline; }
.wand-namen { display: flex; flex-wrap: wrap; gap: 6px; }
.wand-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10.5px;
font-weight: 700;
background: var(--accent-soft);
color: var(--accent);
}
.wand-avatar.ist-klickbar { cursor: pointer; }
.wand-avatar.ist-klickbar:hover { box-shadow: 0 0 0 2px var(--accent); }
.wand-leer { font-size: 13px; color: var(--text-subtle); }
/* Ereignis-Feed (rechte Spalte) — auf Eriks Wahl "Variante C" aus dem
Design-Vergleich: drei Spalten nach Art (Neu/Überfällig/Termine) statt
einer gemischten, chronologisch sortierten Liste. Zeigt auf einen Blick,
WIE VIELE Ereignisse welcher Art anstehen — kostet dafür die vorherige
"zeitlich am nächsten zuerst"-Durchmischung über die Arten hinweg;
innerhalb einer Spalte sortiert wandAktuelleEreignisse() unverändert nach
zeitlicher Nähe. Jeder Eintrag bleibt für sich antippbar und springt
direkt ins betroffene Projekt. */
.wand-feed-wrap { flex: 1; min-height: 0; overflow: hidden; }
.wand-feed-gruppen { display: flex; gap: 16px; height: 100%; }
.wand-feed-gruppe { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
.wand-feed-gruppe-kopf { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; flex-shrink: 0; }
.wand-feed-gruppe-punkt { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.wand-feed-gruppe-punkt.neu { background: var(--accent); }
.wand-feed-gruppe-punkt.ueberfaellig { background: var(--red); }
.wand-feed-gruppe-punkt.termin { background: var(--amber); }
.wand-feed-gruppe-titel {
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted);
}
.wand-feed-gruppe-anzahl { font-size: 10.5px; color: var(--text-subtle); margin-left: auto; }
.wand-feed-eintrag {
font-size: 12px; line-height: 1.4; padding: 8px 0; border-top: 1px solid var(--line-soft);
/* "Aktuell": flache, chronologisch sortierte Ereignisliste (wandAktuellInhalt(),
js/wand/wand.js) — ein farbiger Punkt statt einer Pille zeigt die Art
(Neu/Überfällig/Termin). Jeder Eintrag einzeln antippbar, springt direkt
ins betroffene Projekt. */
.dash-feed-eintrag {
display: flex; align-items: flex-start; gap: 10px; padding: 9px 2px; border-top: 1px solid var(--dash-line);
cursor: pointer; border-radius: var(--r-md); transition: background .15s var(--ease);
}
.wand-feed-eintrag:first-of-type { border-top: none; padding-top: 0; }
.wand-feed-eintrag:hover { background: var(--surface-sunk); }
.wand-feed-eintrag b { display: block; font-weight: 600; color: var(--text); margin-bottom: 1px; }
.wand-feed-eintrag span { color: var(--text-muted); }
.wand-feed-zeit { display: block; font-size: 10.5px; color: var(--text-subtle); margin-top: 2px; }
.wand-stand { text-align: center; font-size: 11px; color: var(--text-subtle); margin-top: 10px; flex-shrink: 0; }
.dash-feed-eintrag:first-of-type { border-top: none; padding-top: 0; }
.dash-feed-eintrag:hover { background: var(--dash-hover); }
.dash-feed-punkt { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; }
.dash-feed-punkt.neu { background: var(--dash-ink); }
.dash-feed-punkt.ueberfaellig { background: var(--dash-crit); }
.dash-feed-punkt.termin { background: var(--dash-warn); }
.dash-feed-text { flex: 1; font-size: 12.5px; line-height: 1.5; color: var(--dash-text); }
.dash-feed-zeit { display: block; font-size: 10.5px; color: var(--dash-text-subtle); margin-top: 2px; }
/* Vollbild-Übersichten (überfällige/offene Aufgaben, Auslastung/Besprochen
je Gewerk, Abwesenheiten) — ersetzen das zweispaltige Layout vorübergehend
je Gewerk, Abwesenheiten) — ersetzen die Normalansicht vorübergehend
vollständig (wandUebersicht). Die Liste ist die EINE bewusste Ausnahme vom
"kein Scrollen"-Grundsatz der Wandtafel: ein gezielter Einstieg mit
potenziell vielen Zeilen, kein passiver Dauerzustand. */
potenziell vielen Zeilen, kein passiver Dauerzustand. In derselben
Logo-Farbwelt wie die Normalansicht, damit der Wechsel nicht wie ein
Bruch in eine andere App wirkt. */
.wand-uebersicht {
flex: 1; min-height: 0; display: flex; flex-direction: column;
background: var(--surface); border: 1px solid var(--line-soft); border-radius: var(--r-xl);
box-shadow: var(--sh-2); padding: 20px 24px;
background: var(--dash-surface); border: 1px solid var(--dash-line); border-radius: var(--r-xl);
box-shadow: var(--sh-2); padding: 20px 24px; color: var(--dash-text);
}
.wand-uebersicht-kopf { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; flex-shrink: 0; }
.wand-uebersicht-kopf h2 { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: -.01em; }
.wand-uebersicht-kopf h2 { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: -.01em; font-family: 'Fredoka', sans-serif; }
.wand-uebersicht-liste { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; }
.wand-uebersicht-zeile {
display: flex; align-items: center; gap: 18px; padding: 13px 6px; border-top: 1px solid var(--line-soft);
display: flex; align-items: center; gap: 18px; padding: 13px 6px; border-top: 1px solid var(--dash-line);
transition: background .15s var(--ease);
}
.wand-uebersicht-zeile:first-child { border-top: none; }
.wand-uebersicht-zeile.ist-klickbar { cursor: pointer; }
.wand-uebersicht-zeile.ist-klickbar:hover { background: var(--surface-sunk); }
.wand-uebersicht-projekt { flex: 0 0 280px; font-weight: 600; font-size: 14.5px; color: var(--text); }
.wand-uebersicht-titel { flex: 1; font-size: 14.5px; color: var(--text); }
/* Neutral, nicht mehr fest rot: eine Übersichts-Zeile kann auch eine
unauffällige Angabe zeigen (Stunden, "noch offen", Abwesenheitsende) —
.ist-rot markiert gezielt die wirklich überfälligen Zeilen. */
.wand-uebersicht-seit { flex-shrink: 0; font-size: 13px; font-weight: 600; color: var(--text-muted); }
.wand-uebersicht-seit.ist-rot { color: var(--red); }
.wand-uebersicht-zeile.ist-klickbar:hover { background: var(--dash-hover); }
.wand-uebersicht-projekt { flex: 0 0 280px; font-weight: 600; font-size: 14.5px; color: var(--dash-text); }
.wand-uebersicht-titel { flex: 1; font-size: 14.5px; color: var(--dash-text); }
/* Neutral, nicht fest rot: eine Übersichts-Zeile kann auch eine unauffällige
Angabe zeigen (Stunden, "noch offen", Abwesenheitsende) — .ist-rot
markiert gezielt die wirklich überfälligen Zeilen. */
.wand-uebersicht-seit { flex-shrink: 0; font-size: 13px; font-weight: 600; color: var(--dash-text-muted); }
.wand-uebersicht-seit.ist-rot { color: var(--dash-crit); }
.wand-leer { font-size: 13px; color: var(--dash-text-subtle); }
/* Vollständige Projektliste unterhalb der zwei Spalten (wandProjektlisteHtml,
js/wand/wand.js) — nur sichtbar/erreichbar durch Herunterscrollen auf
/* Vollständige Projektliste unterhalb der Kennzahlen-/Panel-Reihe
(wandProjektlisteHtml(), js/wand/wand.js) — nur sichtbar/erreichbar durch
Herunterscrollen auf
einem echten Touch-Monitor. Auf Eriks Wunsch optisch identisch mit der
echten Projektliste: Gruppenüberschrift und Zeilen sind exakt
.section-title/.board-list/.board-row aus der Projektliste selbst (keine
@@ -1437,36 +1449,89 @@ tr:nth-child(even) td { background: var(--surface-alt); }
}
/* === Dichte Fassung der Karte (.board-drawer-dicht, Wochenbericht) ===
Gleiche Angaben, gleiche Bedienelemente, enger gesetzt. Den Ausschlag gibt
die Leistungsphasen-Leiste: als Zeilen untereinander bestimmten bis zu zehn
Phasen die Höhe der ganzen Karte — bei 29 Projekten wurde der Bericht damit
rund 17 Bildschirmseiten lang. Nebeneinander braucht dieselbe Information
eine Zeile. */
.board-drawer-dicht .board-drawer-grid {
/* Die Beteiligten brauchen so viel Breite wie der Kommentar: Sobald ihre
Kürzel umbrechen, bestimmt ihre Spalte die Höhe der ganzen Karte. Mit
1.45/1.15/1 waren es 14,2 Bildschirmseiten, mit gleich breiter
Beteiligten-Spalte 11,6 — mehr Breite bringt darüber hinaus nichts,
weil dann ohnehin nichts mehr umbricht. */
/* Die rechte Spalte bekommt eine MINDESTBREITE: Zehn Phasenfelder in einer
reinen fr-Spalte wurden 27 px schmal, und "100" passte nicht mehr ins
Eingabefeld — 79 Werte waren abgeschnitten. Unter 620 px Containerbreite
gibt die Mindestbreite nach und die Spalten rutschen untereinander. */
grid-template-columns: minmax(0, 1fr) minmax(340px, 1fr);
grid-template-areas: "kommentar kompakt";
gap: 18px;
Fokus-Modus (Eriks Wahl "C" aus dem Darstellungs-Vergleich): nur der
Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in
aufklappbaren Abschnitten (.bericht-disclosure, siehe renderBoardDrawer()s
dichte Fassung in js/board/board.js) statt dauerhaft nebeneinander zu
stehen — bei 29 Projekten wurde der Bericht mit allem gleichzeitig
sichtbar als "zu überladen" zurückgemeldet. Kein eigenes Raster mehr (die
drei Blöcke stehen schlicht untereinander), deshalb sitzt die dichte
Fassung flächig in ihrem Container statt als eigene Schublade mit
zusätzlichem Rahmen/Hintergrund darin. */
.board-drawer-dicht { padding: 0; background: transparent; border-top: none; }
.bericht-disclosure {
border: 1px solid var(--line-soft);
border-radius: var(--r-md);
margin-top: 10px;
overflow: hidden;
background: var(--surface);
}
@media (max-width: 860px) {
.board-drawer-dicht .board-drawer-grid {
grid-template-columns: 1fr;
grid-template-areas: "kommentar" "kompakt";
gap: 14px;
}
.bericht-disclosure summary {
list-style: none;
cursor: pointer;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 650;
color: var(--text-muted);
}
.drawer-col-kompakt { grid-area: kompakt; min-width: 0; }
/* Die zweite Überschrift in derselben Spalte braucht Abstand nach oben, den
die erste (am Spaltenanfang) nicht haben darf. */
.drawer-h4-zweit { margin-top: 12px; }
.bericht-disclosure summary::-webkit-details-marker { display: none; }
.bericht-disclosure summary:hover { background: var(--surface-alt); }
.bericht-disclosure summary .count {
font-size: 11px;
color: var(--text-subtle);
font-weight: 500;
margin-left: 2px;
}
.bericht-disclosure summary .chev {
margin-left: auto;
color: var(--text-subtle);
display: inline-flex;
transition: transform .15s var(--ease);
}
.bericht-disclosure[open] summary .chev { transform: rotate(90deg); }
.bericht-disclosure[open] summary { border-bottom: 1px solid var(--line-soft); }
.bericht-disclosure-body { padding: 10px 14px 12px; }
/* Leistungsphasen als Balkenzeilen statt der Kästchen-Leiste des
Projektboards (.board-lph-leiste/-cell bleiben dort unverändert) — in der
schmalen Fokus-Spalte brauchen zehn Kästchen nebeneinander zu wenig Platz
je Phase, eine Zeile je Phase dagegen hat Raum für Label, Balken UND
Eingabefeld. */
.bericht-lph-row { display: flex; align-items: center; gap: 10px; padding: 5px 0; font-size: 12.5px; }
.bericht-lph-label { width: 48px; flex-shrink: 0; color: var(--text-muted); }
.bericht-lph-track { flex: 1; height: 4px; background: var(--line-soft); border-radius: 2px; overflow: hidden; }
.bericht-lph-track i { display: block; height: 100%; background: var(--text-subtle); }
.bericht-lph-row.is-aktiv .bericht-lph-track i { background: var(--accent); }
.bericht-lph-row.is-aktiv .bericht-lph-label { color: var(--accent); font-weight: 650; }
.bericht-lph-pct {
display: flex; align-items: center; gap: 1px; flex-shrink: 0;
color: var(--text-subtle); font-variant-numeric: tabular-nums; font-size: 11.5px;
}
/* .bericht-lph-row (zwei Klassen) statt der bloßen Element-Klasse: nötig, um
den globalen input[type=text]-Grundstil (Polsterung/Rahmen/Hintergrund,
weiter oben) zu schlagen — sonst gewinnt dessen höhere Spezifität und die
Polsterung von 7px 10px ließ "100" im 42px schmalen Feld auf "10"
abschneiden. */
.bericht-lph-row .bericht-lph-input {
width: 28px; flex-shrink: 0; border: none; border-radius: 0; background: transparent;
text-align: right; font-size: 11.5px; font-variant-numeric: tabular-nums; color: var(--text);
font-family: inherit; padding: 1px 0;
}
.bericht-lph-row .bericht-lph-input:focus { outline: 1.5px solid var(--accent); outline-offset: -1px; border-radius: 3px; box-shadow: none; }
.bericht-lph-row .bericht-lph-input.locked-cell { cursor: default; }
/* Der Kommentar bleibt das größte Element der dichten Fassung — etwas
größere Schrift als im Projektboard, wo er eine Spalte unter mehreren
teilt. Radius/Schatten wie eine freistehende Karte (Entwurf "Fokus-Modus"). */
.board-drawer-dicht .comment-editor .comment-field,
.board-drawer-dicht .comment-readonly { font-size: 14px; line-height: 1.6; min-height: 76px; }
/* .comment-editor hat bereits overflow:hidden (Basis-Regel) — ein größerer
Radius hier genügt, Kopf/Fuß werden automatisch mitgerundet. */
.board-drawer-dicht .comment-editor { border-radius: var(--r-lg); box-shadow: var(--sh-1); }
.board-lph-leiste {
display: flex;
@@ -2302,36 +2367,14 @@ a.secondary:hover { background: var(--surface-sunk); border-color: var(--text-su
}
/* === Anwesenheitsblock der Sitzung ======================================
Eigener Block oben im Projektboard (renderAttendance(), js/board/board.js).
Zustände sind bewusst binär (anwesend / nicht anwesend, Tabelle attendance);
der Zähler in der Kopfzeile macht sichtbar, wenn noch niemand geklickt hat. */
.attendance-block {
background: var(--surface);
border: 1px solid var(--line-soft);
border-radius: var(--r-lg);
box-shadow: var(--sh-1);
margin-top: 16px;
overflow: hidden;
}
.attendance-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 9px 14px;
background: var(--surface-alt);
border-bottom: 1px solid var(--line-soft);
}
.attendance-title { font-weight: 600; font-size: 13px; }
.attendance-count {
font-size: 11.5px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.attendance-spacer { margin-left: auto; }
/* === Anwesenheit der Sitzung ============================================
Fokus-Modus (Eriks Wahl "C"): ein aufklappbarer Abschnitt wie Leistungs-
phasen/Beteiligte im Wochenbericht (.bericht-disclosure, siehe weiter
oben), kein dauerhaft sichtbarer Block mehr. Zustände bleiben binär
(anwesend / nicht anwesend, Tabelle attendance); der Zähler in der
Kopfzeile macht sichtbar, wenn noch niemand geklickt hat. */
#weekAttendance .bericht-disclosure { margin-top: 16px; }
.attendance-actions { display: flex; gap: 8px; margin-bottom: 10px; }
.attendance-grid {
display: flex;
@@ -2485,34 +2528,46 @@ input[type=text].edit-name:focus {
html:not([data-theme="light"]) { color-scheme: dark; }
html:not([data-theme="light"]) {
--accent: #0a84ff;
--accent-hover: #2f95ff;
--accent-active: #0060df;
--accent-soft: #10263f;
/* Auf Eriks Wunsch ("das gesamte dark mode Farbschema an das der
Wandtafel anpassen") übernimmt der GESAMTE App-Dark-Mode die
Logo-Blau-Familie des Dashboards (--dash-* in body.wandtafel-aktiv
weiter oben) statt des vorherigen, generischen Apple-Blaus — alle
Komponenten der App lesen ausschließlich über diese Tokens, eine
Änderung hier genügt also für die ganze Anwendung. Rot/Gelb/Grün
bleiben als eigenständige Statusfarben bestehen (müssen unabhängig
von der Markenfarbe sofort erkennbar sein, siehe Ampel-Logik in
js/load/load.js), nur in denselben gedeckteren Tönen wie am Dashboard. */
--accent: #7fc3dd;
--accent-hover: #96cfe3;
--accent-active: #5fa3c0;
--accent-soft: #16222c;
--text: #f5f5f7;
--text-muted: #a1a1a6;
--text-subtle: #86868b;
--text: #e7eef3;
--text-muted: #9fb3c0;
--text-subtle: #70838f;
--bg: #000000;
--surface: #1c1c1e;
--surface-alt: #232326;
--surface-sunk: #2c2c2e;
--bg: #10181f;
--surface: #141c23;
--surface-alt: #16222c;
--surface-sunk: #1c2730;
--line: #3a3a3c;
--line-soft: #2c2c2e;
--line: #2c3e49;
--line-soft: #1f2b33;
--green: #30d158;
--amber: #ff9f0a;
--red: #ff453a;
--red-hover: #ff453a;
--red-active: #a3000f;
--green: #4cd080;
--amber: #e8a15c;
--red: #ef6f63;
--red-hover: #f58a7f;
--red-active: #c65349;
--attend-bg: rgba(48,209,88,.16);
--attend-bg-hover: rgba(48,209,88,.24);
--attend-bg: rgba(76,208,128,.14);
--attend-bg-hover: rgba(76,208,128,.22);
--band-neu: #0a6fd0;
--band-fortschritt: #1e7a38;
/* Auf Weiß gesetzt — deshalb dunkler als --band-neu/--green gebraucht
(Kontrastgrund wie im Hell-Modus): --band-neu ist das dunkle
Logo-Blau selbst (dasselbe #116896 wie am Dashboard). */
--band-neu: #116896;
--band-fortschritt: #1f7a4f;
--protokoll-neu-bg: rgba(255,214,10,.14);
--protokoll-neu-border: #ffd60a;
@@ -2526,34 +2581,34 @@ input[type=text].edit-name:focus {
html[data-theme="dark"] {
color-scheme: dark;
--accent: #0a84ff;
--accent-hover: #2f95ff;
--accent-active: #0060df;
--accent-soft: #10263f;
--accent: #7fc3dd;
--accent-hover: #96cfe3;
--accent-active: #5fa3c0;
--accent-soft: #16222c;
--text: #f5f5f7;
--text-muted: #a1a1a6;
--text-subtle: #86868b;
--text: #e7eef3;
--text-muted: #9fb3c0;
--text-subtle: #70838f;
--bg: #000000;
--surface: #1c1c1e;
--surface-alt: #232326;
--surface-sunk: #2c2c2e;
--bg: #10181f;
--surface: #141c23;
--surface-alt: #16222c;
--surface-sunk: #1c2730;
--line: #3a3a3c;
--line-soft: #2c2c2e;
--line: #2c3e49;
--line-soft: #1f2b33;
--green: #30d158;
--amber: #ff9f0a;
--red: #ff453a;
--red-hover: #ff453a;
--red-active: #a3000f;
--green: #4cd080;
--amber: #e8a15c;
--red: #ef6f63;
--red-hover: #f58a7f;
--red-active: #c65349;
--attend-bg: rgba(48,209,88,.16);
--attend-bg-hover: rgba(48,209,88,.24);
--attend-bg: rgba(76,208,128,.14);
--attend-bg-hover: rgba(76,208,128,.22);
--band-neu: #0a6fd0;
--band-fortschritt: #1e7a38;
--band-neu: #116896;
--band-fortschritt: #1f7a4f;
--protokoll-neu-bg: rgba(255,214,10,.14);
--protokoll-neu-border: #ffd60a;
@@ -3862,6 +3917,10 @@ html[data-theme="dark"] .absence-type-fortbildung { background: rgba(255,159,10,
.ai-url-panel.is-open { display: block; }
.ai-url-panel label { display: block; font-size: 11px; color: var(--text-subtle); margin-bottom: 5px; }
.ai-url-panel .ai-provider-row { margin-bottom: 0; }
/* Zweites Label-/Zeilenpaar im selben Panel (Modellname, unter der
Server-Adresse): braucht Abstand nach oben, den das erste (am
Panel-Anfang) nicht haben darf — gleiches Muster wie .drawer-h4-zweit. */
.ai-url-panel label:nth-of-type(2) { margin-top: 12px; }
/* "Neu verbinden": prüft die gespeicherte Konfiguration wirklich (POST
/my-ai-settings/test), statt nur anzuzeigen, dass irgendein Schlüssel
@@ -4115,19 +4174,62 @@ td.actions-cell button { margin: 2px; }
color: var(--text-muted);
}
/* === Wochenbericht als Tagesordnung (js/wochenbericht/wochenbericht.js) ===
Links die vollständige Projektliste, rechts das gewählte Projekt. Die
frühere Abfolge gestapelter Karten war bei 29 Projekten 18 Bildschirmseiten
lang; hier bleibt die Liste stehen und man sieht jederzeit, wo man steht
und was noch kommt. */
/* === Wochenbericht als Tagesordnung, Fokus-Modus (Eriks Wahl "C" aus dem
Darstellungs-Vergleich, js/wochenbericht/wochenbericht.js) ===
Links eine schmale Nummern-Leiste (jedes Projekt nur eine Position + ggf.
ein Änderungs-Punkt, Name im Tooltip), rechts das gewählte Projekt — nur
Kommentar und Kernstatus liegen dort offen, Leistungsphasen/Beteiligte/
Anwesenheit stecken in aufklappbaren Abschnitten (.bericht-disclosure).
Ersetzt die vorherige ausgeschriebene Liste|Detail-Darstellung, die mit
Gewerke-Dropdown, Fortschrittsbalken, Suchfeld, mehreren Badges je Zeile
und allem gleichzeitig sichtbar als "zu überladen" zurückgemeldet wurde.
Eine ganze Fläche (.bericht-panel), nicht zwei lose nebeneinanderstehende
Karten — genau wie im freigegebenen Entwurf: eine Kopfleiste mit eigenem
Hintergrund/Trennlinie, darunter die Leiste als Spalte MIT dem Ergebnis
verbunden (Trennlinie statt eigenem Rahmen). */
.bericht-titelzeile { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.bericht-titelzeile button { margin-left: auto; }
.bericht-kopf {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
margin-bottom: 12px;
.bericht-panel {
background: var(--surface); border: 1px solid var(--line-soft);
border-radius: var(--r-lg); box-shadow: var(--sh-1); overflow: hidden;
}
.bericht-gewerk-select { font-size: 12.5px; padding: 5px 10px; min-width: 170px; }
.bericht-topbar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 10px 20px; border-bottom: 1px solid var(--line-soft);
}
.bericht-gewerk-select {
font-size: 12px; font-weight: 600; padding: 5px 10px;
border-radius: var(--r-sm); background: var(--surface-alt);
border: 1px solid var(--line); color: var(--text); cursor: pointer;
}
.bericht-ring {
width: 26px; height: 26px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
}
.bericht-ring span { width: 18px; height: 18px; border-radius: 50%; background: var(--surface); display: block; }
.bericht-fortschritt-text { font-size: 11.5px; color: var(--text-muted); font-weight: 600; white-space: nowrap; }
/* Reines Textlink, kein Knopf — im Entwurf steht an dieser Stelle nur der
Fortschritt, nicht noch eine zusätzliche Schaltfläche daneben. */
.bericht-reset-link {
font-size: 11.5px; font-weight: 600; border: none; background: none;
color: var(--text-subtle); cursor: pointer; padding: 0; text-decoration: underline;
text-decoration-color: var(--line);
}
.bericht-reset-link:hover { color: var(--text); }
.bericht-search-btn {
margin-left: auto; border: 1px solid var(--line); background: var(--surface-alt);
color: var(--text-subtle); font-size: 12px; padding: 5px 10px;
border-radius: var(--r-pill); cursor: pointer;
}
.bericht-search-btn:hover { color: var(--text); border-color: var(--text-subtle); }
.bericht-suche-input {
margin-left: auto; border: 1px solid var(--line); border-radius: var(--r-pill);
background: var(--surface-sunk); color: var(--text); font-size: 12px;
padding: 6px 12px; width: 170px;
}
.bericht-suche-input:focus { outline: none; border-color: var(--accent); background: var(--surface); }
/* ---------- Planung (Terminplan-Sandbox, js/planung/planung.js) ---------- */
.planung-row {
@@ -4161,118 +4263,168 @@ td.actions-cell button { margin: 2px; }
.choice-card-desc { font-size: 11.5px; color: var(--text-muted); line-height: 1.4; }
.empty-hint { text-align: center; padding: 60px 20px; color: var(--text-subtle); font-size: 13px; }
.empty-hint .big { font-size: 15px; color: var(--text-muted); margin-bottom: 6px; display: block; }
.bericht-fortschritt { display: flex; align-items: center; gap: 9px; margin-left: auto; }
.bericht-fortschritt-text { font-size: 12.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
.bericht-fortschritt-bar { width: 120px; height: 6px; border-radius: 3px; background: var(--surface-sunk); overflow: hidden; }
.bericht-fortschritt-bar i { display: block; height: 100%; background: var(--green); transition: width .25s var(--ease); }
.bericht-split {
display: grid;
grid-template-columns: 272px minmax(0, 1fr);
gap: 16px;
align-items: start;
/* ---------- Plan Checker (Studenten, js/planchecker/planchecker.js) ----------
Darstellung "Arbeitsfläche" (Eriks Wahl "B" aus dem Design-Vergleich): zwei
Spalten dauerhaft nebeneinander — links Upload/Konfiguration, rechts das
Ergebnis, damit ein erneutes Prüfen nach einer Korrektur das vorherige
Ergebnis nicht aus dem Blick verliert. Eigene .pc-*-Klassen statt
bestehender Komponenten (.board-drawer, .settings-card …): keine davon
passt auf eine Zwei-Spalten-Arbeitsfläche mit Ablagefläche + Ergebnisliste,
ein Zurechtbiegen hätte mehr Sonderfälle erzeugt als eine schlanke eigene
Regelgruppe. */
.pc-layout { display: grid; grid-template-columns: 340px 1fr; gap: 20px; align-items: start; }
@media (max-width: 860px) { .pc-layout { grid-template-columns: 1fr; } }
.pc-panel { background: var(--surface); border: 1px solid var(--line-soft); border-radius: var(--r-lg); box-shadow: var(--sh-1); padding: 20px; }
.pc-drop {
display: block; border: 1.5px dashed var(--line); border-radius: var(--r-md);
padding: 20px 14px; text-align: center; color: var(--text-muted);
background: var(--surface-alt); cursor: pointer; font-size: 12.5px;
transition: border-color .15s var(--ease), color .15s var(--ease), background .15s var(--ease);
}
@media (max-width: 900px) { .bericht-split { grid-template-columns: 1fr; } }
.pc-drop:hover, .pc-drop.is-dragover { border-color: var(--accent); color: var(--text); background: var(--accent-soft); }
.pc-drop-name { font-weight: 650; color: var(--text); margin-top: 4px; font-size: 13px; word-break: break-word; }
.bericht-liste-spalte {
background: var(--surface); border-radius: var(--r-md); box-shadow: var(--sh-1);
overflow: hidden;
/* Die Liste bleibt beim Scrollen im Blick — sie IST die Tagesordnung. */
.pc-section { margin-top: 18px; }
.pc-label { display: block; font-size: 11.5px; font-weight: 650; color: var(--text-muted); margin-bottom: 5px; }
.pc-merkmal-chip { font-size: 12.5px; padding: 5px 2px; color: var(--text-muted); }
.pc-zusatz { margin-top: 8px; }
.pc-zusatz .pc-input { width: 100%; }
/* background-color statt der background-Kurzschreibweise: Die Kurzschreibweise
setzt background-repeat/-position implizit auf ihre Vorgabewerte zurück
(repeat/0% 0%) und schlägt dabei wegen höherer Klassen-Spezifität die
globalen select-Regeln (background-repeat:no-repeat, siehe weiter oben),
die den eigenen SVG-Pfeil sonst nur einmal rechts zeigen — das gekachelte
Pfeil-Muster, das als "komisch" auffiel, war genau das. */
.pc-select, .pc-input {
border: 1px solid var(--line); border-radius: var(--r-md); background-color: var(--surface);
color: var(--text); font-size: 13px; font-family: inherit; padding: 8px 10px;
}
.pc-select:focus, .pc-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); }
.pc-btn {
border: 1px solid var(--line); background: var(--surface); color: var(--text);
font-size: 13px; font-weight: 600; padding: 9px 16px; border-radius: var(--r-md); cursor: pointer;
}
.pc-btn:hover { background: var(--surface-alt); }
.pc-btn:disabled { opacity: .6; cursor: default; }
.pc-btn-primary { border: none; background: var(--accent); color: #fff; }
.pc-btn-primary:hover { background: var(--accent-hover); }
.pc-error { color: var(--red); margin: 8px 0 0; }
.pc-verdict {
display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
width: 20px; height: 20px; border-radius: 50%; font-size: 12px; font-weight: 700; color: #fff;
}
.pc-verdict.ok { background: var(--green); }
.pc-verdict.fail { background: var(--red); }
.pc-results { min-height: 420px; display: flex; flex-direction: column; }
.pc-results-empty { margin: auto; text-align: center; color: var(--text-subtle); padding: 40px 20px; }
.pc-results-empty .big { font-size: 14px; color: var(--text-muted); margin-bottom: 4px; display: block; }
.pc-result-head { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--line-soft); flex-wrap: wrap; }
.pc-result-head .filename { font-weight: 650; }
.pc-result-head .meta { color: var(--text-subtle); font-size: 12px; margin-left: auto; }
.pc-result-row { display: flex; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--line-soft); }
.pc-result-row:last-child { border-bottom: none; }
.pc-result-row .titel { font-weight: 650; margin-bottom: 2px; font-size: 13px; }
.pc-result-row .begruendung { color: var(--text-muted); font-size: 12.5px; }
.pc-result-text { flex: 1 1 auto; min-width: 0; }
/* pc-result-shot: Screenshot des Planausschnitts, den die KI für das Merkmal
als Beleg gewählt hat (server.js, pruefePlanMitKI(), bildIndex) — nur
vorhanden, wenn die KI einen gültigen Bild-Index geliefert hat. Ein
<button>, kein <a> (öffnet #pcShotModal statt eines Links auf die
data:-URL, s. planCheckerZeigeScreenshot() — ein Link blieb wirkungslos,
da Chrome das Öffnen einer data:-URL in einem neuen Tab blockiert),
deshalb die Button-Vorgaben zurückgesetzt. */
.pc-result-shot { flex: 0 0 auto; display: block; background: none; border: none; padding: 0; cursor: pointer; }
.pc-result-shot img {
width: 110px; height: 80px; object-fit: cover; display: block;
border: 1px solid var(--line-soft); border-radius: var(--r-sm); background: var(--surface-alt);
}
.pc-result-shot:hover img { border-color: var(--accent); }
.bericht-layout { display: grid; grid-template-columns: 64px minmax(0, 1fr); }
@media (max-width: 760px) { .bericht-layout { grid-template-columns: 48px minmax(0, 1fr); } }
.bericht-rail {
background: var(--surface-alt);
border-right: 1px solid var(--line-soft);
padding: 10px 0; display: flex; flex-direction: column; align-items: center; gap: 3px;
/* Die Leiste bleibt beim Scrollen im Blick — sie IST die Tagesordnung. Kein
eigener Rahmen/Radius/Schatten mehr und keine eigene Höhenbegrenzung:
die Fläche gehört zu .bericht-panel, und eine gedeckelte Höhe zwang den
eigenen (inzwischen entfernten) Tooltip zum Abschneiden — ein Element mit
overflow-y ungleich visible schneidet nach der CSS-Spezifikation auch in
X-Richtung ab. */
position: sticky; top: 64px;
}
.bericht-suche { padding: 8px 9px; border-bottom: 1px solid var(--line-soft); }
.bericht-suche input { width: 100%; padding: 5px 9px; font-size: 12.5px; }
.bericht-liste { max-height: min(62vh, 620px); overflow-y: auto; }
.bericht-rail-item {
position: relative; width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: var(--text-subtle); cursor: pointer;
}
.bericht-rail-item:hover { background: var(--surface-sunk); color: var(--text); }
.bericht-rail-item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.bericht-rail-item.is-aktiv { background: var(--accent); color: #fff; }
/* Abgehakt: zurückgenommen, aber erkennbar — man muss im Meeting noch sehen
können, was man schon besprochen hat. */
.bericht-rail-item.is-ab { opacity: .55; }
.bericht-rail-item.is-aktiv.is-ab { opacity: 1; }
.bericht-rail-num { font-size: 10.5px; font-weight: 700; font-variant-numeric: tabular-nums; }
.bericht-rail-dot { position: absolute; top: 3px; right: 3px; width: 6px; height: 6px; border-radius: 50%; }
.bericht-rail-dot.is-new, .bericht-rail-dot.is-status { background: var(--band-neu); }
.bericht-rail-dot.is-progress { background: var(--band-fortschritt); }
.bericht-rail-item.is-aktiv .bericht-rail-dot { outline: 1.5px solid var(--accent-soft); outline-offset: -1px; }
.bericht-rail-tooltip {
position: absolute; left: 48px; top: 50%; transform: translateY(-50%);
background: var(--text); color: var(--surface); font-size: 11px; padding: 4px 8px;
border-radius: var(--r-sm); white-space: nowrap; opacity: 0; pointer-events: none;
transition: opacity .12s var(--ease); z-index: 5;
}
.bericht-rail-item:hover .bericht-rail-tooltip,
.bericht-rail-item:focus-visible .bericht-rail-tooltip { opacity: 1; }
.bericht-gruppe {
font-size: 10px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase;
color: var(--text-subtle); padding: 9px 11px 3px;
}
.bericht-zeile {
display: flex; align-items: center; gap: 7px;
padding: 5px 10px; cursor: pointer;
border-left: 2px solid transparent;
font-size: 12.5px;
}
.bericht-zeile:hover { background: var(--surface-alt); }
.bericht-zeile:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.bericht-zeile.is-aktiv { background: var(--accent-soft); border-left-color: var(--accent); }
/* Abgehakt: zurückgenommen, aber lesbar — man muss im Meeting noch erkennen
können, was man besprochen hat. */
.bericht-zeile.is-ab .bericht-zeile-name,
.bericht-zeile.is-ab .bericht-zeile-nr { color: var(--text-subtle); }
.bericht-zeile-nr {
font-family: ui-monospace, 'SF Mono', monospace; font-size: 11px;
color: var(--text-muted); flex-shrink: 0;
}
.bericht-zeile-name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* max-width: die Hauptspalte bleibt eine schmale Lesespalte statt bis zum
rechten Rand zu laufen — Teil der Fokus-Idee, nicht nur die Nebeninfos
wegzuklappen, sondern auch dem Kommentar selbst keine unnötige Breite zu
geben. */
.bericht-main { padding: 26px 36px 30px; max-width: 640px; min-width: 0; }
.bericht-haken {
width: 17px; height: 17px; flex-shrink: 0; padding: 0;
display: inline-flex; align-items: center; justify-content: center;
border: 1.5px solid var(--line); border-radius: 50%;
background: transparent; color: transparent;
font-size: 10px; line-height: 1; cursor: pointer;
.bericht-head-line { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.bericht-head-nr {
font-family: ui-monospace, 'SF Mono', monospace; font-size: 11.5px;
color: var(--text-subtle);
}
.bericht-haken:hover:not(:disabled) { border-color: var(--green); color: var(--green); }
.bericht-haken.ab { background: var(--green); border-color: var(--green); color: #fff; }
.bericht-haken:disabled { cursor: default; opacity: .5; }
/* Änderungs-Kennzeichen: nur noch ein Punkt in der Liste — die Gruppierung
nach Änderungsart ist entfallen, weil im Meeting ohnehin jedes Projekt
besprochen wird. Die ausformulierte Begründung steht im Detail. */
/* Die Zustandsklassen tragen ein "is-"-Präfix: Ohne es hieß eine davon
schlicht "status" und traf die Regel .status { min-width: 240px } weiter
oben — der 7-px-Punkt wurde 240 px breit und quetschte den Projektnamen
auf null. */
.bericht-punkt { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.bericht-punkt.is-new, .bericht-punkt.is-status { background: var(--band-neu); }
.bericht-punkt.is-progress { background: var(--band-fortschritt); }
.bericht-abwesend {
font-size: 10px; font-weight: 600; color: var(--amber);
background: color-mix(in srgb, var(--amber) 15%, transparent);
border-radius: var(--r-pill); padding: 0 5px; flex-shrink: 0;
.bericht-change-pill {
font-size: 10.5px; font-weight: 700; color: #fff; padding: 2px 9px; border-radius: var(--r-pill);
}
.bericht-detail {
background: var(--surface); border-radius: var(--r-md); box-shadow: var(--sh-1);
padding: 15px 17px 16px; min-width: 0;
}
.bericht-detail-kopf { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; }
.bericht-detail-kopf h3 { margin: 0; font-size: 16px; font-weight: 600; }
.bericht-detail-zaehler {
font-family: ui-monospace, 'SF Mono', monospace; font-size: 11px;
color: var(--text-subtle); font-variant-numeric: tabular-nums;
}
.bericht-detail-meta { color: var(--text-muted); font-size: 12px; margin: 2px 0 12px; }
.bericht-kennz {
font-size: 11px; border-radius: var(--r-pill); padding: 1px 9px; color: #fff;
}
.bericht-kennz.is-new, .bericht-kennz.is-status { background: var(--band-neu); }
.bericht-kennz.is-progress { background: var(--band-fortschritt); }
.bericht-change-pill.is-new, .bericht-change-pill.is-status { background: var(--band-neu); }
.bericht-change-pill.is-progress { background: var(--band-fortschritt); }
.bericht-h1 { font-size: 21px; font-weight: 650; letter-spacing: -0.012em; margin: 0 0 3px; }
.bericht-sub { color: var(--text-subtle); font-size: 12px; margin-bottom: 16px; }
.bericht-abwesend-hinweis {
font-size: 12.5px; color: var(--amber);
background: color-mix(in srgb, var(--amber) 10%, transparent);
border-radius: var(--r-sm); padding: 6px 10px; margin-bottom: 10px;
display: inline-flex; align-items: center; gap: 6px;
color: var(--amber); font-size: 12px; margin-bottom: 16px;
}
/* Der Kommentar der Vorwoche steht ÜBER dem Eingabefeld: "Was hatten wir
letzte Woche vereinbart?" ist die häufigste Frage im Meeting. */
.bericht-vorwoche {
display: flex; gap: 9px; align-items: baseline;
padding: 7px 10px; margin-bottom: 11px;
background: var(--surface-alt); border-left: 2px solid var(--line);
border-radius: 0 var(--r-sm) var(--r-sm) 0;
font-size: 12.5px;
background: var(--surface-sunk); border-radius: var(--r-md);
padding: 9px 12px; margin-bottom: 14px; font-size: 12px; color: var(--text-muted);
}
.bericht-vorwoche-label {
display: block; font-weight: 600; color: var(--text-subtle);
text-transform: uppercase; font-size: 10.5px; letter-spacing: .05em; margin-bottom: 3px;
}
.bericht-vorwoche-label { font-weight: 600; color: var(--text-muted); white-space: nowrap; flex-shrink: 0; }
.bericht-vorwoche-text { color: var(--text-muted); }
.bericht-detail-fuss {
display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line-soft);
}
.bericht-detail-fuss { display: flex; align-items: center; gap: 14px; margin-top: 20px; }
.bericht-fuss-spacer { flex: 1; }
.person-chip-label { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }