Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb

- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan
  oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues
  Notizfeld je Aufgabe (plan_todos.notiz)
- Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile,
  "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen
- Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang
- Glas-Darstellung, Logo als Favicon
- Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und
  Prüfskript inkl. WLAN-Wächter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-29 14:16:52 +01:00
1 parent f4fb3c550b
commit b7e0bd0d92
56 files changed
+12265 -2974

No files matched your search

+187 -76
View File
@@ -10,7 +10,7 @@
// Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter)
// und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt,
// offene Aufgaben und nächster Meilenstein aus denselben Plan-Daten;
// Team-Auslastung dagegen über berechneAuslastung() aus js/load/load.js,
// Team-Auslastung dagegen über aufgabenVerlaufJePerson() 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
@@ -26,7 +26,7 @@
// renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts
// neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den
// Projektzeitraum), berücksichtigt Urlaub, kritischen Pfad, Sperrstatus und
// Projektzeitraum), berücksichtigt Urlaub, Sperrstatus und
// Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die
// Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert.
window.renderPlanGantt = function(){
@@ -41,16 +41,23 @@
document.getElementById('planGanttStart').value = formatDateISO(start);
document.getElementById('planGanttEnd').value = formatDateISO(end);
}
// Zeitraum auch sichtbar ins Feld schreiben (es zeigte sonst nur den
// Platzhalter, solange niemand einen Zeitraum gewählt hatte).
const zeitraumAnzeige = document.getElementById('planGanttRange');
if(zeitraumAnzeige) zeitraumAnzeige.value = formatDateDE(start) + ' – ' + formatDateDE(end);
const showVacation = document.getElementById('planShowVacation').checked;
const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id);
const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : [];
const ghost = collectGhostDates();
container.innerHTML = buildGanttHtml([app.project], start, end, {
showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id,
showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath,
showTodos: true, vacationPeople, pxPerDay: app.pxPerDay,
ghostTodos: ghost.todos, ghostMilestones: ghost.milestones, ghostLabel: ghost.label,
filter: app.ganttFilter || 'alle', phasenZu: app.phasenZu || (app.phasenZu = {}),
eigenePersonId: (typeof currentUser !== 'undefined' && currentUser && currentUser.personId) || '',
});
renderOhneTermin();
container.scrollLeft = prevScroll;
if(typeof ganttGriffeEinsetzen === 'function') ganttGriffeEinsetzen(container);
requestAnimationFrame(() => renderGanttDependencies(container));
renderPlanWarnings();
renderResourceLoad();
@@ -61,6 +68,7 @@
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
if(typeof renderPlanTasks === 'function') renderPlanTasks();
if(typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
};
@@ -121,71 +129,68 @@
</div>`;
};
// kpiTile: baut das HTML einer einzelnen Kachel für renderPlanKpis().
// Parameter: label (Kachel-Titel), value (Hauptzahl), sub (Unterzeile),
// warn (optional, hebt den Wert farblich hervor, z. B. bei überfälligen
// Aufgaben). Rückgabe: HTML-String.
function kpiTile(label, value, sub, warn){
return `<div class="plan-kpi-tile${warn ? ' warn' : ''}">
// bandZelle: eine Kennzahl im Lageband des Dashboards. Parameter: label,
// value, sub (Texte), warn (Unterzeile rot). Rückgabe: HTML-String.
function bandZelle(label, value, sub, warn){
return `<div class="plan-lage-zelle">
<div class="kpi-label">${esc(label)}</div>
<div class="kpi-value">${esc(value)}</div>
<div class="kpi-sub">${esc(sub)}</div>
<div class="kpi-sub${warn ? ' ist-rot' : ''}">${esc(sub)}</div>
</div>`;
}
// renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout,
// #planKpiRow in App.html) mit vier Kennzahlen, die alle aus den
// bereits vorhandenen Plan-Daten abgeleitet sind (keine neue Datenquelle):
// Gesamtfortschritt (Ø-Fortschritt aller TODOs), offene Aufgaben (inkl.
// überfällige), nächster anstehender Meilenstein und Team-Auslastung
// (Ø-Prozentwert aus computeResourceLoads(), dieselbe Zahl wie im
// Auslastungs-Modal). Wird nach jedem GANTT-Rendering aufgerufen, damit sie
// nach TODO-/Meilenstein-/Zuordnungsänderungen sofort aktuell ist.
// renderPlanKpis: füllt das Lageband oben im Dashboard (#planKpiRow,
// Anordnung "Lagebild"): links die laufende Leistungsphase mit Ring
// (amtierende LPH und ihr Fortschritt aus dem Wochenbericht), daneben offene
// Aufgaben (mit Überfälligem), nächster Meilenstein und Team-Auslastung
// (computeResourceLoads(), dieselbe Zahl wie im Auslastungs-Tab). Der
// frühere "Gesamtfortschritt" (Ø der Aufgaben) ist entfallen — für den
// Stand eines Projekts zählt die Leistungsphase. Wird nach jedem
// GANTT-Rendering aufgerufen, damit es sofort aktuell ist.
window.renderPlanKpis = function(){
const row = document.getElementById('planKpiRow');
if(!row || !app.project) return;
const p = app.project;
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
const today = new Date();
today.setHours(0, 0, 0, 0);
let progressTile;
if(todos.length === 0){
progressTile = kpiTile('Gesamtfortschritt', '–', 'Keine Aufgaben angelegt');
} else {
const avg = Math.round(todos.reduce((s, t) => s + (parseInt(t.progress, 10) || 0), 0) / todos.length);
const doneCount = todos.filter(t => t.done).length;
progressTile = kpiTile('Gesamtfortschritt', avg + '%', doneCount + ' von ' + todos.length + ' Aufgaben erledigt');
}
const lph = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || '';
const roh = lph && p.lph ? p.lph[lph] : '';
const pct = roh === undefined || roh === '' ? null : Math.max(0, Math.min(100, Number(roh) || 0));
const phase = `<div class="plan-lage-zelle plan-lage-phase">
<span class="plan-lage-ring" style="--p:${pct || 0}"></span>
<div><div class="kpi-label">Laufende Phase</div>
<div class="kpi-value">${lph ? esc(lph) + (pct === null ? '' : ' · ' + pct + ' %') : '–'}</div>
<div class="kpi-sub">${lph ? (pct === 100 ? 'abgeschlossen' : 'Fortschritt laut Wochenbericht') : 'Keine amtierende Phase gesetzt'}</div></div>
</div>`;
const open = todos.filter(t => !t.done);
const overdue = open.filter(t => t.end && parseDateISO(t.end) < today);
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'Im Zeitplan' : 'Alle Aufgaben erledigt');
const openTile = kpiTile('Offene Aufgaben', String(open.length), openSub, overdue.length > 0);
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'im Zeitplan' : 'alle erledigt');
const upcoming = milestones
.filter(m => m.date && parseDateISO(m.date) >= today)
.sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date));
let milestoneTile;
if(upcoming.length > 0){
milestoneTile = kpiTile('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein');
} else {
milestoneTile = kpiTile('Nächster Meilenstein', '–', milestones.length > 0 ? 'Keine anstehenden Meilensteine' : 'Keine Meilensteine angelegt');
}
const msZelle = upcoming.length > 0
? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein')
: bandZelle('Nächster Meilenstein', '–', milestones.length > 0 ? 'keine anstehenden' : 'keine angelegt');
const { loads } = computeResourceLoads();
const loadValues = Object.values(loads);
let loadTile;
let loadZelle;
if(loadValues.length === 0){
loadTile = kpiTile('Team-Auslastung', '–', 'Keine Beteiligten zugeordnet');
loadZelle = bandZelle('Team-Auslastung', '–', 'niemand zugeordnet');
} else {
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');
loadZelle = bandZelle('Team-Auslastung', avgPct + ' %', top.stunden > 0
? 'höchste: ' + (top.person.fullName || top.person.name) + ' ' + Math.round(top.quote * 100) + ' %' : 'noch nicht bewertbar');
}
row.innerHTML = progressTile + openTile + milestoneTile + loadTile;
row.innerHTML = phase + bandZelle('Offene Aufgaben', String(open.length), openSub, overdue.length > 0) + msZelle + loadZelle;
};
// renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die
@@ -202,7 +207,13 @@
let svg = chart.querySelector('.gantt-dependencies-svg');
if(svg) svg.remove();
const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'));
// Linien abgeschaltet (Menü "Mehr") — dann nichts zeichnen.
const pfeile = document.getElementById('planShowPfeile');
if(pfeile && !pfeile.checked) return;
// Ausgeblendete Zeilen (zugeklappte Phase, Filter) haben keine Lage —
// Linien dorthin würden ins Leere zeigen.
const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'))
.filter(b => b.offsetParent !== null);
if(nodeBars.length === 0) return;
const chartRect = chart.getBoundingClientRect();
@@ -283,6 +294,97 @@
renderPlanGantt();
};
// ---------------------------------------------------------------------------
// Ruhige Leiste (Eriks Wahl "A"): Maßstab, Heute, Filter, Menüs.
// ---------------------------------------------------------------------------
const TP_STUFEN = { fit: null, woche: 8, tag: 32 };
// tpMassstab: Maßstab "Übersicht" (an die Breite angepasst), "Wochen" oder
// "Tage" — feste Pixel je Tag statt der früheren −/+-Knöpfe.
window.tpMassstab = function(stufe){
app.pxPerDay = TP_STUFEN[stufe] === undefined ? null : TP_STUFEN[stufe];
document.querySelectorAll('#tpMassstab button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.stufe === stufe)));
renderPlanGantt();
if(app.pxPerDay) tpZuHeuteScrollen();
};
function tpZuHeuteScrollen(){
const c = document.getElementById('planGanttContainer');
const heute = c && c.querySelector('.gantt-grid-today');
if(!c || !heute) return;
const r = heute.getBoundingClientRect(), cr = c.getBoundingClientRect();
c.scrollLeft += (r.left - cr.left) - c.clientWidth / 3;
}
// tpHeute: Zeitraum ab zwei Wochen vor heute, gleiche Länge wie bisher
// (mindestens 12 Wochen), und heute ins Bild holen.
window.tpHeute = function(){
const s = parseDateISO(document.getElementById('planGanttStart').value);
const e = parseDateISO(document.getElementById('planGanttEnd').value);
const tage = s && e ? Math.max(84, daysBetween(s, e)) : 84;
const von = new Date(); von.setHours(0, 0, 0, 0); von.setDate(von.getDate() - 14);
const bis = new Date(von); bis.setDate(bis.getDate() + tage);
document.getElementById('planGanttStart').value = formatDateISO(von);
document.getElementById('planGanttEnd').value = formatDateISO(bis);
const anzeige = document.getElementById('planGanttRange');
if(anzeige) anzeige.value = formatDateDE(von) + ' – ' + formatDateDE(bis);
renderPlanGantt();
if(app.pxPerDay) tpZuHeuteScrollen();
};
// tpFilter: Alle / Meine (eigene Zuständigkeit) / Überfällig. Gefilterte
// Zeilen werden nur ausgeblendet, nicht weggelassen — das Umsortieren per
// Ziehen (plan-drag.js) zählt die Zeilen im DOM.
window.tpFilter = function(filter){
app.ganttFilter = filter;
document.querySelectorAll('#tpFilter button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.filter === filter)));
renderPlanGantt();
};
// tpPhaseUmschalten: klappt eine Leistungsphase im Terminplan auf/zu.
window.tpPhaseUmschalten = function(phase){
if(!app.phasenZu) app.phasenZu = {};
app.phasenZu[phase] = !app.phasenZu[phase];
renderPlanGantt();
};
// tpMenueZu: schließt die Menüs "Mehr" und "+ Neu".
window.tpMenueZu = function(){
document.querySelectorAll('.tp-menue[open]').forEach(d => d.removeAttribute('open'));
};
// Ein Klick außerhalb schließt ein offenes Menü; ein Menü öffnen schließt
// das andere.
document.addEventListener('click', e => {
document.querySelectorAll('.tp-menue[open]').forEach(d => { if(!d.contains(e.target)) d.removeAttribute('open'); });
});
document.addEventListener('keydown', e => { if(e.key === 'Escape') window.tpMenueZu(); });
// Die Liste hängt rechtsbündig unter dem Knopf. Bricht die Leiste in einem
// schmalen Fenster um, steht "+ Neu" links und die Liste rutschte unter die
// Seitenleiste — dann linksbündig aufklappen (Klasse .nach-rechts).
document.addEventListener('toggle', e => {
const d = e.target;
if(!d.classList || !d.classList.contains('tp-menue') || !d.open) return;
const liste = d.querySelector('.tp-menue-liste');
if(!liste) return;
liste.classList.remove('nach-rechts');
const seite = document.querySelector('.side-menu');
const r = seite ? seite.getBoundingClientRect() : null;
const linkeGrenze = r && r.width && getComputedStyle(seite).position !== 'fixed' ? r.right : 0;
if(liste.getBoundingClientRect().left < linkeGrenze + 8) liste.classList.add('nach-rechts');
}, true);
// renderOhneTermin: Hinweis unter dem Diagramm auf Aufgaben, die keinen
// Balken haben (ohne Zeitraum oder bewusst ausgeblendet) — sie stehen nur
// unter "Aufgaben". Vorher fehlte jeder Hinweis, dass es sie gibt.
function renderOhneTermin(){
const el = document.getElementById('planOhneTermin');
if(!el) return;
const todos = (app.project && app.project.plan && Array.isArray(app.project.plan.todos)) ? app.project.plan.todos : [];
const ohne = todos.filter(t => !t.done && !(t.start && t.end && t.showInGantt !== false));
el.hidden = ohne.length === 0;
el.innerHTML = ohne.length ? `<span class="tp-ohne-zahl">${ohne.length}</span> ${ohne.length === 1 ? 'offene Aufgabe' : 'offene Aufgaben'} ohne Termin <span class="tp-ohne-hinweis">stehen nur unter „Aufgaben“ →</span>` : '';
}
// zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms.
// Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit).
window.zoomPlanGantt = function(dir){
@@ -291,7 +393,7 @@
let end = parseDateISO(document.getElementById('planGanttEnd').value);
if(!start || !end) return;
const totalDays = Math.max(1, daysBetween(start, end) + 1);
const cw = container ? Math.max(200, container.clientWidth - 250) : 900;
const cw = container ? Math.max(200, container.clientWidth - (ganttSpaltenBreite() + 30)) : 900;
const fitPx = Math.max(3, cw / totalDays);
const base = app.pxPerDay || fitPx;
let next;
@@ -308,33 +410,20 @@
// ---------- Ressourcen-Auslastung ----------
// 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).
// NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel:
// Stunden dieser Woche aus Aufgaben mit Aufwand, gleich geteilt auf die
// Zuständigen (aufgabenVerlaufJePerson(), js/load/load.js), bezogen auf die
// Kapazität der Person. Für JEDES Projekt der Sitzung, aufgeteilt in den
// Anteil DIESES Projekts und den der übrigen — sonst sähe jemand hier
// entspannt aus, der in vier anderen Projekten ausgebucht ist.
//
// Gemeinsam genutzt von renderResourceLoad() (Detailkarten im
// 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).
// (Titel der eigenen Aufgaben in DIESEM Projekt, ohne Phasenbalken),
// offenProjekt (offene Aufgabenstunden dieses Projekts, alle Wochen).
function computeResourceLoads(){
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
@@ -353,25 +442,42 @@
if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role);
});
// Zuständige einer Aufgabe zählen mit, auch wenn sie unter „Beteiligte“
// fehlen — wie auf der Auslastungsseite. Sonst fielen ihre
// Aufgabenstunden hier stillschweigend weg.
const personen = (app.week && app.week.people) || [];
todos.forEach(t => {
if(!Array.isArray(t.assignees)) return;
if(!Array.isArray(t.assignees) || balkenPhase(t)) return;
t.assignees.forEach(pid => {
if(loads[pid]) loads[pid].todos.push(t.title);
if(!loads[pid]){
const person = personen.find(x => x.id === pid);
if(!person) return;
loads[pid] = {
person, roles: ['nur Aufgabe'], todos: [],
kapazitaet: kapazitaetsFaktor(person) * vollzeit,
stundenProjekt: 0, stundenAndere: 0, andereProjekte: [],
};
}
loads[pid].todos.push(t.title);
});
});
// Offene Aufgabenstunden dieses Projekts insgesamt (nicht nur diese
// Woche) — liegt eine Aufgabe erst später, zeigte die Wochenzahl sonst 0.
Object.values(loads).forEach(l => { l.offenProjekt = 0; });
offeneAufwandsAufgaben(app.project).forEach(({ t, rest }) => {
t.assignees.forEach(pid => { if(loads[pid]) loads[pid].offenProjekt += rest / t.assignees.length; });
});
// Stunden dieser Woche allein aus Aufgaben mit Aufwand — dieselbe
// Rechnung wie auf der Auslastungsseite (aufgabenVerlaufJePerson(), load.js).
((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 av = typeof aufgabenVerlaufJePerson === 'function' ? aufgabenVerlaufJePerson(pr) : {};
Object.keys(av).forEach(pid => {
if(!loads[pid] || !(av[pid][0] > 0)) return;
if(pr.id === app.project.id) loads[pid].stundenProjekt += av[pid][0];
else {
loads[pid].stundenAndere += av[pid][0];
const label = pr.nr ? (pr.nr + ' ' + (pr.name || '')) : (pr.name || 'Projekt');
if(!loads[pid].andereProjekte.includes(label)) loads[pid].andereProjekte.push(label);
}
@@ -456,10 +562,15 @@
</div>`
: `<div class="plan-resource-other muted">keine weiteren Projekte</div>`;
const offenText = l.offenProjekt > 0
? `<div class="plan-resource-other muted">${stundenFormat(l.offenProjekt)} h offen in Aufgaben dieses Projekts</div>`
: `<div class="plan-resource-other muted">keine Aufgaben mit Aufwand</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;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h</span></div>
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h diese Woche</span></div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
${offenText}
${otherBadge}
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
${absBadge}