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:
1 parent
db63a29365
commit
6ba93b2a92
308 files changed
+64441
-743
No files matched your search
+113
-4
@@ -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 & 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 & Sicherheit</button>
|
||||
<button class="menu-item sidebar-subitem" data-admin-section="datenbank" onclick="showAdminSection('datenbank')">Datenbank & 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>
|
||||
|
||||
@@ -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
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ const TAB_SLUGS = {
|
||||
load: 'Auslastung',
|
||||
people: 'Projektbeteiligte',
|
||||
planung: 'Planung',
|
||||
planchecker: 'PlanChecker',
|
||||
dashboard:'MeineAufgaben',
|
||||
wand: 'Wandtafel',
|
||||
settings: 'Einstellungen',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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('');
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user