Plan Checker, Wochenbericht-Fokus-Modus und Wandtafel-Dashboard-Feinschliff

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Sonnet 5 committed 2026-10-05 14:01:41 +02:00
1 parent db63a29365
commit 6ba93b2a92
308 files changed
+64441 -743

No files matched your search

+91
View File
@@ -59,6 +59,7 @@ const ADMIN_SECTIONS = [
{ key: 'termine', title: 'Termine & Sitzungen' },
{ key: 'feiertage', title: 'Feiertage' },
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen' },
{ key: 'planchecker', title: 'Plan-Checker-Merkmale' },
{ key: 'betrieb', title: 'Betrieb & Sicherheit' },
{ key: 'datenbank', title: 'Datenbank & Sitzungen' },
];
@@ -66,6 +67,10 @@ const ADMIN_SECTIONS = [
// adminSection: der gerade sichtbare Abschnitt.
let adminSection = 'benutzer';
// adminPlanCheckerMerkmale: zuletzt geladene Plan-Checker-Merkmale-Liste
// (siehe Abschnitt "Plan-Checker-Merkmale" weiter unten).
let adminPlanCheckerMerkmale = [];
// renderAdminArea: Einstiegspunkt des Bereichs, von showTab('admin')
// gerufen. Befüllt alle Masken und wählt danach den sichtbaren Abschnitt.
// Keine Parameter, keine Rückgabe.
@@ -74,6 +79,7 @@ function renderAdminArea(){
loadAdminHonorar();
loadAdminTemplates();
loadAdminPlanCheckerMerkmale();
// Die firmenweiten Zahlen und Feiertage stehen bereits in APP_SETTINGS bzw.
// HOLIDAYS (beim Seitenstart geladen, js/store.js) — die Masken füllen sich
// also ohne einen weiteren Serveraufruf.
@@ -694,6 +700,91 @@ function deleteTemplateEntry(id){
});
}
// ---------- Abschnitt Plan-Checker-Merkmale ----------
//
// Dieselbe add/delete-only Liste wie die Aufgaben-Vorlagen oben, nur ohne
// Gruppierung (die Merkmale gelten unterschiedslos für jede Prüfung) — siehe
// plan_checker_merkmale/GET+POST+DELETE /plan-checker-merkmale, server.js.
// Die Liste selbst nutzt auch der Plan Checker (js/planchecker/planchecker.js),
// um zu zeigen, was geprüft wird — beide Stellen lesen denselben Endpunkt.
// loadAdminPlanCheckerMerkmale: holt alle Merkmale und rendert die Liste.
// Keine Parameter, keine Rückgabe.
function loadAdminPlanCheckerMerkmale(){
const listEl = document.getElementById('planCheckerMerkmaleAdminListe');
if(listEl) listEl.innerHTML = '<span class="hint">Lade Merkmale…</span>';
fetch(baseUrl() + '/plan-checker-merkmale')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
adminPlanCheckerMerkmale = Array.isArray(data.merkmale) ? data.merkmale : [];
renderAdminPlanCheckerMerkmale();
})
.catch(err => {
console.error('Plan-Checker-Merkmale laden fehlgeschlagen:', err);
if(listEl) listEl.innerHTML = '<span class="hint">Merkmale konnten nicht geladen werden.</span>';
});
}
// renderAdminPlanCheckerMerkmale: zeigt die Merkmale in gepflegter Reihenfolge,
// je Zeile mit Prüfanweisung und Lösch-Knopf. Keine Parameter, keine Rückgabe.
function renderAdminPlanCheckerMerkmale(){
const listEl = document.getElementById('planCheckerMerkmaleAdminListe');
if(!listEl) return;
listEl.innerHTML = adminPlanCheckerMerkmale.length === 0
? '<span class="hint">Noch kein Merkmal hinterlegt.</span>'
: adminPlanCheckerMerkmale.map(m => `<div class="template-admin-row">
<span class="template-admin-title">${esc(m.titel)}</span>
<span class="hint" style="margin:0">${esc(m.prompt)}</span>
<button class="danger small" onclick="deletePlanCheckerMerkmal('${esc(m.id)}')" title="Merkmal löschen">×</button>
</div>`).join('');
}
// addPlanCheckerMerkmal: legt ein neues Merkmal an (POST, Admin-only).
// Keine Parameter, keine Rückgabe.
function addPlanCheckerMerkmal(){
const titelEl = document.getElementById('merkmalNeuTitel');
const promptEl = document.getElementById('merkmalNeuPrompt');
const titel = titelEl ? titelEl.value.trim() : '';
const prompt = promptEl ? promptEl.value.trim() : '';
if(!titel){ alert('Bitte einen Titel eingeben.'); return; }
if(!prompt){ alert('Bitte eine Prüfanweisung eingeben.'); return; }
fetch(baseUrl() + '/plan-checker-merkmale', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ titel, prompt })
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.');
if(titelEl) titelEl.value = '';
if(promptEl) promptEl.value = '';
loadAdminPlanCheckerMerkmale();
showAdminNote('Merkmal angelegt.');
})
.catch(err => {
console.error('Merkmal anlegen fehlgeschlagen:', err);
alert('Merkmal konnte nicht angelegt werden: ' + (err.message || err));
});
}
// deletePlanCheckerMerkmal: löscht ein Merkmal (DELETE, Admin-only).
// Parameter: id (String). Keine Rückgabe.
function deletePlanCheckerMerkmal(id){
if(!confirm('Dieses Merkmal löschen?')) return;
fetch(baseUrl() + '/plan-checker-merkmale/' + encodeURIComponent(id), { method: 'DELETE' })
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Löschen fehlgeschlagen.');
loadAdminPlanCheckerMerkmale();
})
.catch(err => {
console.error('Merkmal löschen fehlgeschlagen:', err);
alert('Merkmal konnte nicht gelöscht werden: ' + (err.message || err));
});
}
// ---------- Rückmeldung ----------
+52 -21
View File
@@ -28,6 +28,22 @@
// persistiert — jede Sitzung startet mit eingeklappten Zeilen.
let boardRowOpen = {};
// berichtDisclosureState: merkt sich je Abschnitt (Leistungsphasen, Beteiligte,
// Anwesenheit), ob er im Wochenbericht (Fokus-Modus — Eriks Wahl "C" aus dem
// Darstellungs-Vergleich: renderBoardDrawer()s dichte Fassung weiter unten +
// renderAttendance() in js/wochenbericht/wochenbericht.js) gerade auf- oder
// zugeklappt ist. Ohne dieses Gedächtnis klappte jeder Abschnitt bei jedem
// Neu-Rendern wieder auf seinen Vorgabewert zurück — und JEDE Änderung in der
// Schublade (eine Zuordnung, ein LPH-Wert) baut den ganzen Bericht neu auf.
// 'toggle' bubbelt nicht, ein Lauscher in der Capture-Phase sieht das
// Ereignis trotzdem auf dem Weg nach unten (gleiches Muster wie der
// scroll-Lauscher der Wandtafel).
const berichtDisclosureState = { lph: true, beteiligte: false, anwesenheit: false };
document.addEventListener('toggle', function(e){
const key = e.target && e.target.dataset && e.target.dataset.berichtAbschnitt;
if(key) berichtDisclosureState[key] = e.target.open;
}, true);
// toggleBoardRow: klappt das Detailpanel einer Projekt-Zeile auf bzw. zu.
// Parameter: pid (Projekt-ID). Keine Rückgabe.
function toggleBoardRow(pid){
@@ -500,34 +516,49 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
return `<div class="board-role-row"><span class="board-role-label ${roleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`;
}).join('');
// Dichte Fassung: zwei Spalten — links der Kommentar, rechts die
// Leistungsphasen-Leiste und darunter die Beteiligten.
//
// Zwischenzeitlich waren es drei Spalten, weil die gestapelten Beteiligten
// die Karte höher machten und damals 29 Karten untereinander standen. Seit
// der Wochenbericht eine Tagesordnung ist, ist immer nur EIN Projekt
// geöffnet — die Höhe der Karte kostet nichts mehr, während die Breite
// knapp ist: Neben der Projektliste blieben für drei Spalten nur noch
// 137 px für die Beteiligten, zu wenig für Rollenname und Kürzel.
// Dichte Fassung (Wochenbericht): erst zwei, dann drei Spalten nebeneinander
// — bei 29 Projekten mit allem gleichzeitig sichtbar kam von Erik die
// Rückmeldung "zu überladen". Jetzt (Fokus-Modus, Eriks Wahl "C" aus dem
// Darstellungs-Vergleich) liegt nur der Kommentar offen, Leistungsphasen
// und Beteiligte stecken in aufklappbaren Abschnitten untereinander.
//
// Die Beteiligten behalten ihre Zeile je Rolle — anders als im Entwurf, wo
// sie zu einer flachen Reihe zusammengefasst waren: Ein Kürzel kann in
// mehreren Rollen vorkommen, und cycleAssign() braucht die Rolle. Ohne sie
// wäre beim Klicken nicht erkennbar, welche Zuordnung man durchschaltet.
if(opts && opts.dicht){
// Fokus-Modus (Eriks Wahl "C" aus dem Darstellungs-Vergleich): nur der
// Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in
// aufklappbaren Abschnitten (<details>, Zustand in berichtDisclosureState)
// statt dauerhaft nebeneinander zu stehen. Die Leistungsphasen stehen hier
// als Balkenzeilen (bericht-lph-row) statt als Kästchen-Leiste
// (board-lph-leiste bleibt die Darstellung des Projektboards, unverändert).
const lphBarRows = sichtbareLphs.map(l=>{
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
const aktiv = l === aktLp;
const inputAttr = starr ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
const pct = v !== "" ? Math.min(100, parseFloat(v)||0) : 0;
return `<div class="bericht-lph-row${aktiv ? ' is-aktiv' : ''}">
<span class="bericht-lph-label">${esc(l.replace('LPH ', 'LP '))}</span>
<span class="bericht-lph-track"><i style="width:${pct}%"></i></span>
<span class="bericht-lph-pct"><input type="text" class="bericht-lph-input${locked ? ' locked-cell' : ''}" value="${esc(v)}" ${inputAttr} placeholder="-">%</span>
</div>`;
}).join('');
const lphInfo = aktLp
? esc(aktLp.replace('LPH ', 'LP ')) + ' aktiv' + (p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? ' · ' + esc(p.lph[aktLp]) + '%' : '')
: 'Keine aktive Leistungsphase';
const zugeordnetAnzahl = allPeople.filter(e => (p.assign[assignKey(e.person.id, e.role)] || 0) > 0).length;
return `<div class="board-drawer board-drawer-dicht">
<div class="board-drawer-grid">
<div class="drawer-col drawer-col-comment">
<h4>Kommentar${weekLabel ? ' zur Sitzung ' + esc(weekLabel) : ''}</h4>
${renderCommentEditor(p, weekLabel, locked, true)}
</div>
<div class="drawer-col drawer-col-kompakt">
<h4>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</h4>
<div class="board-lph-leiste">${lphLeiste}</div>
<h4 class="drawer-h4-zweit">Beteiligte</h4>
<div class="board-role-mini">${roleRows}</div>
</div>
</div>
${renderCommentEditor(p, weekLabel, locked, true)}
<details class="bericht-disclosure" data-bericht-abschnitt="lph" ${berichtDisclosureState.lph ? 'open' : ''}>
<summary><span>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</span><span class="count">${lphInfo}</span><span class="chev">${icon('chevronRight')}</span></summary>
<div class="bericht-disclosure-body">${lphBarRows}</div>
</details>
<details class="bericht-disclosure" data-bericht-abschnitt="beteiligte" ${berichtDisclosureState.beteiligte ? 'open' : ''}>
<summary><span>Beteiligte</span><span class="count">${zugeordnetAnzahl} zugeordnet</span><span class="chev">${icon('chevronRight')}</span></summary>
<div class="bericht-disclosure-body"><div class="board-role-mini">${roleRows}</div></div>
</details>
${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''}
</div>`;
}
+1
View File
@@ -145,6 +145,7 @@ const TAB_SLUGS = {
load: 'Auslastung',
people: 'Projektbeteiligte',
planung: 'Planung',
planchecker: 'PlanChecker',
dashboard:'MeineAufgaben',
wand: 'Wandtafel',
settings: 'Einstellungen',
+82 -62
View File
@@ -9,9 +9,12 @@
// (computeResourceLoads/renderResourceLoad/openResourceModal, Cockpit-Layout:
// Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter)
// und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt,
// offene Aufgaben, nächster Meilenstein, Team-Auslastung, alle aus denselben
// Plan-Daten abgeleitet). renderPlanGantt ist die zentrale Neu-Render-
// Funktion (rendert am Ende auch Auslastung und KPIs neu) und wird nach
// offene Aufgaben und nächster Meilenstein aus denselben Plan-Daten;
// Team-Auslastung dagegen über berechneAuslastung() aus js/load/load.js,
// siehe computeResourceLoads() weiter unten — dieselbe Rechnung wie im
// Auslastungs-Tab, nicht aus den Plan-Daten selbst). renderPlanGantt ist die
// zentrale Neu-Render-Funktion (rendert am Ende auch Auslastung und KPIs
// neu) und wird nach
// Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js erneut
// aufgerufen.
//
@@ -171,15 +174,15 @@
milestoneTile = kpiTile('Nächster Meilenstein', '–', milestones.length > 0 ? 'Keine anstehenden Meilensteine' : 'Keine Meilensteine angelegt');
}
const { loads, maxDays } = computeResourceLoads();
const { loads } = computeResourceLoads();
const loadValues = Object.values(loads);
let loadTile;
if(loadValues.length === 0){
loadTile = kpiTile('Team-Auslastung', '–', 'Keine Beteiligten zugeordnet');
} else {
const avgPct = Math.round(loadValues.reduce((s, l) => s + (l.days / maxDays * 100), 0) / loadValues.length);
const top = loadValues.slice().sort((a, b) => b.days - a.days)[0];
loadTile = kpiTile('Team-Auslastung', avgPct + '%', top.days > 0 ? 'Höchste Auslastung: ' + top.person.name : 'Noch keine Aufgaben zugewiesen');
const avgPct = Math.round(loadValues.reduce((s, l) => s + l.quote * 100, 0) / loadValues.length);
const top = loadValues.slice().sort((a, b) => b.quote - a.quote)[0];
loadTile = kpiTile('Team-Auslastung', avgPct + '%', top.stunden > 0 ? 'Höchste Auslastung: ' + top.person.name : 'Noch nicht bewertbar');
}
row.innerHTML = progressTile + openTile + milestoneTile + loadTile;
@@ -304,66 +307,83 @@
// ---------- Ressourcen-Auslastung ----------
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten in
// Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs.
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten
// NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel
// (berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor(),
// js/load/load.js) — Wochenstunden aus Honorar × Phasenanteil ×
// Restfortschritt, bezogen auf die Kapazität der Person.
//
// Vorher zählte diese Stelle etwas ganz anderes: Aufgabentage aus den
// TODO-Zeiträumen DIESES Terminplans, ohne jeden Bezug zu Honorar oder
// Kapazität — ein Blick auf dieselbe Person zeigte hier eine andere Zahl
// als im Auslastungs-Tab. Erik: "Die Auslastung der Projektbeteiligten im
// Terminplan ist eine andere als die in der sonstigen Anwendung... gleich-
// setzen mit der in der sonstigen Anwendung." Jetzt wird für JEDES Projekt
// der Sitzung (dieses UND alle übrigen) genau derselbe
// berechneAuslastung()-Aufruf gemacht wie in renderLoad() — dieselbe
// Person zeigt hier also exakt dieselbe Gesamtzahl wie im Auslastungs-Tab,
// nur aufgeteilt in den Anteil DIESES Projekts und den Anteil der übrigen
// Projekte der Sitzung (sonst wäre eine Überbuchung hier unsichtbar:
// jemand kann in diesem Projekt entspannt aussehen und trotzdem in vier
// weiteren ausgebucht sein — dieselbe Überlegung wie zuvor, nur jetzt in
// Stunden statt Tagen).
//
// Gemeinsam genutzt von renderResourceLoad() (Detailkarten im
// Auslastungs-Modal) und renderPlanKpis() (Team-Auslastung-Kachel), damit
// beide dieselbe Zahl zeigen.
//
// Neben der Auslastung IN DIESEM Projekt (days/todos) wird zusätzlich
// erfasst, wie stark dieselbe Person in den ÜBRIGEN Projekten derselben
// Sitzung gebunden ist (otherDays/otherProjects). Ohne diesen Blick über den
// Projektrand ist eine Überbuchung strukturell unsichtbar: jemand kann hier
// entspannt aussehen und trotzdem in vier weiteren Projekten verplant sein.
// Die Daten dafür sind bereits vorhanden — app.week.projects enthält alle
// Projekte der Sitzung (GET /load-all lädt sie ohnehin komplett).
//
// Rückgabe: { loads, maxDays } — loads ist ein Objekt Person-ID ->
// { person, roles, days, todos, otherDays, otherProjects }, maxDays das
// Maximum über alle Personen (mindestens 1, für Prozent-Berechnungen; bezieht
// sich bewusst nur auf die projekteigenen Tage, damit die Balken im Modal
// untereinander vergleichbar bleiben).
// Terminplan-Bereich) und renderPlanKpis() (Team-Auslastung-Kachel), damit
// beide dieselbe Zahl zeigen. Rückgabe: { loads } — loads ist ein Objekt
// Person-ID -> { person, roles, kapazitaet, stundenProjekt, stundenAndere,
// stunden (Summe), quote (stunden/kapazitaet), andereProjekte, todos
// (Titel der eigenen TODOs in DIESEM Projekt, rein informativ — zählt
// nicht mehr in die Stundenrechnung mit ein).
function computeResourceLoads(){
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos.filter(t => t.start && t.end) : [];
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const assignees = projectAssignees(app.project, app.week);
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
const loads = {};
assignees.forEach(a => {
if(!loads[a.person.id]){
loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [], otherDays: 0, otherProjects: [] };
loads[a.person.id] = {
person: a.person, roles: [], todos: [],
kapazitaet: kapazitaetsFaktor(a.person) * vollzeit,
stundenProjekt: 0, stundenAndere: 0, andereProjekte: [],
};
}
if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role);
});
todos.forEach(t => {
if(!Array.isArray(t.assignees) || t.assignees.length === 0) return;
const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end));
if(!Array.isArray(t.assignees)) return;
t.assignees.forEach(pid => {
if(!loads[pid]) return;
loads[pid].days += days;
loads[pid].todos.push(t.title);
if(loads[pid]) loads[pid].todos.push(t.title);
});
});
// Auslastung in den anderen Projekten derselben Sitzung
((app.week && app.week.projects) || []).forEach(other => {
if(other.id === app.project.id) return;
const otherTodos = (other.plan && Array.isArray(other.plan.todos)) ? other.plan.todos : [];
otherTodos.forEach(t => {
if(!t.start || !t.end || !Array.isArray(t.assignees)) return;
const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end));
t.assignees.forEach(pid => {
if(!loads[pid]) return; // nur Personen, die auch hier beteiligt sind
loads[pid].otherDays += days;
const label = other.nr ? (other.nr + ' ' + (other.name || '')) : (other.name || 'Projekt');
if(!loads[pid].otherProjects.includes(label)) loads[pid].otherProjects.push(label);
});
((app.week && app.week.projects) || []).forEach(pr => {
const aktivePhase = lpActiveList(pr)[0];
Object.keys(pr.assign || {}).forEach(aKey => {
const pid = aKey.slice(0, aKey.indexOf('|'));
if(!loads[pid]) return; // nur Personen, die auch in DIESEM Projekt beteiligt sind
const rolle = aKey.slice(aKey.indexOf('|') + 1);
const al = berechneAuslastung(null, pr, aktivePhase, rolle, pr.assign[aKey], pr.assign);
if(al.stunden <= 0) return;
if(pr.id === app.project.id){
loads[pid].stundenProjekt += al.stunden;
} else {
loads[pid].stundenAndere += al.stunden;
const label = pr.nr ? (pr.nr + ' ' + (pr.name || '')) : (pr.name || 'Projekt');
if(!loads[pid].andereProjekte.includes(label)) loads[pid].andereProjekte.push(label);
}
});
});
const maxDays = Math.max(1, Object.values(loads).reduce((m, l) => Math.max(m, l.days), 0));
return { loads, maxDays };
Object.values(loads).forEach(l => {
l.stunden = l.stundenProjekt + l.stundenAndere;
l.quote = l.kapazitaet > 0 ? l.stunden / l.kapazitaet : 0;
});
return { loads };
}
// openResourceModal: springt in den Bereich "Terminplan", in dem die
@@ -387,7 +407,7 @@
const container = document.getElementById('resourceLoadContainer');
const summaryEl = document.getElementById('planResourceSummary');
if(!container || !app.project) return;
const { loads, maxDays } = computeResourceLoads();
const { loads } = computeResourceLoads();
const loadValues = Object.values(loads);
if(loadValues.length === 0){
@@ -398,11 +418,10 @@
if(summaryEl){
const cells = loadValues.map(l => {
const pct = Math.round(l.days / maxDays * 100);
const otherHint = l.otherDays > 0 ? ` (+${l.otherDays} anderswo)` : '';
return `<div class="plan-heat-cell" style="opacity:${Math.max(.15, pct / 100)}" title="${esc(l.person.name)}: ${l.days} Tage${otherHint}"></div>`;
const otherHint = l.stundenAndere > 0 ? ` (+${stundenFormat(l.stundenAndere)} h anderswo)` : '';
return `<div class="plan-heat-cell" style="opacity:${Math.max(.15, Math.min(1, l.quote))}" title="${esc(l.person.name)}: ${stundenFormat(l.stunden)} h${otherHint}"></div>`;
}).join('');
const alsoBooked = loadValues.filter(l => l.otherDays > 0).length;
const alsoBooked = loadValues.filter(l => l.stundenAndere > 0).length;
const chip = alsoBooked > 0
? `<span class="plan-mini-chip warn">${alsoBooked} auch in anderen Projekten</span>`
: `<span class="plan-mini-chip">nur in diesem Projekt</span>`;
@@ -410,10 +429,11 @@
<div class="plan-mini-chip-row">${chip}</div>`;
}
let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)</div>';
let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Wochenstunden, wie im Auslastungs-Tab)</div>';
html += '<div class="load-grid plan-resource-grid">';
Object.values(loads).forEach(l => {
const pct = Math.round(l.days / maxDays * 100);
const ampel = loadAmpelColor(l.quote);
const breite = Math.min(100, l.quote * 100);
const absCount = app.countPersonAbsences(l.person.id);
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">${absCount} Abwesenheit(en)</div>` : '';
@@ -427,19 +447,19 @@
}
}
// Gegenüberstellung: Tage in diesem Projekt vs. in den übrigen Projekten
// derselben Sitzung (siehe computeResourceLoads) — macht sichtbar, wenn
// jemand hier entspannt aussieht, aber anderswo ausgebucht ist.
const otherBadge = l.otherDays > 0
? `<div class="plan-resource-other" title="${esc(l.otherProjects.join('\n'))}">
+${l.otherDays} Tage in ${l.otherProjects.length} weiteren Projekt${l.otherProjects.length === 1 ? '' : 'en'}
// Gegenüberstellung: Stunden aus diesem Projekt vs. aus den übrigen
// Projekten der Sitzung (siehe computeResourceLoads) — macht sichtbar,
// wenn jemand hier entspannt aussieht, aber anderswo ausgebucht ist.
const otherBadge = l.stundenAndere > 0
? `<div class="plan-resource-other" title="${esc(l.andereProjekte.join('\n'))}">
+${stundenFormat(l.stundenAndere)} h in ${l.andereProjekte.length} weiteren Projekt${l.andereProjekte.length === 1 ? '' : 'en'}
</div>`
: `<div class="plan-resource-other muted">keine weiteren Projekte</div>`;
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3>
<div class="load-num" style="font-size:18px">${l.days}</div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${pct}%;background:${pct>75?'#c53030':pct>45?'#d69e2e':'#2f855a'}"></div></div>
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h</span></div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
${otherBadge}
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
${absBadge}
+13 -2
View File
@@ -101,11 +101,22 @@
lpSel.disabled = locked;
}
// Fortschritt je Leistungsphase — nur Anzeige, ohne Eingabefeld
// Fortschritt je Leistungsphase — nur Anzeige, ohne Eingabefeld.
// Zeigt nur die BEAUFTRAGTEN Leistungsphasen (p.beauftragteLP, siehe
// "Einstellungen"), genau wie die Wochenbericht-/Board-Karte
// (BOARD_OPTS.nurBeauftragteLph, js/board/board.js) — dieselbe Regel,
// hier nur fest statt als opt-in, da das Dashboard keine zweite Fassung
// mit allen Phasen kennt. Ist nichts beauftragt (ältere Bestands-
// projekte), bleibt es bei der vollständigen Liste, sonst stünde dort
// gar nichts. Vorher zeigte diese Liste IMMER alle Leistungsphasen,
// unabhängig von den Einstellungen — von Erik gemeldet.
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
const sichtbareLphs = beauftragt.length > 0 ? LPHS.filter(l => beauftragt.includes(l)) : LPHS;
const lphList = document.getElementById('planLphList');
if(lphList){
if(!p.lph) p.lph = {};
lphList.innerHTML = LPHS.map(l => {
lphList.innerHTML = sichtbareLphs.map(l => {
const v = (p.lph[l] !== undefined && p.lph[l] !== '') ? p.lph[l] : '';
const aktiv = l === aktLp;
const pct = v !== '' ? Math.min(100, parseFloat(v)||0) : 0;
+261
View File
@@ -0,0 +1,261 @@
// -----------------------------
// Projektübersicht — Plan Checker (Bereich "Studenten")
// -----------------------------
// Eigenständiges Prüf-Werkzeug: lädt einen Bauplan als PDF hoch und lässt
// eine KI (ChatGPT oder Claude, Anbieter wählbar — exakt wie beim
// bestehenden Protokoll-Import, js/plan/plan-protokoll.js) ihn gegen eine
// admin-gepflegte Liste von Merkmalen prüfen (z. B. "Maßketten vorhanden",
// siehe Admin → Plan-Checker-Merkmale, js/admin/admin.js). Zusätzlich lässt
// sich je Prüfung EIN weiteres Merkmal ergänzen, das nur für diese eine
// Prüfung gilt und nicht gespeichert wird.
//
// Darstellung "Arbeitsfläche" (Eriks Wahl "B" aus dem Design-Vergleich):
// zwei Spalten dauerhaft nebeneinander — links Upload/Konfiguration, rechts
// das Ergebnis. Nach einer Korrektur am Plan lässt sich erneut hochladen und
// prüfen, ohne das vorherige Ergebnis aus den Augen zu verlieren.
//
// Server-Gegenstück: POST /plan-checker-pruefen (server.js) — Roh-Binärkörper
// wie beim Protokoll-Import, rendert die erste PDF-Seite serverseitig zu
// mehreren Bildern (Übersicht + hochaufgelöste Kachel-Ausschnitte,
// renderPdfAlsBildsatz()) und schickt die an die KI (Maßketten/Plankopf sind
// optische Merkmale, kein Fließtext). Die KI nennt zu jedem Merkmal
// zusätzlich den Index DESJENIGEN der gesendeten Bilder, auf dem der
// Nachweis am besten zu sehen ist (bildIndex) — genau dieses, bereits
// gerenderte Bild kommt unverändert als "screenshot"-Feld je Ergebnis
// zurück, s. renderPlanCheckerErgebnis() unten. (Ein erster Versuch mit
// einem Beschriftungsraster + nachträglichem Zuschnitt einer genannten
// Rasterzelle erwies sich als nicht gut genug und wurde durch diese
// einfachere Variante ersetzt.) Weder die PDF- noch die Bilddateien werden
// gespeichert — kein Dokumentenarchiv, ein Ad-hoc-Werkzeug.
//
// Läuft wie js/planung/planung.js als klassisches globales Skript (kein
// Modul/IIFE), geladen nach js/store.js/js/weeks.js, vor js/tabs-auth.js
// (das renderPlanChecker() beim Tab-Wechsel aufruft) — siehe App.html.
// planCheckerDatei: die aktuell gewählte PDF-Datei (File-Objekt) oder null.
let planCheckerDatei = null;
// planCheckerMerkmale: die zuletzt geladene Grundliste der Merkmale
// ({id, titel, prompt}), für die Vorschau links und als Grundlage der
// Ergebnisliste rechts (die Reihenfolge der Antwort folgt derselben Liste).
let planCheckerMerkmale = [];
// renderPlanChecker: Einstiegspunkt, von showTab('planchecker') gerufen.
// Lädt die Merkmale-Grundliste und die KI-Anbieter-Auswahl neu — beide
// können sich seit dem letzten Besuch geändert haben (Admin-Pflege bzw.
// eigener Schlüssel unter Einstellungen → Mein Konto). Keine Parameter,
// keine Rückgabe.
function renderPlanChecker(){
ladePlanCheckerMerkmale();
ladePlanCheckerAnbieter();
}
// ladePlanCheckerMerkmale: holt die admin-gepflegte Grundliste (GET
// /plan-checker-merkmale, jeder angemeldete Nutzer darf lesen) und zeigt sie
// read-only als Vorschau, damit vor dem Prüfen klar ist, was geprüft wird.
// Keine Parameter, keine Rückgabe.
function ladePlanCheckerMerkmale(){
const el = document.getElementById('planCheckerMerkmaleListe');
if(el) el.innerHTML = '<span class="hint" style="margin:0">Lade Merkmale…</span>';
fetch(baseUrl() + '/plan-checker-merkmale')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
planCheckerMerkmale = Array.isArray(data.merkmale) ? data.merkmale : [];
if(!el) return;
el.innerHTML = planCheckerMerkmale.length === 0
? '<span class="hint" style="margin:0">Noch kein Merkmal hinterlegt (siehe Admin → Plan-Checker-Merkmale).</span>'
: planCheckerMerkmale.map(m => `<div class="pc-merkmal-chip">${esc(m.titel)}</div>`).join('');
})
.catch(err => {
console.error('Plan-Checker-Merkmale laden fehlgeschlagen:', err);
if(el) el.innerHTML = '<span class="hint" style="margin:0">Merkmale konnten nicht geladen werden.</span>';
});
}
// ladePlanCheckerAnbieter: baut die Anbieter-Auswahl aus GET /my-ai-settings
// auf — derselbe Weg wie beim Protokoll-Import-Dialog
// (oeffneProtokollImportDialog(), js/plan/plan-protokoll.js): der Hinweis
// "(kein Schlüssel hinterlegt)" bezieht sich auf den EIGENEN Schlüssel des
// angemeldeten Kontos. Keine Parameter, keine Rückgabe.
function ladePlanCheckerAnbieter(){
const sel = document.getElementById('planCheckerAnbieter');
if(!sel) return;
sel.innerHTML = `<option value="chatgpt">ChatGPT</option><option value="claude">Claude</option>`;
fetch(baseUrl() + '/my-ai-settings')
.then(r => r.json())
.then(d => {
if(!d.ok) return;
sel.innerHTML = `
<option value="chatgpt">ChatGPT${d.chatgpt && d.chatgpt.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>
<option value="claude">Claude${d.claude && d.claude.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>`;
})
.catch(err => console.error('KI-Einstellungen laden fehlgeschlagen:', err));
}
// ---------- Datei-Auswahl (Klick oder Ziehen-und-Ablegen) ----------
// planCheckerDateiGewaehlt: übernimmt eine gewählte/abgelegte Datei, prüft
// grob die Endung (die eigentliche Formatprüfung — PDF-Magic-Bytes — läuft
// serverseitig) und zeigt den Dateinamen in der Ablagefläche an.
// Parameter: file (File oder undefined). Keine Rückgabe.
function planCheckerDateiGewaehlt(file){
const fehlerEl = document.getElementById('planCheckerError');
if(fehlerEl){ fehlerEl.hidden = true; fehlerEl.textContent = ''; }
if(!file) return;
if(!/\.pdf$/i.test(file.name)){
if(fehlerEl){ fehlerEl.textContent = 'Bitte eine PDF-Datei auswählen.'; fehlerEl.hidden = false; }
planCheckerDatei = null;
return;
}
planCheckerDatei = file;
const nameEl = document.getElementById('planCheckerDateiname');
const textEl = document.getElementById('planCheckerDropText');
if(nameEl){ nameEl.textContent = file.name; nameEl.hidden = false; }
if(textEl) textEl.textContent = 'Andere Datei wählen';
}
function planCheckerDragOver(e){
e.preventDefault();
const drop = document.getElementById('planCheckerDrop');
if(drop) drop.classList.add('is-dragover');
}
function planCheckerDragLeave(e){
const drop = document.getElementById('planCheckerDrop');
if(drop) drop.classList.remove('is-dragover');
}
function planCheckerDrop(e){
e.preventDefault();
const drop = document.getElementById('planCheckerDrop');
if(drop) drop.classList.remove('is-dragover');
const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
planCheckerDateiGewaehlt(file);
}
// ---------- Prüfen ----------
// planCheckerLadezustand: sperrt Eingaben/Knopf während der KI-Aufruf läuft
// — kann bei einem lokal laufenden Modell mehrere Minuten dauern (siehe
// KI_TIMEOUT_MS_LOKAL, server.js); ohne Rückmeldung wirkte der Bildschirm
// sonst hängengeblieben, und ein zweiter Klick hätte dieselbe Prüfung
// doppelt losgeschickt. Parameter: aktiv (Boolean). Keine Rückgabe.
function planCheckerLadezustand(aktiv){
const btn = document.getElementById('planCheckerPruefenBtn');
if(btn){ btn.disabled = aktiv; btn.textContent = aktiv ? 'Wird geprüft…' : 'Plan prüfen'; }
['planCheckerFile', 'planCheckerZusatz', 'planCheckerAnbieter'].forEach(id => {
const el = document.getElementById(id);
if(el) el.disabled = aktiv;
});
}
// planCheckerPruefen: schickt die gewählte Datei als rohen Binär-Body an
// POST /plan-checker-pruefen (gleiches Vorgehen wie importiereProtokoll(),
// js/plan/plan-protokoll.js) und zeigt das Ergebnis rechts an. Keine
// Parameter, keine Rückgabe.
function planCheckerPruefen(){
const fehlerEl = document.getElementById('planCheckerError');
const zeigeFehler = msg => { if(fehlerEl){ fehlerEl.textContent = msg; fehlerEl.hidden = false; } };
if(fehlerEl){ fehlerEl.hidden = true; fehlerEl.textContent = ''; }
if(!planCheckerDatei) return zeigeFehler('Bitte zuerst eine PDF-Datei wählen.');
const anbieterEl = document.getElementById('planCheckerAnbieter');
const provider = anbieterEl ? anbieterEl.value : '';
const zusatzEl = document.getElementById('planCheckerZusatz');
const zusatz = zusatzEl ? zusatzEl.value.trim() : '';
const url = baseUrl() + '/plan-checker-pruefen'
+ '?provider=' + encodeURIComponent(provider)
+ '&zusatzMerkmal=' + encodeURIComponent(zusatz);
const datei = planCheckerDatei;
planCheckerLadezustand(true);
fetch(url, {
method: 'POST',
headers: { 'Content-Type': datei.type || 'application/pdf' },
body: datei
})
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
if(!ok || !d.ok) throw new Error((d && d.error) || 'Prüfung fehlgeschlagen.');
renderPlanCheckerErgebnis(datei.name, provider, d.ergebnisse || []);
})
.catch(err => {
console.error('Plan-Prüfung fehlgeschlagen:', err);
zeigeFehler(err.message || String(err));
})
.finally(() => planCheckerLadezustand(false));
}
// planCheckerLetzteErgebnisse: die zuletzt gerenderten Ergebniszeilen,
// gemerkt für planCheckerZeigeScreenshot() unten — ein Klick auf eine Zeile
// übergibt nur den Index, nicht die (potenziell sehr lange) Data-URL selbst
// als Inline-onclick-Parameter.
let planCheckerLetzteErgebnisse = [];
// renderPlanCheckerErgebnis: zeigt das Prüfergebnis in der rechten Spalte —
// Dateiname/Anbieter/Zeitpunkt als Kopfzeile, danach je Merkmal eine Zeile
// mit grünem/rotem Kreis, der Begründung der KI und (falls vorhanden) einem
// Screenshot — demjenigen der an die KI gesendeten Bilder (Übersicht oder
// eine Kachel), das die KI über bildIndex als besten Beleg für dieses
// Merkmal benannt hat (server.js, pruefePlanMitKI()). Parameter: dateiname
// (String), provider ('chatgpt'|'claude'), ergebnisse (Array {merkmal,
// erfuellt, begruendung, screenshot}, screenshot eine Data-URL oder null).
// Keine Rückgabe.
function renderPlanCheckerErgebnis(dateiname, provider, ergebnisse){
const leerEl = document.getElementById('planCheckerLeer');
const zielEl = document.getElementById('planCheckerErgebnis');
if(!zielEl) return;
if(leerEl) leerEl.hidden = true;
planCheckerLetzteErgebnisse = ergebnisse;
const anbieterLabel = provider === 'claude' ? 'Claude' : 'ChatGPT';
const zeit = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
// Der Screenshot öffnet NICHT als <a target="_blank"> auf die data:-URL —
// Chrome blockiert das Öffnen einer data:-URL als Top-Level-Navigation in
// einem neuen Tab, der Klick blieb dadurch wirkungslos (Eriks Rückmeldung:
// "wenn ich auf das Bild im Protokoll öffne, wird es nicht in groß
// angezeigt"). Stattdessen ein Knopf, der #pcShotModal füllt und einblendet
// (planCheckerZeigeScreenshot(), App.html) — bleibt auf derselben Seite,
// keine Navigation nötig.
const zeilen = ergebnisse.map((e, i) => `<div class="pc-result-row">
<span class="pc-verdict ${e.erfuellt ? 'ok' : 'fail'}">${e.erfuellt ? '✓' : '✕'}</span>
<div class="pc-result-text"><div class="titel">${esc(e.merkmal)}</div><div class="begruendung">${esc(e.begruendung)}</div></div>
${e.screenshot ? `<button type="button" class="pc-result-shot" onclick="planCheckerZeigeScreenshot(${i})" title="Planausschnitt vergrößern">
<img src="${e.screenshot}" alt="Planausschnitt zu ${esc(e.merkmal)}">
</button>` : ''}
</div>`).join('');
zielEl.innerHTML = `
<div class="pc-result-head">
<span class="filename">${esc(dateiname)}</span>
<span class="meta">geprüft mit ${esc(anbieterLabel)} · ${esc(zeit)}</span>
</div>
${zeilen || '<span class="hint">Keine Merkmale geprüft.</span>'}`;
}
// planCheckerZeigeScreenshot: füllt #pcShotModal (App.html) mit dem
// Screenshot der Ergebniszeile index (aus planCheckerLetzteErgebnisse) und
// blendet es ein. Parameter: index (Number). Keine Rückgabe.
function planCheckerZeigeScreenshot(index){
const e = planCheckerLetzteErgebnisse[index];
if(!e || !e.screenshot) return;
const img = document.getElementById('pcShotModalImg');
const titel = document.getElementById('pcShotModalTitel');
const modal = document.getElementById('pcShotModal');
if(img) img.src = e.screenshot;
if(titel) titel.textContent = e.merkmal;
if(modal) modal.style.display = 'flex';
}
// planCheckerSchliesseScreenshot: blendet #pcShotModal wieder aus. Leert
// zusätzlich img.src (statt die Data-URL im DOM zu belassen) - unnötig, das
// Bild erneut im Speicher zu halten, solange der Dialog geschlossen ist.
// Keine Parameter, keine Rückgabe.
function planCheckerSchliesseScreenshot(){
const modal = document.getElementById('pcShotModal');
const img = document.getElementById('pcShotModalImg');
if(modal) modal.style.display = 'none';
if(img) img.src = '';
}
+28
View File
@@ -139,6 +139,7 @@ function setAiMode(provider, modus){
async function renderSettingsAiKeys(){
const statusEl = { chatgpt: document.getElementById('aiOpenaiStatus'), claude: document.getElementById('aiClaudeStatus') };
const urlEl = { chatgpt: document.getElementById('setAiOpenaiUrl'), claude: document.getElementById('setAiClaudeUrl') };
const modelEl = { chatgpt: document.getElementById('setAiOpenaiModel'), claude: document.getElementById('setAiClaudeModel') };
const clearBtn = { chatgpt: document.getElementById('aiOpenaiClearBtn'), claude: document.getElementById('aiClaudeClearBtn') };
const reconnectBtn = { chatgpt: document.getElementById('aiOpenaiReconnectBtn'), claude: document.getElementById('aiClaudeReconnectBtn') };
const testErrorEl = { chatgpt: document.getElementById('aiOpenaiTestError'), claude: document.getElementById('aiClaudeTestError') };
@@ -156,6 +157,7 @@ async function renderSettingsAiKeys(){
chip.innerHTML = '<span class="dot"></span>' + (info.configured ? 'Verbunden' : 'Nicht eingerichtet');
}
if(urlEl[provider] && document.activeElement !== urlEl[provider]) urlEl[provider].value = info.apiUrl || '';
if(modelEl[provider] && document.activeElement !== modelEl[provider]) modelEl[provider].value = info.model || '';
if(clearBtn[provider]) clearBtn[provider].disabled = !info.configured;
if(reconnectBtn[provider]) reconnectBtn[provider].disabled = !info.configured;
// Ein neuer Seitenaufruf ist ein neuer Anlauf — ein Fehler aus einem
@@ -270,6 +272,32 @@ async function saveMyAiUrl(provider){
}
}
// saveMyAiModell: übernimmt den eingegebenen Modellnamen — leer löscht eine
// zuvor gesetzte Abweichung und fällt auf die im Code hinterlegte Vorgabe
// zurück (gpt-4o-mini/gpt-4o bzw. claude-haiku/claude-sonnet-5, je nach
// Aufrufer). Nötig für einen eigenständigen Anbieter mit abweichendem
// Modell-Katalog (z. B. Mammouth.ai), bei dem die festen OpenAI-/
// Anthropic-Modellnamen nicht existieren. Eigenständig von saveMyAiUrl() aus
// demselben Grund, aus dem URL und Schlüssel schon getrennte Speichern-
// Knöpfe haben. Parameter: provider. Keine Rückgabe.
async function saveMyAiModell(provider){
const errEl = document.getElementById('settingsAiError');
const show = msg => { if(errEl){ errEl.textContent = msg; errEl.hidden = false; } };
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
const modelEl = document.getElementById(provider === 'chatgpt' ? 'setAiOpenaiModel' : 'setAiClaudeModel');
const modelValue = modelEl ? modelEl.value.trim() : '';
try {
await putMyAiKey(provider, { model: modelValue });
renderSettingsAiKeys();
showSettingsNote(modelValue ? 'Modellname gespeichert.' : 'Modellname entfernt — Vorgabe wird wieder verwendet.');
} catch(err){
console.error('Modellname speichern fehlgeschlagen:', err);
show(err.message || String(err));
}
}
// clearMyAiKey: löscht nur den Schlüssel, lässt eine hinterlegte API-URL
// unangetastet (patch enthält absichtlich kein apiUrl-Feld). Parameter:
// provider. Keine Rückgabe.
+5 -4
View File
@@ -3,9 +3,9 @@
// -----------------------------
// Modul für die Tab-Navigation der Hauptseite (nur von App.html geladen, nach
// js/wand/wand.js, vor js/pdf.js). showTab() wechselt zwischen Board,
// Wochenbericht, Auslastung, Projekte, Projektbeteiligte, Meine Aufgaben,
// Wandtafel, Einstellungen, Admin und dem Projektbereich eines einzelnen
// Projekts ('plan', siehe TAB_PANELS).
// Wochenbericht, Auslastung, Projekte, Projektbeteiligte, Planung, Plan
// Checker, Meine Aufgaben, Wandtafel, Einstellungen, Admin und dem
// Projektbereich eines einzelnen Projekts ('plan', siehe TAB_PANELS).
// Einstellungen und Admin sind keine eigenen Tabs mehr, sondern aufklappbare
// Bereiche direkt in der Seitenleiste (siehe toggleSidebarAccordion() in
// js/ui.js). Gerendert werden beim Tab-Wechsel renderBoard()/
@@ -23,7 +23,7 @@
// Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der
// Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom
// Dashboard ins Projektboard.
const TAB_PANELS = ['board','wochenbericht','load','people','planung','dashboard','wand','settings','admin','plan'];
const TAB_PANELS = ['board','wochenbericht','load','people','planung','planchecker','dashboard','wand','settings','admin','plan'];
function showTab(t){
// Der Bereich „Admin" steht nur Administratoren offen. Ohne diese Weiche
@@ -60,6 +60,7 @@ function showTab(t){
if(t==='load') renderLoad();
if(t==='people') renderPeople();
if(t==='planung') renderPlanung();
if(t==='planchecker') renderPlanChecker();
if(t==='dashboard') renderDashboard();
if(t==='wand') renderWandboard();
if(t==='settings') renderSettings();
+163 -133
View File
@@ -9,19 +9,29 @@
// Touch-Display mit einem eigens dafür angelegten Kiosk-Konto — siehe
// CLAUDE.md "Wandtafel".
//
// Zweispaltig (Eriks Wahl "Variante C" aus dem Layout-Vergleich): links drei
// kompakte Kennzahl-Kacheln, rechts der Ereignis-Feed — seit einem zweiten,
// eigenen Vergleich (Feed-Vergleich) nach Art in drei Spalten gruppiert
// (Neu/Überfällig/Termine) statt chronologisch gemischt, siehe
// wandFeedHtml(). Die Kacheln selbst sind NICHT antippbar (kein Auf-/Zuklappen
// mehr, auf Eriks Wunsch) — stattdessen ist jedes einzelne darin dargestellte
// Element für sich antippbar (eine Gewerk-Zeile, eine Kennzahl, ein Kürzel,
// ein Feed-Eintrag) und führt in eine Vollbild-Übersicht bzw. direkt ins
// Aufbau: aus einem Vergleich mehrerer komplett unterschiedlicher
// Gesamtentwürfe wählte Erik die "Leitstand"-Struktur (Kopf → eine
// Kennzahlen-Reihe aus vier Kacheln → eine Reihe aus drei Panels), dann in
// einem zweiten Durchgang die konkrete Gestaltung ("Pastell Freundlich"),
// zuletzt umgefärbt in die zwei Farben des Firmenlogos (aus public/logo.ico
// ausgelesen) statt einer generischen Pastellmischung — siehe die
// --dash-*-Eigenschaften in styles.css. Kopf: Marke "Dashboard" + Sitzung/
// Uhrzeit + wer heute abwesend ist (renderWandDashKopfHtml()). Kennzahlen-
// Reihe: Auslastung gesamt, Besprochen gesamt, offene Aufgaben, überfällig
// (wandKpiReiheHtml()/wandGesamtKennzahlen()). Panel-Reihe: Auslastung je
// Gewerk, Besprochen je Gewerk, Aktuell — ein chronologisch sortierter
// Ereignis-Feed (wandAuslastungInhalt()/wandBesprochenInhalt()/
// wandAktuellInhalt(), gemeinsam gerahmt von wandPanel()). Die Kacheln/
// Panels selbst sind NICHT antippbar (kein Auf-/Zuklappen, auf Eriks
// Wunsch) — stattdessen ist jedes einzelne darin dargestellte Element für
// sich antippbar (eine Gewerk-Zeile, eine Kennzahl, ein Kürzel, ein
// Feed-Eintrag) und führt in eine Vollbild-Übersicht bzw. direkt ins
// betroffene Projekt. Jede Kachel/jedes Ereignis ist eine reine Projektion
// bereits geladener Daten (dasselbe db aus GET /load-all, das jeder andere
// Tab auch nutzt) — keine eigene Server-Route, keine schreibenden Aktionen.
// Wiederverwendet werden berechneAuslastung()/loadAmpelColor()/
// kapazitaetsFaktor()/stundenFormat()/lpActiveList() aus js/load/load.js,
// kapazitaetsFaktor()/stundenFormat()/lpActiveList()/schwelleGelb()/
// schwelleRot() aus js/load/load.js,
// classifyProjectChange()/findPreviousWeek()/springZumWochenbericht() aus
// js/wochenbericht/wochenbericht.js bzw. js/plan/plan-info.js,
// statusPreviewText()/renderBoardRow()/renderBoardDrawer()/BOARD_OPTS aus
@@ -35,8 +45,8 @@
// Projektbereich, Seitenleiste und Kopfzeile kommen dabei zurück — wie bei
// jedem anderen Tab-Wechsel auch).
//
// Die Normalansicht ist höher als ein Bildschirm: unterhalb der zwei
// Kennzahl-Spalten folgt die vollständige Projektliste
// Die Normalansicht ist höher als ein Bildschirm: unterhalb von Kopf,
// Kennzahlen- und Panel-Reihe folgt die vollständige Projektliste
// (wandProjektlisteHtml()) — auf Eriks ausdrücklichen Wunsch optisch
// identisch mit der echten Projektliste (Tab "Projekte"), nicht eine eigene,
// vereinfachte Zeilenform: Jede Gruppe/Zeile entsteht über denselben
@@ -68,7 +78,7 @@
const WAND_GEWERKE = ['ELT', 'HLS', 'ARCH'];
const WAND_REFRESH_MS = 60000; // Hintergrund-Aktualisierung
const WAND_FEED_MAX = 5; // Höchstzahl Einträge JE SPALTE im Ereignis-Feed (Neu/Überfällig/Termine)
const WAND_FEED_MAX = 6; // Höchstzahl Einträge im "Aktuell"-Panel
// WAND_IDLE_MS: Erik hat einen echten Touch-Monitor — er darf davortreten und
// in der Projektliste unterhalb der Kacheln scrollen ("ich kann mich vor die
// Tafel stellen und scrollen"). Nach dieser Zeit ohne jede Berührung kehrt
@@ -260,10 +270,10 @@ async function wandRefreshTick(){
load(true); // silent — ruft am Ende renderAll()+showTab(currentTab) auf
}
// renderWandboard: baut entweder die zweispaltige Normalansicht (mit der
// vollständigen Projektliste darunter) oder eine offene Vollbild-Übersicht
// auf. Wird von showTab('wand') sowie nach jedem load() (über
// showTab(currentTab)) aufgerufen.
// renderWandboard: baut entweder die Normalansicht (Kopf + Kennzahlen-Reihe +
// Panel-Reihe, mit der vollständigen Projektliste darunter) oder eine offene
// Vollbild-Übersicht auf. Wird von showTab('wand') sowie nach jedem load()
// (über showTab(currentTab)) aufgerufen.
//
// Die Normalansicht ist höher als ein Bildschirm — Erik hat einen echten
// Touch-Monitor und wollte "unterhalb der Wandtafel... weitere Informationen
@@ -295,18 +305,98 @@ function renderWandboard(opts){
}
container.classList.add('wand-scrollable');
container.innerHTML = '<div class="wand-ambient"><div class="wand-c-layout">'
+ '<div class="wand-c-links">' + wandLinkeSpalteHtml(w) + '</div>'
+ '<div class="wand-c-rechts">'
+ '<h3 class="wand-kachel-titel">Aktuell diese Woche</h3>'
+ '<div class="wand-feed-wrap">' + wandFeedHtml(w) + '</div>'
+ renderWandStand()
container.innerHTML = '<div class="wand-ambient">'
+ wandDashKopfHtml(w)
+ wandKpiReiheHtml(w)
+ '<div class="dash-grid">'
+ wandPanel('Auslastung / Gewerk', wandAuslastungInhalt(w))
+ wandPanel('Besprochen / Gewerk', wandBesprochenInhalt(w))
+ wandPanel('Aktuell', wandAktuellInhalt(w))
+ '</div>'
+ '</div>'
+ '</div></div>'
+ wandProjektlisteHtml(w);
container.scrollTop = vorherigerScroll;
}
// wandGesamtKennzahlen: zwei über alle Gewerke hinweg zusammengefasste
// Kennzahlen für die Kennzahlen-Reihe oben — dieselbe Rechnung wie die
// Gewerk-Aufschlüsselung darunter (wandAuslastungJeGewerk()/
// wandBesprochenDaten()), nur aufsummiert statt aufgeschlüsselt. Die
// bewusste Mehrfach-Gewerke-Dopplung (ein Projekt mit mehreren Gewerken
// zählt seinen vollen Beitrag bei jedem Gewerk) gilt hier automatisch mit,
// da beide Quellfunktionen sie bereits je Gewerk anwenden. Parameter: w.
// Rückgabe: {auslastung: Zahl|null, besprochen: Zahl|null} — null, wenn sich
// die Quote mangels Kapazität bzw. Projekten nicht bilden lässt.
function wandGesamtKennzahlen(w){
const ausl = wandAuslastungJeGewerk(w);
let stunden = 0, kapazitaet = 0;
WAND_GEWERKE.forEach(g => { stunden += ausl[g].stunden; kapazitaet += ausl[g].kapazitaet; });
const bespr = wandBesprochenDaten(w);
let besprochen = 0, gesamt = 0;
WAND_GEWERKE.forEach(g => { besprochen += bespr[g].besprochen; gesamt += bespr[g].gesamt; });
return {
auslastung: kapazitaet > 0 ? stunden / kapazitaet : null,
besprochen: gesamt > 0 ? besprochen / gesamt : null,
};
}
// wandDashKopfHtml: Kopfzeile der Normalansicht — Marke "Dashboard" links,
// rechts wer heute abwesend ist (antippbar, öffnet dieselbe Abwesenheiten-
// Übersicht wie zuvor die Heute-Kachel) und Sitzung/Uhrzeit als laufendes
// Lebenszeichen (ersetzt das frühere separate "Stand: HH:MM Uhr" am Fuß der
// rechten Spalte — ein Uhrzeit-Hinweis genügt, jetzt prominent oben statt
// versteckt unten). Parameter: w. Rückgabe: HTML-String.
function wandDashKopfHtml(w){
const d = wandHeuteDaten(w);
const uhrzeit = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const abwesendHtml = d.abwesend.length
? '<div class="dash-abwesend-mini ist-klickbar" onclick="wandZeigeUebersicht(\'abwesend\')" title="Abwesend heute">'
+ d.abwesend.map(p => '<span class="dash-avatar-mini">' + esc(initialsFor(p.name)) + '</span>').join('')
+ '</div>'
: '';
return '<div class="dash-kopf"><span class="dash-marke">Dashboard</span>'
+ '<div class="dash-kopf-rechts">' + abwesendHtml
+ '<span class="dash-datum">' + esc(w.label) + ' — ' + esc(uhrzeit) + ' Uhr</span>'
+ '</div></div>';
}
// wandKpiReiheHtml: die vier Kennzahlen-Kacheln oben — Auslastung gesamt,
// Besprochen gesamt (wandGesamtKennzahlen()), offene Aufgaben und
// überfällig (wandHeuteDaten(), dieselben Zahlen, die zuvor die Heute-Kachel
// zeigte). Die beiden letzten bleiben wie zuvor antippbar und öffnen
// dieselbe Übersicht. Parameter: w. Rückgabe: HTML-String.
function wandKpiReiheHtml(w){
const g = wandGesamtKennzahlen(w);
const heute = wandHeuteDaten(w);
const auslastungTxt = g.auslastung === null ? '—' : Math.round(g.auslastung * 100) + '%';
const auslastungKlasse = g.auslastung === null ? ''
: (g.auslastung >= schwelleRot() ? 'crit' : g.auslastung >= schwelleGelb() ? 'warn' : 'ok');
const besprochenTxt = g.besprochen === null ? '—' : Math.round(g.besprochen * 100) + '%';
const ueberfaelligKlasse = heute.ueberfaelligeListe.length > 0 ? 'crit' : 'ok';
return '<div class="dash-kpi-reihe">'
+ '<div class="dash-kpi"><div class="dash-kpi-label">Auslastung ges.</div>'
+ '<div class="dash-kpi-wert ' + auslastungKlasse + '">' + auslastungTxt + '</div></div>'
+ '<div class="dash-kpi"><div class="dash-kpi-label">Besprochen</div>'
+ '<div class="dash-kpi-wert info">' + besprochenTxt + '</div></div>'
+ '<div class="dash-kpi ist-klickbar" onclick="wandZeigeUebersicht(\'offen\')" title="Alle offenen Aufgaben anzeigen">'
+ '<div class="dash-kpi-label">Offene Aufgaben</div><div class="dash-kpi-wert ok">' + heute.offen + '</div></div>'
+ '<div class="dash-kpi ist-klickbar" onclick="wandZeigeUebersicht(\'ueberfaellig\')" title="Alle überfälligen Aufgaben anzeigen">'
+ '<div class="dash-kpi-label">Überfällig</div><div class="dash-kpi-wert ' + ueberfaelligKlasse + '">' + heute.ueberfaelligeListe.length + '</div></div>'
+ '</div>';
}
// wandPanel: die gemeinsame, nicht-interaktive Panel-Umrahmung der
// Drei-Panel-Reihe (Auslastung/Besprochen/Aktuell). Parameter: titel,
// inhalt (HTML). Rückgabe: HTML-String.
function wandPanel(titel, inhalt){
return '<div class="dash-panel"><div class="dash-panel-titel">' + esc(titel) + '</div>'
+ '<div class="dash-panel-body">' + inhalt + '</div></div>';
}
// wandProjektlisteHtml: die vollständige Projektliste unterhalb der
// Kennzahl-Kacheln — auf Eriks ausdrücklichen Wunsch ("Die Projektliste soll
// so aussehen wie die Originale") keine eigene, vereinfachte Darstellung,
@@ -350,26 +440,11 @@ function wandProjektlisteHtml(w){
}).join('');
return '<div class="wand-projektliste">'
+ '<h3 class="wand-kachel-titel">Alle Projekte</h3>'
+ '<h3 class="dash-panel-titel">Alle Projekte</h3>'
+ abschnitte
+ '</div>';
}
// wandLinkeSpalteHtml: die drei Kennzahl-Kacheln (nicht mehr antippbar —
// nur die Elemente darin sind es). Parameter: w. Rückgabe: HTML-String.
function wandLinkeSpalteHtml(w){
return wandTileAuslastung(w) + wandTileBesprochen(w) + wandTileHeute(w);
}
// wandKachel: die gemeinsame, nicht-interaktive Kachel-Umrahmung. Parameter:
// titel, inhalt (HTML). Rückgabe: HTML-String.
function wandKachel(titel, inhalt){
return '<section class="wand-kachel">'
+ '<h3 class="wand-kachel-titel">' + esc(titel) + '</h3>'
+ '<div class="wand-kachel-body">' + inhalt + '</div>'
+ '</section>';
}
// wandSpringeZuProjekt: verlässt die Wandtafel und öffnet den Projektbereich
// eines Projekts, optional direkt in einem bestimmten Abschnitt — derselbe
// Weg (Hash VOR dem Tab-Wechsel setzen, showTab('plan') übernimmt ihn über
@@ -496,14 +571,16 @@ function wandAuslastungJeGewerk(w){
return ergebnis;
}
// wandTileAuslastung: ein Meter je Gewerk, Ampelfarbe wie im Auslastungs-Tab.
// Jede Zeile ist einzeln antippbar und öffnet die Projektliste dieses
// Gewerks (wandZeigeUebersicht('auslastung', g)). Eine Null bekommt KEINE
// grüne Farbe (dieselbe Regel wie in renderLoad()) — "keine Daten" statt
// eines irreführend guten Balkens. Parameter: w. Rückgabe: HTML-String.
function wandTileAuslastung(w){
// wandAuslastungInhalt: ein Meter je Gewerk, Ampelfarbe wie im
// Auslastungs-Tab — der Inhalt des Panels "Auslastung / Gewerk"
// (wandPanel() rahmt ihn). Jede Zeile ist einzeln antippbar und öffnet die
// Projektliste dieses Gewerks (wandZeigeUebersicht('auslastung', g)). Eine
// Null bekommt KEINE grüne Farbe (dieselbe Regel wie in renderLoad()) —
// "keine Daten" statt eines irreführend guten Balkens. Parameter: w.
// Rückgabe: HTML-String.
function wandAuslastungInhalt(w){
const daten = wandAuslastungJeGewerk(w);
const inhalt = WAND_GEWERKE.map(g => {
return WAND_GEWERKE.map(g => {
const d = daten[g];
const label = (DEPARTMENTS[g] && DEPARTMENTS[g].label) || g;
const klick = ' onclick="wandZeigeUebersicht(\'auslastung\',\'' + g + '\')"';
@@ -521,7 +598,6 @@ function wandTileAuslastung(w){
+ '<div class="wand-meter"><div class="wand-meter-fill" style="width:' + breite + '%;background:' + ampel + '"></div></div>'
+ '<span class="wand-meter-wert" style="color:' + ampel + '">' + stundenFormat(d.stunden) + '<span class="wand-einheit">h</span></span></div>';
}).join('');
return wandKachel('Auslastung je Gewerk', inhalt);
}
// wandUebersichtAuslastung: alle Projekte, die zu einem Gewerk beitragen,
@@ -565,13 +641,16 @@ function wandBesprochenDaten(w){
return ergebnis;
}
// wandTileBesprochen: ein Meter je Gewerk ("X von Y"). Jede Zeile ist
// wandBesprochenInhalt: ein Meter je Gewerk ("X von Y") — der Inhalt des
// Panels "Besprochen / Gewerk" (wandPanel() rahmt ihn). Jede Zeile ist
// einzeln antippbar und öffnet die Liste der noch offenen Projekte dieses
// Gewerks (wandZeigeUebersicht('besprochen', g)). Parameter: w. Rückgabe:
// HTML-String.
function wandTileBesprochen(w){
// Gewerks (wandZeigeUebersicht('besprochen', g)). Die Füllfarbe nutzt das
// dunklere Logo-Blau (--dash-ink-soft) statt des früheren generischen
// App-Akzents, damit sie zur Wandtafel-eigenen Farbwelt passt (styles.css).
// Parameter: w. Rückgabe: HTML-String.
function wandBesprochenInhalt(w){
const daten = wandBesprochenDaten(w);
const inhalt = WAND_GEWERKE.map(g => {
return WAND_GEWERKE.map(g => {
const d = daten[g];
const label = (DEPARTMENTS[g] && DEPARTMENTS[g].label) || g;
if(d.gesamt === 0){
@@ -582,10 +661,9 @@ function wandTileBesprochen(w){
const klick = ' onclick="wandZeigeUebersicht(\'besprochen\',\'' + g + '\')"';
return '<div class="wand-meter-zeile ist-klickbar"' + klick + '>'
+ '<span class="wand-meter-label">' + esc(label) + '</span>'
+ '<div class="wand-meter"><div class="wand-meter-fill" style="width:' + (quote * 100) + '%;background:var(--accent)"></div></div>'
+ '<div class="wand-meter"><div class="wand-meter-fill" style="width:' + (quote * 100) + '%;background:var(--dash-ink-soft)"></div></div>'
+ '<span class="wand-meter-wert">' + d.besprochen + ' von ' + d.gesamt + '</span></div>';
}).join('');
return wandKachel('Wochenbericht besprochen', inhalt);
}
// wandUebersichtBesprochen: die noch nicht besprochenen Projekte eines
@@ -653,27 +731,9 @@ function wandHeuteDaten(w){
return { offen: offeneListe.length, offeneListe, ueberfaelligeListe, abwesend };
}
// wandTileHeute: zwei Kennzahlen (offen/überfällig) plus Abwesenheits-Kürzel
// — jede einzeln antippbar, kein Auf-/Zuklappen der Kachel mehr. Parameter:
// w. Rückgabe: HTML-String.
function wandTileHeute(w){
const d = wandHeuteDaten(w);
const offenStat = '<div class="wand-stat ist-klickbar" onclick="wandZeigeUebersicht(\'offen\')" title="Alle offenen Aufgaben anzeigen">'
+ '<b>' + d.offen + '</b><span>offene Aufgaben</span></div>';
const ueberfaelligStat = '<div class="wand-stat' + (d.ueberfaelligeListe.length > 0 ? ' ist-rot' : '') + ' ist-klickbar"'
+ ' onclick="wandZeigeUebersicht(\'ueberfaellig\')" title="Alle überfälligen Aufgaben anzeigen">'
+ '<b>' + d.ueberfaelligeListe.length + '</b><span>überfällig</span></div>';
const namenHtml = d.abwesend.length
? '<div class="wand-namen">' + d.abwesend.map(p =>
'<span class="wand-avatar ist-klickbar" title="' + esc(p.name) + ' — Alle Abwesenheiten anzeigen" onclick="wandZeigeUebersicht(\'abwesend\')">'
+ esc(initialsFor(p.name)) + '</span>').join('') + '</div>'
: '<div class="wand-leer-klein">niemand abwesend</div>';
const inhalt = '<div class="wand-stat-reihe">' + offenStat + ueberfaelligStat + '</div>' + namenHtml;
return wandKachel('Heute', inhalt);
}
// wandTileHeute ist entfallen — "offene Aufgaben"/"überfällig" stehen jetzt
// in der Kennzahlen-Reihe (wandKpiReiheHtml()), das Abwesenheits-Kürzel im
// Kopf (wandDashKopfHtml()); beide lesen weiterhin aus wandHeuteDaten().
// wandUebersichtOffeneAufgaben: ALLE offenen Aufgaben (nicht nur
// überfällige), nächste Frist zuerst. Parameter: w. Rückgabe: HTML-String.
@@ -718,20 +778,18 @@ function wandUebersichtAbwesend(w){
return wandUebersichtRahmen('Abwesend heute (' + d.abwesend.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
}
// ---------- Ereignis-Feed (rechte Spalte) ----------
// ---------- Ereignis-Feed (Panel "Aktuell") ----------
// wandAktuelleEreignisse: sammelt drei Ereignisarten aus allen Projekten der
// Sitzung — neue/aktualisierte Kommentare (classifyProjectChange()), frisch
// überfällige Aufgaben und anstehende Meilensteine — als EINE Liste, nach
// zeitlicher Nähe zu heute sortiert (|Tage bis Datum| aufsteigend, nicht nach
// Datum selbst: Ein Ereignis von gestern und eines von morgen sind beide
// "gerade jetzt relevant", eines von vor zwei Wochen oder in zwei Monaten ist
// es nicht — unabhängig davon, ob es in der Vergangenheit oder Zukunft
// liegt). wandFeedHtml() gruppiert diese EINE Liste anschließend nach `art`
// in drei Spalten (Variante C aus dem Feed-Vergleich) — die Sortierung
// bleibt deshalb unverändert, sie gilt nur jetzt INNERHALB jeder Spalte statt
// über alle Arten hinweg. Jeder Eintrag trägt projekt/beschreibung getrennt
// (statt einem fertigen Text) sowie pid/abschnitt für den Sprung ins
// überfällige Aufgaben und anstehende Meilensteine — als EINE, nach
// zeitlicher Nähe zu heute sortierte Liste (|Tage bis Datum| aufsteigend,
// nicht nach Datum selbst: Ein Ereignis von gestern und eines von morgen
// sind beide "gerade jetzt relevant", eines von vor zwei Wochen oder in zwei
// Monaten ist es nicht — unabhängig davon, ob es in der Vergangenheit oder
// Zukunft liegt). wandAktuellInhalt() zeigt daraus die ersten WAND_FEED_MAX.
// Jeder Eintrag trägt projekt/beschreibung getrennt (statt einem fertigen
// Text) sowie pid/abschnitt für den Sprung ins
// Projekt. Parameter: w. Rückgabe: Array aus {art, projekt (HTML, bereits
// escaped), beschreibung (HTML, bereits escaped), zeit, pid, abschnitt}.
function wandAktuelleEreignisse(w){
@@ -786,51 +844,23 @@ function wandAktuelleEreignisse(w){
return ereignisse;
}
// WAND_FEED_SPALTEN: Reihenfolge und Beschriftung der drei Feed-Spalten.
const WAND_FEED_SPALTEN = [
{ art: 'neu', label: 'Neu' },
{ art: 'ueberfaellig', label: 'Überfällig' },
{ art: 'termin', label: 'Termine' },
];
// wandFeedHtml: gruppiert wandAktuelleEreignisse() nach Art in drei Spalten
// (Eriks Wahl "Variante C" aus dem Feed-Vergleich) — zeigt auf einen Blick,
// wie viele Ereignisse welcher Art anstehen, statt sie chronologisch zu
// mischen. Die Spaltenüberschrift nennt die TATSÄCHLICHE Gesamtzahl dieser
// Art, auch wenn nur WAND_FEED_MAX Einträge angezeigt werden. Jeder Eintrag
// bleibt für sich antippbar und springt direkt ins zugehörige Projekt.
// Parameter: w. Rückgabe: HTML-String.
function wandFeedHtml(w){
const ereignisse = wandAktuelleEreignisse(w);
const spalten = WAND_FEED_SPALTEN.map(s => {
const alle = ereignisse.filter(e => e.art === s.art);
const gezeigt = alle.slice(0, WAND_FEED_MAX);
const eintraegeHtml = gezeigt.length
? gezeigt.map(e =>
'<div class="wand-feed-eintrag" onclick="wandSpringeZuProjekt(\'' + esc(e.pid) + '\',\'' + esc(e.abschnitt) + '\')">'
+ '<b>' + e.projekt + '</b><span>' + e.beschreibung + '</span>'
+ '<span class="wand-feed-zeit">' + esc(e.zeit) + '</span>'
+ '</div>'
).join('')
: '<div class="wand-leer-klein">keine</div>';
return '<div class="wand-feed-gruppe">'
+ '<div class="wand-feed-gruppe-kopf">'
+ '<span class="wand-feed-gruppe-punkt ' + s.art + '"></span>'
+ '<span class="wand-feed-gruppe-titel">' + esc(s.label) + '</span>'
+ '<span class="wand-feed-gruppe-anzahl">' + alle.length + '</span>'
+ '</div>'
+ eintraegeHtml
+ '</div>';
}).join('');
return '<div class="wand-feed-gruppen">' + spalten + '</div>';
}
// ---------- Stand ----------
// renderWandStand: reiner Zeitstempel der letzten erfolgreichen
// Aktualisierung — das einzige Lebenszeichen, dass das unbeaufsichtigte
// Display nicht eingefroren ist. Keine Parameter. Rückgabe: HTML-String.
function renderWandStand(){
const jetzt = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
return '<div class="wand-stand">Stand: ' + jetzt + ' Uhr</div>';
// wandAktuellInhalt: der Inhalt des Panels "Aktuell" (wandPanel() rahmt
// ihn) — eine flache, chronologisch sortierte Liste (wandAktuelleEreignisse()
// ist bereits nach zeitlicher Nähe zu heute sortiert), gedeckelt auf
// WAND_FEED_MAX Einträge. Ein farbiger Punkt zeigt die Art
// (Neu/Überfällig/Termin). Jeder Eintrag bleibt für sich antippbar und
// springt direkt ins zugehörige Projekt. Parameter: w. Rückgabe:
// HTML-String.
function wandAktuellInhalt(w){
const ereignisse = wandAktuelleEreignisse(w).slice(0, WAND_FEED_MAX);
if(ereignisse.length === 0){
return '<div class="wand-leer-klein">Nichts Neues diese Woche.</div>';
}
return ereignisse.map(e =>
'<div class="dash-feed-eintrag" onclick="wandSpringeZuProjekt(\'' + esc(e.pid) + '\',\'' + esc(e.abschnitt) + '\')">'
+ '<span class="dash-feed-punkt ' + e.art + '"></span>'
+ '<div class="dash-feed-text">' + e.projekt + ' — ' + e.beschreibung
+ '<span class="dash-feed-zeit">' + esc(e.zeit) + '</span></div>'
+ '</div>'
).join('');
}
+139 -88
View File
@@ -164,11 +164,16 @@ function renderAttendance(){
const locked = isWeekLocked(w);
const att = w.attendance || {};
const people = Array.isArray(w.people) ? w.people : [];
// Fokus-Modus (Eriks Wahl "C"): die Anwesenheit ist ein aufklappbarer
// Abschnitt wie Leistungsphasen/Beteiligte, kein dauerhaft sichtbarer Block
// mehr — Zustand in berichtDisclosureState (js/board/board.js).
const offen = berichtDisclosureState.anwesenheit ? 'open' : '';
if(people.length === 0){
container.innerHTML = `<div class="attendance-block"><div class="attendance-bar">
<span class="attendance-title">Anwesenheit</span></div>
<div class="attendance-grid"><span class="hint">Für diese Sitzung sind keine Projektbeteiligten hinterlegt.</span></div></div>`;
container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}>
<summary><span>Anwesenheit</span><span class="chev">${icon('chevronRight')}</span></summary>
<div class="bericht-disclosure-body"><span class="hint" style="margin:0">Für diese Sitzung sind keine Projektbeteiligten hinterlegt.</span></div>
</details>`;
return;
}
@@ -177,9 +182,10 @@ function renderAttendance(){
// ein offener Kommentar-Editor beim Klick den Fokus, sein Blur-Netz baut den
// ganzen Bericht neu auf, die Schaltfläche verschwindet dabei — und der
// Klick kommt nie an (erst der zweite Versuch wirkte).
const actions = locked ? '' : `
const actions = locked ? '' : `<div class="attendance-actions">
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(true)">Alle anwesend</button>
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(false)">Zurücksetzen</button>`;
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(false)">Zurücksetzen</button>
</div>`;
const tiles = people.map(p => {
const present = !!att[p.id];
@@ -201,15 +207,10 @@ function renderAttendance(){
</button>`;
}).join('');
container.innerHTML = `<div class="attendance-block">
<div class="attendance-bar">
<span class="attendance-title">Anwesenheit</span>
<span class="attendance-count">${anwesend} von ${people.length} anwesend</span>
<span class="attendance-spacer"></span>
${actions}
</div>
<div class="attendance-grid">${tiles}</div>
</div>`;
container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}>
<summary><span>Anwesenheit der Sitzung</span><span class="count">${anwesend} von ${people.length}</span><span class="chev">${icon('chevronRight')}</span></summary>
<div class="bericht-disclosure-body">${actions}<div class="attendance-grid">${tiles}</div></div>
</details>`;
}
// toggleAttendance: schaltet die Anwesenheit einer Person für die aktuelle
@@ -353,18 +354,14 @@ function waehleBerichtProjekt(uid){
// ---------- Besprochen-Haken ----------
// toggleBesprochen: hakt ein Projekt als besprochen ab bzw. nimmt den Haken
// zurück. Der Haken gehört zur Sitzung (Spalte projects.besprochen) und geht
// deshalb durch den normalen Speicherweg. Parameter: pid. Keine Rückgabe.
function toggleBesprochen(pid){
if(guardWeekLocked()) return;
const w = currentWeek();
const p = (w.projects || []).find(x => x.id === pid);
if(!p) return;
p.besprochen = !p.besprochen;
save();
renderWochenbericht();
}
// HINWEIS: toggleBesprochen() ist entfallen — die schmale Nummern-Leiste des
// Fokus-Modus (Eriks Wahl "C") hat keinen Platz mehr für einen Haken je
// Zeile, und die Detail-Kopfzeile zeigt bewusst keinen eigenen mehr (siehe
// freigegebener Entwurf). Einzelne Projekte abhaken läuft über
// besprochenUndWeiter() (einzeln, beim Weitergehen), alle zurücknehmen über
// setzeAlleBesprochen(false) ("Zurücksetzen" in der Kopfleiste) — ein
// bewusstes Zurücknehmen nur EINES bereits abgehakten Projekts ohne die
// übrigen anzutasten ist damit nicht mehr vorgesehen.
// besprochenUndWeiter: hakt das aktuelle Projekt ab und springt zum nächsten,
// das noch offen ist. Der Motor des Meetings — ohne ihn sucht man nach jedem
@@ -435,12 +432,15 @@ function passtZurSuche(p){
// ---------- Rendern ----------
// renderWochenbericht: die Tagesordnung des Teammeetings — links die
// vollständige Projektliste, rechts das gewählte Projekt in voller Größe.
// renderWochenbericht: die Tagesordnung des Teammeetings — links eine
// schmale Nummern-Leiste über die vollständige Projektliste (Fokus-Modus,
// Eriks Wahl "C" aus dem Darstellungs-Vergleich: Nummer und Änderungs-Punkt
// sichtbar, Nummer und Name im Tooltip), rechts das gewählte Projekt in
// voller Größe.
//
// Diese Aufteilung hat die frühere Abfolge gestapelter Karten abgelöst: Bei
// 29 Projekten war der Bericht 18 Bildschirmseiten lang, und man verlor beim
// Durchgehen die Orientierung. Jetzt bleibt die Liste stehen, man sieht
// Durchgehen die Orientierung. Jetzt bleibt die Leiste stehen, man sieht
// jederzeit, wo man steht und was noch kommt, und scrollt nicht mehr.
//
// Die Liste ist nach PROJEKTGRUPPE geordnet (wie im Projektboard), nicht mehr
@@ -478,25 +478,25 @@ function renderWochenbericht(){
</div>`;
}
html += renderBerichtKopf(w, liste, prevWeek, locked);
if(liste.length === 0){
html += renderBerichtKopf(w, liste, prevWeek, locked);
html += `<div class="hint">In dieser Sitzung ist noch kein Projekt angelegt.</div>`;
container.innerHTML = html;
renderAttendance();
return;
}
html += `<div class="bericht-split">
<div class="bericht-liste-spalte">
<div class="bericht-suche">
<input type="search" id="berichtSuche" placeholder="Nummer oder Name…"
value="${esc(berichtSuche)}" oninput="setzeBerichtSuche(this.value)"
aria-label="Zu einem Projekt springen">
</div>
<div class="bericht-liste" id="berichtListe"></div>
// Eine Fläche (.bericht-panel), nicht zwei lose nebeneinanderstehende Karten
// — Kopfleiste und Leiste|Hauptspalte gehören sichtbar zusammen (Entwurf
// "Fokus-Modus"). Die Kopfleiste bekommt einen eigenen Container, damit das
// Öffnen/Schließen des Sprungfelds (oeffneBerichtSuche()) nur sie neu
// zeichnen muss, nicht die ganze Tagesordnung samt offenem Kommentarfeld.
html += `<div class="bericht-panel">
<div id="berichtKopf">${renderBerichtKopf(w, liste, prevWeek, locked)}</div>
<div class="bericht-layout">
<div class="bericht-rail" id="berichtListe"></div>
<div class="bericht-main" id="berichtDetail">${renderBerichtDetail(aktiv, w, prevWeek, locked)}</div>
</div>
<div class="bericht-detail" id="berichtDetail">${renderBerichtDetail(aktiv, w, prevWeek, locked)}</div>
</div>`;
container.innerHTML = html;
@@ -504,72 +504,124 @@ function renderWochenbericht(){
renderAttendance();
}
// renderBerichtKopf: Vergleichs-Hinweis und Fortschrittsanzeige.
// berichtSucheOffen: ob das Sprungfeld gerade als Eingabefeld statt als
// Knopf dargestellt wird (Fokus-Modus, Eriks Wahl "C") — der Entwurf zeigt
// die Suche standardmäßig nur als schmalen Knopf, nicht als dauerhaft
// sichtbares Eingabefeld. Bleibt auch offen, solange tatsächlich gesucht
// wird (berichtSuche nicht leer), sonst verschwände der eigene Suchtext beim
// nächsten Neu-Rendern wieder hinter dem Knopf.
let berichtSucheOffen = false;
// oeffneBerichtSuche: blendet das Eingabefeld ein und fokussiert es.
// Keine Parameter, keine Rückgabe.
function oeffneBerichtSuche(){
berichtSucheOffen = true;
renderBerichtKopfNeu();
const feld = document.getElementById('berichtSuche');
if(feld) feld.focus();
}
// schliesseBerichtSucheFallsLeer: fällt beim Verlassen des leeren Feldes
// zurück auf den Knopf — ein leeres Feld brächte sonst dauerhaft unnötige
// Breite in die Kopfleiste. Keine Parameter, keine Rückgabe.
function schliesseBerichtSucheFallsLeer(){
if(berichtSuche) return;
berichtSucheOffen = false;
renderBerichtKopfNeu();
}
// renderBerichtKopfNeu: zeichnet nur die Kopfleiste neu (Gewerk, Fortschritt,
// Sprungfeld) — eigene Funktion aus demselben Grund wie renderBerichtListe():
// ein Klick auf den Such-Knopf darf weder die Leiste noch einen gerade
// offenen Kommentar im Detail antasten. Keine Parameter, keine Rückgabe.
function renderBerichtKopfNeu(){
const w = currentWeek();
const el = document.getElementById('berichtKopf');
if(!w || !el) return;
const prevWeek = findPreviousWeek(w);
const liste = berichtProjekte(w);
const locked = isWeekLocked(w);
el.innerHTML = renderBerichtKopf(w, liste, prevWeek, locked);
}
// renderBerichtKopf: Gewerke-Wahl, Fortschrittsring und das Sprungfeld.
// Parameter: w, liste, prevWeek, locked. Rückgabe: HTML.
function renderBerichtKopf(w, liste, prevWeek, locked){
const ab = liste.filter(p => p.besprochen).length;
const pct = liste.length ? Math.round(ab / liste.length * 100) : 0;
// Der Vergleichs-Hinweis ("Vergleich mit KW …") steht nicht mehr als eigene
// Zeile da, sondern als Titel-Tooltip am Fortschritt — dieselbe Angabe,
// ohne eine weitere, dauerhaft sichtbare Textzeile im Entwurf zu ergänzen.
const vergleich = prevWeek
? `Vergleich mit ${esc(prevWeek.label)}${prevWeek.date ? ' (' + esc(formatWeekDate(prevWeek.date)) + ')' : ''}.`
: 'Dies ist die früheste vorhandene Sitzung — kein Vergleich zur Vorwoche möglich.';
return `<div class="bericht-kopf">
const resetBtn = locked ? '' : (ab > 0
? `<button type="button" class="bericht-reset-link" onclick="setzeAlleBesprochen(false)">Zurücksetzen</button>`
: `<button type="button" class="bericht-reset-link" onclick="setzeAlleBesprochen(true)">Alle abhaken</button>`);
const sucheAktiv = berichtSucheOffen || !!berichtSuche;
const sucheHtml = sucheAktiv
? `<input type="search" id="berichtSuche" class="bericht-suche-input" placeholder="Nummer oder Name…"
value="${esc(berichtSuche)}" oninput="setzeBerichtSuche(this.value)"
onblur="schliesseBerichtSucheFallsLeer()" aria-label="Zu einem Projekt springen">`
: `<button type="button" class="bericht-search-btn" onclick="oeffneBerichtSuche()">Projekt suchen…</button>`;
return `<div class="bericht-topbar">
${renderGewerkDropdown()}
<span class="hint" style="margin:0">${vergleich}</span>
<span class="bericht-fortschritt">
<span class="bericht-fortschritt-text">${ab} von ${liste.length} ${esc(wochenberichtGewerk)}-Projekten besprochen</span>
<span class="bericht-fortschritt-bar"><i style="width:${pct}%"></i></span>
${locked ? '' : (ab > 0
? `<button type="button" class="secondary small" onclick="setzeAlleBesprochen(false)">Haken zurücksetzen</button>`
: `<button type="button" class="secondary small" onclick="setzeAlleBesprochen(true)">Alle abhaken</button>`)}
</span>
<span class="bericht-ring" style="background:conic-gradient(var(--green) 0% ${pct}%, var(--line-soft) ${pct}% 100%)"><span></span></span>
<span class="bericht-fortschritt-text" title="${esc(vergleich)}">${ab} / ${liste.length} besprochen</span>
${resetBtn}
${sucheHtml}
</div>`;
}
// renderBerichtListe: die Tagesordnung links. Eigene Funktion, weil das
// Sprung-Feld sie allein neu zeichnet — das Detail rechts und vor allem ein
// gerade getippter Kommentar dürfen dabei nicht verlorengehen.
// Keine Parameter, keine Rückgabe.
// renderBerichtListe: die Tagesordnung als schmale Nummern-Leiste (Fokus-
// Modus, Eriks Wahl "C" aus dem Darstellungs-Vergleich) — jede Kachel trägt
// nur die Position in der Liste plus ggf. den Änderungs-Punkt, Nummer und
// Name stehen in einem Tooltip. Eigene Funktion, weil das Sprung-Feld sie
// allein neu zeichnet — das Detail und vor allem ein gerade getippter
// Kommentar dürfen dabei nicht verlorengehen. Keine Parameter, keine
// Rückgabe.
function renderBerichtListe(){
const el = document.getElementById('berichtListe');
const w = currentWeek();
if(!el || !w) return;
const locked = isWeekLocked(w);
const prevWeek = findPreviousWeek(w);
const aktiv = aktuellesBerichtProjekt(w);
const liste = berichtProjekte(w).filter(passtZurSuche);
// voll bleibt UNGEFILTERT: die Kachel-Nummer ist die Position in der
// vollständigen Tagesordnung, damit sie beim Tippen im Sprung-Feld nicht
// ständig neu durchnummeriert wird und mit der Zählung im Detail
// übereinstimmt.
const voll = berichtProjekte(w);
const liste = voll.filter(passtZurSuche);
if(liste.length === 0){
el.innerHTML = `<div class="hint" style="padding:10px">Kein Projekt passt zu „${esc(berichtSuche)}".</div>`;
el.innerHTML = `<div class="hint" style="padding:10px 6px">Kein Projekt passt zu „${esc(berichtSuche)}".</div>`;
return;
}
let letzteGruppe = null;
el.innerHTML = liste.map(p => {
const info = classifyProjectChange(p, prevWeek);
const fehlen = abwesendeBeteiligte(p, w);
const kopf = p.group !== letzteGruppe
? (letzteGruppe = p.group, `<div class="bericht-gruppe">${esc(p.group || 'Ohne Gruppe')}</div>`)
: '';
const istAktiv = aktiv && aktiv.id === p.id;
return kopf + `<div class="bericht-zeile${istAktiv ? ' is-aktiv' : ''}${p.besprochen ? ' is-ab' : ''}"
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}" data-pid="${esc(p.id)}"
aria-current="${istAktiv ? 'true' : 'false'}">
<button type="button" class="bericht-haken${p.besprochen ? ' ab' : ''}"
${locked ? 'disabled' : `onclick="event.stopPropagation();toggleBesprochen('${esc(p.id)}')"`}
title="${p.besprochen ? 'Haken zurücknehmen' : 'Als besprochen markieren'}"
aria-label="${esc(p.name)} als besprochen markieren">✓</button>
<span class="bericht-zeile-nr">${esc(p.nr || '—')}</span>
<span class="bericht-zeile-name">${esc(p.name)}</span>
${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''}
${fehlen.length ? `<span class="bericht-abwesend" title="Fehlt heute: ${esc(fehlen.join(', '))}">${esc(fehlen.length)}✗</span>` : ''}
${info.bucket === 'unchanged' ? '' : `<span class="bericht-punkt is-${esc(info.bucket)}" title="${esc(info.reason)}"></span>`}
const position = voll.findIndex(x => x.id === p.id) + 1;
const tooltip = (p.nr ? p.nr + ' · ' : '') + p.name;
// Eigene, schwebende Tooltip-Box (wie im freigegebenen Entwurf) statt
// des nativen Browser-Tooltips: Seit die Leiste nicht mehr selbst
// scrollt (kein overflow-y:auto mehr, siehe .bericht-rail in styles.css),
// gibt es auch keine abschneidende Overflow-Begrenzung mehr dafür.
return `<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${p.besprochen ? ' is-ab' : ''}"
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}"
aria-current="${istAktiv ? 'true' : 'false'}" aria-label="${esc(tooltip)}">
<span class="bericht-rail-num">${String(position).padStart(2, '0')}</span>
${info.bucket === 'unchanged' ? '' : `<span class="bericht-rail-dot is-${esc(info.bucket)}" aria-hidden="true"></span>`}
<span class="bericht-rail-tooltip">${esc(tooltip)}</span>
</div>`;
}).join('');
el.querySelectorAll('.bericht-zeile').forEach(z => {
el.querySelectorAll('.bericht-rail-item').forEach(z => {
const waehlen = () => waehleBerichtProjekt(z.dataset.uid || null);
z.addEventListener('click', e => { if(!e.target.closest('.bericht-haken')) waehlen(); });
z.addEventListener('click', waehlen);
z.addEventListener('keydown', e => {
if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); waehlen(); }
});
@@ -589,19 +641,18 @@ function renderBerichtDetail(p, w, prevWeek, locked){
const info = classifyProjectChange(p, prevWeek);
const tags = Array.isArray(p.tags) ? p.tags : [];
const fehlen = abwesendeBeteiligte(p, w);
const liste = berichtProjekte(w);
const nr = liste.findIndex(x => x.id === p.id) + 1;
// Reiner Text wie im freigegebenen Entwurf — kein zusätzlicher
// Auftragsstatus-Chip mehr an dieser Stelle (der Status bleibt über die
// Projektliste/-einstellungen erreichbar, "Ein Ort je Angabe").
const subText = esc(p.group || 'Ohne Gruppe') + (tags.length ? ' · ' + tags.map(t => esc(t)).join(' · ') : '');
return `<div class="bericht-detail-kopf">
<span class="bericht-detail-zaehler">${nr} / ${liste.length}</span>
<span class="bericht-zeile-nr">${esc(p.nr || '—')}</span>
<h3>${esc(p.name)}</h3>
${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''}
${tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('')}
${info.bucket === 'unchanged' ? '' : `<span class="bericht-kennz is-${esc(info.bucket)}">${esc(info.reason)}</span>`}
return `<div class="bericht-head-line">
<span class="bericht-head-nr">${esc(p.nr || '—')}</span>
${info.bucket === 'unchanged' ? '' : `<span class="bericht-change-pill is-${esc(info.bucket)}">${esc(info.reason)}</span>`}
</div>
<div class="bericht-detail-meta">${esc(p.group || 'Ohne Gruppe')}</div>
${fehlen.length ? `<div class="bericht-abwesend-hinweis">Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.</div>` : ''}
<h1 class="bericht-h1">${esc(p.name)}</h1>
<div class="bericht-sub">${subText}</div>
${fehlen.length ? `<span class="bericht-abwesend-hinweis">Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''}
${renderVorwochenzeile(p, prevWeek)}
${renderBoardDrawer(p, allPeople, locked, w.label, BERICHT_OPTS)}
${locked ? '' : `<div class="bericht-detail-fuss">
@@ -624,7 +675,7 @@ function renderVorwochenzeile(p, prevWeek){
const text = statusPlainText(vor.status);
if(!text) return '';
return `<div class="bericht-vorwoche">
<span class="bericht-vorwoche-label">${esc(prevWeek.label)}</span>
<span class="bericht-vorwoche-label">Vorwoche · ${esc(prevWeek.label)}</span>
<span class="bericht-vorwoche-text">${esc(text)}</span>
</div>`;
}