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
+12119 -2828

No files matched your search

+983 -27
View File
File diff suppressed because it is too large. Load diff
+9 -5
View File
@@ -7,10 +7,10 @@ WORKDIR /app
# package.json und package-lock.json zuerst kopieren, damit npm-Installation gecacht wird # package.json und package-lock.json zuerst kopieren, damit npm-Installation gecacht wird
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --production RUN npm config set fetch-retries 6 && npm config set fetch-retry-maxtimeout 180000 && npm config set fetch-timeout 600000 && npm ci --omit=dev
# Nur die Laufzeit-Dateien kopieren # Nur die Laufzeit-Dateien kopieren
COPY server.js ./ COPY server.js pdf-arbeit.js ./
COPY public ./public COPY public ./public
# Sessions-Ordner fuer die SQLite-Datenbank sicherstellen # Sessions-Ordner fuer die SQLite-Datenbank sicherstellen
@@ -20,7 +20,11 @@ USER node
EXPOSE 3003 EXPOSE 3003
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ # wget (busybox) statt eines eigenen Node-Prozesses je Prüfung: auf dem
CMD node -e "require('http').get('http://localhost:3003/health', r => r.statusCode === 200 ? process.exit(0) : process.exit(1)).on('error', () => process.exit(1))" # Pi Zero kostete jeder Healthcheck sonst ~40 MB und eine Sekunde CPU.
HEALTHCHECK --interval=60s --timeout=10s --start-period=60s --retries=3 \
CMD wget -qO- http://127.0.0.1:3003/health >/dev/null || exit 1
CMD ["node", "server.js"] # --max-old-space-size: Node räumt früher auf, statt bis an die
# Container-Grenze zu wachsen (Pi Zero 2, 512 MB). Siehe docker-compose.yml.
CMD ["node", "--max-old-space-size=160", "server.js"]
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env bash
# pi-einrichten.sh — macht Ubuntu Server auf dem Raspberry Pi Zero 2 (512 MB)
# stabil für die Projektübersicht. Mehrfach ausführbar (prüft jeden Schritt).
#
# sudo bash Docker/pi-einrichten.sh # Standard
# sudo bash Docker/pi-einrichten.sh --snapd-aus # zusätzlich snapd abschalten
#
# Was es tut (Einzelheiten in CLAUDE.md, "Betrieb auf dem Pi"):
# 1. Hardware-Watchdog: hängt das System, startet der Pi selbst neu
# 2. zram: komprimierter Auslagerungsspeicher im RAM (statt SD-Karte)
# 3. Speichergrenzen für Container erlauben (cgroup memory)
# 4. gpu_mem=16 — kein Bildschirm, mehr RAM für Linux
# 5. Unnötige Dienste aus (multipathd, ModemManager, udisks2 …)
# 6. earlyoom: greift ein, BEVOR das System im Speichermangel festfriert
# 7. Systemprotokoll auf 50 MB begrenzen
# 8. Nächtlicher Neustart des Containers (3:30 Uhr)
# 9. Prüfung der Stromversorgung (Unterspannung)
# 10. WLAN: Energiesparen aus, Wächter verbindet neu und startet den Pi
# neu, wenn das Netz 10 Minuten lang weg bleibt
set -u
if [ "$(id -u)" -ne 0 ]; then echo "Bitte mit sudo ausführen."; exit 1; fi
SNAPD_AUS=0
[ "${1:-}" = "--snapd-aus" ] && SNAPD_AUS=1
NEUSTART_NOETIG=0
FIRMWARE=/boot/firmware
[ -d "$FIRMWARE" ] || FIRMWARE=/boot
ok() { echo " ✓ $*"; }
info() { echo " • $*"; }
warn() { echo " ! $*"; }
echo "== Projektübersicht: Pi einrichten =="
echo "System: $(. /etc/os-release; echo "$PRETTY_NAME"), $(uname -m), $(free -m | awk '/Mem:/{print $2}') MB RAM"
# 1. Hardware-Watchdog ------------------------------------------------------
echo "[1] Hardware-Watchdog"
mkdir -p /etc/systemd/system.conf.d
cat > /etc/systemd/system.conf.d/10-watchdog.conf <<'EOF'
# Hängt das System, löst der Watchdog des Pi nach 14 s einen Neustart aus
# (der bcm2835-Watchdog kann höchstens ~15 s).
[Manager]
RuntimeWatchdogSec=14s
RebootWatchdogSec=2min
EOF
if [ -e /dev/watchdog ]; then ok "Watchdog aktiv nach Neustart (/dev/watchdog vorhanden)"
else warn "/dev/watchdog fehlt — Kernelmodul bcm2835_wdt wird beim Start geladen"; echo bcm2835_wdt > /etc/modules-load.d/watchdog.conf; NEUSTART_NOETIG=1; fi
systemctl daemon-reexec
# 2. zram -------------------------------------------------------------------
echo "[2] zram (komprimierter Auslagerungsspeicher)"
apt-get update -qq
if apt-cache show systemd-zram-generator >/dev/null 2>&1; then
apt-get install -y -qq systemd-zram-generator >/dev/null
cat > /etc/systemd/zram-generator.conf <<'EOF'
[zram0]
zram-size = ram / 2
compression-algorithm = zstd
swap-priority = 100
EOF
systemctl daemon-reload
systemctl restart systemd-zram-setup@zram0.service 2>/dev/null || true
ok "systemd-zram-generator eingerichtet (halber RAM, zstd)"
else
apt-get install -y -qq zram-tools >/dev/null
printf 'ALGO=zstd\nPERCENT=50\nPRIORITY=100\n' > /etc/default/zramswap
systemctl restart zramswap 2>/dev/null || true
ok "zram-tools eingerichtet (halber RAM, zstd)"
fi
cat > /etc/sysctl.d/90-zram.conf <<'EOF'
# zram ist schnell: früh auslagern, Seiten einzeln holen
vm.swappiness=100
vm.page-cluster=0
vm.vfs_cache_pressure=200
EOF
sysctl -q --system
swapon --show | sed 's/^/ /'
# 3. Speichergrenzen für Container -------------------------------------------
echo "[3] Speichergrenzen für Container (cgroup memory)"
if grep -qw memory /sys/fs/cgroup/cgroup.controllers 2>/dev/null; then
ok "bereits verfügbar"
else
CMDLINE="$FIRMWARE/cmdline.txt"
if [ -f "$CMDLINE" ] && ! grep -q "cgroup_enable=memory" "$CMDLINE"; then
cp "$CMDLINE" "$CMDLINE.vor-projektuebersicht"
sed -i '1 s/$/ cgroup_enable=memory cgroup_memory=1/' "$CMDLINE"
ok "in $CMDLINE ergänzt (Sicherung: cmdline.txt.vor-projektuebersicht)"
NEUSTART_NOETIG=1
else
warn "nicht verfügbar — $CMDLINE prüfen"
fi
fi
# 4. GPU-Speicher -----------------------------------------------------------
echo "[4] GPU-Speicher"
CONFIG="$FIRMWARE/config.txt"
if [ -f "$CONFIG" ]; then
if grep -q "^gpu_mem=" "$CONFIG"; then sed -i 's/^gpu_mem=.*/gpu_mem=16/' "$CONFIG"
else printf '\n# Projektübersicht: kein Bildschirm, mehr RAM für Linux\ngpu_mem=16\n' >> "$CONFIG"; fi
ok "gpu_mem=16"
NEUSTART_NOETIG=1
else warn "$CONFIG nicht gefunden"; fi
# 5. Unnötige Dienste -------------------------------------------------------
echo "[5] Unnötige Dienste abschalten"
for d in multipathd multipathd.socket ModemManager udisks2 packagekit bluetooth avahi-daemon avahi-daemon.socket; do
if systemctl list-unit-files "$d.service" "$d" 2>/dev/null | grep -q "$d"; then
systemctl disable --now "$d" >/dev/null 2>&1 && ok "$d aus"
fi
done
if [ "$SNAPD_AUS" = 1 ]; then
if command -v snap >/dev/null && snap list docker >/dev/null 2>&1; then
warn "Docker ist als Snap installiert — snapd bleibt an"
else
systemctl disable --now snapd.service snapd.socket snapd.seeded.service >/dev/null 2>&1 && ok "snapd aus"
fi
else
info "snapd bleibt an (spart ~40 MB mit --snapd-aus)"
fi
# 6. earlyoom ---------------------------------------------------------------
echo "[6] earlyoom"
apt-get install -y -qq earlyoom >/dev/null
cat > /etc/default/earlyoom <<'EOF'
# Bei unter 5 % freiem RAM UND unter 10 % freiem Swap den größten Prozess
# beenden — nie SSH, Docker oder systemd. Der App-Container startet dann neu.
# Ohne innere Anführungszeichen: systemd reicht sie sonst wörtlich weiter.
EARLYOOM_ARGS="-m 5 -s 10 -r 3600 --avoid ^(sshd|dockerd|containerd|systemd|systemd-journal)$ --prefer ^node$"
EOF
systemctl enable --now earlyoom >/dev/null 2>&1
systemctl restart earlyoom
ok "aktiv"
# 7. Systemprotokoll --------------------------------------------------------
echo "[7] Systemprotokoll begrenzen"
mkdir -p /etc/systemd/journald.conf.d
printf '[Journal]\nSystemMaxUse=50M\n' > /etc/systemd/journald.conf.d/10-groesse.conf
systemctl restart systemd-journald
ok "höchstens 50 MB"
# 8. Nächtlicher Neustart ---------------------------------------------------
echo "[8] Nächtlicher Neustart des Containers"
cat > /etc/cron.d/projektuebersicht <<'EOF'
# Projektübersicht: frischer Speicher jede Nacht (gegen schleichende Lecks)
30 3 * * * root docker restart projektuebersicht >/dev/null 2>&1
EOF
systemctl enable docker >/dev/null 2>&1
ok "täglich 3:30 Uhr; Docker startet beim Booten"
# 9. Stromversorgung --------------------------------------------------------
echo "[9] Stromversorgung"
T=""
[ -r /sys/devices/platform/soc/soc:firmware/get_throttled ] && T=$(cat /sys/devices/platform/soc/soc:firmware/get_throttled)
command -v vcgencmd >/dev/null && T=$(vcgencmd get_throttled | cut -d= -f2)
if [ -z "$T" ]; then info "nicht auslesbar (Paket libraspberrypi-bin installieren)"
elif [ "$((T))" -eq 0 ]; then ok "keine Unterspannung seit dem Start ($T)"
else warn "Unterspannung/Drosselung erkannt ($T) — besseres Netzteil (5 V / 2,5 A) verwenden!"; fi
# 10. WLAN ------------------------------------------------------------------
# Befund 29.10.: Der Pi "stürzte" nicht ab, sondern verlor das WLAN
# (wlan0: Lost carrier) und kam danach nie wieder in das Netz
# (ASSOC-REJECT in Schleife) — erreichbar war er erst nach Stecker ziehen.
echo "[10] WLAN stabil halten"
apt-get install -y -qq iw >/dev/null 2>&1
cat > /etc/systemd/system/wlan-energiesparen-aus.service <<'EOF'
[Unit]
Description=WLAN-Energiesparen aus (Pi Zero 2 verliert sonst die Verbindung)
After=sys-subsystem-net-devices-wlan0.device
Wants=sys-subsystem-net-devices-wlan0.device
[Service]
Type=oneshot
ExecStart=/usr/sbin/iw dev wlan0 set power_save off
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
cat > /usr/local/sbin/wlan-waechter <<'EOF'
#!/bin/sh
# Prüft jede Minute, ob das Gateway erreichbar ist. Ohne Netz: alle 2
# Minuten WLAN neu verbinden, nach 10 Minuten den Pi neu starten.
ZAEHLER=/run/wlan-waechter.zaehler
GW=$(ip route | awk '/^default/{print $3; exit}')
if [ -n "$GW" ] && ping -c 2 -W 3 "$GW" >/dev/null 2>&1; then
rm -f "$ZAEHLER"; exit 0
fi
N=$(( $(cat "$ZAEHLER" 2>/dev/null || echo 0) + 1 ))
echo "$N" > "$ZAEHLER"
logger -t wlan-waechter "kein Netz seit $N Minute(n)"
if [ "$N" -ge 10 ]; then
logger -t wlan-waechter "Neustart des Pi"; systemctl reboot
elif [ $((N % 2)) -eq 0 ]; then
logger -t wlan-waechter "WLAN neu verbinden"
wpa_cli -i wlan0 reassociate >/dev/null 2>&1
if [ "$N" -ge 4 ]; then
ip link set wlan0 down; sleep 2; ip link set wlan0 up
networkctl reconfigure wlan0 >/dev/null 2>&1
fi
fi
EOF
chmod 755 /usr/local/sbin/wlan-waechter
cat > /etc/systemd/system/wlan-waechter.service <<'EOF'
[Unit]
Description=WLAN-Wächter der Projektübersicht
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/wlan-waechter
EOF
cat > /etc/systemd/system/wlan-waechter.timer <<'EOF'
[Unit]
Description=WLAN-Wächter jede Minute
[Timer]
OnBootSec=3min
OnUnitActiveSec=1min
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now wlan-energiesparen-aus.service >/dev/null 2>&1
systemctl enable --now wlan-waechter.timer >/dev/null 2>&1
if iw dev wlan0 get power_save 2>/dev/null | grep -q off; then ok "Energiesparen aus"; else warn "Energiesparen ließ sich nicht abschalten"; fi
ok "Wächter aktiv (neu verbinden ab 2 min, Neustart nach 10 min ohne Netz)"
echo
if [ "$NEUSTART_NOETIG" = 1 ]; then
echo "Fertig. Bitte den Pi jetzt neu starten: sudo reboot"
else
echo "Fertig."
fi
echo "Danach prüfen: bash Docker/pi-pruefen.sh"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# pi-pruefen.sh — zeigt, ob der Pi und die Projektübersicht stabil laufen.
# sudo bash Docker/pi-pruefen.sh (ohne sudo fehlen Docker und Strom)
echo "== Speicher =="
free -m
echo
echo "== Auslagerung (zram) =="
swapon --show 2>/dev/null || echo "keine"
echo
echo "== Watchdog =="
systemctl show -p RuntimeWatchdogUSec 2>/dev/null
[ -e /dev/watchdog ] && echo "/dev/watchdog vorhanden" || echo "/dev/watchdog FEHLT"
echo
echo "== Speichergrenzen für Container =="
grep -qw memory /sys/fs/cgroup/cgroup.controllers 2>/dev/null && echo "verfügbar" || echo "NICHT verfügbar (cmdline.txt, siehe pi-einrichten.sh)"
echo
echo "== Container =="
docker ps -a --filter name=projektuebersicht --format 'Status: {{.Status}}'
docker inspect projektuebersicht --format 'Neustarts: {{.RestartCount}} Grenze: {{.HostConfig.Memory}} Byte zuletzt gestartet: {{.State.StartedAt}} OOM: {{.State.OOMKilled}}' 2>/dev/null
docker stats --no-stream --format 'Verbrauch: {{.MemUsage}} ({{.MemPerc}}) CPU: {{.CPUPerc}}' projektuebersicht 2>/dev/null
echo
echo "== App =="
curl -s --max-time 5 http://localhost:3003/health || wget -qO- http://localhost:3003/health || echo "antwortet NICHT"
echo
echo
echo "== Neustarts der App (letzte 10 Meldungen) =="
docker logs projektuebersicht 2>&1 | grep -E "Wächter|Unerwarteter Fehler|server running" | tail -10
echo
echo "== Speichermangel im System (earlyoom / Kernel) =="
journalctl -u earlyoom --since "-7 days" --no-pager 2>/dev/null | grep -i "sending\|killing" | tail -5
journalctl -k --since "-7 days" --no-pager 2>/dev/null | grep -i "out of memory\|oom-kill" | tail -5
echo
echo "== Stromversorgung =="
if command -v vcgencmd >/dev/null; then vcgencmd get_throttled
elif [ -r /sys/devices/platform/soc/soc:firmware/get_throttled ]; then echo "throttled=$(cat /sys/devices/platform/soc/soc:firmware/get_throttled) (0 = gut)"
else echo "nicht auslesbar"; fi
echo
echo "== WLAN =="
iw dev wlan0 get power_save 2>/dev/null || echo "nicht auslesbar"
systemctl is-active wlan-waechter.timer 2>/dev/null | sed 's/^/Wächter: /'
journalctl -t wlan-waechter --since "-7 days" --no-pager 2>/dev/null | tail -5
echo
echo "== Letzte Systemstarts =="
last -x reboot 2>/dev/null | head -5
+26 -5
View File
@@ -14,10 +14,31 @@ services:
# Sommerzeit gälte damit zwischen 00:00 und 02:00 Ortszeit noch der # Sommerzeit gälte damit zwischen 00:00 und 02:00 Ortszeit noch der
# Vortag, und Termine von heute erschienen als überfällig. # Vortag, und Termine von heute erschienen als überfällig.
TZ: Europe/Berlin TZ: Europe/Berlin
restart: unless-stopped # Speicher-Wächter: darüber startet der Server sich selbst sauber neu,
# bevor die Grenze von 320 MB (mem_limit) erreicht ist.
SPEICHER_MAX_MB: "220"
# PDF-Kindprozess (Protokoll-Import, Plan Checker): bricht darüber ab.
PDF_KIND_MAX_MB: "220"
PDF_KIND_HEAP_MB: "160"
# Hänger-Wächter: reagiert der Server so lange nicht, Neustart.
HAENGER_MAX_S: "90"
restart: always
# Betrieb auf dem Pi Zero 2 (512 MB), siehe CLAUDE.md "Betrieb auf dem Pi":
# tini als PID 1 — Signale kommen sauber bei Node an (DB wird geschlossen).
init: true
# Feste Speichergrenze: Läuft die App über, trifft es nur diesen
# Container (restart: always holt ihn zurück), nicht SSH oder Docker.
mem_limit: 320m
memswap_limit: 512m
# Die Protokolle wuchsen sonst unbegrenzt auf der SD-Karte.
logging:
driver: json-file
options:
max-size: "5m"
max-file: "3"
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3003/health', r => r.statusCode === 200 ? process.exit(0) : process.exit(1)).on('error', () => process.exit(1))"] test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/health >/dev/null || exit 1"]
interval: 30s interval: 60s
timeout: 5s timeout: 10s
start_period: 10s start_period: 60s
retries: 3 retries: 3
+239
View File
@@ -0,0 +1,239 @@
// pdf-arbeit.js: das Lesen und Rendern von PDFs (pdfjs-dist, @napi-rs/canvas)
// für Protokoll-Import, "TODOs aus Protokoll" und Plan Checker. Aus server.js
// herausgelöst, damit es in einem EIGENEN Kindprozess läuft (pdfImKindprozess()
// in server.js): Ein großes PDF braucht schnell 100–200 MB; läuft der Speicher
// über, stirbt nur der Kindprozess, nicht der Server (Pi Zero 2, 512 MB).
//
// Doppelte Rolle: require() aus server.js liefert nur die Konstanten (die
// Bibliotheken werden erst beim Aufruf geladen); per child_process.fork()
// gestartet, nimmt es genau EINEN Auftrag über IPC entgegen, antwortet und
// beendet sich — jeder Auftrag beginnt mit leerem Speicher.
'use strict';
const path = require('path');
let pdfjsLibPromise = null;
function ladePdfjs(){
if(!pdfjsLibPromise) pdfjsLibPromise = import('pdfjs-dist/legacy/build/pdf.mjs');
return pdfjsLibPromise;
}
async function extrahierePdfText(buf){
const pdfjsLib = await ladePdfjs();
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf) });
try {
const doc = await loadingTask.promise;
let text = '';
for(let i = 1; i <= doc.numPages; i++){
const page = await doc.getPage(i);
const content = await page.getTextContent();
// Textfragmente stehen ohne Zeilenumbrüche in content.items — erkennePunkteAusText()
// braucht aber echte Zeilen (Punkt-Marker müssen am Zeilenanfang stehen). Ein
// Umbruch wird eingefügt, sobald sich die Y-Koordinate (transform[5]) ändert,
// exakt wie es das vormals genutzte pdf-parse intern selbst tat.
let lastY = null;
content.items.forEach(item => {
if(lastY !== null && item.transform[5] !== lastY) text += '\n';
else if(lastY !== null) text += ' ';
text += item.str;
lastY = item.transform[5];
});
text += '\n\n';
}
return text;
} finally {
loadingTask.destroy();
}
}
// ladeCanvas/renderPdfAlsBildsatz: fürs "Plan Checker"-Feature weiter unten
// (POST /plan-checker-pruefen, aufgerufen aus pruefePlanMitKI()) — rendert
// ausschließlich die ERSTE Seite eines PDFs zu einem Bildsatz (Übersicht +
// hochaufgelöste Kachel-Ausschnitte, s. renderPdfAlsBildsatz() dort), für
// BEIDE Anbieter gleich (siehe Projekthistorie bei pruefePlanMitClaude()
// unten — ein einzelnes, von uns oder vom Anbieter selbst erzeugtes
// Gesamtbild der Seite erwies sich für feine Maßzahlen/Plankopf-Text
// wiederholt als zu grob). Ein Planblatt pro Prüfung ist der Normalfall;
// mehrseitige PDFs mit mehreren Planblättern sind vorerst nicht vorgesehen.
let canvasLibPromise = null;
function ladeCanvas(){
if(!canvasLibPromise) canvasLibPromise = import('@napi-rs/canvas');
return canvasLibPromise;
}
// PDFJS_STANDARD_FONTS: Pfad zu den von pdfjs-dist mitgelieferten Standard-
// Schriftdaten (Foxit-Ersatzschriften für Helvetica/Times/Symbol/...), mit
// abschließendem Trenner wie von pdf.js intern per String-Konkatenation
// erwartet (`${standardFontDataUrl}${dateiname}`). OHNE diesen Parameter
// wirft pdf.js beim Rendern pro fehlender Schriftart die Warnung
// "UnknownErrorException: Ensure that the standardFontDataUrl API parameter
// is provided" und kann Zeichen nicht auf Glyphen abbilden — ein einfacher
// Testtext (reine Helvetica-Buchstaben) rendert trotzdem noch halbwegs über
// einen groben Ersatz, aber bei echten CAD-/Architektur-Export-PDFs
// (Revit/AutoCAD, oft mit eigens eingebetteten oder nicht eingebetteten
// Spezialschriften für Maßzahlen und Plankopf-Text) blieben genau diese
// Textstellen dadurch leer oder unleserlich — die Maßketten-LINIEN kamen an
// (reine Vektorgrafik, schriftunabhängig), die Zahlen und der Plankopf-Text
// nicht. Nur für renderPdfAlsBildsatz() relevant: extrahierePdfText()
// liest ausschließlich getTextContent() (die im PDF gespeicherten
// Zeichencodes), ohne je eine Glyphe zu rendern, und ist von fehlenden
// Schriftdaten nicht betroffen.
const PDFJS_STANDARD_FONTS = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts') + path.sep;
// PDFJS_WASM: Pfad zu den von pdfjs-dist mitgelieferten WASM-Modulen
// (u. a. jbig2.wasm, openjpeg.wasm), im selben Muster wie PDFJS_STANDARD_FONTS
// oben. OHNE diesen Parameter wirft pdf.js beim Laden eines WASM-Decoders
// "UnknownErrorException: Ensure that the wasmUrl API parameter is provided"
// und versucht dann, JBIG2-kodierte Bildinhalte (verbreitetes Kompressions-
// format für eingebettete Scan-/Rasterbilder, z. B. ein eingescanntes Logo im
// Plankopf) über einen Fallback-Pfad zu laden, der mangels `wasmUrl` zu einem
// kaputten Modulnamen zusammengesetzt wird ("nulljbig2_nowasm_fallback.js")
// und mit ERR_MODULE_NOT_FOUND scheitert — der betroffene Bildinhalt fehlt
// dadurch im gerenderten PNG, genau wie zuvor die Ebenen/Schriften.
const PDFJS_WASM = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'wasm') + path.sep;
// PLAN_CHECK_KACHEL_*: Rastergröße und Überlappung für renderPdfAlsBildsatz()
// unten. 2x2 (vier Kacheln) mit 12% Überlappung je Rand - genug
// Sicherheitsrand, falls ein Feld (z. B. der Plankopf) genau auf einer
// Kachelgrenze liegt, ohne die Zahl der pro Prüfung gesendeten Bilder
// (= Kosten/Dauer je Anbieter-Anfrage) unnötig in die Höhe zu treiben.
const PLAN_CHECK_KACHEL_SPALTEN = 2;
const PLAN_CHECK_KACHEL_ZEILEN = 2;
const PLAN_CHECK_KACHEL_UEBERLAPPUNG = 0.12;
const PLAN_CHECK_UEBERSICHT_BREITE = 2000;
const PLAN_CHECK_KACHEL_BREITE = 2200;
// renderPdfAlsBildsatz: löst die vorherige renderPdfErstePageAlsPng() ab, die
// die ganze Planseite als EIN einzelnes PNG rendert hat. Grund für die
// Umstellung: Nach den vorherigen Fixes (Ebenen/Schrift/WASM, s.o.) meldete
// Erik einen neuen, grundsätzlicheren Befund - der Plankopf wurde zwar jetzt
// gefunden und der Plantitel korrekt gelesen, aber Projektname,
// Planersteller, Maßstab, Datum und Plannummer blieben "in der vorliegenden
// Auflösung" nicht sämtlich lesbar. Das bestätigt in letzter Konsequenz den
// Verdacht, der schon zur vorherigen PDF-Direktübergabe an Claude geführt
// hatte (s. historischer Kommentar bei pruefePlanMitClaude() weiter unten):
// JEDER Anbieter skaliert ein übermitteltes Bild bzw. eine PDF-Seite intern
// auf eine eigene Obergrenze herunter, BEVOR das Modell draufschaut (bei
// Claude laut Dokumentation ca. 1568px lange Kante, bei OpenAI vergleichbar).
// Ein Plankopf ist nur ein kleiner Ausschnitt eines großen Planblatts - seine
// Feinschrift liegt nach dieser anbieterseitigen Herunterskalierung weit
// unter Lesbarkeit, UNABHÄNGIG davon, wie hoch WIR die Ausgangsauflösung
// wählen (mehr Pixel in der Quelle ändern nichts, wenn der Anbieter ohnehin
// wieder auf dieselbe Obergrenze herunterrechnet). Claudes PDF-Direktübergabe
// half laut Erik auch nicht durchgreifend genug - vermutlich, weil Anthropics
// eigene Dokumenten-Verarbeitung intern denselben Weg geht (PDF-Seite zu
// einem einzigen Bild, dann dieselbe Auflösungsgrenze).
//
// Der einzige verlässliche Ausweg: der KI ZUSÄTZLICH zur Übersicht mehrere,
// überlappende Ausschnitte der Seite in höherer Pixel-Dichte mitgeben, sodass
// Feinschrift gleich welcher Lage auf dem Blatt in mindestens einem Bild
// unterhalb der anbieterseitigen Herunterskalierungs-Schwelle bleibt - OHNE
// dafür die Lage des Plankopfs erkennen zu müssen (die variiert je
// Büro-Vorlage: beim getesteten Plan oben rechts, DIN-Konvention wäre unten
// rechts). Jede Kachel wird NICHT aus einem fertigen Raster-Bild
// zugeschnitten (das brächte keine zusätzliche Schärfe, nur eine vergrößerte
// Unschärfe), sondern pdf.js erneut mit einer höheren scale-Vorgabe gerendert
// und per Context-Transform (ctx.translate) auf den gewünschten Ausschnitt
// verschoben - der Ausschnitt entsteht also selbst aus dem Vektor-Original in
// höherer Auflösung, nicht durch Hochskalieren eines bereits gerenderten
// Bilds. Gilt jetzt für BEIDE Anbieter (Eriks ausdrückliche Entscheidung):
// Claude bekommt dieselben gerenderten Bilder wie ChatGPT statt wieder der
// rohen PDF, damit für beide dasselbe, selbst kontrollierte Verfahren gilt
// statt für Claude erneut auf unbekanntes anbieterinternes Verhalten zu
// vertrauen.
//
// Rückgabe: { uebersicht: Buffer, kacheln: Buffer[] } - je ein PNG-Buffer.
async function renderPdfAlsBildsatz(buf){
const pdfjsLib = await ladePdfjs();
const { createCanvas } = await ladeCanvas();
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf), standardFontDataUrl: PDFJS_STANDARD_FONTS, wasmUrl: PDFJS_WASM });
try {
const doc = await loadingTask.promise;
const page = await doc.getPage(1);
const basisViewport = page.getViewport({ scale: 1 });
// Alle optionalen Ebenen (PDF Optional Content Groups) erzwungen
// sichtbar schalten, BEVOR gerendert wird: CAD-/BIM-Exporte (AutoCAD,
// Revit, ...) legen technische Pläne sehr häufig in Ebenen an (z. B.
// "Architektur-Grundriss", "ELT-Planung", "Legende", "Plankopf") und
// markieren einen Teil davon im PDF selbst als standardmäßig AUS - oft
// bewusst so exportiert, damit jedes Gewerk seine eigene Ebene in
// Acrobat selbst einschaltet. Ohne diese Ergänzung rendert pdf.js exakt
// diesen Vorgabe-Zustand nach: bei einem echten Plan blieben dadurch nur
// der Architektur-Grundriss übrig, ELT-Planung, Legende UND Plankopf
// fehlten im gerenderten Bild komplett (von Erik direkt bestätigt, nach
// Ansehen des gespeicherten Debug-PNGs) - die KI konnte diese Merkmale
// also gar nicht als "nicht vorhanden" FALSCH einstufen, sie bekam sie
// schlicht nie zu sehen.
const ebenen = await doc.getOptionalContentConfig();
for(const [ebenenId] of ebenen){
ebenen.setVisibility(ebenenId, true);
}
// ausschnitt (optional): {x, y, breite, hoehe} in Geräte-Pixeln DIESES
// scale-Werts. Ohne ausschnitt wird die ganze Seite gerendert.
async function renderAusschnitt(scale, ausschnitt){
const viewport = page.getViewport({ scale });
const breite = ausschnitt ? Math.round(ausschnitt.breite) : Math.round(viewport.width);
const hoehe = ausschnitt ? Math.round(ausschnitt.hoehe) : Math.round(viewport.height);
const canvas = createCanvas(breite, hoehe);
const ctx = canvas.getContext('2d');
if(ausschnitt) ctx.translate(-ausschnitt.x, -ausschnitt.y);
await page.render({ canvasContext: ctx, viewport, optionalContentConfigPromise: Promise.resolve(ebenen) }).promise;
return canvas.toBuffer('image/png');
}
// Übersicht: die ganze Seite, moderate Auflösung - liefert der KI den
// Gesamtkontext (Art/Lage der Zeichnung), aber keine verlässliche
// Feinschrift.
const uebersicht = await renderAusschnitt(PLAN_CHECK_UEBERSICHT_BREITE / basisViewport.width, null);
// Kacheln: Raster mit Überlappung, JEDE Kachel mit eigener, hoher
// Skalierung gerendert (nicht aus der Übersicht zugeschnitten, s. o.).
const anteilBreite = (1 / PLAN_CHECK_KACHEL_SPALTEN) + PLAN_CHECK_KACHEL_UEBERLAPPUNG;
const kachelScale = PLAN_CHECK_KACHEL_BREITE / (basisViewport.width * anteilBreite);
const kachelViewport = page.getViewport({ scale: kachelScale });
const kacheln = [];
for(let zeile = 0; zeile < PLAN_CHECK_KACHEL_ZEILEN; zeile++){
for(let spalte = 0; spalte < PLAN_CHECK_KACHEL_SPALTEN; spalte++){
const startAnteilX = Math.max(0, spalte / PLAN_CHECK_KACHEL_SPALTEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2);
const startAnteilY = Math.max(0, zeile / PLAN_CHECK_KACHEL_ZEILEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2);
const endAnteilX = Math.min(1, (spalte + 1) / PLAN_CHECK_KACHEL_SPALTEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2);
const endAnteilY = Math.min(1, (zeile + 1) / PLAN_CHECK_KACHEL_ZEILEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2);
kacheln.push(await renderAusschnitt(kachelScale, {
x: startAnteilX * kachelViewport.width,
y: startAnteilY * kachelViewport.height,
breite: (endAnteilX - startAnteilX) * kachelViewport.width,
hoehe: (endAnteilY - startAnteilY) * kachelViewport.height
}));
}
}
return { uebersicht, kacheln };
} finally {
loadingTask.destroy();
}
}
module.exports = { PLAN_CHECK_KACHEL_SPALTEN, PLAN_CHECK_KACHEL_ZEILEN, extrahierePdfText, renderPdfAlsBildsatz };
// ---------- Kindprozess ----------
// Speicher-Wächter im Kind: Native Speicher (Canvas) begrenzt
// --max-old-space-size nicht; über PDF_KIND_MAX_MB beendet sich das Kind
// selbst, der Server meldet dann "PDF zu groß".
if(require.main === module && process.send){
const MAX_MB = Number(process.env.PDF_KIND_MAX_MB) || 260;
setInterval(() => {
if(process.memoryUsage().rss > MAX_MB * 1024 * 1024) process.exit(99);
}, 250).unref();
process.once('message', async msg => {
try {
const buf = Buffer.from(msg.daten);
const ergebnis = msg.art === 'text'
? await extrahierePdfText(buf)
: await renderPdfAlsBildsatz(buf);
process.send({ ok: true, ergebnis }, () => process.exit(0));
} catch(err){
process.send({ ok: false, fehler: String(err && err.message || err) }, () => process.exit(0));
}
});
}
+430 -327
View File
File diff suppressed because it is too large. Load diff
+400
View File
@@ -0,0 +1,400 @@
/* ===========================================================================
GLAS-DARSTELLUNG (Eriks Wahl: Gestaltungsvorschlag "Projektübersicht in
Glas", Annäherung an Apple Liquid Glass)
---------------------------------------------------------------------------
Eine eigene Schicht über styles.css und plan-styles.css, geladen als LETZTES
Stylesheet. Alles hängt an der Klasse html.glas, die ein kleines Skript im
<head> von App.html setzt — ohne sie sieht die App aus wie zuvor. Abschalten
lässt sie sich je Browser unter Einstellungen → Mein Konto ("Glas-
Darstellung", localStorage-Schlüssel projuebersicht-glas).
Prinzip:
- Tokens statt Einzelregeln: --surface & Co. werden durchscheinend, damit
wirken die ~70 Stellen, die var(--surface) als Fläche nutzen, ohne dass
jede einzeln angefasst wird.
- Echtes Glas (Unschärfe des Hintergrunds) nur auf den großen Flächen —
Menüband, Seitenleiste, Karten, Dialoge. Kleine Elemente bleiben ohne
backdrop-filter: verschachtelte Unschärfe kostet Rechenzeit und bringt
auf einer bereits gläsernen Karte nichts.
- Drei Dichten: Rahmen (Menüband/Seitenleiste) am dichtesten, Inhalt
mittel, Dialoge und Menüs fast deckend (sie liegen über Inhalt, der
sonst durchscheinen und den Text stören würde).
- Hintergrund: Lichtflächen in den Logo-Blautönen und das Logo als großes
Wasserzeichen (logo.svg, vektorisiert aus logo.png), beides fixiert.
- Die Wandtafel (body.wandtafel-aktiv) bleibt außen vor: eigenes Design,
und auf dem Kiosk-Rechner wäre Unschärfe zu teuer.
=========================================================================== */
/* ---------- Tokens: hell ---------- */
html.glas {
--bg: #e8eef3;
--surface: rgba(255,255,255,.58);
--surface-alt: rgba(255,255,255,.40);
--surface-sunk: rgba(15,90,133,.07);
--line: rgba(20,45,65,.16);
--line-soft: rgba(20,45,65,.09);
--accent: #0f5a85;
--accent-hover: #0d5079;
--accent-active: #0a4565;
--accent-soft: rgba(15,90,133,.11);
--band-neu: #0f5a85;
--text: #14212b;
--text-muted: #4b5d6b;
--text-subtle: #6f8291;
--r-md: 10px;
--r-lg: 18px;
--r-xl: 22px;
--sh-1: 0 8px 28px rgba(21,58,86,.10), 0 1px 4px rgba(21,58,86,.06);
--sh-2: 0 12px 34px rgba(21,58,86,.13), 0 2px 6px rgba(21,58,86,.07);
--sh-3: 0 20px 60px rgba(21,58,86,.20), 0 4px 14px rgba(21,58,86,.10);
--glas-rahmen: rgba(255,255,255,.62);
--glas-dicht: rgba(255,255,255,.86);
--glas-deckend: #f7fafc;
--glas-kante: rgba(255,255,255,.80);
--glas-kante-2: rgba(255,255,255,.35);
--glas-blur: 24px;
--glas-hell: #94b0cc;
--glas-tief: #116896;
--glas-logo: .16;
}
/* ---------- Tokens: dunkel (System und manuell) ---------- */
@media (prefers-color-scheme: dark) {
html.glas:not([data-theme="light"]) {
--bg: #0b141b;
--surface: rgba(30,46,60,.50);
--surface-alt: rgba(30,46,60,.34);
--surface-sunk: rgba(255,255,255,.06);
--line: rgba(255,255,255,.14);
--line-soft: rgba(255,255,255,.08);
--accent: #8cc9e6;
--accent-hover: #a3d4ec;
--accent-active: #6fb3d3;
--accent-soft: rgba(140,201,230,.15);
--band-neu: #116896;
--text: #e8f0f5;
--text-muted: #a9bccb;
--text-subtle: #7790a2;
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30);
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30);
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35);
--glas-rahmen: rgba(28,42,54,.58);
--glas-dicht: rgba(22,34,44,.90);
--glas-deckend: #15212b;
--glas-kante: rgba(255,255,255,.18);
--glas-kante-2: rgba(255,255,255,.07);
--glas-hell: #2c5675;
--glas-tief: #1d6f99;
--glas-logo: .10;
}
}
html.glas[data-theme="dark"] {
--bg: #0b141b;
--surface: rgba(30,46,60,.50);
--surface-alt: rgba(30,46,60,.34);
--surface-sunk: rgba(255,255,255,.06);
--line: rgba(255,255,255,.14);
--line-soft: rgba(255,255,255,.08);
--accent: #8cc9e6;
--accent-hover: #a3d4ec;
--accent-active: #6fb3d3;
--accent-soft: rgba(140,201,230,.15);
--band-neu: #116896;
--text: #e8f0f5;
--text-muted: #a9bccb;
--text-subtle: #7790a2;
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30);
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30);
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35);
--glas-rahmen: rgba(28,42,54,.58);
--glas-dicht: rgba(22,34,44,.90);
--glas-deckend: #15212b;
--glas-kante: rgba(255,255,255,.18);
--glas-kante-2: rgba(255,255,255,.07);
--glas-hell: #2c5675;
--glas-tief: #1d6f99;
--glas-logo: .10;
}
/* ---------- Hintergrund: Lichtflächen und Logo ---------- */
html.glas body {
background:
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%),
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%),
radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%),
radial-gradient(40% 40% at 28% 82%, color-mix(in srgb, var(--glas-hell) 42%, transparent), transparent 70%),
var(--bg);
background-attachment: fixed;
}
/* Wasserzeichen: fixiert, links unten, teils unter Seitenleiste und Karten —
dort schimmert es durch das Glas, daneben ist es klar zu sehen. */
html.glas body::before {
content: "";
position: fixed;
left: -3vw; bottom: -14vh;
width: min(1100px, 68vw); aspect-ratio: 256 / 216;
background: url("/logo.svg") no-repeat center / contain;
opacity: var(--glas-logo);
pointer-events: none;
z-index: 0;
}
html.glas .app-shell { position: relative; z-index: 1; }
html.glas body.wandtafel-aktiv::before { display: none; }
/* ---------- Das Material ---------- */
html.glas .app-topbar,
html.glas #headerMenu.side-menu,
html.glas .modal,
html.glas .user-menu-dropdown,
html.glas .user-menu,
html.glas .board-list,
html.glas .abw-team-kopf,
html.glas .report-card,
html.glas .load-card,
html.glas .settings-card,
html.glas .dash-karte,
html.glas .dash-band,
html.glas .bericht-panel,
html.glas .pc-panel,
html.glas .table-wrap,
html.glas .gantt-container,
html.glas .people-readonly,
html.glas .bubble-card,
html.glas .range-picker,
html.glas .sync-problem-bar,
html.glas .plan-header,
html.glas .plan-nav,
html.glas .plan-panel,
html.glas .plan-einst-nav,
html.glas .load-karte,
html.glas .plan-kpi-tile,
html.glas .plan-lage-band,
html.glas .plan-mini-card,
html.glas .protokoll-liste,
html.glas .protokoll-schreiben,
html.glas .plan-toast,
html.glas .glocke-menue,
html.glas .wiz {
background: var(--surface);
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%);
backdrop-filter: blur(var(--glas-blur)) saturate(170%);
border: 1px solid var(--glas-kante-2);
box-shadow: var(--sh-1), inset 0 1px 0 var(--glas-kante), inset 0 -1px 0 var(--glas-kante-2);
}
/* Rahmen: Menüband und Seitenleiste dichter als der Inhalt. */
html.glas .app-topbar {
background: var(--glas-rahmen);
border-width: 0 0 1px 0;
box-shadow: 0 1px 0 var(--glas-kante-2), 0 6px 24px rgba(21,58,86,.06);
}
html.glas .topbar-logo { height: 26px; }
/* Dialoge, Menüs, Hinweise: fast deckend — sie liegen über Inhalt, der sonst
durchscheint und den Text unruhig macht. */
html.glas .modal,
html.glas .user-menu-dropdown,
html.glas .user-menu,
html.glas .range-picker,
html.glas .plan-toast,
html.glas .glocke-menue,
html.glas .sync-problem-bar {
background: var(--glas-dicht);
box-shadow: var(--sh-3), inset 0 1px 0 var(--glas-kante);
}
html.glas .modal-bg {
background: rgba(15,35,50,.28);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
/* ---------- Seitenleiste: schwebende Glasfläche (Desktop) ---------- */
@media (min-width: 769px) {
html.glas #headerMenu.side-menu {
background: var(--glas-rahmen);
top: calc(52px + 12px);
height: calc(100vh - 52px - 24px);
margin: 12px 0 12px 12px;
border-radius: var(--r-xl);
border: 1px solid var(--glas-kante-2);
box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante);
overflow: hidden;
}
}
/* ---------- "Alles schwebt" (Eriks Wahl C, Desktop) ----------
Vorher stieß der eckige Projektkopf (.plan-header + .plan-nav, ein
durchgehender Streifen ohne Abstand) an die runde, schwebende
Seitenleiste. Jetzt schweben Menüband, Seitenleiste und Projektkopf mit
demselben Abstand (12px) und denselben Rundungen; der aktive Reiter ist
eine Kapsel wie der aktive Eintrag der Seitenleiste. */
@media (min-width: 769px) {
html.glas .app-topbar {
top: 12px;
margin: 12px 12px 0;
border-radius: var(--r-lg);
border: 1px solid var(--glas-kante-2);
box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante);
}
html.glas #headerMenu.side-menu {
top: calc(52px + 24px);
height: calc(100vh - 52px - 36px);
}
html.glas .plan-kopf {
margin: 12px 12px 0;
border-radius: var(--r-xl);
background: var(--glas-rahmen);
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%);
backdrop-filter: blur(var(--glas-blur)) saturate(170%);
border: 1px solid var(--glas-kante-2);
box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante);
padding: 4px 12px 10px;
}
/* Kopf und Reiter sind innerhalb der Karte keine eigenen Flächen mehr. */
html.glas .plan-kopf .plan-header,
html.glas .plan-kopf .plan-nav {
background: transparent;
-webkit-backdrop-filter: none;
backdrop-filter: none;
border: 0;
box-shadow: none;
}
/* Inhalt bündig unter der Kopfkarte und im selben 12px-Abstand. */
html.glas .plan-page .plan-main-content { padding-left: 12px; padding-right: 12px; padding-top: 12px; }
html.glas .plan-kopf .plan-header { padding: 10px 12px 6px; }
html.glas .plan-kopf .plan-nav { padding: 0; gap: 2px; }
html.glas .plan-kopf .plan-nav-item {
border-bottom: 0;
border-radius: 999px;
padding: 8px 16px;
font-size: 13.5px;
}
html.glas .plan-kopf .plan-nav-item:hover { background: var(--surface-sunk); }
html.glas .plan-kopf .plan-nav-item.is-active {
background: var(--glas-dicht);
color: var(--accent);
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10);
}
}
@media (min-width: 769px) and (prefers-color-scheme: dark) {
html.glas:not([data-theme="light"]) .plan-kopf .plan-nav-item.is-active { background: rgba(255,255,255,.10); }
}
@media (min-width: 769px) {
html.glas[data-theme="dark"] .plan-kopf .plan-nav-item.is-active { background: rgba(255,255,255,.10); }
}
/* Projektbereich: .plan-main hat in plan-styles.css einen deckenden
Grund (var(--bg)). Unter Glas verdeckte er die Lichtflächen und das
Logo-Wasserzeichen — im Projekt sah man nur flaches Dunkelblau bzw.
Grau, auf allen anderen Seiten den Glas-Hintergrund. */
html.glas .plan-main { background: transparent; }
html.glas .nav-item { border-radius: 999px; }
html.glas .nav-item:hover,
html.glas .nav-item:visited:hover { background: var(--surface-sunk); }
html.glas .nav-item.active {
background: var(--glas-dicht);
color: var(--text);
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10);
}
html.glas .nav-item.active .nav-icon { color: var(--accent); }
/* Die bestehenden Dunkel-Regeln für die Navigation setzen feste Werte —
hier auf die Glas-Tokens zurückführen. */
@media (prefers-color-scheme: dark) {
html.glas:not([data-theme="light"]) .nav-item:hover { background: var(--surface-sunk); }
html.glas:not([data-theme="light"]) .nav-item.active { background: rgba(255,255,255,.10); }
html.glas:not([data-theme="light"]) .app-topbar { background: var(--glas-rahmen); }
}
html.glas[data-theme="dark"] .nav-item:hover { background: var(--surface-sunk); }
html.glas[data-theme="dark"] .nav-item.active { background: rgba(255,255,255,.10); }
html.glas[data-theme="dark"] .app-topbar { background: var(--glas-rahmen); }
/* ---------- Bedienelemente ---------- */
/* Zweitknöpfe als klare Glaskapsel statt grauer Fläche. */
html.glas button.secondary {
background: var(--surface);
border: 1px solid var(--glas-kante-2);
box-shadow: inset 0 1px 0 var(--glas-kante);
color: var(--text);
}
html.glas button.secondary:hover { background: var(--glas-dicht); }
html.glas button:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent); }
/* Hauptknöpfe (ohne eigene Klasse bzw. .small) mit leichtem Glanz oben —
bewusst nicht jeder <button>: Kalendertage, Projektköpfe und Kacheln sind
ebenfalls Knöpfe und bekämen sonst eine helle Kante. */
html.glas button:not([class]),
html.glas button.small:not(.secondary):not(.danger) {
box-shadow: inset 0 1px 0 rgba(255,255,255,.28);
}
/* Text auf Akzentfläche: bisher var(--surface) (deckend weiß), jetzt
durchscheinend — feste Farbe je Thema statt Flächen-Token. */
html.glas .dash-kal-tag.ist-gewaehlt,
html.glas .pt-badge-person { color: #fff; }
@media (prefers-color-scheme: dark) {
html.glas:not([data-theme="light"]) .dash-kal-tag.ist-gewaehlt,
html.glas:not([data-theme="light"]) .pt-badge-person { color: #0b141b; }
}
html.glas[data-theme="dark"] .dash-kal-tag.ist-gewaehlt,
html.glas[data-theme="dark"] .pt-badge-person { color: #0b141b; }
html.glas input[type=text],
html.glas input[type=email],
html.glas input[type=password],
html.glas input[type=number],
html.glas input[type=date],
html.glas input[type=search],
html.glas select,
html.glas textarea {
background-color: var(--glas-dicht);
border-color: var(--line);
border-radius: 12px;
}
/* ---------- Kleine Korrekturen an durchscheinenden Flächen ---------- */
/* Sticky-Leisten bekamen bisher eine deckende Fläche; durchscheinend würde
der darunter wegscrollende Inhalt den Text überlagern. */
html.glas .bericht-rail { background: var(--glas-rahmen); }
/* GANTT: die feststehende Namensspalte muss deckend sein — beim seitlichen
Scrollen laufen die Balken darunter durch. */
/* Die feste Namensspalte des GANTT muss DECKEND sein: beim waagerechten
Scrollen laufen die Balken unter ihr durch, und mit dem halbdurchsichtigen
--glas-deckend (58 %) schienen sie durch die Namen hindurch (Eriks
Meldung). --gantt-label-grund ist derselbe Farbton, nur ohne Transparenz. */
html.glas { --gantt-label-grund: #f6f9fb; }
@media (prefers-color-scheme: dark) { html.glas:not([data-theme="light"]) { --gantt-label-grund: #15212b; } }
html.glas[data-theme="dark"] { --gantt-label-grund: #15212b; }
html.glas .gantt-label,
html.glas .gantt-row.gantt-header .gantt-label,
html.glas .gantt-row.gantt-days .gantt-label { background: var(--gantt-label-grund); }
/* Login: derselbe Hintergrund wie die App, aber deckend — darunter liegt
die (noch leere) App-Oberfläche, die nicht durchscheinen darf. */
html.glas .login-seite {
background:
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%),
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%),
radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%),
var(--bg);
}
/* ---------- Ohne Glas: Wandtafel, reduzierte Transparenz ---------- */
html.glas body.wandtafel-aktiv * {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
@media (prefers-reduced-transparency: reduce) {
html.glas body::before { display: none; }
html.glas * { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
html.glas {
--surface: #ffffff; --surface-alt: #fafcfd; --glas-rahmen: #f7f9fb; --glas-dicht: #ffffff;
}
}
/* Schalter in Einstellungen → Mein Konto (gilt auch ohne html.glas). */
.settings-glas-schalter { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
/* Mit Id-Gewicht, weil eine spätere Regel die Aufgabenzeilen wieder auf das
halbdurchsichtige --surface setzte. Getönte Zeilen (Phasen, Projekt)
legen ihre Tönung als background-image darüber (plan-styles.css). */
html.glas #planGanttContainer .gantt-row .gantt-label { background-color: var(--gantt-label-grund); }
+430
View File
@@ -0,0 +1,430 @@
// js/abwesenheit/abwesenheit.js — Bereich „Abwesenheiten“ (Tab abwesenheit,
// Eriks Wahl „Entwurf A“): oben die Schnellerfassung, darunter die Liste der
// gewählten Person, unten die Team-Zeitleiste eines Monats.
//
// Rechte (Rollen & Rechte, Gruppe „Team“): team.abw (für mich),
// team.abwProjekte, team.abwGewerk, team.abwAlle. Der Server rechnet daraus
// je Person `darfAbw` (loadGlobalPeople(req.user)) und schickt Abwesenheiten
// ANDERER Personen nur als „abwesend“ ohne Art und Notiz (`verdeckt`) — Art
// und Notiz sieht nur die Person selbst und der Administrator. Verdeckte Einträge lassen sich
// deshalb nicht ändern (die Art ist unbekannt), nur löschen. Diese Datei
// verlässt sich darauf, entscheidet selbst also nichts über Rechte.
//
// Daten: db.globalPeople (aus /load-all bzw. GET /personen). Nach jedem
// Speichern lädt personenNachladen() (js/store.js) die Liste neu; andere
// Browser bekommen das Live-Ereignis {typ:'personen'}.
const ABW_ARTEN = ['Urlaub', 'Krank', 'Fortbildung', 'Sonstiges'];
const ABW_KLASSE = { Urlaub: 'abw-u', Krank: 'abw-k', Fortbildung: 'abw-f', Sonstiges: 'abw-s' };
let abwMonat = null; // Date: erster Tag des angezeigten Monats
let abwFilter = 'Alle'; // Gewerk-Filter der Zeitleiste
let abwFuer = null; // userId der Person, für die eingetragen wird
let abwBearbeiten = null; // id der Abwesenheit, die gerade geändert wird
let abwVergangen = false; // Liste: vergangene statt kommende
let abwLoeschFrage = null; // id, deren Löschen gerade bestätigt werden soll
let abwFehler = '';
let abwEntwurf = { start: '', end: '', type: 'Urlaub', note: '' };
// darfIrgendeineAbwesenheit: ob die Rolle überhaupt Abwesenheiten eintragen
// darf (Menüpunkt sichtbar). Rückgabe: Boolean.
function darfIrgendeineAbwesenheit(){
return ['team.abw', 'team.abwProjekte', 'team.abwGewerk', 'team.abwAlle'].some(r => hatRecht(r));
}
function abwPersonen(){ return Array.isArray(db.globalPeople) ? db.globalPeople : []; }
function abwIch(){ return abwPersonen().find(p => currentUser && p.userId === currentUser.id) || null; }
function abwPerson(userId){ return abwPersonen().find(p => p.userId === userId) || null; }
function abwName(p){ return p ? (p.fullName || p.name || '') : ''; }
// abwDatum: "2026-11-02" -> "02.11." (mit Jahr, wenn mitJahr). Rückgabe: String.
function abwDatum(iso, mitJahr){
const d = parseDateISO(iso);
if(!d) return '';
const s = String(d.getDate()).padStart(2, '0') + '.' + String(d.getMonth() + 1).padStart(2, '0') + '.';
return mitJahr ? s + d.getFullYear() : s;
}
// abwZeitraum: lesbarer Zeitraum einer Abwesenheit.
function abwZeitraum(a){
if(a.start === a.end) return abwDatum(a.start, true);
const gleichesJahr = a.start.slice(0, 4) === a.end.slice(0, 4);
return abwDatum(a.start, !gleichesJahr) + ' – ' + abwDatum(a.end, true);
}
function abwArbeitstage(start, end){
const a = parseDateISO(start), b = parseDateISO(end);
return (a && b) ? workdaysBetween(a, b) : 0;
}
function abwUeberlappt(a1, e1, a2, e2){ return a1 <= e2 && a2 <= e1; }
// ---------- Rendern ----------
// Gestaltung „Variante 2 im Stil der Projektübersicht“ (Eriks Wahl): dieselben
// Bausteine wie die Projektliste — Titelzeile mit Legende (.legend), Glas-
// Karten (.board-list), Zeilen (.board-row/.board-row-main, .board-nr,
// .board-name, .board-status-line, .phase-pill, .board-row-actions) und
// Kürzel-Kreise in Fachrollen-Farbe (.board-avatar.role-*). Links „Neue
// Abwesenheit“, rechts die Liste der gewählten Person, darunter das Team je
// Gewerk in einer eigenen Karte.
const ABW_WOCHENTAG = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const ABW_WOCHENTAG_LANG = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
// abwRollen: alle Fachrollen der Person in der festen Reihenfolge von ROLES
// (die gespeicherte Reihenfolge ist je Konto zufällig). Rückgabe: Array.
function abwRollen(p){
const eigene = Array.isArray(p && p.role) ? p.role : [];
return ROLES.filter(r => eigene.includes(r)).concat(eigene.filter(r => !ROLES.includes(r)));
}
// abwKreis: Kürzel-Kreis wie in der Projektliste, Farbe nach der ersten
// Fachrolle der Person (Reihenfolge ROLES). Rückgabe: HTML.
function abwKreis(p){
if(!p) return '';
const rollen = abwRollen(p);
const rolle = rollen.length ? roleClass(rollen[0]) : '';
return `<span class="board-avatar ${rolle} state-1 abw-kreis" title="${esc(abwName(p))}">${esc(p.name || '')}</span>`;
}
function renderAbwesenheiten(){
const el = document.getElementById('abwesenheit');
if(!el) return;
if(!darfIrgendeineAbwesenheit()){
el.innerHTML = '<div class="section-title">Abwesenheiten</div><p class="hint">Deine Rolle darf keine Abwesenheiten eintragen.</p>';
return;
}
if(!abwMonat){ const h = new Date(); abwMonat = new Date(h.getFullYear(), h.getMonth(), 1); }
if(abwFuer === null || !abwPerson(abwFuer) || !abwPerson(abwFuer).darfAbw) abwFuer = abwStandardFuer();
const erlaubte = abwPersonen().filter(p => p.darfAbw);
el.innerHTML = `
<div class="abw-zwei">
<div class="abw-spalte">${abwFuer !== null ? abwFormularHtml(erlaubte) : abwOhnePersonHtml()}</div>
<div class="abw-spalte">${abwFuer !== null ? abwListeHtml() : ''}</div>
</div>
${abwTeamHtml()}`;
}
// abwStandardFuer: wer in der Erfassung vorausgewählt ist — immer die Person,
// die die Seite öffnet; hat ihr Konto keine Person (bzw. kein Recht für sich),
// die erste Person, für die sie eintragen darf. Rückgabe: userId oder null.
function abwStandardFuer(){
const ich = abwIch();
if(ich && ich.darfAbw) return ich.userId;
const erste = abwPersonen().filter(p => p.darfAbw).sort((a, b) => abwName(a).localeCompare(abwName(b), 'de'))[0];
return erste ? erste.userId : null;
}
// abwBeimOeffnen: beim Betreten des Bereichs (showTab) — Erfassung leer, für
// die eigene Person.
function abwBeimOeffnen(){
abwFuer = abwStandardFuer(); abwBearbeiten = null; abwLoeschFrage = null; abwFehler = '';
abwEntwurf = { start: '', end: '', type: 'Urlaub', note: '' };
}
// abwOhnePersonHtml: Platzhalter-Karte, wenn niemand wählbar ist.
function abwOhnePersonHtml(){
return `<section class="board-list abw-karte"><div class="section-title abw-karten-titel">Neue Abwesenheit</div><p class="hint" style="margin:0">Für dein Konto lässt sich keine Abwesenheit eintragen: Es ist keiner Person zugeordnet (Person-ID fehlt). Ein Administrator kann das in der Benutzerverwaltung nachtragen.</p></section>`;
}
// abwFormularHtml: „Neue Abwesenheit“ (bzw. „Abwesenheit ändern“) als Karte.
function abwFormularHtml(erlaubte){
const p = abwPerson(abwFuer);
const e = abwEntwurf;
const ich = abwIch();
const istIch = p === ich;
const fuerAuswahl = erlaubte.length > 1 && !abwBearbeiten
? `<select id="abwFuer" class="abw-fuer-wahl" onchange="abwFuerWaehlen(this.value)" aria-label="Für wen">
${erlaubte.slice().sort((a, b) => (a === ich ? -1 : b === ich ? 1 : abwName(a).localeCompare(abwName(b), 'de')))
.map(x => `<option value="${x.userId}" ${x.userId === abwFuer ? 'selected' : ''}>${esc(x === ich ? 'Für mich' : abwName(x))}</option>`).join('')}
</select>`
: '';
const kachel = (titel, iso, unter) => {
const d = parseDateISO(iso);
return `<button type="button" class="abw-datum" onclick="abwZeitraumOeffnen(this)">
<small>${titel}</small>
<b>${d ? abwDatum(iso) : '–'}</b>
<span>${d ? ABW_WOCHENTAG_LANG[d.getDay()] + (unter || '') : 'Datum wählen'}</span>
</button>`;
};
const tage = e.start ? abwArbeitstage(e.start, e.end) : 0;
const warnungen = e.start ? abwWarnungen(p, e.start, e.end) : [];
return `<section class="board-list abw-karte" aria-label="Abwesenheit eintragen">
<div class="section-title abw-karten-titel">${abwBearbeiten ? 'Abwesenheit ändern' : 'Neue Abwesenheit'}</div>
<div class="abw-fuer">
${abwKreis(p)}
<span><b>${istIch ? 'Für mich' : 'Für ' + esc(abwName(p))}</b>${istIch ? ' · ' + esc(abwName(p)) : (p && p.gewerk ? ' · ' + esc(p.gewerk) : '')}</span>
${fuerAuswahl}
</div>
<div class="abw-datumzeile">
${kachel('Von', e.start, '')}
<span class="abw-pfeil" aria-hidden="true">→</span>
${kachel('Bis', e.end || e.start, e.start ? ' · ' + tage + (tage === 1 ? ' Arbeitstag' : ' Arbeitstage') : '')}
</div>
<input type="hidden" id="abwStart" value="${esc(e.start)}">
<input type="hidden" id="abwEnde" value="${esc(e.end)}">
<div class="abw-arten" role="group" aria-label="Art">
${ABW_ARTEN.map(a => `<button type="button" class="${ABW_KLASSE[a]}${a === e.type ? ' ist-an' : ''}" aria-pressed="${a === e.type}" onclick="abwArtWaehlen('${a}')"><i></i>${a}</button>`).join('')}
</div>
<div class="abw-unten">
<input type="text" id="abwNotiz" class="abw-notiz" placeholder="Notiz (optional)" value="${esc(e.note)}" oninput="abwEntwurf.note = this.value">
${abwBearbeiten ? '<button type="button" class="secondary small" onclick="abwAbbrechen()">Abbrechen</button>' : ''}
<button type="button" onclick="abwSpeichern()">${abwBearbeiten ? 'Speichern' : 'Eintragen'}</button>
</div>
${abwFehler ? `<div class="abw-fehler" role="alert">${esc(abwFehler)}</div>` : ''}
${warnungen.map(w => `<div class="abw-warnung">${w}</div>`).join('')}
</section>`;
}
// abwWarnungen: Hinweise zum gewählten Zeitraum — Überschneidung mit einer
// eigenen Abwesenheit der Person, ihre offenen Aufgaben im Zeitraum und
// Kollegen desselben Gewerks, die ebenfalls fehlen. Rückgabe: HTML-Strings.
function abwWarnungen(p, start, end){
if(!p) return [];
const w = [];
const doppelt = (p.absences || []).filter(a => a.id !== abwBearbeiten && abwUeberlappt(start, end, a.start, a.end));
if(doppelt.length) w.push('Überschneidet sich mit ' + doppelt.map(a => esc((a.verdeckt ? 'abwesend' : a.type) + ' ' + abwZeitraum(a))).join(', ') + '.');
const wk = typeof currentWeek === 'function' ? currentWeek() : null;
const aufgaben = [];
((wk && wk.projects) || []).forEach(pr => {
((pr.plan && pr.plan.todos) || []).forEach(t => {
if(t.done || !Array.isArray(t.assignees) || !t.assignees.includes(p.id)) return;
const tEnde = t.end || t.start;
if(!tEnde) return;
if(abwUeberlappt(start, end, t.start || tEnde, tEnde)) aufgaben.push(`„${esc(t.title || 'Aufgabe')}“ (${esc(pr.name || '')}, ${abwDatum(tEnde)})`);
});
});
if(aufgaben.length){
const zuviel = aufgaben.length - 3;
w.push(`In diesen Zeitraum ${aufgaben.length === 1 ? 'fällt 1 offene Aufgabe' : 'fallen ' + aufgaben.length + ' offene Aufgaben'}: ${aufgaben.slice(0, 3).join(', ')}${zuviel > 0 ? ' und ' + zuviel + ' weitere' : ''}.`);
}
if(p.gewerk){
const andere = abwPersonen().filter(x => x !== p && x.gewerk === p.gewerk
&& (x.absences || []).some(a => abwUeberlappt(start, end, a.start, a.end)));
if(andere.length) w.push(`${andere.map(x => esc(abwName(x))).join(', ')} (${esc(p.gewerk)}) ${andere.length === 1 ? 'ist' : 'sind'} in dieser Zeit ebenfalls weg.`);
}
return w;
}
// abwListeHtml: Abwesenheiten der gewählten Person als Zeilen wie in der
// Projektliste — Startdatum in der Nummernspalte, Kalenderwoche als Kapsel.
function abwListeHtml(){
const p = abwPerson(abwFuer);
if(!p) return '';
const heute = heuteIso();
const alle = (p.absences || []).slice().sort((a, b) => a.start.localeCompare(b.start));
const kommend = alle.filter(a => a.end >= heute);
const vergangen = alle.filter(a => a.end < heute).reverse();
const liste = abwVergangen ? vergangen : kommend;
const titel = p === abwIch() ? 'Meine Abwesenheiten' : 'Abwesenheiten von ' + abwName(p);
const zeilen = liste.map(a => {
const tage = abwArbeitstage(a.start, a.end);
const ds = parseDateISO(a.start), de = parseDateISO(a.end);
const kwA = getWeekNumber(ds), kwE = getWeekNumber(de);
const zr = ABW_WOCHENTAG[ds.getDay()] + ' ' + abwDatum(a.start, a.start.slice(0, 4) !== a.end.slice(0, 4))
+ (a.start === a.end ? a.start.slice(0, 4) : ' – ' + ABW_WOCHENTAG[de.getDay()] + ' ' + abwDatum(a.end, true));
const fragt = abwLoeschFrage === a.id;
const verdeckt = !!a.verdeckt;
const aendern = verdeckt ? '' : ` onclick="abwAendern('${esc(a.id)}')"`;
return `<div class="board-row abw-zeile${abwBearbeiten === a.id ? ' ist-aktiv' : ''} ${verdeckt ? 'abw-verdeckt-zeile' : (ABW_KLASSE[a.type] || 'abw-s')}">
<div class="board-row-main"${aendern}>
<span class="board-nr">${abwDatum(a.start)}</span>
<div class="board-name-wrap">
<span class="board-name abw-zeile-name"><i class="abw-punkt"></i>${verdeckt ? 'abwesend' : esc(a.type)}<span class="abw-tag">${tage} ${tage === 1 ? 'Arbeitstag' : 'Arbeitstage'}</span></span>
<div class="board-status-line">${esc(zr)}${!verdeckt && a.note ? ' · ' + esc(a.note) : ''}</div>
</div>
<div class="board-row-actions${fragt ? ' abw-fragt' : ''}" onclick="event.stopPropagation()">
${verdeckt ? '' : `<button type="button" class="secondary small" onclick="abwAendern('${esc(a.id)}')">Ändern</button>`}
<button type="button" class="danger small" onclick="abwLoeschen('${esc(a.id)}')">${fragt ? 'Wirklich löschen?' : 'Löschen'}</button>
</div>
<span class="phase-pill">KW ${kwA}${kwE !== kwA ? '–' + kwE : ''}</span>
</div>
</div>`;
}).join('');
return `<section class="board-list abw-listen-karte">
<div class="abw-listen-kopf">
<div class="section-title abw-karten-titel">${esc(titel)}</div>
<span class="abw-reiter" role="group">
<button type="button" class="${abwVergangen ? '' : 'ist-an'}" onclick="abwVergangenZeigen(false)">Kommende ${kommend.length}</button>
<button type="button" class="${abwVergangen ? 'ist-an' : ''}" onclick="abwVergangenZeigen(true)">Vergangene ${vergangen.length}</button>
</span>
</div>
<div class="abw-listen-zeilen">${zeilen || `<div class="abw-leer">${abwVergangen ? 'Keine vergangenen Abwesenheiten.' : 'Keine kommenden Abwesenheiten.'}</div>`}</div>
</section>`;
}
// abwTeamHtml: Kopf des Team-Monats und je Gewerk eine Karte (wie die
// Projektgruppen), Personen ohne Gewerk zuletzt.
function abwTeamHtml(){
const monatName = abwMonat.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
const gewerke = Object.keys(DEPARTMENTS);
const sichtbar = abwFilter === 'Alle' ? gewerke.concat(['']) : [abwFilter];
const karten = sichtbar.map(g => {
const ps = abwPersonen().filter(p => (p.gewerk || '') === g);
return ps.length ? abwGewerkKarteHtml(g, ps) : '';
}).join('');
return `<div class="bericht-titelzeile abw-team-kopf">
<div class="section-title" style="margin:0">Team im ${esc(abwMonat.toLocaleDateString('de-DE', { month: 'long' }))}</div>
<span class="abw-team-steuer">
<span class="abw-monat">
<button type="button" class="abw-rund" onclick="abwMonatWechseln(-1)" aria-label="Voriger Monat">‹</button>
<strong>${esc(monatName)}</strong>
<button type="button" class="abw-rund" onclick="abwMonatWechseln(1)" aria-label="Nächster Monat">›</button>
<button type="button" class="secondary small" onclick="abwMonatWechseln(0)">Heute</button>
</span>
<span class="abw-reiter" role="group" aria-label="Gewerk">${['Alle'].concat(gewerke).map(g => `<button type="button" class="${g === abwFilter ? 'ist-an' : ''}" onclick="abwFilterSetzen('${g}')">${g}</button>`).join('')}</span>
</span>
</div>
${karten || '<p class="hint">Keine Personen in diesem Gewerk.</p>'}`;
}
// abwGewerkKarteHtml: eine Karte je Gewerk — Tageskopf, dann eine Zeile je
// Person mit Kürzel-Kreis und je Abwesenheit ein Balken.
function abwGewerkKarteHtml(gewerk, personen){
const jahr = abwMonat.getFullYear(), monat = abwMonat.getMonth();
const tage = new Date(jahr, monat + 1, 0).getDate();
const heute = heuteIso();
const ich = abwIch();
const iso = d => jahr + '-' + String(monat + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0');
const ersterIso = iso(1), letzterIso = iso(tage);
const sortiert = personen.slice().sort((a, b) => (a === ich ? -1 : b === ich ? 1 : abwName(a).localeCompare(abwName(b), 'de')));
const darfIrgendwen = sortiert.some(p => p.darfAbw);
let h = `<div class="abw-tl-wer abw-tl-kopf"><small>${sortiert.length} ${sortiert.length === 1 ? 'Person' : 'Personen'}</small></div>`;
for(let d = 1; d <= tage; d++){
const dt = new Date(jahr, monat, d);
h += `<div class="abw-tl-tag${!isWorkday(dt) ? ' ist-frei' : ''}${iso(d) === heute ? ' ist-heute' : ''}"><small>${ABW_WOCHENTAG[dt.getDay()]}</small><b>${d}</b></div>`;
}
sortiert.forEach(p => {
const sichtbar = (p.absences || []).filter(a => abwUeberlappt(a.start, a.end, ersterIso, letzterIso));
let tageImMonat = 0;
if(p.darfAbw) sichtbar.forEach(a => {
tageImMonat += abwArbeitstage(a.start < ersterIso ? ersterIso : a.start, a.end > letzterIso ? letzterIso : a.end);
});
const rolle = abwRollen(p).join(', ');
const meta = p === ich ? 'Ich' + (rolle ? ' · ' + rolle : '') : (p.darfAbw ? (rolle || 'ohne Fachrolle') : 'nur „abwesend“');
const waehlen = p.darfAbw ? ` ist-klickbar" role="button" tabindex="0" title="Abwesenheiten von ${esc(abwName(p))} anzeigen und eintragen" onclick="abwPersonAusTabelle(${p.userId})" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();abwPersonAusTabelle(${p.userId})}` : '';
h += `<div class="abw-tl-wer${p.userId === abwFuer ? ' ist-gewaehlt' : ''}${waehlen}">
${abwKreis(p)}
<span class="abw-tl-wer-text"><span class="abw-tl-wer-name">${esc(abwName(p))}</span>
<span class="abw-tl-wer-meta" title="${esc(meta)}">${esc(meta)}${tageImMonat ? ' · ' + tageImMonat + (tageImMonat === 1 ? ' Tag' : ' Tage') + ' im ' + abwMonat.toLocaleDateString('de-DE', { month: 'short' }).replace('.', '') + '.' : ''}</span></span>
</div>`;
for(let d = 1; d <= tage; d++){
const dt = new Date(jahr, monat, d);
const tagIso = iso(d);
let balken = '';
sichtbar.forEach(a => {
const beginn = a.start < ersterIso ? ersterIso : a.start;
if(beginn !== tagIso) return;
const ende = a.end > letzterIso ? letzterIso : a.end;
const len = Number(ende.slice(8)) - d + 1;
const verdeckt = a.verdeckt || !p.darfAbw;
const text = verdeckt ? 'abwesend' : a.type;
const titel = abwName(p) + ': ' + text + ' ' + abwZeitraum(a) + (!verdeckt && a.note ? ' — ' + a.note : '');
balken += `<span class="abw-balken ${verdeckt ? 'abw-verdeckt' : (ABW_KLASSE[a.type] || 'abw-s')}" style="width:calc(${len * 100}% - 4px)" title="${esc(titel)}"
${!verdeckt ? `onclick="event.stopPropagation(); abwAendernFuer(${p.userId}, '${esc(a.id)}')"` : ''}>${len > 1 && !verdeckt ? esc(text) : ''}</span>`;
});
const klick = p.darfAbw ? ` onclick="abwTagGewaehlt(${p.userId}, '${tagIso}')"` : '';
h += `<div class="abw-tl-zelle${!isWorkday(dt) ? ' ist-frei' : ''}${tagIso === heute ? ' ist-heute' : ''}${p.darfAbw ? ' ist-klickbar' : ''}"${klick}>${balken}</div>`;
}
});
return `<div class="abw-gewerk-kopf"><div class="section-title" style="margin:0">${esc(gewerk || 'Ohne Gewerk')}</div>${darfIrgendwen ? '' : '<small>nur Ansicht</small>'}</div>
<div class="board-list abw-tl-karte">
<div class="abw-tl" style="grid-template-columns: 230px repeat(${tage}, minmax(22px, 1fr))">${h}</div>
</div>`;
}
// ---------- Bedienung ----------
function abwFuerWaehlen(userId){
abwFuer = Number(userId);
abwLoeschFrage = null; abwFehler = '';
renderAbwesenheiten();
}
// abwPersonAusTabelle: Klick auf eine Person in der Team-Tabelle — sie wird
// oben in Erfassung und Liste gewählt (eine laufende Änderung wird verworfen).
// Die Seite bleibt, wo sie ist (Eriks Wunsch, kein Springen nach oben).
function abwPersonAusTabelle(userId){
abwBearbeiten = null;
abwEntwurf = { start: '', end: '', type: abwEntwurf.type || 'Urlaub', note: '' };
abwFuerWaehlen(userId);
}
// abwZeitraumOeffnen: Von- oder Bis-Kachel öffnet den Bereichs-Kalender.
function abwZeitraumOeffnen(anker){
openRangePicker({ anchor: anker, startInputId: 'abwStart', endInputId: 'abwEnde', onApply: abwZeitraumGewaehlt });
}
function abwArtWaehlen(art){ abwEntwurf.type = art; renderAbwesenheiten(); }
function abwZeitraumGewaehlt(){
abwEntwurf.start = document.getElementById('abwStart').value;
abwEntwurf.end = document.getElementById('abwEnde').value || abwEntwurf.start;
abwFehler = '';
renderAbwesenheiten();
}
function abwVergangenZeigen(v){ abwVergangen = !!v; abwLoeschFrage = null; renderAbwesenheiten(); }
function abwFilterSetzen(g){ abwFilter = g; renderAbwesenheiten(); }
function abwMonatWechseln(n){
if(n === 0){ const h = new Date(); abwMonat = new Date(h.getFullYear(), h.getMonth(), 1); }
else abwMonat = new Date(abwMonat.getFullYear(), abwMonat.getMonth() + n, 1);
renderAbwesenheiten();
}
// abwTagGewaehlt: Klick auf einen leeren Tag der Zeitleiste — Schnellerfassung
// für diese Person und diesen Tag vorbelegen.
function abwTagGewaehlt(userId, tagIso){
abwFuer = userId; abwBearbeiten = null; abwLoeschFrage = null; abwFehler = '';
abwEntwurf = { start: tagIso, end: tagIso, type: abwEntwurf.type || 'Urlaub', note: '' };
renderAbwesenheiten();
const f = document.querySelector('#abwesenheit .abw-datum');
if(f){ f.scrollIntoView({ block: 'center', behavior: 'smooth' }); }
}
function abwAendernFuer(userId, id){ abwFuer = userId; abwAendern(id); }
function abwAendern(id){
const p = abwPerson(abwFuer);
const a = p && (p.absences || []).find(x => x.id === id);
if(!a || a.verdeckt) return;
abwBearbeiten = id; abwLoeschFrage = null; abwFehler = '';
abwEntwurf = { start: a.start, end: a.end, type: ABW_ARTEN.includes(a.type) ? a.type : 'Sonstiges', note: a.note || '' };
renderAbwesenheiten();
const f = document.querySelector('#abwesenheit .abw-datum');
if(f) f.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
function abwAbbrechen(){
abwBearbeiten = null; abwFehler = ''; abwFuer = abwStandardFuer();
abwEntwurf = { start: '', end: '', type: 'Urlaub', note: '' };
renderAbwesenheiten();
}
// abwAnfrage: Aufruf der Abwesenheits-Routen. Rückgabe: Promise<{ok, json}>.
function abwAnfrage(methode, pfad, daten){
return fetch(baseUrl() + pfad, {
method: methode,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: daten ? JSON.stringify(daten) : undefined
}).then(r => r.json().catch(() => ({})).then(json => ({ ok: r.ok && json && json.ok, json })));
}
function abwSpeichern(){
const e = abwEntwurf;
e.note = ((document.getElementById('abwNotiz') || {}).value || '').trim();
if(!e.start){ abwFehler = 'Bitte einen Zeitraum wählen.'; renderAbwesenheiten(); return; }
const pfad = '/users/' + abwFuer + '/absences' + (abwBearbeiten ? '/' + encodeURIComponent(abwBearbeiten) : '');
abwAnfrage(abwBearbeiten ? 'PATCH' : 'POST', pfad, { start: e.start, end: e.end || e.start, type: e.type, note: e.note })
.then(({ ok, json }) => {
if(!ok){ abwFehler = (json && json.error) || 'Die Abwesenheit konnte nicht gespeichert werden.'; renderAbwesenheiten(); return; }
abwBearbeiten = null; abwFehler = ''; abwFuer = abwStandardFuer();
abwEntwurf = { start: '', end: '', type: 'Urlaub', note: '' };
return personenNachladen().then(renderAbwesenheiten);
})
.catch(() => { abwFehler = 'Keine Verbindung zum Server.'; renderAbwesenheiten(); });
}
// abwLoeschen: zweistufig im Knopf („Löschen“ → „Wirklich löschen?“).
function abwLoeschen(id){
if(abwLoeschFrage !== id){ abwLoeschFrage = id; renderAbwesenheiten(); return; }
abwLoeschFrage = null;
abwAnfrage('DELETE', '/users/' + abwFuer + '/absences/' + encodeURIComponent(id))
.then(({ ok, json }) => {
if(!ok){ abwFehler = (json && json.error) || 'Die Abwesenheit konnte nicht gelöscht werden.'; renderAbwesenheiten(); return; }
if(abwBearbeiten === id) abwAbbrechen();
return personenNachladen().then(renderAbwesenheiten);
})
.catch(() => { abwFehler = 'Keine Verbindung zum Server.'; renderAbwesenheiten(); });
}
+52 -4
View File
@@ -73,13 +73,44 @@
return Array.from(document.querySelectorAll('#' + containerId + ' input[type="checkbox"]:checked')).map(cb => cb.value); return Array.from(document.querySelectorAll('#' + containerId + ' input[type="checkbox"]:checked')).map(cb => cb.value);
} }
// systemRollen: die Rollen aus „Rollen & Rechte“ (GET /rollen) für die
// Auswahl „Rolle“ und die Anzeige in der Tabelle.
let systemRollen = [];
function ladeSystemRollen(){
fetch(baseUrl() + '/rollen')
.then(r => r.json())
.then(j => {
if(!j || !j.ok) return;
systemRollen = j.rollen || [];
['newUserRole', 'editUserRole'].forEach(id => {
const sel = document.getElementById(id);
if(!sel) return;
const wert = sel.value;
sel.innerHTML = systemRollen.map(r => `<option value="${esc(r.key)}">${esc(r.name)}${nurWandtafelRolle(r.key) ? ' (nur Wandtafel)' : ''}</option>`).join('');
if(wert && systemRollen.some(r => r.key === wert)) sel.value = wert;
});
loadAndRenderUsers();
})
.catch(err => console.error('Rollen laden fehlgeschlagen:', err));
}
// nurWandtafelRolle: Rolle ohne „Anwendung nutzen“ — sieht nur die
// Wandtafel und trägt keine Fachrolle (früher das Kiosk-Konto).
function nurWandtafelRolle(key){
const r = systemRollen.find(x => x.key === key);
return !!r && !r.fest && !(r.rechte || []).includes('app');
}
function rollenName(key){
const r = systemRollen.find(x => x.key === key);
return r ? r.name : systemRolleText(key);
}
// onUserRoleChange: wird beim Wechsel der Systemrolle in einem der beiden // onUserRoleChange: wird beim Wechsel der Systemrolle in einem der beiden
// Formulare aufgerufen (which = 'new'|'edit'). Ein Kiosk-Konto kann keine // Formulare aufgerufen (which = 'new'|'edit'). Ein Kiosk-Konto kann keine
// Fachrolle haben (server.js weist das sonst mit 400 zurück) — die // Fachrolle haben (server.js weist das sonst mit 400 zurück) — die
// Checkboxen werden deshalb schon hier abgehakt entfernt und gesperrt, // Checkboxen werden deshalb schon hier abgehakt entfernt und gesperrt,
// statt den Fehler erst nach dem Absenden zu zeigen. // statt den Fehler erst nach dem Absenden zu zeigen.
window.onUserRoleChange = function(which){ window.onUserRoleChange = function(which){
const istKiosk = document.getElementById(which + 'UserRole').value === 'kiosk'; const istKiosk = nurWandtafelRolle(document.getElementById(which + 'UserRole').value);
const hint = document.getElementById(which + 'UserRoleHint'); const hint = document.getElementById(which + 'UserRoleHint');
if(hint) hint.style.display = istKiosk ? '' : 'none'; if(hint) hint.style.display = istKiosk ? '' : 'none';
document.querySelectorAll('#' + which + 'UserRoleCheckboxes input[type="checkbox"]').forEach(cb => { document.querySelectorAll('#' + which + 'UserRoleCheckboxes input[type="checkbox"]').forEach(cb => {
@@ -100,6 +131,7 @@
// erreichbar (showTab() leitet sie um) und die Endpunkte sind serverseitig // erreichbar (showTab() leitet sie um) und die Endpunkte sind serverseitig
// abgesichert. Keine Parameter, keine Rueckgabe. // abgesichert. Keine Parameter, keine Rueckgabe.
window.renderAdminUsers = function(){ window.renderAdminUsers = function(){
ladeSystemRollen();
if(!formulareVorbereitet){ if(!formulareVorbereitet){
renderDepartmentOptions('newUserDepartment'); renderDepartmentOptions('newUserDepartment');
renderDepartmentOptions('editUserDepartment'); renderDepartmentOptions('editUserDepartment');
@@ -151,7 +183,7 @@
<td>${esc(u.username)}</td> <td>${esc(u.username)}</td>
<td>${esc(u.displayName)}</td> <td>${esc(u.displayName)}</td>
<td>${esc(u.email || '')}</td> <td>${esc(u.email || '')}</td>
<td>${esc(systemRolleText(u.role))}</td> <td>${esc(rollenName(u.role))}</td>
<td>${deptLabel}</td> <td>${deptLabel}</td>
<td>${esc(u.initials || '')}</td> <td>${esc(u.initials || '')}</td>
<td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td> <td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td>
@@ -202,10 +234,14 @@
document.getElementById('newUserFirstName').value = ''; document.getElementById('newUserFirstName').value = '';
document.getElementById('newUserLastName').value = ''; document.getElementById('newUserLastName').value = '';
document.getElementById('newUserEmail').value = ''; document.getElementById('newUserEmail').value = '';
document.getElementById('newUserRole').value = 'member'; document.getElementById('newUserRole').value = systemRollen.some(r => r.key === 'member') ? 'member' : ((systemRollen.find(r => !r.fest) || {}).key || '');
onUserRoleChange('new');
document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT; document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT;
document.getElementById('newUserInitials').value = ''; document.getElementById('newUserInitials').value = '';
document.getElementById('newUserPersonId').value = ''; document.getElementById('newUserPersonId').value = '';
// Sonst übernahm das nächste Konto unbemerkt die Wochenstunden des
// zuvor abgebrochenen Dialogs (Teilzeit ohne Absicht).
document.getElementById('newUserCapacity').value = '';
document.querySelectorAll('#newUserRoleCheckboxes input[type="checkbox"]').forEach(cb => { cb.checked = false; }); document.querySelectorAll('#newUserRoleCheckboxes input[type="checkbox"]').forEach(cb => { cb.checked = false; });
document.getElementById('newUserPassword').value = ''; document.getElementById('newUserPassword').value = '';
document.getElementById('newUserPassword').style.display = 'none'; document.getElementById('newUserPassword').style.display = 'none';
@@ -312,7 +348,7 @@
// im PDF-Export, ohne dass es dafür irgendeinen Hinweis gäbe. Für ein // im PDF-Export, ohne dass es dafür irgendeinen Hinweis gäbe. Für ein
// Kiosk-Konto gilt das nicht — es soll gerade NICHT in Boards/Auslastung // Kiosk-Konto gilt das nicht — es soll gerade NICHT in Boards/Auslastung
// auftauchen, server.js weist eine gesetzte Fachrolle dort sogar zurück. // auftauchen, server.js weist eine gesetzte Fachrolle dort sogar zurück.
if(role !== 'kiosk' && (!roles || roles.length === 0)){ if(!nurWandtafelRolle(role) && (!roles || roles.length === 0)){
errEl.textContent = 'Bitte mindestens eine Rolle wählen (Planung, Projektleitung oder Bauüberwachung) — sonst erscheint der Benutzer in keinem Projektboard.'; errEl.textContent = 'Bitte mindestens eine Rolle wählen (Planung, Projektleitung oder Bauüberwachung) — sonst erscheint der Benutzer in keinem Projektboard.';
return; return;
} }
@@ -341,6 +377,10 @@
} }
closeNewUserModal(); closeNewUserModal();
loadAndRenderUsers(); loadAndRenderUsers();
// Der Server hat das Konto in den Teilnehmerkreis der aktuellen
// Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis
// zum nächsten Seitenaufruf in Projekten und im Assistenten.
if(res.data.sitzungGeaendert && typeof load === 'function') load(true);
if(res.data.generatedPassword){ if(res.data.generatedPassword){
showGeneratedPassword(username, res.data.generatedPassword); showGeneratedPassword(username, res.data.generatedPassword);
} }
@@ -415,6 +455,10 @@
} }
closeEditUserModal(); closeEditUserModal();
loadAndRenderUsers(); loadAndRenderUsers();
// Der Server hat das Konto in den Teilnehmerkreis der aktuellen
// Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis
// zum nächsten Seitenaufruf in Projekten und im Assistenten.
if(res.data.sitzungGeaendert && typeof load === 'function') load(true);
}).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; }); }).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; });
}; };
@@ -521,6 +565,10 @@
return; return;
} }
loadAndRenderUsers(); loadAndRenderUsers();
// Der Server hat das Konto in den Teilnehmerkreis der aktuellen
// Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis
// zum nächsten Seitenaufruf in Projekten und im Assistenten.
if(res.data.sitzungGeaendert && typeof load === 'function') load(true);
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message)); }).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
}; };
+168 -90
View File
@@ -42,7 +42,7 @@
// Dass der Bereich für Nicht-Administratoren gar nicht erst auftaucht, ist // Dass der Bereich für Nicht-Administratoren gar nicht erst auftaucht, ist
// wie überall in dieser App ein reines UI-Gate; die eigentliche Sperre sitzt // wie überall in dieser App ein reines UI-Gate; die eigentliche Sperre sitzt
// serverseitig in requireRole('admin') auf PUT /app-settings, // serverseitig in requireRole('admin') auf PUT /app-settings,
// PUT /honorar-config, POST/DELETE /lph-templates, POST/DELETE /holidays, // PUT /honorar-config, POST/PATCH/DELETE /lph-templates, POST/DELETE /holidays,
// GET /backup, POST /import und POST /delete-week. // GET /backup, POST /import und POST /delete-week.
// ADMIN_SECTIONS: die Abschnitte in der Reihenfolge der Seitenleiste. Die // ADMIN_SECTIONS: die Abschnitte in der Reihenfolge der Seitenleiste. Die
@@ -52,19 +52,37 @@
// Unterpunkt in der Seitenleiste; ein neuer Abschnitt braucht damit nur einen // Unterpunkt in der Seitenleiste; ein neuer Abschnitt braucht damit nur einen
// Eintrag hier plus die Karte. `wide` hebt die Breitenbegrenzung des Rasters // Eintrag hier plus die Karte. `wide` hebt die Breitenbegrenzung des Rasters
// auf — die Benutzertabelle hat zehn Spalten und scrollte sonst quer. // auf — die Benutzertabelle hat zehn Spalten und scrollte sonst quer.
// recht: welches Recht (Rollen & Rechte) den Abschnitt öffnet; bei einer
// Liste genügt eines davon.
const ADMIN_SECTIONS = [ const ADMIN_SECTIONS = [
{ key: 'benutzer', title: 'Benutzerverwaltung', wide: true }, { key: 'benutzer', title: 'Benutzerverwaltung', wide: true, recht: 'a.benutzer' },
{ key: 'honorar', title: 'Honorar' }, { key: 'rollen', title: 'Rollen & Rechte', wide: true, recht: 'a.rollen' },
{ key: 'auslastung', title: 'Auslastung' }, { key: 'honorar', title: 'Honorar', recht: 'a.honorar' },
{ key: 'termine', title: 'Termine & Sitzungen' }, { key: 'auslastung', title: 'Auslastung', recht: 'a.auslastung' },
{ key: 'feiertage', title: 'Feiertage' }, { key: 'termine', title: 'Termine & Sitzungen', recht: 'a.termine' },
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen' }, { key: 'feiertage', title: 'Feiertage', recht: 'a.feiertage' },
{ key: 'planchecker', title: 'Plan-Checker-Merkmale' }, { key: 'vorlagen', title: 'Aufgaben-Vorlagen', recht: 'a.vorlagen' },
{ key: 'betrieb', title: 'Betrieb & Sicherheit' }, { key: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' },
{ key: 'papierkorb', title: 'Papierkorb' }, { key: 'betrieb', title: 'Betrieb & Sicherheit', recht: 'a.betrieb' },
{ key: 'datenbank', title: 'Datenbank & Sitzungen' }, { key: 'papierkorb', title: 'Papierkorb', recht: 'a.papierkorb' },
{ key: 'datenbank', title: 'Datenbank & Sitzungen', recht: ['a.backup', 'a.import'] },
]; ];
// adminNavAbgleichen: blendet Unterpunkte der Seitenleiste ohne Recht aus
// (auch von renderUserBar() nach der Anmeldung gerufen).
function adminNavAbgleichen(){
ADMIN_SECTIONS.forEach(sec => {
const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]');
if(item) item.hidden = !adminAbschnittErlaubt(sec);
});
}
// adminAbschnittErlaubt: darf das Konto diesen Abschnitt sehen?
function adminAbschnittErlaubt(sec){
const r = Array.isArray(sec.recht) ? sec.recht : [sec.recht];
return r.some(x => hatRecht(x));
}
// adminSection: der gerade sichtbare Abschnitt. // adminSection: der gerade sichtbare Abschnitt.
let adminSection = 'benutzer'; let adminSection = 'benutzer';
@@ -95,13 +113,13 @@ function renderAdminArea(){
// isAdminUser: Kurzform für die Sichtbarkeit des Bereichs. // isAdminUser: Kurzform für die Sichtbarkeit des Bereichs.
// Rückgabe: Boolean. // Rückgabe: Boolean.
function isAdminUser(){ function isAdminUser(){
return !!(currentUser && currentUser.role === 'admin'); return hatAdminRecht();
} }
// adminSectionExists: prüft, ob ein Schlüssel ein bekannter Abschnitt ist. // adminSectionExists: prüft, ob ein Schlüssel ein bekannter Abschnitt ist.
// Parameter: key. Rückgabe: Boolean. // Parameter: key. Rückgabe: Boolean.
function adminSectionExists(key){ function adminSectionExists(key){
return ADMIN_SECTIONS.some(x => x.key === key); return ADMIN_SECTIONS.some(x => x.key === key && adminAbschnittErlaubt(x));
} }
// showAdminSection: schaltet auf einen Abschnitt um — blendet die zugehörige // showAdminSection: schaltet auf einen Abschnitt um — blendet die zugehörige
@@ -111,8 +129,9 @@ function adminSectionExists(key){
// (Klick aus einem anderen Bereich heraus), wird zuerst dorthin gewechselt. // (Klick aus einem anderen Bereich heraus), wird zuerst dorthin gewechselt.
// Parameter: key. Keine Rückgabe. // Parameter: key. Keine Rückgabe.
function showAdminSection(key){ function showAdminSection(key){
if(!adminSectionExists(key)) key = ADMIN_SECTIONS[0].key; if(!adminSectionExists(key)) key = (ADMIN_SECTIONS.find(adminAbschnittErlaubt) || ADMIN_SECTIONS[0]).key;
adminSection = key; adminSection = key;
adminNavAbgleichen();
if(currentTab !== 'admin'){ showTab('admin'); return; } if(currentTab !== 'admin'){ showTab('admin'); return; }
@@ -140,6 +159,7 @@ function showAdminSection(key){
// Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso // Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso
// frisch holen. // frisch holen.
if(key === 'papierkorb') loadAdminPapierkorb(); if(key === 'papierkorb') loadAdminPapierkorb();
if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte();
try { try {
history.replaceState(null, '', location.pathname + location.search + '#' + key); history.replaceState(null, '', location.pathname + location.search + '#' + key);
@@ -186,8 +206,8 @@ const APP_SETTING_FIELDS = {
// Kapazität der Person — ein volles Band ist eine volle Woche. Die feste // Kapazität der Person — ein volles Band ist eine volle Woche. Die feste
// Vorgabe 8 hatte zur Folge, dass ein roter Balken (ab 1,0) nur zu einem // Vorgabe 8 hatte zur Folge, dass ein roter Balken (ab 1,0) nur zu einem
// Achtel gefüllt war; Balken und Ampel widersprachen einander. // Achtel gefüllt war; Balken und Ampel widersprachen einander.
setReferenzHonorar: 'referenzHonorar', // Referenzhonorar und Verteilzeitraum sind entfallen: Die Auslastung
setVerteilWochen: 'verteilzeitraumWochen', // rechnet nur noch mit Aufgaben-Stunden (js/load/load.js).
}, },
termine: { termine: {
setStundenProTag: 'arbeitsstundenProTag', setStundenProTag: 'arbeitsstundenProTag',
@@ -213,49 +233,6 @@ function renderAppSettingFields(){
}); });
const autoLock = document.getElementById('setAutoLock'); const autoLock = document.getElementById('setAutoLock');
if(autoLock) autoLock.checked = !!Number(APP_SETTINGS.autoLockAktiv); if(autoLock) autoLock.checked = !!Number(APP_SETTINGS.autoLockAktiv);
renderAufwandMatrix();
}
// renderAufwandMatrix: die firmenweite Gewichtung als Tabelle Leistungsphase ×
// Rolle. „W+M" fehlt bewusst — das Aufwandsmodell kennt nur die
// Leistungsphasen (AUFWAND_LPS in js/load/load.js). Ein leeres Feld bedeutet
// „kein Eintrag"; dafür zählt die Auslastung dann den vollen Faktor 1,0.
// Keine Parameter, keine Rückgabe.
function renderAufwandMatrix(){
const wrap = document.getElementById('setAufwandMatrix');
if(!wrap) return;
const vorlage = APP_SETTINGS.aufwandVorlage || {};
const phasen = LPHS.filter(l => l !== 'W+M');
wrap.innerHTML = `<table class="settings-matrix">
<thead><tr><th>Phase</th>${ROLES.map(r => `<th>${esc(r)}</th>`).join('')}</tr></thead>
<tbody>${phasen.map(l => `<tr>
<th>${esc(l)}</th>
${ROLES.map(r => {
const v = vorlage[l] ? vorlage[l][r] : undefined;
return `<td><input type="number" min="0" max="1" step="0.1"
data-lph="${esc(l)}" data-role="${esc(r)}"
value="${v === undefined ? '' : esc(v)}" placeholder="1,0"></td>`;
}).join('')}
</tr>`).join('')}</tbody>
</table>`;
}
// collectAufwandMatrix: liest die Matrix aus den Feldern zurück. Leere Felder
// werden weggelassen (siehe renderAufwandMatrix), 0 ist ein gültiger Wert und
// bleibt erhalten. Rückgabe: Objekt LPH -> { Rolle: Faktor }.
function collectAufwandMatrix(){
const wrap = document.getElementById('setAufwandMatrix');
const out = {};
if(!wrap) return out;
wrap.querySelectorAll('input[data-lph]').forEach(inp => {
if(inp.value.trim() === '') return;
const v = parseFloat(inp.value);
if(!Number.isFinite(v)) return;
const lph = inp.dataset.lph;
(out[lph] = out[lph] || {})[inp.dataset.role] = v;
});
return out;
} }
// putAppSettings: schickt einen Teil-Satz an PUT /app-settings und übernimmt // putAppSettings: schickt einen Teil-Satz an PUT /app-settings und übernimmt
@@ -294,11 +271,9 @@ function fieldValues(gruppe){
return patch; return patch;
} }
// saveAuslastungSettings: Schwellen, Skala, Vollzeit-Stunden und die // saveAuslastungSettings: Schwellen und Vollzeit-Stunden in einem Zug. Keine Parameter, keine Rückgabe.
// Aufwandsvorlage in einem Zug. Keine Parameter, keine Rückgabe.
function saveAuslastungSettings(){ function saveAuslastungSettings(){
const patch = fieldValues('auslastung'); const patch = fieldValues('auslastung');
patch.aufwandVorlage = collectAufwandMatrix();
putAppSettings(patch, 'Auslastungs-Einstellungen gespeichert.'); putAppSettings(patch, 'Auslastungs-Einstellungen gespeichert.');
} }
@@ -365,7 +340,7 @@ function renderAdminSitzungen(){
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.id)}')">Fertig – wieder sperren</button>`; aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.id)}')">Fertig – wieder sperren</button>`;
} else if(w.locked){ } else if(w.locked){
chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>'; chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>';
aktionen = `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.id)}')">Entsperren</button>`; aktionen = hatRecht('s.entsperren') ? `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.id)}')">Entsperren</button>` : '';
} else { } else {
chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>'; chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>';
aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.id)}')">Öffnen</button>`; aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.id)}')">Öffnen</button>`;
@@ -375,7 +350,7 @@ function renderAdminSitzungen(){
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span> <span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
${chip} ${chip}
${aktionen} ${aktionen}
<button class="danger small" onclick="adminDeleteWeek('${esc(w.id)}')" title="Sitzung löschen">×</button> ${hatRecht('s.loeschen') ? `<button class="danger small" onclick="adminDeleteWeek('${esc(w.id)}')" title="Sitzung löschen">×</button>` : ''}
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -609,17 +584,31 @@ function deleteHoliday(date){
} }
// ---------- Abschnitt Aufgaben-Vorlagen ---------- // ---------- Abschnitt Aufgaben-Vorlagen ----------
//
// Vorlagen gibt es je Gewerk und Leistungsphase (Eriks Wunsch). Oben wählt
// man das Gewerk (Umschalter) und die Leistungsphase, darunter steht die
// Liste der Schritte: Bezeichnung, Start und Dauer direkt bearbeitbar
// (gespeichert beim Verlassen des Feldes, PATCH /lph-templates/:id),
// Reihenfolge über ↑/↓ (POST …/verschieben), Löschen über ×.
// Vorlagen ohne Gewerk (Altbestand) stehen unter jedem Gewerk mit dem
// Vermerk „alle Gewerke“.
let adminTemplates = [];
let adminTemplateGewerk = null;
// loadAdminTemplates: holt alle Vorlagen-Schritte (GET /lph-templates) und // loadAdminTemplates: holt alle Vorlagen-Schritte (GET /lph-templates) und
// rendert die Liste der gerade gewählten Leistungsphase. // rendert die Liste. Keine Parameter, keine Rückgabe.
// Keine Parameter, keine Rückgabe.
function loadAdminTemplates(){ function loadAdminTemplates(){
const select = document.getElementById('adminTemplateLph'); const select = document.getElementById('adminTemplateLph');
if(select && !select.options.length){ if(select && !select.options.length){
select.innerHTML = LPHS.map(l => `<option value="${esc(l)}">${esc(l)}</option>`).join(''); select.innerHTML = LPHS.map(l => `<option value="${esc(l)}">${esc(l)}</option>`).join('');
} }
if(!adminTemplateGewerk){
const eigen = currentUser && Object.keys(DEPARTMENTS).includes(currentUser.department) ? currentUser.department : '';
adminTemplateGewerk = eigen || Object.keys(DEPARTMENTS)[0];
}
const listEl = document.getElementById('templateAdminList'); const listEl = document.getElementById('templateAdminList');
if(listEl) listEl.innerHTML = '<span class="hint">Lade Vorlagen…</span>'; if(listEl && !adminTemplates.length) listEl.innerHTML = '<span class="hint">Lade Vorlagen…</span>';
fetch(baseUrl() + '/lph-templates') fetch(baseUrl() + '/lph-templates')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); }) .then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
@@ -633,33 +622,74 @@ function loadAdminTemplates(){
}); });
} }
// renderAdminTemplates: zeigt die Schritte der gewählten Leistungsphase in // vorlagenAnteilText: Anzeige der Spalte „Anteil %“ — Zahl mit Komma oder
// ihrer gepflegten Reihenfolge, je Zeile mit Versatz/Dauer und Lösch-Knopf. // „in a)“ / „in a) + b)“ (Siemon: in anderen Schritten enthalten).
// Wird auch vom onchange der Leistungsphasen-Auswahl gerufen. function vorlagenAnteilText(t){
// Keine Parameter, keine Rückgabe. if(t.enthaltenIn) return 'in ' + t.enthaltenIn.split(',').map(x => x + ')').join(' + ');
function renderAdminTemplates(){ return t.anteil ? String(t.anteil).replace('.', ',') : '';
const listEl = document.getElementById('templateAdminList');
if(!listEl) return;
const select = document.getElementById('adminTemplateLph');
const lph = select ? select.value : '';
const entries = adminTemplates.filter(t => t.lph === lph);
listEl.innerHTML = entries.length === 0
? '<span class="hint">Für diese Leistungsphase ist noch kein Schritt hinterlegt.</span>'
: entries.map(t => `<div class="template-admin-row">
<span class="template-admin-title">${esc(t.title)}</span>
<span class="hint" style="margin:0">Start +${esc(t.offsetDays)} Tage · Dauer ${esc(t.durationDays)} Tage</span>
<button class="danger small" onclick="deleteTemplateEntry('${esc(t.id)}')" title="Schritt aus der Vorlage löschen">×</button>
</div>`).join('');
} }
// addTemplateEntry: legt einen Schritt für die gewählte Leistungsphase an // waehleAdminTemplateGewerk: Umschalter Gewerk. Parameter: g. Keine Rückgabe.
// (POST /lph-templates, Admin-only). Keine Parameter, keine Rückgabe. function waehleAdminTemplateGewerk(g){
adminTemplateGewerk = g;
renderAdminTemplates();
}
// renderAdminTemplates: Gewerk-Umschalter (mit Anzahl je Gewerk), die
// Anzahl je Leistungsphase in der Auswahl und die Schritte der gewählten
// Kombination. Keine Parameter, keine Rückgabe.
function renderAdminTemplates(){
const g = adminTemplateGewerk;
const gewEl = document.getElementById('adminTemplateGewerk');
if(gewEl){
gewEl.innerHTML = Object.keys(DEPARTMENTS).map(d => {
const n = adminTemplates.filter(t => t.gewerk === d).length;
return `<button type="button" class="vorlagen-gewerk${d === g ? ' is-active' : ''}" aria-pressed="${d === g}"
title="${esc(DEPARTMENTS[d].label)}" onclick="waehleAdminTemplateGewerk('${esc(d)}')">${esc(d)}<span>${n}</span></button>`;
}).join('');
}
const select = document.getElementById('adminTemplateLph');
const lph = select ? select.value : '';
if(select){
Array.from(select.options).forEach(o => {
const n = adminTemplates.filter(t => t.lph === o.value && t.gewerk === g).length;
o.textContent = o.value + (n ? ' (' + n + ')' : '');
});
}
const listEl = document.getElementById('templateAdminList');
if(!listEl) return;
const eigene = adminTemplates.filter(t => t.lph === lph && t.gewerk === g);
const alle = adminTemplates.filter(t => t.lph === lph && !t.gewerk);
const zeile = (t, i, n) => `<div class="template-admin-row vorlagen-zeile">
<input type="text" value="${esc(t.title)}" aria-label="Bezeichnung" onchange="aendereTemplateEntry('${esc(t.id)}', 'title', this.value)">
<input type="text" value="${esc(vorlagenAnteilText(t))}" placeholder="–" aria-label="Anteil in Prozent oder „in a)“" title="Zahl in Prozent oder „in a)“ = zeitgleich mit Schritt a)" onchange="aendereTemplateEntry('${esc(t.id)}', 'anteil', this.value)">
<input type="number" min="0" value="${t.durationDays ? esc(t.offsetDays) : ''}" placeholder="–" aria-label="Start in Tagen" onchange="aendereTemplateEntry('${esc(t.id)}', 'offsetDays', this.value === '' ? 0 : this.value)">
<input type="number" min="0" value="${t.durationDays ? esc(t.durationDays) : ''}" placeholder="ohne" aria-label="Dauer in Tagen, leer = ohne Termin" onchange="aendereTemplateEntry('${esc(t.id)}', 'durationDays', this.value === '' ? 0 : this.value)">
<span class="vorlagen-aktionen">
${n !== null ? `<button class="secondary small" title="nach oben" ${i === 0 ? 'disabled' : ''} onclick="verschiebeTemplateEntry('${esc(t.id)}', -1)">↑</button>
<button class="secondary small" title="nach unten" ${i === n - 1 ? 'disabled' : ''} onclick="verschiebeTemplateEntry('${esc(t.id)}', 1)">↓</button>` : '<span class="hint" style="margin:0">alle Gewerke</span>'}
<button class="danger small" onclick="deleteTemplateEntry('${esc(t.id)}')" title="Schritt aus der Vorlage löschen">×</button>
</span>
</div>`;
// Summe der Anteile: Beim Einfügen zählt das Verhältnis (der Anteil eines
// Schritts an der Summe der Phase) — die Summe ist nur zur Kontrolle da.
const summe = Math.round([...eigene, ...alle].reduce((a, t) => a + (Number(t.anteil) || 0), 0) * 100) / 100;
listEl.innerHTML = (eigene.length || alle.length)
? eigene.map((t, i) => zeile(t, i, eigene.length)).join('') + alle.map(t => zeile(t, 0, null)).join('')
+ `<div class="vorlagen-summe">Summe der Anteile in ${esc(lph)}: <b>${summe ? String(summe).replace('.', ',') + ' %' : 'keine hinterlegt'}</b>${summe ? '' : ' — Schritte ohne Anteil werden mit „Start/Dauer“ bzw. ohne Termin eingefügt.'}</div>`
: `<span class="hint">Für ${esc(g)} in ${esc(lph)} ist noch kein Schritt hinterlegt.</span>`;
}
// addTemplateEntry: legt einen Schritt für das gewählte Gewerk und die
// gewählte Leistungsphase an (POST /lph-templates). Keine Parameter.
function addTemplateEntry(){ function addTemplateEntry(){
const select = document.getElementById('adminTemplateLph'); const select = document.getElementById('adminTemplateLph');
const titleEl = document.getElementById('templateNewTitle'); const titleEl = document.getElementById('templateNewTitle');
const offsetEl = document.getElementById('templateNewOffset'); const offsetEl = document.getElementById('templateNewOffset');
const durationEl = document.getElementById('templateNewDuration'); const durationEl = document.getElementById('templateNewDuration');
const anteilEl = document.getElementById('templateNewAnteil');
const title = titleEl ? titleEl.value.trim() : ''; const title = titleEl ? titleEl.value.trim() : '';
if(!title){ alert('Bitte eine Bezeichnung für den Schritt eingeben.'); return; } if(!title){ alert('Bitte eine Bezeichnung für den Schritt eingeben.'); return; }
@@ -668,15 +698,19 @@ function addTemplateEntry(){
headers: { 'Content-Type': 'application/json; charset=utf-8' }, headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ body: JSON.stringify({
lph: select ? select.value : '', lph: select ? select.value : '',
gewerk: adminTemplateGewerk,
title, title,
offsetDays: offsetEl ? parseInt(offsetEl.value, 10) || 0 : 0, offsetDays: offsetEl ? parseInt(offsetEl.value, 10) || 0 : 0,
durationDays: durationEl ? parseInt(durationEl.value, 10) || 1 : 1, // leer oder 0 = ohne Termin
durationDays: durationEl ? parseInt(durationEl.value, 10) || 0 : 0,
anteil: anteilEl ? anteilEl.value : '',
}) })
}) })
.then(r => r.json().then(json => ({ ok: r.ok, json }))) .then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => { .then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.'); if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.');
if(titleEl) titleEl.value = ''; if(titleEl){ titleEl.value = ''; titleEl.focus(); }
if(anteilEl) anteilEl.value = '';
loadAdminTemplates(); loadAdminTemplates();
showAdminNote('Schritt angelegt.'); showAdminNote('Schritt angelegt.');
}) })
@@ -686,6 +720,50 @@ function addTemplateEntry(){
}); });
} }
// aendereTemplateEntry: speichert ein geändertes Feld eines Schritts
// (PATCH /lph-templates/:id). Parameter: id, feld, wert. Keine Rückgabe.
function aendereTemplateEntry(id, feld, wert){
const body = {};
body[feld] = (feld === 'title' || feld === 'anteil') ? wert : parseInt(wert, 10);
fetch(baseUrl() + '/lph-templates/' + encodeURIComponent(id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(body)
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.');
const t = adminTemplates.find(x => x.id === id);
if(t) Object.assign(t, json.template);
renderAdminTemplates();
showAdminNote('Gespeichert.');
})
.catch(err => {
console.error('Vorlagen-Schritt ändern fehlgeschlagen:', err);
alert('Änderung konnte nicht gespeichert werden: ' + (err.message || err));
loadAdminTemplates();
});
}
// verschiebeTemplateEntry: Schritt eine Stelle nach oben/unten.
// Parameter: id, richtung (-1/1). Keine Rückgabe.
function verschiebeTemplateEntry(id, richtung){
fetch(baseUrl() + '/lph-templates/' + encodeURIComponent(id) + '/verschieben', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ richtung })
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Verschieben fehlgeschlagen.');
loadAdminTemplates();
})
.catch(err => {
console.error('Vorlagen-Schritt verschieben fehlgeschlagen:', err);
alert('Schritt konnte nicht verschoben werden: ' + (err.message || err));
});
}
// deleteTemplateEntry: löscht einen Vorlagen-Schritt (DELETE, Admin-only). // deleteTemplateEntry: löscht einen Vorlagen-Schritt (DELETE, Admin-only).
// Bereits in Projekte eingefügte Aufgaben bleiben unberührt — sie sind nach // Bereits in Projekte eingefügte Aufgaben bleiben unberührt — sie sind nach
// dem Einfügen ganz normale plan_todos ohne Verbindung zur Vorlage. // dem Einfügen ganz normale plan_todos ohne Verbindung zur Vorlage.
@@ -706,7 +784,7 @@ function deleteTemplateEntry(id){
// ---------- Abschnitt Plan-Checker-Merkmale ---------- // ---------- Abschnitt Plan-Checker-Merkmale ----------
// //
// Dieselbe add/delete-only Liste wie die Aufgaben-Vorlagen oben, nur ohne // Eine add/delete-only Liste wie früher die Aufgaben-Vorlagen oben, ohne
// Gruppierung (die Merkmale gelten unterschiedslos für jede Prüfung) — siehe // Gruppierung (die Merkmale gelten unterschiedslos für jede Prüfung) — siehe
// plan_checker_merkmale/GET+POST+DELETE /plan-checker-merkmale, server.js. // plan_checker_merkmale/GET+POST+DELETE /plan-checker-merkmale, server.js.
// Die Liste selbst nutzt auch der Plan Checker (js/planchecker/planchecker.js), // Die Liste selbst nutzt auch der Plan Checker (js/planchecker/planchecker.js),
+209
View File
@@ -0,0 +1,209 @@
// -----------------------------
// Admin — Rollen & Rechte
// -----------------------------
// Eriks Wahl „B“ aus dem Entwurfsvergleich: links die Rollen (Anzahl Konten,
// Anteil der Rechte), darunter „+ Neue Rolle“; rechts die gewählte Rolle mit
// einem Schalter je Recht, nach Gruppen, und „alle/keine“ je Gruppe. Über
// „Vergleich mit“ steht neben jedem Schalter, ob eine zweite Rolle das Recht
// hat (Abweichungen hervorgehoben).
//
// Daten und Katalog kommen vom Server (GET /rollen, server.js „Rollen &
// Rechte: Routen“); jede Änderung geht sofort hin (PUT /rollen/:key/rechte,
// POST/PATCH/DELETE /rollen) und die Antwort ersetzt den Stand hier. Kein
// eigenes Speichern. Ändert jemand seine eigene Rolle, lädt sich /whoami neu,
// damit Seitenleiste und Knöpfe sofort stimmen.
let rrDaten = null; // { katalog:[{gruppe, rechte:[{key,name,hinweis}]}], rollen:[…] }
let rrAuswahl = 'member'; // gewählte Rolle
let rrVergleich = ''; // Vergleichsrolle ('' = keiner)
let rrNeuOffen = false; // Formular „+ Neue Rolle“ aufgeklappt
let rrMeldung = '';
let rrMeldungTimer = null;
// renderRollenRechte: Einstieg beim Öffnen des Abschnitts (showAdminSection).
function renderRollenRechte(){
const el = document.getElementById('rollenRechte');
if(!el) return;
if(!rrDaten) el.innerHTML = '<span class="hint">Lade Rollen…</span>';
fetch(baseUrl() + '/rollen')
.then(r => r.json())
.then(j => { if(!j.ok) throw new Error(j.error || 'Laden fehlgeschlagen'); rrUebernehmen(j); })
.catch(err => { el.innerHTML = `<span class="hint">Rollen konnten nicht geladen werden: ${esc(err.message || err)}</span>`; });
}
// rrUebernehmen: neue Serverantwort übernehmen und neu zeichnen.
function rrUebernehmen(j){
rrDaten = j;
if(!j.rollen.some(r => r.key === rrAuswahl)) rrAuswahl = (j.rollen.find(r => !r.fest) || j.rollen[0] || {}).key;
if(rrVergleich && !j.rollen.some(r => r.key === rrVergleich)) rrVergleich = '';
rrZeichnen();
}
function rrMelden(text){
rrMeldung = text;
const el = document.getElementById('rrMeldung');
if(el) el.textContent = text;
clearTimeout(rrMeldungTimer);
rrMeldungTimer = setTimeout(() => { rrMeldung = ''; const e = document.getElementById('rrMeldung'); if(e) e.textContent = ''; }, 2500);
}
// rrSenden: gemeinsamer Weg aller Änderungen. Parameter: methode, pfad,
// body, okText. Rückgabe: Promise der Serverantwort (oder null bei Fehler).
function rrSenden(methode, pfad, body, okText){
return fetch(baseUrl() + pfad, {
method: methode,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: body ? JSON.stringify(body) : undefined
})
.then(r => r.json().then(j => ({ ok: r.ok, j })))
.then(({ ok, j }) => {
if(!ok || !j.ok) throw new Error((j && j.error) || 'Fehlgeschlagen');
rrUebernehmen(j);
if(okText) rrMelden(okText);
rrEigeneRechteAuffrischen();
return j;
})
.catch(err => { rrMelden(err.message || String(err)); rrZeichnen(); return null; });
}
// rrEigeneRechteAuffrischen: betrifft eine Änderung die eigene Rolle, holt
// /whoami die neuen Rechte, damit Seitenleiste und Knöpfe gleich stimmen.
function rrEigeneRechteAuffrischen(){
fetch(baseUrl() + '/whoami').then(r => r.json()).then(j => {
if(j && j.user && currentUser && j.user.id === currentUser.id){
currentUser = j.user;
if(typeof renderUserBar === 'function') renderUserBar();
}
}).catch(() => {});
}
const rrRolle = key => rrDaten && rrDaten.rollen.find(r => r.key === key);
const rrAlleRechte = () => rrDaten.katalog.flatMap(g => g.rechte.map(r => r.key));
// Aktionen (aus den Inline-Handlern)
function rrWaehlen(key){ rrAuswahl = key; rrLoeschenBestaetigen = ''; rrZeichnen(); }
function rrVergleichSetzen(key){ rrVergleich = key; rrZeichnen(); }
function rrNeuUmschalten(offen){
rrNeuOffen = offen; rrZeichnen();
if(offen){ const i = document.getElementById('rrNeuName'); if(i) i.focus(); }
}
function rrRechtSchalten(key, recht){
const r = rrRolle(key); if(!r || r.fest) return;
const an = !r.rechte.includes(recht);
rrSenden('PUT', '/rollen/' + encodeURIComponent(key) + '/rechte', { rechte: [recht], an }, 'Gespeichert');
}
function rrGruppeSchalten(key, gi){
const r = rrRolle(key); if(!r || r.fest) return;
const keys = rrDaten.katalog[gi].rechte.map(x => x.key);
const an = !keys.every(k => r.rechte.includes(k));
rrSenden('PUT', '/rollen/' + encodeURIComponent(key) + '/rechte', { rechte: keys, an }, 'Gespeichert');
}
function rrAnlegen(){
const name = (document.getElementById('rrNeuName') || {}).value || '';
const von = (document.getElementById('rrNeuVon') || {}).value || '';
if(!name.trim()){ rrMelden('Bitte einen Namen eingeben.'); return; }
rrSenden('POST', '/rollen', { name: name.trim(), vonRolle: von }, 'Rolle „' + name.trim() + '“ angelegt.')
.then(j => { if(j && j.neu){ rrAuswahl = j.neu; rrNeuOffen = false; rrZeichnen(); } });
}
function rrUmbenennen(key, name){
const r = rrRolle(key); if(!r || r.fest || !name.trim() || name.trim() === r.name) return;
rrSenden('PATCH', '/rollen/' + encodeURIComponent(key), { name: name.trim() }, 'Umbenannt.');
}
function rrKopieren(key){
const r = rrRolle(key); if(!r) return;
let name = r.name + ' (Kopie)', i = 2;
while(rrDaten.rollen.some(x => x.name.toLowerCase() === name.toLowerCase())) name = r.name + ' (Kopie ' + (i++) + ')';
rrSenden('POST', '/rollen', { name, vonRolle: key }, 'Kopie angelegt.')
.then(j => { if(j && j.neu){ rrAuswahl = j.neu; rrZeichnen(); } });
}
// rrLoeschen: zweistufig im Knopf selbst (kein confirm()) — erster Klick
// fragt nach, zweiter löscht.
let rrLoeschenBestaetigen = '';
function rrLoeschen(key){
if(rrLoeschenBestaetigen !== key){ rrLoeschenBestaetigen = key; rrZeichnen(); return; }
rrLoeschenBestaetigen = '';
rrSenden('DELETE', '/rollen/' + encodeURIComponent(key), null, 'Rolle gelöscht.');
}
// rrZeichnen: baut die ganze Maske. Fokus und Eingaben im Formular „Neue
// Rolle“ bleiben erhalten.
function rrZeichnen(){
const el = document.getElementById('rollenRechte');
if(!el || !rrDaten) return;
const fokusId = document.activeElement && document.activeElement.id;
const neuName = (document.getElementById('rrNeuName') || {}).value || '';
const alle = rrAlleRechte();
const r = rrRolle(rrAuswahl) || rrDaten.rollen[0];
const v = rrVergleich && rrVergleich !== r.key ? rrRolle(rrVergleich) : null;
const rechteVon = x => x.fest ? alle : x.rechte;
const liste = rrDaten.rollen.map(x => {
const n = rechteVon(x).length;
return `<button type="button" class="rr-rolle${x.key === r.key ? ' ist-aktiv' : ''}" onclick="rrWaehlen('${esc(x.key)}')">
<b>${esc(x.name)}</b>
<small>${x.konten} ${x.konten === 1 ? 'Konto' : 'Konten'} · ${n} von ${alle.length} Rechten${x.fest ? ' · fest' : ''}</small>
<span class="rr-balken"><i style="width:${(n / alle.length * 100).toFixed(1)}%"></i></span>
</button>`;
}).join('');
const neu = rrNeuOffen
? `<div class="rr-neu">
<input id="rrNeuName" type="text" placeholder="Name, z. B. Werkstudent" aria-label="Name der neuen Rolle"
onkeydown="if(event.key==='Enter') rrAnlegen(); if(event.key==='Escape') rrNeuUmschalten(false);">
<label>Rechte übernehmen von
<select id="rrNeuVon"><option value="">keiner (leer)</option>${rrDaten.rollen.map(x => `<option value="${esc(x.key)}">${esc(x.name)}</option>`).join('')}</select>
</label>
<div class="rr-neu-knoepfe">
<button type="button" class="small" onclick="rrAnlegen()">Anlegen</button>
<button type="button" class="secondary small" onclick="rrNeuUmschalten(false)">Abbrechen</button>
</div>
</div>`
: `<button type="button" class="rr-plus" onclick="rrNeuUmschalten(true)">+ Neue Rolle</button>`;
const gruppen = rrDaten.katalog.map((g, gi) => {
const rechteR = rechteVon(r);
const n = g.rechte.filter(x => rechteR.includes(x.key)).length;
const zeilen = g.rechte.map(x => {
const an = rechteR.includes(x.key);
const vAn = v ? rechteVon(v).includes(x.key) : null;
return `<div class="rr-zeile">
<button type="button" class="rr-schalter${an ? ' ist-an' : ''}" role="switch" aria-checked="${an}" aria-label="${esc(x.name)}"
${r.fest ? 'disabled' : `onclick="rrRechtSchalten('${esc(r.key)}','${esc(x.key)}')"`}></button>
<span class="rr-name">${esc(x.name)}${x.hinweis ? `<small>${esc(x.hinweis)}</small>` : ''}</span>
${v ? `<span class="rr-vergleich${vAn !== an ? ' ist-anders' : ''}" title="${esc(v.name)}">${vAn ? 'ja' : 'nein'}</span>` : ''}
</div>`;
}).join('');
return `<div class="rr-gruppe">
<div class="rr-gruppe-kopf"><b>${esc(g.gruppe)}</b><small>${n} von ${g.rechte.length}</small><span class="rr-luecke"></span>
${r.fest ? '' : `<button type="button" class="secondary small" onclick="rrGruppeSchalten('${esc(r.key)}', ${gi})">${n === g.rechte.length ? 'keine' : 'alle'}</button>`}
</div>
${zeilen}
</div>`;
}).join('');
const loeschText = rrLoeschenBestaetigen === r.key ? 'Wirklich löschen?' : 'Löschen';
el.innerHTML = `<div class="rr-layout">
<div class="rr-rollen">${liste}${neu}</div>
<div class="rr-detail">
<div class="rr-detail-kopf">
<input id="rrName" class="rr-titel" value="${esc(r.name)}" aria-label="Name der Rolle" ${r.fest ? 'disabled' : ''}
onchange="rrUmbenennen('${esc(r.key)}', this.value)" onkeydown="if(event.key==='Enter') this.blur()">
<label class="rr-vergleich-wahl">Vergleich mit
<select id="rrVergleich" onchange="rrVergleichSetzen(this.value)">
<option value="">keiner</option>
${rrDaten.rollen.filter(x => x.key !== r.key).map(x => `<option value="${esc(x.key)}" ${v && x.key === v.key ? 'selected' : ''}>${esc(x.name)}</option>`).join('')}
</select>
</label>
${r.fest ? '<span class="hint" style="margin:0">Administrator hat immer alle Rechte.</span>'
: `<button type="button" class="secondary small" onclick="rrKopieren('${esc(r.key)}')">Kopieren</button>
<button type="button" class="danger small" onclick="rrLoeschen('${esc(r.key)}')">${loeschText}</button>`}
<span class="rr-meldung" id="rrMeldung" role="status">${esc(rrMeldung)}</span>
</div>
<div class="rr-gruppen">${gruppen}</div>
</div>
</div>`;
const ni = document.getElementById('rrNeuName');
if(ni && neuName) ni.value = neuName;
if(fokusId){ const f = document.getElementById(fokusId); if(f) f.focus(); }
}
+1 -3
View File
@@ -202,9 +202,7 @@ function openBeauftragungModal(pid){
} }
// Gruppenwechsel nur anbieten, wenn das Projekt noch in "In Anbahnung" // Gruppenwechsel nur anbieten, wenn das Projekt noch in "In Anbahnung"
// steht: Diese Gruppe wird von der Auslastungsrechnung ausdrücklich NICHT // steht: Ein beauftragtes Projekt gehört nicht mehr in die Anbahnung.
// eingerechnet (berechneAuslastung(), js/load/load.js). Bliebe ein
// beauftragtes Projekt dort liegen, zählte die Arbeit daran nirgends mit.
const gruppeWrap = document.getElementById('beauftragungGruppeWrap'); const gruppeWrap = document.getElementById('beauftragungGruppeWrap');
const gruppeCheck = document.getElementById('beauftragungGruppeCheck'); const gruppeCheck = document.getElementById('beauftragungGruppeCheck');
if(gruppeWrap) gruppeWrap.style.display = (p.group === 'In Anbahnung') ? '' : 'none'; if(gruppeWrap) gruppeWrap.style.display = (p.group === 'In Anbahnung') ? '' : 'none';
+34 -11
View File
@@ -19,25 +19,30 @@
// zeigte „Passwortwechsel erforderlich" an, erfüllen konnte ihn niemand. // zeigte „Passwortwechsel erforderlich" an, erfüllen konnte ihn niemand.
// currentUser: der aktuell angemeldete Benutzer ({id, username, displayName, // currentUser: der aktuell angemeldete Benutzer ({id, username, displayName,
// email, role, personId, mustChangePassword}) oder null vor dem Login. // email, role, rolleName, rechte, personId, mustChangePassword}) oder null
// vor dem Login.
let currentUser = null; let currentUser = null;
// renderUserBar: zeigt Namen + Rolle des angemeldeten Benutzers im Menueband // renderUserBar: zeigt Namen + Rolle des angemeldeten Benutzers im Menueband
// an (Element #userBarInfo) und blendet den gesamten Admin-Bereich der // an (Element #userBarInfo) und blendet den gesamten Admin-Bereich der
// Seitenleiste (#navAdminSection) nur fuer Administratoren ein. Dieser // Seitenleiste (#navAdminSection) nur bei mindestens einem Admin-Recht ein
// Bereich fuehrt zu allen Admin-Aufgaben — Benutzerverwaltung, firmenweite // (Rollen & Rechte). Es ist ein reines UI-Gate; die eigentliche Sperre sitzt
// Vorgaben, Datenbank und Sitzungen. Es ist ein reines UI-Gate; die // serverseitig in requireRecht() auf den zugehoerigen Routen, und showTab()
// eigentliche Sperre sitzt serverseitig in requireRole('admin') auf den // leitet ohne Recht zusaetzlich vom Bereich „Admin" weg (js/tabs-auth.js).
// zugehoerigen Routen, und showTab() leitet Nicht-Administratoren zusaetzlich
// vom Bereich „Admin" weg (js/tabs-auth.js).
function renderUserBar(){ function renderUserBar(){
const info = document.getElementById('userBarInfo'); const info = document.getElementById('userBarInfo');
if(info && currentUser){ if(info && currentUser){
const roleLabel = systemRolleText(currentUser.role); const roleLabel = currentUser.rolleName || systemRolleText(currentUser.role);
info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')'; info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')';
} }
const navAdminSection = document.getElementById('navAdminSection'); const navAdminSection = document.getElementById('navAdminSection');
if(navAdminSection) navAdminSection.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none'; if(navAdminSection) navAdminSection.style.display = hatAdminRecht() ? '' : 'none';
// Wandtafel-Eintrag nur mit Recht „Wandtafel anzeigen“
document.querySelectorAll('[data-tab="wand"], .nur-wandtafel-recht').forEach(el => { el.hidden = !hatRecht('wand'); });
if(typeof adminNavAbgleichen === 'function') adminNavAbgleichen();
// Allgemeines UI-Gate: Elemente mit class="nur-recht" data-recht="…"
// data-recht="a|b": eines der Rechte genügt.
document.querySelectorAll('.nur-recht[data-recht]').forEach(el => { el.hidden = !el.dataset.recht.split('|').some(r => hatRecht(r)); });
document.querySelectorAll('.nur-projektleitung').forEach(el => { el.hidden = !darfProjektlisteAendern(); }); document.querySelectorAll('.nur-projektleitung').forEach(el => { el.hidden = !darfProjektlisteAendern(); });
} }
@@ -50,13 +55,26 @@ function systemRolleText(role){
} }
// darfProjektlisteAendern: ob das angemeldete Konto Projekte anlegen und aus // darfProjektlisteAendern: ob das angemeldete Konto Projekte anlegen und aus
// der Projektliste löschen darf (Projektleiter und Administratoren). Reines // der Projektliste löschen darf (Recht „Projekte anlegen und löschen“). Reines
// UI-Gate — dieselbe Regel setzt /save-week serverseitig durch // UI-Gate — dieselbe Regel setzt /save-week serverseitig durch
// (darfProjektlisteAendern() in server.js), dort mit 403. Knöpfe zum // (darfProjektlisteAendern() in server.js), dort mit 403. Knöpfe zum
// Anlegen tragen die Klasse .nur-projektleitung und werden von // Anlegen tragen die Klasse .nur-projektleitung und werden von
// renderUserBar() ausgeblendet. // renderUserBar() ausgeblendet.
function darfProjektlisteAendern(){ function darfProjektlisteAendern(){
return !!currentUser && (currentUser.role === 'admin' || currentUser.role === 'projektleiter'); return hatRecht('proj.anlegen');
}
// hatRecht: ob die Rolle des angemeldeten Kontos dieses Recht hat (Admin →
// Rollen & Rechte). /whoami liefert die Liste mit (currentUser.rechte).
// Reines UI-Gate — die Grenze ziehen requireRecht()/hatRecht() im Server.
function hatRecht(recht){
return !!currentUser && Array.isArray(currentUser.rechte) && currentUser.rechte.includes(recht);
}
// hatAdminRecht: mindestens ein Recht aus dem Admin-Bereich (a.*) — dann
// erscheint „Admin“ in der Seitenleiste, mit den erlaubten Abschnitten.
function hatAdminRecht(){
return !!currentUser && Array.isArray(currentUser.rechte) && currentUser.rechte.some(r => r.startsWith('a.'));
} }
// logout: beendet die Server-Session und lädt die Seite neu (zeigt danach // logout: beendet die Server-Session und lädt die Seite neu (zeigt danach
@@ -138,6 +156,10 @@ function afterAuthenticated(onAuthenticated){
renderUserBar(); renderUserBar();
onAuthenticated(); onAuthenticated();
if(currentUser && currentUser.mustChangePassword) openPasswordModal(true); if(currentUser && currentUser.mustChangePassword) openPasswordModal(true);
// Rundgang (js/einfuehrung.js): wartet selbst, bis die Daten geladen sind
// und kein Dialog offen ist — nach einem Pflicht-Passwortwechsel also erst
// danach.
else if(typeof einfuehrungBeiBedarf === 'function') einfuehrungBeiBedarf();
} }
// ---------- Eigenes Passwort ändern (#passwordModal) ---------- // ---------- Eigenes Passwort ändern (#passwordModal) ----------
@@ -218,6 +240,7 @@ async function submitPasswordChange(ev){
// Der Bereich „Einstellungen" zeigt die Kennzeichnung mit an. // Der Bereich „Einstellungen" zeigt die Kennzeichnung mit an.
if(typeof renderSettings === 'function' && typeof currentTab !== 'undefined' && currentTab === 'settings') renderSettings(); if(typeof renderSettings === 'function' && typeof currentTab !== 'undefined' && currentTab === 'settings') renderSettings();
alert('Das Passwort wurde geändert.'); alert('Das Passwort wurde geändert.');
if(typeof einfuehrungBeiBedarf === 'function') einfuehrungBeiBedarf();
} }
// checkAuthAndInit: prüft per GET /whoami, ob eine gültige Session besteht. // checkAuthAndInit: prüft per GET /whoami, ob eine gültige Session besteht.
+209
View File
@@ -0,0 +1,209 @@
// -----------------------------
// Projektübersicht — Benachrichtigungen (Glocke im Menüband + Dashboard)
// -----------------------------
// Eriks Wunsch: Wer eine neue Aufgabe zugewiesen bekommt, soll das in der
// Übersicht sehen. Der Server legt die Einträge beim Speichern einer Sitzung
// an (neueZuweisungenMelden(), server.js); dieses Modul zeigt sie:
//
// - die GLOCKE im Menüband (#glockeBtn) mit der Zahl der ungelesenen, ein
// Klick öffnet die Liste (#glockeMenue). Ein Eintrag führt in den
// Bereich "Aufgaben" seines Projekts und gilt damit als gelesen.
// - auf "Meine Aufgaben" (#dashNeu, js/dashboard/dashboard.js) die
// ungelesenen als eigener Block "Neu zugewiesen".
//
// Abgefragt wird beim Laden der Daten (renderAll()) und danach jede Minute —
// eine Zuweisung durch einen Kollegen erscheint also spätestens nach einer
// Minute, ohne neu zu laden. Kiosk-Konten fragen nicht (der Server würde sie
// ohnehin mit 403 abweisen).
//
// Nutzt baseUrl() (js/server.js), currentUser (js/auth.js), esc()/
// formatWeekDate() (js/utils.js), currentWeek(), openProjectPlan() und
// openSandboxPlan().
let benachrichtigungen = { ungelesen: 0, eintraege: [] };
let benachrichtigungenZuletzt = 0;
const BENACHRICHTIGUNG_INTERVALL_MS = 60 * 1000;
// ladeBenachrichtigungen: holt GET /benachrichtigungen und zeichnet Glocke,
// offene Liste und Dashboard-Block neu. Mehrfachaufrufe innerhalb von fünf
// Sekunden werden zusammengefasst (renderAll() läuft oft mehrmals hintereinander).
// Parameter: erzwingen (Boolean, optional). Keine Rückgabe.
async function ladeBenachrichtigungen(erzwingen){
if(!currentUser || !hatRecht('app')) return;
if(!erzwingen && Date.now() - benachrichtigungenZuletzt < 5000) return;
benachrichtigungenZuletzt = Date.now();
// Die Glocke erscheint für jedes angemeldete Konto, auch wenn der Abruf
// scheitert (etwa ein noch nicht neu gestarteter Server ohne die Route) —
// sie dann still auszublenden, sah aus, als fehle die Funktion ganz.
try {
const res = await fetch(baseUrl() + '/benachrichtigungen');
const data = await res.json().catch(() => null);
if(res.ok && data && data.ok){
benachrichtigungen = { ungelesen: data.ungelesen || 0, eintraege: Array.isArray(data.eintraege) ? data.eintraege : [] };
}
} catch(e){ /* still — die Glocke ist kein Grund für eine Fehlermeldung */ }
renderGlocke();
if(glockeOffen()) renderGlockeMenue();
renderDashNeu();
}
setInterval(() => ladeBenachrichtigungen(true), BENACHRICHTIGUNG_INTERVALL_MS);
// renderGlocke: Zahl am Glockensymbol (ausgeblendet bei 0).
function renderGlocke(){
const btn = document.getElementById('glockeBtn');
const zahl = document.getElementById('glockeZahl');
if(!btn || !zahl) return;
btn.hidden = !currentUser || !hatRecht('app');
const n = benachrichtigungen.ungelesen;
zahl.textContent = n > 9 ? '9+' : String(n);
zahl.hidden = n === 0;
btn.title = n ? n + (n === 1 ? ' neue Benachrichtigung' : ' neue Benachrichtigungen') : 'Benachrichtigungen';
btn.classList.toggle('hat-neue', n > 0);
}
function glockeOffen(){
const m = document.getElementById('glockeMenue');
return !!(m && !m.hidden);
}
// toggleGlocke: Liste öffnen/schließen. Beim Öffnen frisch laden.
function toggleGlocke(){
const m = document.getElementById('glockeMenue');
const btn = document.getElementById('glockeBtn');
if(!m) return;
if(glockeOffen()){ schliesseGlocke(); return; }
m.hidden = false;
if(btn) btn.setAttribute('aria-expanded', 'true');
renderGlockeMenue();
ladeBenachrichtigungen(true);
setTimeout(() => {
document.addEventListener('click', glockeAussenKlick);
document.addEventListener('keydown', glockeEscape);
}, 0);
}
function schliesseGlocke(){
const m = document.getElementById('glockeMenue');
const btn = document.getElementById('glockeBtn');
if(m) m.hidden = true;
if(btn) btn.setAttribute('aria-expanded', 'false');
document.removeEventListener('click', glockeAussenKlick);
document.removeEventListener('keydown', glockeEscape);
}
function glockeAussenKlick(e){
// composedPath() statt e.target.closest(): Ein Klick auf „ד oder „Alle
// löschen“ zeichnet die Liste neu, bevor dieser Lauscher läuft — das
// angeklickte Element hängt dann nicht mehr im Menü, closest() fände es
// nicht, und das Fenster ginge zu. Der Pfad wird beim Auslösen festgehalten.
const pfad = e.composedPath ? e.composedPath() : [];
const drin = el => el && (pfad.includes(el) || e.target.closest && e.target.closest('#' + el.id));
if(drin(document.getElementById('glockeMenue')) || drin(document.getElementById('glockeBtn'))) return;
schliesseGlocke();
}
function glockeEscape(e){ if(e.key === 'Escape') schliesseGlocke(); }
// zeitVor: "gerade eben", "vor 5 Min.", "vor 3 Std.", sonst das Datum.
function zeitVor(iso){
const t = Date.parse(iso);
if(isNaN(t)) return '';
const min = Math.round((Date.now() - t) / 60000);
if(min < 1) return 'gerade eben';
if(min < 60) return 'vor ' + min + ' Min.';
const std = Math.round(min / 60);
if(std < 24) return 'vor ' + std + ' Std.';
return new Date(t).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
// benachrichtigungZeile: eine Zeile für Glocke und Dashboard.
function benachrichtigungZeile(b){
const projekt = (b.projectNr ? b.projectNr + ' ' : '') + (b.projectName || '');
const faellig = b.faellig ? ' · fällig ' + esc(formatWeekDate(b.faellig)) : '';
// Zeile = Öffnen-Knopf + Löschen-Knopf daneben (Knöpfe dürfen nicht
// ineinander stecken).
return '<div class="glocke-zeile' + (b.gelesen ? '' : ' ist-neu') + '">'
+ '<button type="button" class="glocke-eintrag' + (b.gelesen ? '' : ' ist-neu') + '" onclick="oeffneBenachrichtigung(\'' + esc(b.id) + '\')">'
+ '<span class="glocke-punkt" aria-hidden="true"></span>'
+ '<span class="glocke-text">'
+ '<span class="glocke-titel">Neue Aufgabe: ' + esc(b.titel) + '</span>'
+ '<span class="glocke-meta">' + esc(projekt) + faellig + '</span>'
+ '<span class="glocke-meta">' + (b.von ? 'von ' + esc(b.von) + ', ' : '') + esc(zeitVor(b.erstellt)) + '</span>'
+ '</span></button>'
+ '<button type="button" class="glocke-loeschen" title="Benachrichtigung löschen" aria-label="Benachrichtigung löschen" onclick="loescheBenachrichtigungen([\'' + esc(b.id) + '\'])">&times;</button>'
+ '</div>';
}
// renderGlockeMenue: die Liste in der Glocke.
function renderGlockeMenue(){
const m = document.getElementById('glockeMenue');
if(!m) return;
const e = benachrichtigungen.eintraege;
m.innerHTML = '<div class="glocke-kopf"><b>Benachrichtigungen</b><span class="glocke-aktionen">'
+ (benachrichtigungen.ungelesen ? '<button type="button" class="glocke-alle" onclick="alleBenachrichtigungenGelesen()">Alle gelesen</button>' : '')
+ (e.length ? '<button type="button" class="glocke-alle" onclick="loescheBenachrichtigungen(null)">Alle löschen</button>' : '')
+ '</span></div>'
+ (e.length ? '<div class="glocke-liste">' + e.map(benachrichtigungZeile).join('') + '</div>'
: '<div class="glocke-leer">Keine Benachrichtigungen. Neue Aufgaben, die Ihnen jemand zuweist, erscheinen hier.</div>');
}
// renderDashNeu: Block "Neu zugewiesen" auf dem Dashboard — nur, solange es
// Ungelesenes gibt; sonst bleibt der Platz leer.
function renderDashNeu(){
const el = document.getElementById('dashNeu');
if(!el) return;
const neu = benachrichtigungen.eintraege.filter(b => !b.gelesen);
if(!neu.length){ el.innerHTML = ''; return; }
el.innerHTML = '<section class="dash-abschnitt"><div class="dash-abschnitt-kopf">'
+ '<h3 class="dash-abschnitt-titel">Neu zugewiesen</h3><span class="dash-abschnitt-zahl">' + neu.length + '</span>'
+ '<button type="button" class="dash-proj-alle" onclick="alleBenachrichtigungenGelesen()">Alle als gelesen markieren</button></div>'
+ '<div class="dash-karte dash-neu">' + neu.slice(0, 6).map(benachrichtigungZeile).join('') + '</div></section>';
}
// markiereGelesen: lokal und am Server. Parameter: ids (Array) oder null = alle.
async function markiereGelesen(ids){
benachrichtigungen.eintraege.forEach(b => { if(!ids || ids.includes(b.id)) b.gelesen = true; });
benachrichtigungen.ungelesen = benachrichtigungen.eintraege.filter(b => !b.gelesen).length;
renderGlocke(); if(glockeOffen()) renderGlockeMenue(); renderDashNeu();
try {
await fetch(baseUrl() + '/benachrichtigungen/gelesen', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ids ? { ids } : { alle: true }),
});
} catch(e){ /* beim nächsten Abruf stimmt der Stand wieder */ }
ladeBenachrichtigungen(true);
}
function alleBenachrichtigungenGelesen(){ markiereGelesen(null); }
// loescheBenachrichtigungen: entfernt Einträge lokal sofort und am Server
// endgültig. Parameter: ids (Array) oder null = alle. Keine Rückgabe.
async function loescheBenachrichtigungen(ids){
benachrichtigungen.eintraege = ids ? benachrichtigungen.eintraege.filter(b => !ids.includes(b.id)) : [];
benachrichtigungen.ungelesen = benachrichtigungen.eintraege.filter(b => !b.gelesen).length;
renderGlocke(); if(glockeOffen()) renderGlockeMenue(); renderDashNeu();
try {
await fetch(baseUrl() + '/benachrichtigungen/loeschen', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ids ? { ids } : { alle: true }),
});
} catch(e){ /* beim nächsten Abruf stimmt der Stand wieder */ }
ladeBenachrichtigungen(true);
}
// oeffneBenachrichtigung: als gelesen markieren und in die Aufgaben des
// Projekts springen. Gesucht wird das Projekt in der AKTUELLEN Sitzung über
// die Projektnummer (die Projekt-ID wechselt mit jeder Sitzung); Terminpläne
// der Planung haben keine Nummer und werden über ihre ID geöffnet.
function oeffneBenachrichtigung(id){
const b = benachrichtigungen.eintraege.find(x => x.id === id);
if(!b) return;
markiereGelesen([id]);
schliesseGlocke();
if(b.weekLabel === SANDBOX_WEEK_LABEL){
if(typeof openSandboxPlan === 'function'){ location.hash = 'aufgaben'; openSandboxPlan(b.projectId); }
return;
}
const w = typeof currentWeek === 'function' ? currentWeek() : null;
const p = w && (w.projects || []).find(x => (b.projectNr && x.nr === b.projectNr) || x.id === b.projectId)
|| (w && (w.projects || []).find(x => x.name === b.projectName));
if(!p) return;
location.hash = 'aufgaben';
openProjectPlan(p.id);
}
+83 -21
View File
@@ -117,7 +117,7 @@ function renderBoardRow(p, allPeople, locked, weekLabel, opts){
const isOpen = !!boardRowOpen[p.id]; const isOpen = !!boardRowOpen[p.id];
const tags = Array.isArray(p.tags) ? p.tags : []; const tags = Array.isArray(p.tags) ? p.tags : [];
const tagsHtml = tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join(''); const tagsHtml = tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
const previewText = statusPreviewText(p.status); const previewText = statusPreviewText(kommentarVon(p, kommentarGewerk()).status);
const isNew = p.statusState === 'new'; const isNew = p.statusState === 'new';
// Avatar-Stapel der eingeklappten Zeile: nur tatsächlich zugeordnete // Avatar-Stapel der eingeklappten Zeile: nur tatsächlich zugeordnete
@@ -186,21 +186,47 @@ function renderBoardRow(p, allPeople, locked, weekLabel, opts){
// Wird bei jedem Aufbau der Schublade neu gesetzt. // Wird bei jedem Aufbau der Schublade neu gesetzt.
let boardCommentBaseline = {}; let boardCommentBaseline = {};
// boardCommentUebernommen: je Projekt-ID, ob der gespeicherte Kommentar die
// unveränderte Übernahme aus der Vorwoche ist (Sitzungswechsel kopiert ihn,
// ensureCurrentWeekRollover() in server.js). Dann leert "Verwerfen" das Feld.
let boardCommentUebernommen = {};
// kommentarAusVorwocheUebernommen: true, wenn der Kommentar des gewählten
// Gewerks wortgleich und mit demselben Zeitstempel schon in einer früheren
// Sitzung steht — dort der jüngste nicht leere (die einmalige Übernahme der
// Einführung kam nicht immer aus der direkten Vorwoche).
// Parameter: p (Projekt), weekLabel (String). Rückgabe: Boolean.
function kommentarAusVorwocheUebernommen(p, weekLabel){
const g = kommentarGewerk();
if(!g || !p.projectUid) return false;
const jetzt = kommentarVon(p, g);
if(!(jetzt.status || '').trim()) return false;
const versionen = projectVersionsForUid(p.projectUid);
const idx = versionen.findIndex(v => v.week.label === weekLabel);
for(let i = (idx < 0 ? versionen.length : idx) - 1; i >= 0; i--){
const k = kommentarVon(versionen[i].project, g);
if(!(k.status || '').trim()) continue;
return k.status === jetzt.status && (k.statusAt || '') === (jetzt.statusAt || '');
}
return false;
}
// commentAuthorLine: Kopfzeile des Editors — wer den gespeicherten Kommentar // commentAuthorLine: Kopfzeile des Editors — wer den gespeicherten Kommentar
// verfasst hat und wann. Bestandskommentare ohne Verfasser (vor der // verfasst hat und wann. Bestandskommentare ohne Verfasser (vor der
// Einführung von status_author) werden als "unbekannt" ausgewiesen, statt // Einführung von status_author) werden als "unbekannt" ausgewiesen, statt
// stillschweigend die aktuell angemeldete Person zu behaupten. // stillschweigend die aktuell angemeldete Person zu behaupten.
// Parameter: p (Projekt). Rückgabe: HTML-String. // Parameter: p (Projekt). Rückgabe: HTML-String.
function commentAuthorLine(p){ function commentAuthorLine(p){
const hasText = statusPreviewText(p.status) !== ''; const k = kommentarVon(p, kommentarGewerk());
const hasText = statusPreviewText(k.status) !== '';
if(!hasText){ if(!hasText){
const me = (currentUser && currentUser.displayName) || ''; const me = (currentUser && currentUser.displayName) || '';
return me return me
? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(me))}</span>${esc(me)}</span><span class="comment-sep">·</span><span>neuer Eintrag</span>` ? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(me))}</span>${esc(me)}</span><span class="comment-sep">·</span><span>neuer Eintrag</span>`
: `<span>neuer Eintrag</span>`; : `<span>neuer Eintrag</span>`;
} }
const author = p.statusAuthor || ''; const author = k.statusAuthor || '';
const at = p.statusAt ? formatCommentStamp(p.statusAt) : ''; const at = k.statusAt ? formatCommentStamp(k.statusAt) : '';
const who = author const who = author
? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(author))}</span>${esc(author)}</span>` ? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(author))}</span>${esc(author)}</span>`
: `<span class="comment-who comment-unknown">Verfasser unbekannt</span>`; : `<span class="comment-who comment-unknown">Verfasser unbekannt</span>`;
@@ -232,8 +258,10 @@ function formatCommentStamp(iso){
// Parameter: p (Projekt), weekLabel (String), locked (Boolean), // Parameter: p (Projekt), weekLabel (String), locked (Boolean),
// dicht (Boolean). Rückgabe: HTML-String. // dicht (Boolean). Rückgabe: HTML-String.
function renderCommentEditor(p, weekLabel, locked, dicht){ function renderCommentEditor(p, weekLabel, locked, dicht){
const html = sanitizeStatusHtml(p.status || ''); // Kommentar des gewählten Gewerks (Wochenbericht), siehe kommentarVon().
const html = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || '');
boardCommentBaseline[p.id] = html; boardCommentBaseline[p.id] = html;
boardCommentUebernommen[p.id] = kommentarAusVorwocheUebernommen(p, weekLabel);
if(locked){ if(locked){
return `<div class="comment-block" data-pid="${esc(p.id)}"> return `<div class="comment-block" data-pid="${esc(p.id)}">
@@ -367,18 +395,17 @@ function saveBoardComment(el){
const p = currentWeek().projects.find(x => x.id === c.pid); const p = currentWeek().projects.find(x => x.id === c.pid);
if(!p) return; if(!p) return;
const before = (p.status || '').trim(); // Geschrieben wird immer der Kommentar EINES Gewerks — der Editor steht
// nur im Wochenbericht, und der hat stets ein gewähltes Gewerk.
const gewerk = kommentarGewerk();
if(!gewerk) return;
const before = (kommentarVon(p, gewerk).status || '').trim();
if(before === '' && html !== '') p.statusState = 'new'; if(before === '' && html !== '') p.statusState = 'new';
else if(html === '') delete p.statusState; else if(html === '') delete p.statusState;
p.status = html; kommentarSetzen(p, gewerk, html === ''
if(html === ''){ ? { status: '', statusAuthor: '', statusAt: '' }
p.statusAuthor = ''; : { status: html, statusAuthor: (currentUser && currentUser.displayName) || '', statusAt: new Date().toISOString() });
p.statusAt = '';
} else {
p.statusAuthor = (currentUser && currentUser.displayName) || '';
p.statusAt = new Date().toISOString();
}
boardCommentBaseline[c.pid] = html; boardCommentBaseline[c.pid] = html;
save(); save();
renderBoard(); renderBoard();
@@ -393,7 +420,9 @@ function saveBoardComment(el){
function discardBoardComment(el){ function discardBoardComment(el){
const c = commentParts(el); const c = commentParts(el);
if(!c || !c.field) return; if(!c || !c.field) return;
c.field.innerHTML = boardCommentBaseline[c.pid] || ''; // Übernommener Vorwochen-Kommentar: Verwerfen leert das ganze Feld, damit
// der Kommentar dieser Woche neu geschrieben werden kann (Eriks Wunsch).
c.field.innerHTML = boardCommentUebernommen[c.pid] ? '' : (boardCommentBaseline[c.pid] || '');
markCommentDirty(c.field); markCommentDirty(c.field);
richtextCursorAnsEnde(c.field); richtextCursorAnsEnde(c.field);
} }
@@ -408,6 +437,9 @@ function boardCommentKey(e, el){
} else if(e.key === 'Escape'){ } else if(e.key === 'Escape'){
discardBoardComment(el); discardBoardComment(el);
e.preventDefault(); e.preventDefault();
} else {
// Enter: neue Zeile beginnt mit "- " (js/utils.js)
richtextNeueZeileMitStrich(e, el);
} }
} }
@@ -441,15 +473,31 @@ function takeCommentFromHistory(el, weekLabel){
const entry = projectStatusHistory(p.projectUid).find(e => e.week.label === weekLabel); const entry = projectStatusHistory(p.projectUid).find(e => e.week.label === weekLabel);
const html = entry const html = entry
? entry.html ? entry.html
: (v => v ? sanitizeStatusHtml(v.project.status || '') : null)( : (v => v ? sanitizeStatusHtml(kommentarVon(v.project, kommentarGewerk()).status || '') : null)(
projectVersionsForUid(p.projectUid).find(v => v.week.label === weekLabel)); projectVersionsForUid(p.projectUid).find(v => v.week.label === weekLabel));
if(html === null || html === undefined) return; if(html === null || html === undefined) return;
c.field.innerHTML = sanitizeStatusHtml(html); c.field.innerHTML = sanitizeStatusHtml(html);
kommentarVorsatzEntfernen(c.field);
markCommentDirty(c.field); markCommentDirty(c.field);
richtextCursorAnsEnde(c.field); richtextCursorAnsEnde(c.field);
} }
// kommentarVorsatzEntfernen: nimmt beim Übernehmen den Gewerke-Vorsatz am
// Anfang weg ("[ELT] ", "[ELT+HLS] " — Altbestand aus der Zeit vor den
// Kommentaren je Gewerk); im Kommentar eines Gewerks ist er überflüssig.
// Arbeitet auf dem ersten Textknoten, damit Formatierung erhalten bleibt.
// Parameter: feld (Element). Keine Rückgabe.
function kommentarVorsatzEntfernen(feld){
const walker = document.createTreeWalker(feld, NodeFilter.SHOW_TEXT);
let knoten;
while((knoten = walker.nextNode())){
if(!knoten.textContent.trim()) continue;
knoten.textContent = knoten.textContent.replace(/^\s*\[[^\]]{1,20}\]\s*/, '');
break;
}
}
// renderBoardDrawer: baut das aufgeklappte Detailpanel einer Projekt-Zeile — // renderBoardDrawer: baut das aufgeklappte Detailpanel einer Projekt-Zeile —
// links der volle Status-Editor (unverändert gegenüber der früheren Matrix- // links der volle Status-Editor (unverändert gegenüber der früheren Matrix-
// Zelle) plus alle LPH-Balken, rechts die vollständige Rollen-/Personen- // Zelle) plus alle LPH-Balken, rechts die vollständige Rollen-/Personen-
@@ -574,7 +622,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
${!locked ? `<button type="button" class="plan-verweis" style="margin-top:4px" onclick="springZuEinstellungen('${p.id}')">In den Projekteinstellungen bearbeiten →</button>` : ''} ${!locked ? `<button type="button" class="plan-verweis" style="margin-top:4px" onclick="springZuEinstellungen('${p.id}')">In den Projekteinstellungen bearbeiten →</button>` : ''}
</div> </div>
</div> </div>
${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''} ${(opts.ohneVerlauf || typeof renderProjektVerlauf !== 'function') ? '' : renderProjektVerlauf(p, weekLabel, locked)}
</div>`; </div>`;
} }
@@ -608,7 +656,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
// renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen. // renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen.
// Parameter: p (Projekt), locked. Rückgabe: HTML-String. // Parameter: p (Projekt), locked. Rückgabe: HTML-String.
function renderKommentarAnsicht(p, locked){ function renderKommentarAnsicht(p, locked){
const text = sanitizeStatusHtml(p.status || ''); const text = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || '');
return `<div class="status-edit is-ansicht locked-cell">${text || return `<div class="status-edit is-ansicht locked-cell">${text ||
'<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`; '<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`;
} }
@@ -644,7 +692,8 @@ function renderBoard(){
if(container) container.innerHTML = '<div class="hint">Keine Sitzung geladen.</div>'; if(container) container.innerHTML = '<div class="hint">Keine Sitzung geladen.</div>';
return; return;
} }
const locked = isWeekLocked(w); // Ziehen zwischen Gruppen ändert Stammdaten (Rollen & Rechte).
const locked = isWeekLocked(w) || !hatRecht('proj.stamm');
const grouped = peopleByRole(w); const grouped = peopleByRole(w);
const allPeople = []; const allPeople = [];
ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry))); ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry)));
@@ -695,9 +744,22 @@ function renderBoard(){
function editLph(pid,lph,val){ function editLph(pid,lph,val){
if(guardWeekLocked()) return; if(guardWeekLocked()) return;
const p = currentWeek().projects.find(x=>x.id===pid); const p = currentWeek().projects.find(x=>x.id===pid);
val = val.trim(); if(!p) return;
if(!p.lph) p.lph = {};
val = String(val || '').trim();
if(val==="") delete p.lph[lph]; if(val==="") delete p.lph[lph];
else p.lph[lph]=val.replace('%',''); else {
// Nur Zahlen von 0 bis 100 — "abc" oder "250" hätten Auslastung und
// Leistungsstand mit NaN bzw. über 100 % verrechnet. Ungültiges wird
// verworfen (Neuzeichnen stellt den alten Wert wieder her).
const n = Number(val.replace('%','').replace(',','.').trim());
if(!Number.isFinite(n)){
renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht();
return;
}
p.lph[lph] = String(Math.max(0, Math.min(100, Math.round(n))));
}
save(); renderBoard(); save(); renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht(); if(typeof renderWochenbericht === 'function') renderWochenbericht();
} }
+357 -124
View File
@@ -13,14 +13,22 @@
// 2. BAND Der Befund des Tages als SATZ, daneben vier Kennzahlen // 2. BAND Der Befund des Tages als SATZ, daneben vier Kennzahlen
// (renderDashboardBand()). Ist nichts überfällig und nichts // (renderDashboardBand()). Ist nichts überfällig und nichts
// heute fällig, bleibt das Band farblos. // heute fällig, bleibt das Band farblos.
// 3. LINKS Der eigene Arbeitsstrom: "Meine Aufgaben" (persönlich // 3. LINKS "Meine Projekte": je Projekt, dem man zugeordnet ist, ein
// zugewiesene offene TODOs, als Zeilen — // Block mit der eigenen Rolle, der laufenden Leistungsphase und
// renderDashboardAufgabe()) und "Termine meiner Projekte" // den offenen Terminen des Projekts — eigene Aufgaben
// (Aufgaben und Meilensteine der Projekte, denen man zugeordnet // hervorgehoben (renderDashProjekte()). Projekte, in denen man
// ist, nach Dringlichkeit gegliedert — renderDashboardFristen()). // nur eine einzelne Aufgabe hat, stehen ebenfalls dort.
// 4. RECHTS Was man wissen muss, ohne selbst tätig zu werden: ausstehende // 4. RECHTS Ein Monatskalender mit allen Terminen der eigenen Projekte,
// Freigaben und Abwesenheiten der Kollegen, beide in derselben // den eigenen Aufgaben, Feiertagen und den Abwesenheiten der
// schmalen Zeilenform. // Projektbeteiligten; ein Klick auf einen Tag listet ihn
// (renderDashKalender()). Darunter ausstehende Freigaben und
// Abwesenheiten.
//
// Projekte und Kalender rechnen mit der bereits geladenen Sitzung
// (currentWeek(), /load-all) statt mit GET /my-dashboard: Der Kalender soll
// beliebig weit blättern können, der Server liefert Projekttermine aber nur
// 21 Tage voraus. Kopf und Band bleiben bei den Zahlen des Servers.
// Entwurf "B — Monatsblatt" aus Eriks Vergleich dreier Varianten.
// //
// Das war zuvor anders und schlechter: Die Uhrzeit war die größte Zahl auf // Das war zuvor anders und schlechter: Die Uhrzeit war die größte Zahl auf
// dem Bildschirm, vier gleich große Kennzahlkacheln zeigten meist drei Nullen // dem Bildschirm, vier gleich große Kennzahlkacheln zeigten meist drei Nullen
@@ -109,20 +117,27 @@ async function renderDashboard(){
html += '<div class="hint">Ihrem Benutzerkonto ist keine Person zugeordnet — es können daher keine Aufgaben angezeigt werden. Ein Administrator kann die Zuordnung in der Benutzerverwaltung ergänzen.</div>'; html += '<div class="hint">Ihrem Benutzerkonto ist keine Person zugeordnet — es können daher keine Aufgaben angezeigt werden. Ein Administrator kann die Zuordnung in der Benutzerverwaltung ergänzen.</div>';
} }
// Neu zugewiesene Aufgaben (js/benachrichtigungen.js) — vor allem anderen,
// weil es das ist, was sich seit dem letzten Blick geändert hat.
html += '<div id="dashNeu"></div>';
html += '<div class="dash-zonen">'; html += '<div class="dash-zonen">';
// Linke Zone: der eigene Arbeitsstrom // Linke Zone: die eigenen Projekte mit ihren Terminen
const daten = dashDaten();
html += '<div>'; html += '<div>';
html += dashAbschnitt('Meine Aufgaben', tasks.length, 'mir persönlich zugewiesen', const zuAlle = dashZuLesen();
!weekLabel ? dashLeer('Keine Sitzung vorhanden.') const alleOffen = daten.projekte.some(x => !zuAlle.has(dashProjKey(x.p)));
: (tasks.length === 0 ? dashLeer('Keine offenen Aufgaben — alles abgearbeitet.') html += dashAbschnitt('Meine Projekte', daten.projekte.length, null,
: '<div class="dash-karte">' + tasks.map(t => renderDashboardAufgabe(weekLabel, t)).join('') + '</div>')); renderDashProjekte(daten),
html += dashAbschnitt('Termine meiner Projekte', projectDates.length, 'nächste drei Wochen', daten.projekte.length > 1
renderDashboardFristen(weekLabel, projectDates)); ? '<button type="button" class="dash-proj-alle" id="dashAlleKnopf" onclick="dashAlleUmschalten()">'
+ (alleOffen ? 'Alle einklappen' : 'Alle aufklappen') + '</button>'
: '');
html += '</div>'; html += '</div>';
// Rechte Zone: was man wissen muss, ohne es selbst zu tun // Rechte Zone: Kalender, dann was man wissen muss, ohne es selbst zu tun
html += '<div>'; html += '<div>';
html += dashAbschnitt('Kalender', null, null, '<div class="dash-karte" id="dashKalender"></div>');
html += dashAbschnitt('Ausstehende Freigaben', pending.length, null, html += dashAbschnitt('Ausstehende Freigaben', pending.length, null,
pending.length === 0 ? dashLeer('Keine ausstehenden Freigaben.') pending.length === 0 ? dashLeer('Keine ausstehenden Freigaben.')
: '<div class="dash-karte">' + pending.map(renderDashboardFreigabe).join('') + '</div>'); : '<div class="dash-karte">' + pending.map(renderDashboardFreigabe).join('') + '</div>');
@@ -133,9 +148,331 @@ async function renderDashboard(){
html += '</div>'; html += '</div>';
container.innerHTML = html; container.innerHTML = html;
if(typeof renderDashNeu === 'function'){ renderDashNeu(); ladeBenachrichtigungen(true); }
dashKalDaten = daten;
renderDashKalender();
startDashboardClock(); startDashboardClock();
} }
// ---------- Meine Projekte + Kalender (Entwurf B "Monatsblatt") ----------
// dashKalDaten / dashKalMonat / dashKalTag: Zustand des Kalenders. Der Monat
// und der gewählte Tag überleben ein Neuladen des Dashboards nicht absichtlich
// nicht — wer wiederkommt, will "heute" sehen.
let dashKalDaten = null;
let dashKalMonat = null; // { jahr, monat } (monat 0–11)
let dashKalTag = null; // ISO-Datum
// dashDaten: sammelt aus der aktuellen Sitzung (currentWeek()) die Projekte
// des angemeldeten Kontos und alle Kalendereinträge. Ein Projekt gehört dazu,
// wenn die Person ihm zugeordnet ist (assign, Haupt 1 / Unterstützung 2) oder
// dort eine eigene offene Aufgabe hat.
// Rückgabe: { projekte:[{p, rollen:[{rolle,state}], termine:[…]}], eintraege:[…] }.
function dashDaten(){
// geladen: false, solange /load-all noch nicht da ist — das Dashboard wird
// beim Anmelden oft schon vorher aufgebaut; load() zeichnet es danach neu.
const geladen = Array.isArray(db.weeks) && db.weeks.length > 0;
const leer = { projekte: [], eintraege: [], abwesenheiten: [], geladen };
const w = (geladen && typeof currentWeek === 'function') ? currentWeek() : null;
const ich = currentUser && currentUser.personId;
if(!w || !ich) return leer;
const heute = heuteIso();
const namen = {};
(w.people || []).forEach(x => { namen[x.id] = x.fullName || x.name || x.id; });
const projekte = [];
(w.projects || []).forEach(p => {
const rollen = [];
Object.keys(p.assign || {}).forEach(key => {
const [pid, rolle] = key.split('|');
const state = Number(p.assign[key]) || 0;
if(pid === ich && state > 0) rollen.push({ rolle: rolle || '', state });
});
const plan = p.plan || {};
const termine = [];
(plan.todos || []).forEach(t => {
if(t.done) return;
const meins = Array.isArray(t.assignees) && t.assignees.includes(ich);
// Fremde Aufgaben ohne Datum gehören in keinen Kalender und keine Terminliste.
if(!t.end && !meins) return;
termine.push({
art: 'aufgabe', titel: t.title || '(ohne Titel)', datum: t.end || '',
meins, progress: Number(t.progress) || 0,
wer: (t.assignees || []).map(id => namen[id] || id),
});
});
(plan.milestones || []).forEach(m => {
if(!m.date) return;
termine.push({ art: 'meilenstein', titel: m.name || 'Meilenstein', datum: m.date, meins: false, wer: [] });
});
const hatEigene = termine.some(t => t.meins);
if(!rollen.length && !hatEigene) return;
// Erledigte Vergangenheit interessiert hier nicht: Meilensteine vor heute
// fallen weg (sie haben kein Erledigt-Kennzeichen), offene Aufgaben vor
// heute bleiben als überfällig stehen.
const relevant = termine.filter(t => !(t.art === 'meilenstein' && t.datum < heute));
relevant.sort((a, b) => (a.datum || '9999').localeCompare(b.datum || '9999'));
projekte.push({ p, rollen, termine: relevant, alleTermine: termine });
});
// Projekte mit dem dringendsten Termin zuerst, Projekte ohne Termin am Ende.
projekte.sort((a, b) => {
const da = (a.termine.find(t => t.datum) || {}).datum || '9999';
const db_ = (b.termine.find(t => t.datum) || {}).datum || '9999';
return da.localeCompare(db_) || String(a.p.nr || '').localeCompare(String(b.p.nr || ''));
});
// Kalendereinträge: alle datierten Termine dieser Projekte (auch vergangene
// Meilensteine — beim Zurückblättern sollen sie da sein).
const eintraege = [];
projekte.forEach(x => x.alleTermine.forEach(t => {
if(t.datum) eintraege.push(Object.assign({ projekt: x.p }, t));
}));
// Abwesenheiten: das eigene Konto und alle, die einem der eigenen Projekte
// zugeordnet sind — wer sonst fehlt, betrifft die eigene Arbeit nicht.
const beteiligt = new Set([ich]);
projekte.forEach(x => Object.keys(x.p.assign || {}).forEach(key => {
if(Number(x.p.assign[key]) > 0) beteiligt.add(key.split('|')[0]);
}));
const abwesenheiten = [];
(Array.isArray(db.globalPeople) ? db.globalPeople : []).forEach(person => {
if(!beteiligt.has(person.id)) return;
(person.absences || []).forEach(a => {
if(a.start && a.end) abwesenheiten.push({ name: person.id === ich ? 'Sie' : (person.fullName || person.name), start: a.start, end: a.end, type: a.type || 'Abwesend' });
});
});
return { projekte, eintraege, abwesenheiten, geladen };
}
// dashProjektUrl: Adresse des Projektbereichs (gleiches Schema wie
// openProjectPlan() in js/gantt.js). Parameter: p, abschnitt. Rückgabe: String.
function dashProjektUrl(p, abschnitt){
const w = currentWeek();
const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(p.name || ''), window.location.origin);
url.searchParams.set('weekLabel', w ? w.label : '');
url.searchParams.set('projNr', p.nr || '');
url.hash = abschnitt || 'aufgaben';
return url.toString();
}
// DASH_TERMINE_JE_PROJEKT: so viele Termine stehen je Projekt offen da,
// Überfälliges zuerst. Der Rest steckt hinter "weitere Termine" — sonst
// verdrängt ein Projekt mit 25 gerissenen Fristen alle anderen aus dem Blick
// (so im Bestand gesehen). Die Zahl der überfälligen steht ohnehin im Kopf.
const DASH_TERMINE_JE_PROJEKT = 6;
// Eingeklappte Projektblöcke (Eriks Wunsch): je Projekt merkt sich der
// Browser den Zustand über die dauerhafte projectUid — die Projekt-id ändert
// sich mit jeder Sitzung. Reiner Darstellungszustand, deshalb localStorage
// (Schlüssel DASH_ZU_KEY); ohne Speicher (privates Fenster) ist alles offen.
const DASH_ZU_KEY = 'projuebersicht-dash-zu';
function dashZuLesen(){
try { return new Set(JSON.parse(localStorage.getItem(DASH_ZU_KEY) || '[]')); } catch(e){ return new Set(); }
}
function dashZuSchreiben(set){
try { localStorage.setItem(DASH_ZU_KEY, JSON.stringify([...set])); } catch(e){ /* nur Komfort */ }
}
function dashProjKey(p){ return p.projectUid || p.nr || p.id; }
// dashProjUmschalten: klappt einen Projektblock auf oder zu — direkt am DOM,
// ohne das Dashboard neu zu laden. Parameter: knopf (der Kopf-Button).
function dashProjUmschalten(knopf){
const block = knopf.closest('.dash-proj');
if(!block) return;
const zu = !block.classList.contains('ist-zu');
block.classList.toggle('ist-zu', zu);
knopf.setAttribute('aria-expanded', String(!zu));
const set = dashZuLesen();
if(zu) set.add(block.dataset.key); else set.delete(block.dataset.key);
dashZuSchreiben(set);
dashAlleKnopfAktualisieren();
}
// dashAlleUmschalten: alle Projektblöcke auf einmal ein- bzw. ausklappen.
function dashAlleUmschalten(){
const bloecke = [...document.querySelectorAll('#dashboardContent .dash-proj')];
const zuklappen = bloecke.some(b => !b.classList.contains('ist-zu'));
const set = dashZuLesen();
bloecke.forEach(b => {
b.classList.toggle('ist-zu', zuklappen);
const k = b.querySelector('.dash-proj-toggle');
if(k) k.setAttribute('aria-expanded', String(!zuklappen));
if(zuklappen) set.add(b.dataset.key); else set.delete(b.dataset.key);
});
dashZuSchreiben(set);
dashAlleKnopfAktualisieren();
}
function dashAlleKnopfAktualisieren(){
const knopf = document.getElementById('dashAlleKnopf');
if(!knopf) return;
const offen = [...document.querySelectorAll('#dashboardContent .dash-proj')].some(b => !b.classList.contains('ist-zu'));
knopf.textContent = offen ? 'Alle einklappen' : 'Alle aufklappen';
}
// renderDashProjekte: die linke Zone. Parameter: daten (dashDaten()).
// Rückgabe: HTML-String.
function renderDashProjekte(daten){
if(!currentUser || !currentUser.personId) return dashLeer('Ohne zugeordnete Person gibt es keine eigenen Projekte.');
if(!daten.geladen) return dashLeer('Projekte werden geladen…');
if(!daten.projekte.length) return dashLeer('Sie sind in der aktuellen Sitzung keinem Projekt zugeordnet und haben keine offenen Aufgaben.');
const heute = heuteIso();
const zu = dashZuLesen();
return daten.projekte.map(x => {
const p = x.p;
const key = dashProjKey(p);
const istZu = zu.has(key);
const rollen = x.rollen.length
? x.rollen.map(r => '<span class="dash-rolle' + (r.state === 2 ? ' ist-neben' : '') + '" title="'
+ (r.state === 2 ? 'Unterstützung' : 'Hauptverantwortung') + '">' + esc(r.rolle) + '</span>').join('')
: '<span class="dash-rolle ist-neben" title="Nicht zugeordnet, aber mit eigener Aufgabe">nur Aufgabe</span>';
const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0];
const pct = aktLp && p.lph && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? Math.max(0, Math.min(100, Number(p.lph[aktLp]) || 0)) : null;
const phase = aktLp
? '<span class="dash-proj-phase"><span>' + esc(aktLp) + '</span><span class="dash-auf-bar"><i style="width:' + (pct || 0) + '%"></i></span><span class="dash-auf-pct">' + (pct === null ? '—' : pct + '%') + '</span></span>'
: '<span class="dash-proj-phase is-leer">keine amtierende Leistungsphase</span>';
const ueber = x.termine.filter(t => t.datum && t.datum < heute);
const kommend = x.termine.filter(t => !t.datum || t.datum >= heute);
const alle = ueber.concat(kommend);
const sichtbar = alle.slice(0, DASH_TERMINE_JE_PROJEKT);
const rest = alle.length - sichtbar.length;
const zeilen = sichtbar.length
? sichtbar.map(t => renderDashTermin(p, t)).join('')
: '<div class="dash-leer">Keine offenen Termine.</div>';
const mehr = rest > 0
? '<a class="dash-proj-mehr" href="' + dashProjektUrl(p, 'aufgaben') + '" target="_blank" rel="noopener">' + rest + ' weitere Termine im Projekt</a>'
: '';
// Eingeklappt bleibt sichtbar, was Aufmerksamkeit braucht: wie viele
// Termine überfällig bzw. offen sind.
const kurz = (ueber.length ? '<span class="dash-proj-zahl ist-rot">' + ueber.length + ' überfällig</span>' : '')
+ '<span class="dash-proj-zahl">' + kommend.length + ' offen</span>';
return '<div class="dash-karte dash-proj' + (istZu ? ' ist-zu' : '') + '" data-key="' + esc(key) + '">'
+ '<div class="dash-proj-kopf">'
+ '<button type="button" class="dash-proj-toggle" aria-expanded="' + (!istZu) + '" onclick="dashProjUmschalten(this)" title="Ein-/ausklappen">'
+ '<span class="dash-proj-chev" aria-hidden="true">›</span>'
+ '<span class="dash-proj-nr">' + esc(p.nr || '') + '</span><span class="dash-proj-titel">' + esc(p.name || '') + '</span>'
+ '</button>'
+ '<span class="dash-proj-kurz">' + kurz + '</span>'
+ '<span class="dash-rollen">' + rollen + '</span>'
+ '<a class="dash-proj-oeffnen" href="' + dashProjektUrl(p, 'dashboard') + '" target="_blank" rel="noopener" title="Projekt öffnen">Öffnen</a>'
+ '</div>'
+ '<div class="dash-proj-inhalt">'
+ '<div class="dash-proj-sub">' + phase + '</div>'
+ '<div class="dash-proj-termine">' + zeilen + mehr + '</div>'
+ '</div>'
+ '</div>';
}).join('');
}
// renderDashTermin: eine Terminzeile im Projektblock und in der Tagesliste
// des Kalenders. Eigene Aufgaben tragen das Zeichen "A" in Akzentfarbe,
// fremde Aufgaben dazu die Zuständigen, Meilensteine eine Raute.
// Parameter: p (Projekt), t (Termin), mitProjekt (Projekt mit anzeigen).
// Rückgabe: HTML-String.
function renderDashTermin(p, t, mitProjekt){
const heute = heuteIso();
const ueber = t.art === 'aufgabe' && t.datum && t.datum < heute;
const bald = !ueber && t.datum && t.datum <= isoPlusTage(2);
const zeichen = t.art === 'meilenstein' ? '◆' : (t.meins ? 'A' : 'a');
const klasse = t.art === 'meilenstein' ? 'ist-ms' : (t.meins ? 'ist-meins' : 'ist-fremd');
const unter = mitProjekt
? (p.nr ? p.nr + ' ' : '') + (p.name || '')
: (t.art === 'meilenstein' ? 'Meilenstein' : (t.meins ? 'meine Aufgabe' : (t.wer.length ? t.wer.join(', ') : 'niemand zugewiesen')));
return '<a class="dash-termin" href="' + dashProjektUrl(p, t.art === 'meilenstein' ? 'terminplan' : 'aufgaben') + '" target="_blank" rel="noopener">'
+ '<span class="dash-termin-art ' + klasse + '">' + zeichen + '</span>'
+ '<span class="dash-termin-text"><span class="dash-termin-titel">' + esc(t.titel) + '</span>'
+ '<span class="dash-termin-meta">' + esc(unter) + '</span></span>'
+ '<span class="dash-termin-datum' + (ueber ? ' ist-ueberfaellig' : (bald ? ' ist-bald' : '')) + '">'
+ (t.datum ? esc(formatWeekDate(t.datum)) + '<em>' + esc(relativerTag(t.datum)) + '</em>' : 'ohne Frist')
+ '</span></a>';
}
// renderDashKalender: der Monatskalender der rechten Zone samt Tagesliste.
// Zeichnet nur #dashKalender neu (Blättern und Tageswahl lösen keinen neuen
// Abruf von /my-dashboard aus). Keine Parameter.
function renderDashKalender(){
const el = document.getElementById('dashKalender');
if(!el || !dashKalDaten) return;
const heute = heuteIso();
if(!dashKalTag) dashKalTag = heute;
if(!dashKalMonat){ const d = parseDateISO(heute); dashKalMonat = { jahr: d.getFullYear(), monat: d.getMonth() }; }
const { jahr, monat } = dashKalMonat;
const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
// Einträge je Tag vorsortieren.
const jeTag = {};
dashKalDaten.eintraege.forEach(e => { (jeTag[e.datum] = jeTag[e.datum] || []).push(e); });
const abwesendAm = iso => dashKalDaten.abwesenheiten.filter(a => a.start <= iso && iso <= a.end);
const erster = new Date(jahr, monat, 1);
const start = new Date(erster); start.setDate(1 - ((erster.getDay() + 6) % 7));
let zellen = '';
for(let r = 0; r < 6; r++){
zellen += '<span class="dash-kal-kw">' + getWeekNumber(start) + '</span>';
for(let c = 0; c < 7; c++){
const iso = formatDateISO(start);
const e = jeTag[iso] || [];
const punkte = e.slice(0, 3).map(x => '<i class="' + (x.art === 'meilenstein' ? 'ist-ms'
: (x.datum < heute ? 'ist-rot' : (x.meins ? 'ist-meins' : 'ist-fremd'))) + '"></i>').join('')
+ (abwesendAm(iso).length ? '<i class="ist-ab"></i>' : '');
const feiertag = typeof HOLIDAYS !== 'undefined' && HOLIDAYS.has(iso);
const klassen = ['dash-kal-tag',
start.getMonth() !== monat ? 'ist-fremd' : '',
c > 4 || feiertag ? 'ist-frei' : '',
iso === heute ? 'ist-heute' : '',
iso === dashKalTag ? 'ist-gewaehlt' : ''].filter(Boolean).join(' ');
zellen += '<button type="button" class="' + klassen + '" onclick="dashKalWaehle(\'' + iso + '\')"'
+ ' aria-label="' + esc(formatWeekDate(iso)) + (e.length ? ', ' + e.length + ' Termine' : '') + '"'
+ (feiertag ? ' title="Feiertag"' : '') + '>'
+ start.getDate() + '<span class="dash-kal-punkte">' + punkte + '</span></button>';
start.setDate(start.getDate() + 1);
}
}
const tagE = (jeTag[dashKalTag] || []);
const tagA = abwesendAm(dashKalTag);
const feiertagHeute = typeof HOLIDAYS !== 'undefined' && HOLIDAYS.has(dashKalTag);
const tagD = parseDateISO(dashKalTag);
const tagTitel = tagD.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
+ (dashKalTag === heute ? ' (heute)' : '');
const liste = (feiertagHeute ? '<div class="dash-kal-hinweis">Feiertag</div>' : '')
+ tagE.map(e => renderDashTermin(e.projekt, e, true)).join('')
+ tagA.map(a => '<div class="dash-termin"><span class="dash-termin-art ist-ab">–</span>'
+ '<span class="dash-termin-text"><span class="dash-termin-titel">' + esc(a.name) + '</span>'
+ '<span class="dash-termin-meta">' + esc(a.type) + ' bis ' + esc(formatWeekDate(a.end)) + '</span></span></div>').join('');
el.innerHTML = '<div class="dash-kal">'
+ '<div class="dash-kal-kopf">'
+ '<button type="button" class="dash-kal-nav" onclick="dashKalBlaettern(-1)" aria-label="Voriger Monat">‹</button>'
+ '<b>' + MONATE[monat] + ' ' + jahr + '</b>'
+ '<button type="button" class="dash-kal-nav" onclick="dashKalBlaettern(1)" aria-label="Nächster Monat">›</button>'
+ '</div>'
+ '<div class="dash-kal-raster"><span class="dash-kal-wt">KW</span>'
+ ['Mo','Di','Mi','Do','Fr','Sa','So'].map(t => '<span class="dash-kal-wt">' + t + '</span>').join('')
+ zellen + '</div>'
+ '<div class="dash-kal-legende"><span><i class="ist-meins"></i>meine Aufgabe</span><span><i class="ist-fremd"></i>Projektaufgabe</span>'
+ '<span><i class="ist-ms"></i>Meilenstein</span><span><i class="ist-rot"></i>überfällig</span><span><i class="ist-ab"></i>Abwesenheit</span></div>'
+ '</div>'
+ '<div class="dash-kal-liste"><div class="dash-kal-liste-titel">' + esc(tagTitel) + '</div>'
+ (liste || '<div class="dash-leer">Keine Termine an diesem Tag.</div>') + '</div>';
}
// dashKalWaehle / dashKalBlaettern: Tag wählen bzw. Monat wechseln. Ein Tag
// aus dem Vor- oder Folgemonat blättert dorthin.
function dashKalWaehle(iso){
dashKalTag = iso;
const d = parseDateISO(iso);
dashKalMonat = { jahr: d.getFullYear(), monat: d.getMonth() };
renderDashKalender();
}
function dashKalBlaettern(n){
let { jahr, monat } = dashKalMonat;
monat += n;
if(monat < 0){ monat = 11; jahr--; }
if(monat > 11){ monat = 0; jahr++; }
dashKalMonat = { jahr, monat };
renderDashKalender();
}
// renderDashboardKopf: Begrüßung und Datum links, Sitzung und Uhr klein // renderDashboardKopf: Begrüßung und Datum links, Sitzung und Uhr klein
// rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm — sie ist // rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm — sie ist
// die Angabe, nach der man am wenigsten handelt, und steht deshalb am Rand. // die Angabe, nach der man am wenigsten handelt, und steht deshalb am Rand.
@@ -194,12 +531,15 @@ function renderDashboardBand(z){
// dashAbschnitt / dashLeer: die immer gleiche Umrahmung eines Blocks — // dashAbschnitt / dashLeer: die immer gleiche Umrahmung eines Blocks —
// Überschrift, Anzahl, ein erklärender Zusatz am rechten Rand. // Überschrift, Anzahl, ein erklärender Zusatz am rechten Rand.
// Parameter: titel, zahl (oder null), zusatz (oder null), inhalt. // Parameter: titel, zahl (oder null), zusatz (oder null), inhalt.
function dashAbschnitt(titel, zahl, zusatz, inhalt){ // kopfHtml (optional): fertiges HTML am rechten Rand der Überschrift, etwa
// ein Bedienknopf.
function dashAbschnitt(titel, zahl, zusatz, inhalt, kopfHtml){
return '<section class="dash-abschnitt">' return '<section class="dash-abschnitt">'
+ '<div class="dash-abschnitt-kopf">' + '<div class="dash-abschnitt-kopf">'
+ '<h3 class="dash-abschnitt-titel">' + esc(titel) + '</h3>' + '<h3 class="dash-abschnitt-titel">' + esc(titel) + '</h3>'
+ (zahl != null ? '<span class="dash-abschnitt-zahl">' + zahl + '</span>' : '') + (zahl != null ? '<span class="dash-abschnitt-zahl">' + zahl + '</span>' : '')
+ (zusatz ? '<span class="dash-abschnitt-zusatz">' + esc(zusatz) + '</span>' : '') + (zusatz ? '<span class="dash-abschnitt-zusatz">' + esc(zusatz) + '</span>' : '')
+ (kopfHtml || '')
+ '</div>' + inhalt + '</section>'; + '</div>' + inhalt + '</section>';
} }
function dashLeer(text){ function dashLeer(text){
@@ -243,46 +583,6 @@ function startDashboardClock(){
dashboardClockTimer = setInterval(tick, 30000); dashboardClockTimer = setInterval(tick, 30000);
} }
// renderDashboardAufgabe: eine eigene Aufgabe als ZEILE — Titel und Projekt
// links, Fortschritt in der Mitte, Fälligkeit rechts.
//
// Zuvor war das eine Karte, die eine halbe Bildschirmbreite für vier Zeilen
// Inhalt belegte; zwei davon füllten den sichtbaren Bereich. Dieselben
// Angaben stehen jetzt in einer Zeile, acht davon auf derselben Fläche.
// Verlinkt wie bisher auf den Projektbereich (gleiches URL-Schema wie
// openProjectPlan() in js/gantt.js).
// Parameter: weekLabel, task. Rückgabe: HTML-String.
function renderDashboardAufgabe(weekLabel, task){
const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(task.projectName || ''), window.location.origin);
url.searchParams.set('weekLabel', weekLabel);
url.searchParams.set('projNr', task.projectNr || '');
url.hash = 'aufgaben';
const heute = heuteIso();
const bald = isoPlusTage(2);
// Dieselbe Kennzeichnung wie in der Terminliste darunter, damit die Kachel
// "überfällig" und die beiden Listen zusammenpassen.
const klasse = !task.end ? '' : (task.end < heute ? ' ist-ueberfaellig' : (task.end <= bald ? ' ist-bald' : ''));
const datum = task.end ? formatWeekDate(task.end) : '—';
const relativ = task.end ? relativerTag(task.end) : 'ohne Frist';
const progress = Number(task.progress || 0);
const tags = (Array.isArray(task.tags) ? task.tags : [])
.map(t => '<span class="dash-auf-tag">' + esc(t) + '</span>').join('');
const projekt = (task.projectNr ? task.projectNr + ' ' : '') + (task.projectName || '');
return '<a class="dash-auf" href="' + url.toString() + '" target="_blank" rel="noopener">'
+ '<span class="dash-auf-text">'
+ '<span class="dash-auf-titel">' + esc(task.title || '(ohne Titel)') + '</span>'
+ '<span class="dash-auf-meta">' + esc(projekt) + tags + '</span>'
+ '</span>'
+ '<span class="dash-auf-fortschritt">'
+ '<span class="dash-auf-bar"><i style="width:' + Math.min(100, Math.max(0, progress)) + '%"></i></span>'
+ '<span class="dash-auf-pct">' + progress + '%</span>'
+ '</span>'
+ '<span class="dash-auf-frist' + klasse + '">' + esc(datum) + '<em>' + esc(relativ) + '</em></span>'
+ '</a>';
}
// relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem // relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem
// ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht // ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht
// sagt, ob das morgen oder in drei Wochen ist. // sagt, ob das morgen oder in drei Wochen ist.
@@ -296,73 +596,6 @@ function relativerTag(iso){
return 'in ' + d + ' Tagen'; return 'in ' + d + ' Tagen';
} }
// renderDashboardFristen:// renderDashboardFristen: die Termine der Projekte, denen man zugeordnet ist
// (GET /my-dashboard, Feld projectDates — bereits nach Datum sortiert, auf
// 21 Tage begrenzt und um die eigenen Aufgaben bereinigt).
//
// Bewusst eine LISTE und keine Kartenwand wie "Meine Aufgaben": Hier geht es
// um Reihenfolge in der Zeit, und Termine, die untereinander stehen, lassen
// sich der Reihe nach abarbeiten. Gegliedert wird nach Dringlichkeit statt
// nach Projekt — in der Woche, in der etwas fällig wird, ist das Datum die
// Frage, nicht das Projekt.
// Parameter: weekLabel, eintraege (Array). Rückgabe: HTML-String.
function renderDashboardFristen(weekLabel, eintraege){
if(!weekLabel) return dashLeer('Keine Sitzung vorhanden.');
if(!eintraege.length){
return dashLeer('In Ihren Projekten steht in den nächsten drei Wochen kein Termin an.');
}
const wocheEnde = isoPlusTage(7);
const gruppen = [
{ titel: 'Überfällig', klasse: 'ist-ueberfaellig', treffer: d => d.ueberfaellig },
{ titel: 'Diese Woche', klasse: 'ist-bald', treffer: d => !d.ueberfaellig && d.datum <= wocheEnde },
{ titel: 'Danach', klasse: '', treffer: d => !d.ueberfaellig && d.datum > wocheEnde },
];
// In derselben Karte wie "Meine Aufgaben" darüber: Beides ist der eigene
// Arbeitsstrom und soll gleich aussehen.
let html = '<div class="dash-karte">';
gruppen.forEach(g => {
const teil = eintraege.filter(g.treffer);
if(!teil.length) return;
html += '<div class="fristen-gruppe ' + g.klasse + '">'
+ '<div class="fristen-gruppe-titel">' + esc(g.titel) + '<span>' + teil.length + '</span></div>';
teil.forEach(d => { html += renderFristenZeile(weekLabel, d); });
html += '</div>';
});
return html + '</div>';
}
// renderFristenZeile: eine Zeile der Terminliste. Der ganze Eintrag führt in
// den Terminplan des Projekts — dorthin, wo sich der Termin ändern lässt.
// Parameter: weekLabel, d (Eintrag). Rückgabe: HTML-String.
function renderFristenZeile(weekLabel, d){
const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(d.projectName || ''), window.location.origin);
url.searchParams.set('weekLabel', weekLabel);
url.searchParams.set('projNr', d.projectNr || '');
url.hash = 'aufgaben';
const datum = d.datum ? formatWeekDate(d.datum) : '';
const tage = tageBis(d.datum);
// Die Angabe in Tagen steht neben dem Datum, weil "12.08." allein nicht
// sagt, ob das morgen oder in drei Wochen ist.
const relativ = d.heute ? 'heute'
: (tage < 0 ? (Math.abs(tage) === 1 ? 'seit 1 Tag' : 'seit ' + Math.abs(tage) + ' Tagen')
: (tage === 1 ? 'morgen' : 'in ' + tage + ' Tagen'));
const wer = Array.isArray(d.zustaendig) && d.zustaendig.length
? esc(d.zustaendig.join(', '))
: '<span class="hint" style="margin:0">niemand zugewiesen</span>';
return '<a class="fristen-zeile" href="' + url.toString() + '" target="_blank" rel="noopener">'
+ '<span class="fristen-datum"><b>' + esc(datum) + '</b><em>' + esc(relativ) + '</em></span>'
+ '<span class="fristen-art' + (d.art === 'meilenstein' ? ' ist-meilenstein' : '') + '">'
+ (d.art === 'meilenstein' ? 'Meilenstein' : 'Aufgabe') + '</span>'
+ '<span class="fristen-titel">' + esc(d.titel || '(ohne Titel)') + '</span>'
+ '<span class="fristen-projekt">' + esc(d.projectNr ? d.projectNr + ' ' + d.projectName : d.projectName) + '</span>'
+ '<span class="fristen-wer">' + wer + '</span>'
+ '</a>';
}
// tageBis: Kalendertage von heute bis zu einem ISO-Datum (negativ = liegt // tageBis: Kalendertage von heute bis zu einem ISO-Datum (negativ = liegt
// zurück). Parameter: iso. Rückgabe: ganze Zahl. // zurück). Parameter: iso. Rückgabe: ganze Zahl.
function tageBis(iso){ function tageBis(iso){
+213
View File
@@ -0,0 +1,213 @@
// -----------------------------
// Einführung — geführter Rundgang
// -----------------------------
// Eriks Wunsch: Jeder Nutzer, auch bestehende, macht einmal einen geführten
// Rundgang durch die Anwendung; unter Einstellungen → Mein Konto lässt er
// sich jederzeit neu starten. Der Rundgang hebt den jeweiligen Eintrag der
// Seitenleiste hervor (Aussparung in einer abgedunkelten Ebene), wechselt in
// den Bereich und erklärt ihn in einer Sprechblase.
//
// Gezeigt wird nur, was die Rolle darf (hatRecht(), Rollen & Rechte) — ein
// Konto ohne „Anwendung nutzen“ (Wandtafel) bekommt keinen Rundgang.
// Abgeschlossen oder übersprungen → POST /einfuehrung (users.einfuehrung_am),
// danach startet er nicht mehr von selbst. Esc bzw. „Später“ schließt nur
// für diesen Seitenaufruf (sessionStorage) — beim nächsten Laden kommt er
// wieder.
//
// Ein Schritt: { id, recht?, wenn?(), tab?, ziel? (CSS-Selektor), titel,
// text (HTML, aus festen Texten) }. Ohne ziel steht die Blase in der Mitte.
const EINFUEHRUNG_SCHRITTE = [
{ id: 'start', titel: 'Willkommen in der Projektübersicht',
text: () => `Hallo ${esc((currentUser && (currentUser.displayName || '').split(' ')[0]) || '')}! In gut zwei Minuten zeigen wir dir, wo du was findest. Mit den Pfeiltasten oder „Weiter“ geht es durch, „Später“ schließt den Rundgang für jetzt.` },
{ id: 'dashboard', recht: 'app', tab: 'dashboard', ziel: '.sidebar-nav [data-tab="dashboard"]', titel: 'Dashboard',
text: () => 'Deine Startseite: die Aufgaben, die dir zugewiesen sind, die nächsten Termine deiner Projekte und ein Kalender mit Meilensteinen und Abwesenheiten. Überfälliges steht oben.' },
{ id: 'glocke', recht: 'app', ziel: '#glockeBtn', titel: 'Benachrichtigungen',
text: () => 'Die Glocke zeigt, wenn dir jemand eine neue Aufgabe zugewiesen hat. Ein Klick springt direkt in die Aufgaben des Projekts.' },
{ id: 'projekte', recht: 'app', tab: 'board', ziel: '.sidebar-nav [data-tab="board"]', titel: 'Projekte',
text: () => 'Alle Projekte der laufenden Sitzung, nach Gruppen.' + (hatRecht('proj.anlegen') ? ' Über „+ Projekt“ legst du ein neues an — ein Assistent fragt Gewerke, Auftrag, Team und Terminplan ab.' : '') },
{ id: 'projektbereich', recht: 'app', tab: 'board', ziel: '#board .board-name', titel: 'Der Projektbereich',
text: () => 'Ein Klick auf den Projektnamen öffnet den Bereich des Projekts. Oben stehen die Reiter: <b>Dashboard</b>, <b>Terminplan</b>, <b>Leistungsphasen</b>, <b>Aufgaben</b>, <b>Leistung</b>, <b>Dateien</b>, <b>Protokolle</b> und <b>Einstellungen</b>.' },
{ id: 'terminplan', recht: 'proj.plan', tab: 'board', titel: 'Terminplan und Vorlagen',
text: () => 'Im Reiter <b>Terminplan</b> legst du über „+ Neu“ Leistungsphasen, Aufgaben und Meilensteine an. „+ Vorlage“ fügt die Grundleistungen der HOAI für dein Gewerk ein — die Dauer verteilt sich nach den Siemon-Anteilen über den Phasenbalken. Gib Aufgaben einen <b>Aufwand in Stunden</b>, dann zählen sie in der Auslastung.' },
{ id: 'wochenbericht', recht: 'app', tab: 'wochenbericht', ziel: '.sidebar-nav [data-tab="wochenbericht"]', titel: 'Wochenbericht',
text: () => 'Die Tagesordnung der wöchentlichen Besprechung, je Gewerk. Links die Projekte, rechts das gewählte.' + (hatRecht('wb.kommentar') ? ' Hier schreibst du den Kommentar der Woche und den Fortschritt der Leistungsphasen; „Besprochen &amp; weiter“ führt zum nächsten Projekt.' : '') + ' „Als PDF ablegen“ speichert den Bericht.' },
{ id: 'auslastung', recht: 'app', tab: 'load', ziel: '.sidebar-nav [data-tab="load"]', titel: 'Auslastung',
text: () => 'Wie voll die nächsten zwölf Wochen für jede Person sind. Die Stunden kommen aus den Aufgaben mit Aufwand — eine Aufgabe für zwei Personen zählt für jede zur Hälfte.' },
{ id: 'abwesenheiten', wenn: () => typeof darfIrgendeineAbwesenheit === 'function' && darfIrgendeineAbwesenheit(), tab: 'abwesenheit', ziel: '.sidebar-nav [data-tab="abwesenheit"]', titel: 'Abwesenheiten',
text: () => 'Hier trägst du Urlaub, Krankheit und Fortbildung ein. Unten siehst du das Team im Monat — wer außerhalb deines Bereichs liegt, erscheint nur als „abwesend“.' + (hatRecht('team.abwAlle') || hatRecht('team.abwGewerk') || hatRecht('team.abwProjekte') ? ' Ein Klick auf einen Tag in der Zeile einer Person trägt für sie ein.' : '') },
{ id: 'planung', recht: 'wz.planung', tab: 'planung', ziel: '.sidebar-nav [data-tab="planung"]', titel: 'Planung → Terminplan',
text: () => 'Terminpläne ohne echtes Projekt — zum Ausprobieren, für Angebote oder um Kapazität abzuschätzen. Aus einem Honorar errechnet oder frei angelegt.' },
{ id: 'planchecker', recht: 'wz.checker', tab: 'planchecker', ziel: '.sidebar-nav [data-tab="planchecker"]', titel: 'Plan Checker',
text: () => 'Lädt einen Plan als PDF hoch und lässt ihn von einer KI gegen eine Liste von Merkmalen prüfen. Dafür brauchst du einen eigenen KI-Schlüssel unter Einstellungen.' },
{ id: 'einstellungen', tab: 'settings', ziel: '.sidebar-nav [data-tab="settings"]', titel: 'Einstellungen',
text: () => 'Dein Konto: Passwort, E-Mail-Adresse, deine KI-Schlüssel und die Darstellung. Hier kannst du diesen <b>Rundgang jederzeit neu starten</b>.' },
{ id: 'admin', wenn: () => typeof hatAdminRecht === 'function' && hatAdminRecht(), ziel: '#adminAccordionToggle', titel: 'Admin',
text: () => 'Du darfst Teile der Verwaltung pflegen: ' + esc(typeof ADMIN_SECTIONS !== 'undefined' ? ADMIN_SECTIONS.filter(adminAbschnittErlaubt).map(s => s.title).join(', ') : '') + '.' },
{ id: 'ende', tab: 'dashboard', titel: 'Fertig!',
text: () => {
const hinweise = [];
if(currentUser && !currentUser.personId){
hinweise.push('Dein Konto ist noch keiner Person zugeordnet — „Meine Aufgaben“ bleibt deshalb leer. Bitte einen Administrator, das in der Benutzerverwaltung nachzutragen.');
}
return 'Das war der Überblick. Bei Fragen hilft dir ein Kollege — oder du startest den Rundgang unter Einstellungen noch einmal.'
+ (hinweise.length ? '<ul class="tour-hinweise">' + hinweise.map(h => '<li>' + h + '</li>').join('') + '</ul>' : '');
} },
];
let tourSchritte = [];
let tourIndex = 0;
let tourAktiv = false;
let tourVorTab = null;
// einfuehrungBeiBedarf: startet den Rundgang, wenn er für dieses Konto noch
// aussteht — sobald die Daten geladen sind und kein Dialog (Passwort) offen
// ist. Von afterAuthenticated() und nach dem Pflicht-Passwortwechsel gerufen.
function einfuehrungBeiBedarf(){
if(!currentUser || !currentUser.einfuehrungFaellig || !hatRecht('app')) return;
try { if(sessionStorage.getItem('projuebersicht-tour-spaeter')) return; } catch(e){}
let versuche = 0;
const pruefen = () => {
if(tourAktiv) return;
const geladen = typeof currentWeek === 'function' && currentWeek();
const dialogOffen = [...document.querySelectorAll('.modal-bg')].some(m => m.style.display && m.style.display !== 'none');
const login = document.getElementById('loginOverlay');
const loginOffen = login && login.style.display !== 'none' && !login.hidden && login.offsetParent !== null;
if(geladen && !dialogOffen && !loginOffen) return einfuehrungStarten();
if(++versuche < 120) setTimeout(pruefen, 500);
};
setTimeout(pruefen, 600);
}
// einfuehrungStarten: Rundgang von vorn (auch aus Einstellungen).
function einfuehrungStarten(){
tourSchritte = EINFUEHRUNG_SCHRITTE.filter(s => (!s.recht || hatRecht(s.recht)) && (!s.wenn || s.wenn()));
tourIndex = 0;
tourAktiv = true;
tourVorTab = typeof currentTab !== 'undefined' ? currentTab : null;
let el = document.getElementById('tourEbene');
if(!el){
el = document.createElement('div');
el.id = 'tourEbene';
el.className = 'tour';
el.innerHTML = '<div class="tour-loch"></div><div class="tour-blase" role="dialog" aria-modal="true" aria-labelledby="tourTitel" tabindex="-1"></div>';
document.body.appendChild(el);
window.addEventListener('resize', () => { if(tourAktiv) tourPositionieren(); });
document.addEventListener('keydown', tourTaste, true);
}
el.hidden = false;
tourZeigen();
}
function tourTaste(e){
if(!tourAktiv) return;
if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); tourSpaeter(); }
else if(e.key === 'ArrowRight' || (e.key === 'Enter' && !e.target.closest('button'))){ e.preventDefault(); tourWeiter(); }
else if(e.key === 'ArrowLeft'){ e.preventDefault(); tourZurueck(); }
}
// tourZeigen: aktuellen Schritt aufbauen (Bereich wechseln, Blase füllen,
// danach Aussparung und Blase an das Ziel legen).
function tourZeigen(){
const s = tourSchritte[tourIndex];
if(!s) return tourBeenden(true);
if(s.tab && typeof showTab === 'function' && currentTab !== s.tab) showTab(s.tab);
const blase = document.querySelector('#tourEbene .tour-blase');
const letzter = tourIndex === tourSchritte.length - 1;
blase.innerHTML = `
<div class="tour-kopf"><span class="tour-schritt">${tourIndex + 1} von ${tourSchritte.length}</span>
<button type="button" class="tour-x" onclick="tourSpaeter()" title="Später (Esc)" aria-label="Rundgang schließen">×</button></div>
<h3 id="tourTitel">${esc(s.titel)}</h3>
<div class="tour-text">${s.text()}</div>
<div class="tour-punkte" aria-hidden="true">${tourSchritte.map((_, i) => `<i class="${i === tourIndex ? 'ist-aktiv' : i < tourIndex ? 'ist-erledigt' : ''}"></i>`).join('')}</div>
<div class="tour-knoepfe">
${letzter ? '' : '<button type="button" class="tour-skip" onclick="tourBeenden(true)">Überspringen</button>'}
<span class="tour-luecke"></span>
${tourIndex > 0 ? '<button type="button" class="secondary small" onclick="tourZurueck()">Zurück</button>' : '<button type="button" class="secondary small" onclick="tourSpaeter()">Später</button>'}
<button type="button" class="small" id="tourWeiter" onclick="tourWeiter()">${letzter ? 'Fertig' : (tourIndex === 0 ? 'Los geht\'s' : 'Weiter')}</button>
</div>`;
// Nach dem Bereichswechsel kurz warten, bis die Seite steht.
requestAnimationFrame(() => { tourPositionieren(); setTimeout(tourPositionieren, 260); });
const knopf = document.getElementById('tourWeiter');
if(knopf) knopf.focus({ preventScroll: true });
}
// tourZiel: sichtbares Ziel-Element des Schritts oder null (dann Mitte).
function tourZiel(s){
if(!s || !s.ziel) return null;
const el = document.querySelector(s.ziel);
if(!el || el.hidden || el.offsetParent === null) return null;
const r = el.getBoundingClientRect();
if(r.width < 2 || r.height < 2 || r.right < 0 || r.left > innerWidth) return null;
return el;
}
function tourPositionieren(){
const ebene = document.getElementById('tourEbene');
if(!ebene || !tourAktiv) return;
const s = tourSchritte[tourIndex];
const loch = ebene.querySelector('.tour-loch');
const blase = ebene.querySelector('.tour-blase');
const ziel = tourZiel(s);
const schmal = innerWidth < 640;
if(!ziel){
ebene.classList.add('ohne-ziel');
blase.style.left = Math.max(12, (innerWidth - blase.offsetWidth) / 2) + 'px';
blase.style.top = schmal ? '' : Math.max(12, (innerHeight - blase.offsetHeight) / 2) + 'px';
blase.classList.toggle('ist-unten', schmal);
return;
}
ebene.classList.remove('ohne-ziel');
ziel.scrollIntoView({ block: 'nearest', inline: 'nearest' });
const r = ziel.getBoundingClientRect();
const pad = 6;
Object.assign(loch.style, { left: (r.left - pad) + 'px', top: (r.top - pad) + 'px', width: (r.width + 2 * pad) + 'px', height: (r.height + 2 * pad) + 'px' });
blase.classList.toggle('ist-unten', schmal);
if(schmal){ blase.style.left = '12px'; blase.style.top = ''; return; }
const bw = blase.offsetWidth, bh = blase.offsetHeight, abstand = 16;
// Bevorzugt rechts vom Ziel (Seitenleiste links), sonst darunter/darüber.
let left, top;
if(r.right + abstand + bw < innerWidth - 12){
left = r.right + abstand; top = r.top + r.height / 2 - bh / 2;
} else if(r.bottom + abstand + bh < innerHeight - 12){
left = r.left + r.width / 2 - bw / 2; top = r.bottom + abstand;
} else {
left = r.left + r.width / 2 - bw / 2; top = r.top - abstand - bh;
}
blase.style.left = Math.min(Math.max(12, left), innerWidth - bw - 12) + 'px';
blase.style.top = Math.min(Math.max(12, top), innerHeight - bh - 12) + 'px';
}
function tourWeiter(){
if(tourIndex >= tourSchritte.length - 1) return tourBeenden(true);
tourIndex++; tourZeigen();
}
function tourZurueck(){
if(tourIndex === 0) return;
tourIndex--; tourZeigen();
}
// tourSpaeter: schließt nur für diesen Seitenaufruf (kommt beim nächsten
// Laden wieder, solange er nicht abgeschlossen ist).
function tourSpaeter(){
try { sessionStorage.setItem('projuebersicht-tour-spaeter', '1'); } catch(e){}
tourSchliessen();
if(tourVorTab && typeof showTab === 'function') showTab(tourVorTab);
}
// tourBeenden: abgeschlossen oder übersprungen — am Server vermerken.
function tourBeenden(){
tourSchliessen();
if(currentUser) currentUser.einfuehrungFaellig = false;
fetch(baseUrl() + '/einfuehrung', {
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ erledigt: true })
}).catch(err => console.error('Einführung konnte nicht vermerkt werden:', err));
if(typeof showTab === 'function') showTab('dashboard');
}
function tourSchliessen(){
tourAktiv = false;
const el = document.getElementById('tourEbene');
if(el) el.hidden = true;
}
+108 -4
View File
@@ -150,6 +150,9 @@ function buildGanttHtml(projects, start, end, options = {}){
// Kopfzeile: Monate und Tage // Kopfzeile: Monate und Tage
let headerMonths = '<div class="gantt-row gantt-header">'; let headerMonths = '<div class="gantt-row gantt-header">';
// Den Griff zum Ändern der Spaltenbreite setzt ganttGriffeEinsetzen() nach
// dem Rendern in JEDE Zeile (auch die nachträglich eingefügten Aufgaben-
// und Meilensteinzeilen aus js/plan/plan-build-gantt.js).
headerMonths += '<div class="gantt-label">Projekt</div>'; headerMonths += '<div class="gantt-label">Projekt</div>';
let monthsHtml = ''; let monthsHtml = '';
let current = new Date(start.getTime()); let current = new Date(start.getTime());
@@ -280,10 +283,11 @@ function buildGanttHtml(projects, start, end, options = {}){
} }
} }
// Meilensteine: nur die nicht positionierten (keine position) in der Projektzeile anzeigen. // Meilensteine in der Projektzeile nur ohne showTodos. Im Projektplan
// Positionierte Meilensteine werden vom Aufrufer (z.B. Projektplan) als separate Zeilen eingefügt. // bekommt jeder Meilenstein eine eigene Zeile (plan-build-gantt.js) —
// sonst lagen mehrere ohne Aufgabenbezug übereinander in einer Zeile.
milestones.forEach((m, mIdx) => { milestones.forEach((m, mIdx) => {
if(options.showTodos && m.position) return; if(options.showTodos) return;
const d = parseDateISO(m.date); const d = parseDateISO(m.date);
if(!d || d < start || d > end) return; if(!d || d < start || d > end) return;
const offset = daysBetween(start, d); const offset = daysBetween(start, d);
@@ -298,7 +302,7 @@ function buildGanttHtml(projects, start, end, options = {}){
// Zoom breiter als der Container wird und horizontal gescrollt werden kann. // Zoom breiter als der Container wird und horizontal gescrollt werden kann.
let chartStyle = ''; let chartStyle = '';
if(pxPerDay){ if(pxPerDay){
chartStyle = ` style="min-width:${(220 + Math.ceil(totalDays * pxPerDay)).toFixed(0)}px"`; chartStyle = ` style="min-width:${(ganttSpaltenBreite() + Math.ceil(totalDays * pxPerDay)).toFixed(0)}px"`;
} }
return `<div class="gantt-chart"${chartStyle}>${body}</div>`; return `<div class="gantt-chart"${chartStyle}>${body}</div>`;
} }
@@ -346,3 +350,103 @@ function closePlanPage(){
const back = (typeof previousTab === 'string' && previousTab && previousTab !== 'plan') ? previousTab : 'board'; const back = (typeof previousTab === 'string' && previousTab && previousTab !== 'plan') ? previousTab : 'board';
showTab(back); showTab(back);
} }
// ---------- Breite der Spalte "Projekt" (Eriks Wunsch: "wie in Excel") ----------
//
// Die Breite steht in der CSS-Variable --gantt-label-w (styles.css,
// .gantt-label), damit alle Zeilen beim Ziehen gleichzeitig mitgehen, ohne
// das Diagramm neu aufzubauen. Gemerkt wird sie je Browser in localStorage —
// reiner Darstellungszustand wie die eingeklappten Dashboard-Projekte.
const GANTT_SPALTE_KEY = 'projuebersicht-gantt-spalte';
const GANTT_SPALTE_MIN = 120, GANTT_SPALTE_MAX = 640, GANTT_SPALTE_STANDARD = 220;
// ganttSpaltenBreite: aktuelle Breite in Pixeln (gespeichert oder Standard).
function ganttSpaltenBreite(){
let w = GANTT_SPALTE_STANDARD;
try { w = Number(localStorage.getItem(GANTT_SPALTE_KEY)) || GANTT_SPALTE_STANDARD; } catch(e){ /* Standard */ }
return Math.max(GANTT_SPALTE_MIN, Math.min(GANTT_SPALTE_MAX, w));
}
// ganttSpalteSetzen: Breite anwenden; speichern=true merkt sie dauerhaft.
function ganttSpalteSetzen(w, speichern){
w = Math.round(Math.max(GANTT_SPALTE_MIN, Math.min(GANTT_SPALTE_MAX, w)));
document.documentElement.style.setProperty('--gantt-label-w', w + 'px');
if(speichern){ try { localStorage.setItem(GANTT_SPALTE_KEY, String(w)); } catch(e){ /* nur Komfort */ } }
}
ganttSpalteSetzen(ganttSpaltenBreite(), false);
// ganttGriffeEinsetzen: hängt an den rechten Rand jeder Namenszelle einen
// Griff — die Spaltengrenze lässt sich so in jeder Zeile fassen, nicht nur in
// der Kopfzeile (Eriks Wunsch). click/dblclick werden abgefangen, damit ein
// Klick auf die Grenze nicht zusätzlich die Aufgabe der Zeile öffnet.
// Parameter: root (Element mit dem Diagramm). Keine Rückgabe.
function ganttGriffeEinsetzen(root){
if(!root) return;
root.querySelectorAll('.gantt-chart .gantt-label').forEach(label => {
if(label.querySelector(':scope > .gantt-spalten-griff')) return;
const g = document.createElement('span');
g.className = 'gantt-spalten-griff';
g.setAttribute('role', 'separator');
g.setAttribute('aria-orientation', 'vertical');
g.title = 'Spaltenbreite ziehen, Doppelklick passt sie an';
g.addEventListener('pointerdown', ganttSpalteZiehen);
g.addEventListener('mousedown', e => e.stopPropagation());
g.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); });
g.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); ganttSpalteAnpassen(); });
label.appendChild(g);
});
}
// ganttPfeileNachziehen: zeichnet die Abhängigkeitspfeile neu, höchstens
// einmal je Bild. Die Pfeile sind ein SVG mit festen Pixelkoordinaten; ohne
// das blieben sie beim Ziehen der Spalte an der alten Stelle stehen, während
// die Balken mitwandern.
let ganttPfeileGeplant = false;
function ganttPfeileNachziehen(){
if(ganttPfeileGeplant || typeof renderGanttDependencies !== 'function') return;
ganttPfeileGeplant = true;
requestAnimationFrame(() => { ganttPfeileGeplant = false; renderGanttDependencies(); });
}
// ganttNeuZeichnen: nach dem Ändern einmal neu aufbauen — die Mindestbreite
// des Diagramms bei hohem Zoom hängt von der Spaltenbreite ab.
function ganttNeuZeichnen(){
if(typeof renderPlanGantt === 'function' && typeof currentTab !== 'undefined' && currentTab === 'plan') renderPlanGantt();
}
// ganttSpalteZiehen: Ziehen am Griff (Maus, Stift, Touch). Parameter: e
// (pointerdown-Ereignis). Keine Rückgabe.
function ganttSpalteZiehen(e){
if(e.button !== undefined && e.button !== 0) return;
e.preventDefault(); e.stopPropagation();
const griff = e.currentTarget;
const startX = e.clientX, startW = ganttSpaltenBreite();
griff.setPointerCapture && griff.setPointerCapture(e.pointerId);
document.body.classList.add('gantt-spalte-zieht');
let w = startW;
const bewegen = ev => { w = startW + (ev.clientX - startX); ganttSpalteSetzen(w, false); ganttPfeileNachziehen(); };
const ende = () => {
griff.removeEventListener('pointermove', bewegen);
griff.removeEventListener('pointerup', ende);
griff.removeEventListener('pointercancel', ende);
document.body.classList.remove('gantt-spalte-zieht');
ganttSpalteSetzen(w, true);
ganttNeuZeichnen();
};
griff.addEventListener('pointermove', bewegen);
griff.addEventListener('pointerup', ende);
griff.addEventListener('pointercancel', ende);
}
// ganttSpalteAnpassen: Doppelklick auf den Griff — Breite an den längsten
// Eintrag anpassen (wie der Doppelklick auf die Spaltengrenze in Excel).
function ganttSpalteAnpassen(){
let max = 0;
document.querySelectorAll('.gantt-chart .gantt-label').forEach(el => {
// scrollWidth = volle Inhaltsbreite samt Innenabstand, auch wenn der
// Text gerade mit "…" abgeschnitten ist.
max = Math.max(max, el.scrollWidth);
});
if(!max) return;
ganttSpalteSetzen(max + 6, true);
ganttNeuZeichnen();
}
+1
View File
@@ -143,6 +143,7 @@ const TAB_SLUGS = {
board: 'Projektübersicht', board: 'Projektübersicht',
wochenbericht: 'Wochenbericht', wochenbericht: 'Wochenbericht',
load: 'Auslastung', load: 'Auslastung',
abwesenheit: 'Abwesenheiten',
people: 'Projektbeteiligte', people: 'Projektbeteiligte',
planung: 'Planung', planung: 'Planung',
planchecker: 'PlanChecker', planchecker: 'PlanChecker',
+3
View File
@@ -18,10 +18,13 @@ function renderAll(){
const activeTab = document.querySelector('.tab.active')?.dataset.tab || currentTab; const activeTab = document.querySelector('.tab.active')?.dataset.tab || currentTab;
if(activeTab === 'wochenbericht') renderWochenbericht(); if(activeTab === 'wochenbericht') renderWochenbericht();
else if(activeTab === 'load') renderLoad(); else if(activeTab === 'load') renderLoad();
else if(activeTab === 'abwesenheit') renderAbwesenheiten();
else if(activeTab === 'people') renderPeople(); else if(activeTab === 'people') renderPeople();
else if(activeTab === 'dashboard') renderDashboard(); else if(activeTab === 'dashboard') renderDashboard();
else if(activeTab === 'settings') renderSettings(); else if(activeTab === 'settings') renderSettings();
else if(activeTab === 'admin') renderAdminArea(); else if(activeTab === 'admin') renderAdminArea();
// Glocke im Menüband auf den aktuellen Stand bringen (js/benachrichtigungen.js).
if(typeof ladeBenachrichtigungen === 'function') ladeBenachrichtigungen();
// Der Projektbereich rendert nicht hier, sondern in showTab('plan') — // Der Projektbereich rendert nicht hier, sondern in showTab('plan') —
// load() ruft direkt im Anschluss showTab(currentTab) auf, und erst dort // load() ruft direkt im Anschluss showTab(currentTab) auf, und erst dort
// ist der Bereich sichtbar (das GANTT braucht die Containerbreite). // ist der Bereich sichtbar (das GANTT braucht die Containerbreite).
+315 -543
View File
@@ -1,39 +1,10 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Auslastung (Load) & Aufwand je Leistungsphase // Projektübersicht — Auslastung (Load) & Aufwand je Leistungsphase
// ----------------------------- // -----------------------------
// Modul für den Auslastungs-Tab der Hauptseite (App.html, Ladereihenfolge: // Modul für den Auslastungs-Tab (renderLoad()). Die Auslastung kommt allein
// globals → utils → ui → date-picker → csv → server → store → weeks → board → // aus Aufgaben mit Aufwand, gleich geteilt auf ihre Zuständigen (siehe
// people → projects → load → gantt → …). Es stellt renderLoad(), das LP- // "Auslastung aus Aufgaben" unten). aufgabenVerlaufJePerson() nutzen auch
// Aufwand-Modal und das Neues-Projekt-Modal bereit. Genutzt werden LPHS/ROLES // die Ressourcenansicht im Terminplan (plan-gantt.js) und die Wandtafel.
// aus js/globals.js, currentWeek()/save() aus js/store.js, guardWeekLocked()
// aus js/weeks.js sowie peopleByRole()/assignKey() aus js/utils.js.
// Die Auslastungs-Logik (berechneAuslastung, lpActiveList, faktorFormat,
// stundenFormat) wird zusätzlich von js/pdf.js für den
// PDF-Export wiederverwendet.
//
// Datenmodell je Projekt (rollen- und leistungsphasenspezifisch):
// pr.aufwandProLP = {
// 'LPH 1': { Planung: 0.4, Projektleitung: 0.6, Bauüberwachung: 0.0 },
// ...
// } // Gewichtungsfaktor je Rolle (0.0–1.0)
// pr.aktuelleLP = ['LPH 6'] // aktuell amtierende Phase (max. 1)
//
// Es zählt genau eine amtierende Phase je Projekt ("Nur amtierende Einzelphase",
// Absprache mit dem Auftraggeber). Altdaten mit mehreren aktiven Phasen
// verwenden deterministisch die erste aktive Phase der Liste.
//
// Berechnungslogik:
// Auslastung(Mitarbeiter) = Σ Faktor der amtierenden LP je Projekt,
// in dem der Mitarbeiter (in seiner Rolle) zugewiesen ist.
// Der Faktor zählt 1:1 – die Zuordnungsart (Haupt/Optional) skaliert nicht.
// Leere Matrix-Felder zählen bei gesetzter aktiver LP mit dem Standard-Faktor
// 1,0; ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet).
// "In Anbahnung" wird nicht eingerechnet.
//
// HINWEIS (Fachplaner-Fragen, bewusst NICHT eigenständig entschieden):
// - Ampel-Schwellen (50 % / 75 % des Maximalwerts) sind PLATZHALTER und müssen
// fachlich abgestimmt werden.
// - Mitarbeiterkapazität (Teilzeit/Vollzeit) existiert nicht – offener Punkt.
// AUFWAND_LPS: Phasen des Aufwandsmodells — alle echten Leistungsphasen // AUFWAND_LPS: Phasen des Aufwandsmodells — alle echten Leistungsphasen
// (inkl. LPH 9), ohne die Sammelkategorie "W+M". Bewusst per Filter statt per // (inkl. LPH 9), ohne die Sammelkategorie "W+M". Bewusst per Filter statt per
@@ -41,19 +12,6 @@
// Raster fällt. // Raster fällt.
const AUFWAND_LPS = LPHS.filter(l => l !== 'W+M'); const AUFWAND_LPS = LPHS.filter(l => l !== 'W+M');
// aufwandVorlage: die firmenweite Gewichtung je Leistungsphase und Rolle
// (0,0–1,0). Sie greift überall dort, wo ein Projekt in seiner Aufwandsmaske
// keinen eigenen Wert trägt — also bei den meisten — und bestimmt damit still
// einen Großteil der Auslastungszahlen. Genau deshalb steht sie seit dem
// Ausbau der Einstellungen nicht mehr als Konstante hier, sondern wird
// firmenweit gepflegt (Bereich „Einstellungen", nur Administratoren) und über
// APP_SETTINGS geliefert (js/globals.js, gefüllt in js/store.js). LPH 9 steht
// in der Vorgabe bewusst auf 0 — die Faktoren sind fachlich nicht abgestimmt.
// Rückgabe: Objekt LPH -> { Rolle: Faktor }.
function aufwandVorlage(){
return (APP_SETTINGS && APP_SETTINGS.aufwandVorlage) || {};
}
// Ampel-Schwellen und Balkenskala. Die Schwellen beziehen sich auf die // Ampel-Schwellen und Balkenskala. Die Schwellen beziehen sich auf die
// KAPAZITÄT der Person (1,0 = ihre volle Stelle), nicht auf das Maximum der // KAPAZITÄT der Person (1,0 = ihre volle Stelle), nicht auf das Maximum der
// Balkenskala: Mit einer Skala bis 8 lagen sie vorher faktisch bei 6,0 (rot) // Balkenskala: Mit einer Skala bis 8 lagen sie vorher faktisch bei 6,0 (rot)
@@ -98,128 +56,17 @@ function lpActiveList(pr){
return []; return [];
} }
// aufwandMatrixWert: Rollenwert eines Projekts für eine Leistungsphase // ---------- Auslastung aus Aufgaben ----------
// (Gewichtungsfaktor, 0,0–1,0). Liefert null, wenn keine (rollenspezifische) Matrix
// bzw. kein Wert für diese Phase/Rolle hinterlegt ist. 0 ist ein gültiger Wert.
function aufwandMatrixWert(projekt, lp, rolle){
const m = projekt && projekt.aufwandProLP;
const entry = (m && typeof m === 'object') ? m[lp] : undefined;
if(!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
const v = parseFloat(entry[rolle]);
return isNaN(v) ? null : v;
}
// ---------- Auslastung in Stunden ----------
// //
// Die Auslastung wird als WOCHENSTUNDEN gerechnet und erst zum Schluss auf // Eriks Vorgabe (10/2026): Die Auslastung entsteht ALLEIN aus Aufgaben mit
// die Kapazität der Person bezogen. Vorher war sie eine Summe dimensionsloser // eingetragenem Aufwand (t.aufwand, Stunden). Hat eine Aufgabe 8 h und eine
// Gewichte: Ein Projekt in LPH 5 zählte für die Planung 1,0 — ob es 20.000 € // zuständige Person, ist diese Person mit 8 h ausgelastet; bei zwei
// oder 2 Mio. € umfasste. Zwei kleine Projekte wogen damit schwerer als ein // Zuständigen je 4 h, bei drei je ein Drittel. Aufgaben ohne Aufwand zählen
// großes, und die Zahl sagte nichts darüber, wie viel Arbeit tatsächlich // nicht. Das frühere Phasenbudget (Honorar × Phasenanteil ÷ Stundensatz,
// anliegt. // verteilt über eine Gewichtungstabelle Leistungsphase × Rolle) ist
// // entfallen — samt Tabelle (firmenweit im Admin-Bereich und je Projekt).
// Der Weg von einem Projekt zu den Wochenstunden einer Person: // Die Stunden verteilen sich auf die Arbeitstage des Aufgabenzeitraums
// // (verteileAufBalken()); ohne Termine auf die nächsten UEBERFAELLIG_WOCHEN.
// 1. BASIS Honorar des Projekts. Fehlt es, tritt die am Projekt
// gepflegte Größe (0–1) mal dem Referenzhonorar an seine
// Stelle. Fehlt beides, ist das Projekt NICHT BEWERTBAR —
// es wird als Lücke ausgewiesen, nicht als Null gezählt.
// 2. PHASE Anteil der amtierenden Leistungsphase am Honorar
// (HONORAR_ANTEILE, auf die beauftragten Phasen normiert —
// dieselbe Rechnung wie im Leistungsstand des
// Projektbereichs), abzüglich des bereits erbrachten
// Fortschritts dieser Phase. Übrig bleibt der OFFENE Wert.
// 3. STUNDEN Offener Wert geteilt durch den Stundensatz.
// 4. ROLLE Aufteilung auf die Rollen über dieselbe Gewichtung wie
// bisher (Projektmatrix, sonst firmenweite Vorlage), hier
// aber als ANTEIL: Gewicht der Rolle durch die Summe aller
// Rollengewichte dieser Phase.
// 5. KÖPFE Aufteilung auf die Personen, die dieser Rolle im Projekt
// zugeordnet sind. Ohne diesen Schritt zählte jeder von drei
// Planern die vollen Phasenstunden — der Aufwand verdreifacht
// sich nicht dadurch, dass mehr Leute daran arbeiten.
// Hauptverantwortung wiegt 1, Unterstützung 0,5.
// 6. ZEIT Verteilung der Reststunden über die Wochen bis zum Ende
// des Terminplans; ohne Terminplan über den firmenweiten
// Verteilzeitraum. Ohne Horizont wäre die Wochenlast nicht
// bestimmt: Dieselben Reststunden bedeuten über vier Wochen
// etwas anderes als über vierzig.
//
// "In Anbahnung" bleibt wie bisher ausgeklammert.
// projektBasisHonorar: die Geldgrundlage eines Projekts für die
// Auslastungsrechnung. Parameter: projekt. Rückgabe:
// { betrag, quelle: 'honorar'|'groesse'|null }.
function projektBasisHonorar(projekt){
const honorar = Number(projekt && projekt.honorar) || 0;
if(honorar > 0) return { betrag: honorar, quelle: 'honorar' };
const groesse = Number(projekt && projekt.groesse) || 0;
if(groesse > 0){
const referenz = Number(APP_SETTINGS.referenzHonorar) || 250000;
return { betrag: Math.min(1, groesse) * referenz, quelle: 'groesse' };
}
return { betrag: 0, quelle: null };
}
// phasenAnteilProzent: Anteil einer Leistungsphase am Honorar des Projekts,
// normiert auf die BEAUFTRAGTEN Phasen — ein Projekt, das nur für drei Phasen
// beauftragt ist, verteilt sein Honorar auch nur auf diese drei. Sind keine
// beauftragt, gelten die firmenweiten Anteile unnormiert (sie ergeben
// zusammen 100 %). Parameter: projekt, lph. Rückgabe: Prozent (0 = unbekannt).
function phasenAnteilProzent(projekt, lph){
const roh = Number(HONORAR_ANTEILE[lph]) || 0;
if(roh <= 0) return 0;
const beauftragt = Array.isArray(projekt.beauftragteLP) ? projekt.beauftragteLP : [];
if(beauftragt.length === 0) return roh;
if(!beauftragt.includes(lph)) return 0;
const summe = beauftragt.reduce((acc, l) => acc + (Number(HONORAR_ANTEILE[l]) || 0), 0);
return summe > 0 ? roh / summe * 100 : 0;
}
// rollenGewicht: Aufwandsgewicht einer Rolle in einer Phase — Wert der
// Projektmatrix, sonst firmenweite Vorlage, sonst 1,0. Unverändert
// gegenüber der bisherigen Rechnung; neu ist nur, wofür er dient (Anteil
// statt absoluter Last). Parameter: projekt, lph, rolle. Rückgabe: Zahl.
function rollenGewicht(projekt, lph, rolle){
const wert = aufwandMatrixWert(projekt, lph, rolle);
if(wert !== null) return wert;
const vorlage = aufwandVorlage()[lph];
const v = vorlage ? parseFloat(vorlage[rolle]) : NaN;
return isNaN(v) ? 1.0 : v;
}
// zuordnungsGewicht: Wie schwer eine Zuordnung wiegt. 1 = Hauptverantwortung,
// 2 = Unterstützung. Die Unterstützung zählt halb, weil sie sonst dieselbe
// Stundenzahl trüge wie die Hauptverantwortung und die Arbeit rechnerisch
// gleichmäßig verteilt würde, obwohl sie es nicht ist.
function zuordnungsGewicht(status){
return status === 2 ? 0.5 : status === 1 ? 1 : 0;
}
// projektVerteilWochen: über wie viele Wochen die offenen Stunden eines
// Projekts verteilt werden. Aus dem Terminplan das späteste Datum (dieselbe
// Quelle, aus der auch der Verlauf das Projektende liest), gerechnet ab
// heute; ohne Terminplan oder bei bereits verstrichenem Ende gilt der
// firmenweite Verteilzeitraum. Parameter: projekt. Rückgabe: Wochen (>= 1).
function projektVerteilWochen(projekt){
const vorgabe = Math.max(1, Number(APP_SETTINGS.verteilzeitraumWochen) || 12);
const plan = projekt && projekt.plan;
if(!plan) return vorgabe;
const daten = [];
(Array.isArray(plan.todos) ? plan.todos : []).forEach(t => { if(t && t.end) daten.push(t.end); });
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach(m => { if(m && m.date) daten.push(m.date); });
if(daten.length === 0) return vorgabe;
const ende = new Date(daten.sort().pop());
if(isNaN(ende.getTime())) return vorgabe;
const heute = new Date();
heute.setHours(0, 0, 0, 0);
const tage = daysBetween(heute, ende);
// Ein Ende in der Vergangenheit sagt nichts über die kommenden Wochen —
// dann gilt wieder die Vorgabe, statt die Restarbeit auf null Wochen zu
// verteilen und eine unendliche Wochenlast zu erzeugen.
if(!Number.isFinite(tage) || tage <= 0) return vorgabe;
return Math.max(1, tage / 7);
}
// fortschrittProzent: Fortschritt einer Leistungsphase am Projekt (0–100). // fortschrittProzent: Fortschritt einer Leistungsphase am Projekt (0–100).
// Die Werte kommen aus einem Textfeld und können "80", "80 %" oder "12,5" // Die Werte kommen aus einem Textfeld und können "80", "80 %" oder "12,5"
@@ -254,63 +101,138 @@ function arbeitsPhase(projekt, amtierend){
return folgende.find(l => fortschrittProzent(projekt, l) < 100) || null; return folgende.find(l => fortschrittProzent(projekt, l) < 100) || null;
} }
// berechneAuslastung: Wochenstunden EINER Zuordnung (Person/Rolle) zu einem // ---------------------------------------------------------------------------
// Projekt. Parameter: mitarbeiter (ungenutzt, Signatur beibehalten), projekt, // Wochenvorschau: VORSCHAU_WOCHEN Kalenderwochen ab dieser Woche; "diese
// aktuelleLP, rolle, status (1 = Haupt, 2 = Unterstützung), alleZuordnungen // Woche" ist Woche 0 — die Zahl, die Wandtafel und Projektbereich zeigen.
// (optional: das assign-Objekt des Projekts, für die Aufteilung auf mehrere // ---------------------------------------------------------------------------
// Köpfe derselben Rolle). const VORSCHAU_WOCHEN = 12;
// Rückgabe: { stunden, load, anbahnung?, unbewertbar?, grund? }. // Eine offene Aufgabe, deren Ende schon vorbei ist (oder die keine Termine
// stunden = Wochenstunden dieser Person aus diesem Projekt // hat): ihre Reststunden fallen in die nächsten Wochen an.
// load = dieselbe Größe als Anteil einer Vollzeitstelle (Rückwärts- const UEBERFAELLIG_WOCHEN = 4;
// kompatibilität für js/pdf.js, das den Wert weiterhin so nennt)
function berechneAuslastung(mitarbeiter, projekt, aktuelleLP, rolle, status, alleZuordnungen){
const leer = { stunden: 0, load: 0 };
if(!projekt || projekt.group === 'In Anbahnung') return { stunden: 0, load: 0, anbahnung: true };
if(!aktuelleLP) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne amtierende Leistungsphase' });
const basis = projektBasisHonorar(projekt); // vorschauMontag: Montag der aktuellen Woche, 0 Uhr Ortszeit.
if(basis.betrag <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar und ohne Projektgröße' }); function vorschauMontag(){
const d = new Date();
// Gerechnet wird mit der Phase, in der tatsächlich gearbeitet wird — der d.setHours(0, 0, 0, 0);
// amtierenden oder, wenn die fertig ist, der nächsten offenen. d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
const phase = arbeitsPhase(projekt, aktuelleLP); return d;
if(!phase) return Object.assign({}, leer, { fertig: true });
const anteil = phasenAnteilProzent(projekt, phase);
if(anteil <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar-Anteil für diese Phase' });
const offenerWert = basis.betrag * anteil / 100 * (1 - fortschrittProzent(projekt, phase) / 100);
if(offenerWert <= 0) return Object.assign({}, leer, { fertig: true });
const satz = Number(APP_SETTINGS.stundensatz) || 100;
const gesamtStunden = offenerWert / satz;
// Rollenanteil — nach der Phase, in der gearbeitet wird
const gewichte = ROLES.map(r => rollenGewicht(projekt, phase, r));
const summeGewichte = gewichte.reduce((a, b) => a + b, 0);
const eigenesGewicht = rollenGewicht(projekt, phase, rolle);
if(summeGewichte <= 0 || eigenesGewicht <= 0) return leer;
const rollenStunden = gesamtStunden * (eigenesGewicht / summeGewichte);
// Aufteilung auf die Köpfe dieser Rolle
const eigenAnteil = zuordnungsGewicht(status);
if(eigenAnteil <= 0) return leer;
let summeKoepfe = eigenAnteil;
if(alleZuordnungen && typeof alleZuordnungen === 'object'){
summeKoepfe = 0;
Object.keys(alleZuordnungen).forEach(key => {
if(key.slice(key.indexOf('|') + 1) !== rolle) return;
summeKoepfe += zuordnungsGewicht(alleZuordnungen[key]);
});
if(summeKoepfe <= 0) summeKoepfe = eigenAnteil;
} }
const personStunden = rollenStunden * (eigenAnteil / summeKoepfe);
const wochen = projektVerteilWochen(projekt); // vorschauWochen: die Kalenderwochen der Vorschau. Rückgabe: [{montag, kw}].
const stunden = personStunden / wochen; function vorschauWochen(){
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40; const m = vorschauMontag();
return Array.from({ length: VORSCHAU_WOCHEN }, (_, i) => {
const d = new Date(m); d.setDate(d.getDate() + 7 * i);
return { montag: d, kw: getWeekNumber(d) };
});
}
return { stunden, load: stunden / vollzeit, quelle: basis.quelle, wochen, phase }; // balkenPhase: zu welcher Leistungsphase ein Terminplan-Balken gehört —
// allein über das Feld lph (Eriks Wunsch: nicht mehr über den Titel). Gesetzt
// von "+ Neu" → "Leistungsphase", "Terminplan aus Honorar" und dem
// Projekt-Assistenten; ältere Balken hat der Server einmalig anhand ihres
// Titels gekennzeichnet (migrateAddPlanTodoLeistungsphase()).
function balkenPhase(t){
return (t && t.lph) || '';
}
// verteileAufBalken: legt Stunden gleichmäßig auf die Arbeitstage eines
// Zeitraums (ohne Wochenenden und Feiertage) und summiert sie je Woche der
// Vorschau. Was vor heute läge, rückt nach vorn; ein schon abgelaufener
// Balken verteilt seinen Rest auf die nächsten UEBERFAELLIG_WOCHEN.
function verteileAufBalken(verlauf, stunden, start, ende){
const heute = new Date(); heute.setHours(0, 0, 0, 0);
let von = start, bis = ende;
if(bis < heute){
von = heute;
bis = new Date(heute); bis.setDate(bis.getDate() + UEBERFAELLIG_WOCHEN * 7 - 1);
} else if(von < heute) von = heute;
const montag = vorschauMontag();
const tage = [];
const d = new Date(von);
for(let n = 0; d <= bis && n < 1200; n++, d.setDate(d.getDate() + 1)){
if(isWorkday(d)) tage.push(new Date(d));
}
if(!tage.length) tage.push(new Date(von));
const proTag = stunden / tage.length;
tage.forEach(t => {
const i = Math.floor((t - montag) / (7 * 864e5));
if(i >= 0 && i < VORSCHAU_WOCHEN) verlauf[i] += proTag;
});
}
// ---------------------------------------------------------------------------
// Aufgaben mit Aufwand: Der noch offene Teil (Aufwand × (1 − Fortschritt))
// verteilt sich zu gleichen Teilen auf die Zuständigen und auf die
// Arbeitstage des Aufgabenzeitraums. Phasenbalken selbst sind keine Aufgaben
// in diesem Sinn.
// ---------------------------------------------------------------------------
// offeneAufwandsAufgaben: offene Aufgaben eines Projekts mit Aufwand und
// mindestens einem Zuständigen. Rückgabe: [{t, rest}].
function offeneAufwandsAufgaben(projekt){
const todos = (projekt && projekt.plan && Array.isArray(projekt.plan.todos)) ? projekt.plan.todos : [];
return todos
.filter(t => t && !t.done && !balkenPhase(t) && Number(t.aufwand) > 0
&& Array.isArray(t.assignees) && t.assignees.length)
.map(t => ({ t, rest: Number(t.aufwand) * (1 - Math.min(100, Math.max(0, Number(t.progress) || 0)) / 100) }))
.filter(a => a.rest > 0);
}
// aufgabenZeitraum: Zeitraum einer Aufgabe; ohne Termine die nächsten
// UEBERFAELLIG_WOCHEN ab heute.
function aufgabenZeitraum(t){
const a = t.start ? parseDateISO(t.start) : null;
const e = t.end ? parseDateISO(t.end) : a;
if(a && e && e >= a) return { start: a, ende: e };
const heute = new Date(); heute.setHours(0, 0, 0, 0);
const bis = new Date(heute); bis.setDate(bis.getDate() + UEBERFAELLIG_WOCHEN * 7 - 1);
return { start: heute, ende: bis };
}
// aufgabenVerlaufJePerson: Aufgabenstunden je Person und Woche der
// Vorschau. Rückgabe: { personId: [Stunden je Woche] }.
function aufgabenVerlaufJePerson(projekt){
const out = {};
offeneAufwandsAufgaben(projekt).forEach(({ t, rest }) => {
const z = aufgabenZeitraum(t);
const jeKopf = rest / t.assignees.length;
t.assignees.forEach(id => {
const v = out[id] || (out[id] = new Array(VORSCHAU_WOCHEN).fill(0));
verteileAufBalken(v, jeKopf, z.start, z.ende);
});
});
return out;
}
// globalePerson: der Eintrag aus db.globalPeople (Konto) zu einer Person der
// Sitzung — per id, sonst über den vollen Namen (siehe kapazitaetsFaktor()).
function globalePerson(person){
const list = db.globalPeople || [];
return list.find(p => p.id === person.id) || (person.fullName ? list.find(p => p.fullName === person.fullName) : null);
}
// wochenKapazitaet: Kapazität einer Person je Woche der Vorschau in
// Stunden — Vertragsstunden je Arbeitstag mal verfügbare Tage (ohne
// Wochenende, Feiertage und eingetragene Abwesenheiten).
// Rückgabe: [{ stunden, tage, abwesend }].
function wochenKapazitaet(person){
const voll = (Number(APP_SETTINGS.vollzeitStundenProWoche) || 40) * kapazitaetsFaktor(person);
const proTag = voll / 5;
const g = globalePerson(person);
const abw = (g && Array.isArray(g.absences) ? g.absences : [])
.map(a => ({ von: a.start || '', bis: a.end || a.start || '' })).filter(a => a.von);
return vorschauWochen().map(w => {
let tage = 0, abwesend = 0;
for(let i = 0; i < 5; i++){
const d = new Date(w.montag); d.setDate(d.getDate() + i);
if(!isWorkday(d)) continue;
const iso = formatDateISO(d);
if(abw.some(a => iso >= a.von && iso <= a.bis)){ abwesend++; continue; }
tage++;
}
return { stunden: tage * proTag, tage, abwesend };
});
} }
// stundenFormat: Wochenstunden in deutscher Schreibweise, eine // stundenFormat: Wochenstunden in deutscher Schreibweise, eine
@@ -352,121 +274,6 @@ function loadAmpelColor(x){
return x >= schwelleRot() ? 'var(--red)' : x >= schwelleGelb() ? 'var(--amber)' : 'var(--green)'; return x >= schwelleRot() ? 'var(--red)' : x >= schwelleGelb() ? 'var(--amber)' : 'var(--green)';
} }
// ---------- LP-Aufwand Eingabemaske ----------
// lpModalProjectId: Projekt-ID des aktuell offenen LP-Aufwand-Modals (null = geschlossen).
let lpModalProjectId = null;
// lpRowsHtml: erzeugt die Aufwand-Maske als Matrix-Tabelle.
// Zeilen = Leistungsphasen (alle ohne "W+M"), Spalten = Rollen (Planung/Projektleitung/
// Bauüberwachung). Die amtierende Leistungsphase wird pro Projekt im Tab
// "Projekte" gewählt (renderProjects / editProjectActiveLP).
// prefix: 'lp' = Bearbeiten-Modal, 'np' = Neues-Projekt-Modal (eindeutige IDs).
// Leere Felder werden mit den Werten der firmenweiten Vorlage vorbefüllt.
// Die Feld-IDs bleiben identisch, damit collectLpRows() unverändert funkioniert.
function lpRowsHtml(p, prefix){
const head = ROLES.map(r => `<th>${esc(r)}</th>`).join('');
const rows = AUFWAND_LPS.map(l => {
const idx = l.replace(/[^\d]/g,'');
const row = (p.aufwandProLP && typeof p.aufwandProLP[l] === 'object') ? p.aufwandProLP[l] : {};
const cells = ROLES.map((r, ri) => {
const raw = row[r];
const vorlage = aufwandVorlage()[l] ? aufwandVorlage()[l][r] : undefined;
const base = (raw !== undefined && raw !== null && raw !== '') ? raw
: (vorlage !== undefined && vorlage !== null && vorlage !== '') ? vorlage : '';
const val = base !== '' ? String(parseFloat(base)).replace('.', ',') : '';
// Platzhalter zeigt den Vorgabewert dieser Phase/Rolle, denn genau der
// gilt, solange das Feld leer bleibt (siehe berechneAuslastung()).
const ph = (vorlage !== undefined && vorlage !== null && vorlage !== '') ? String(parseFloat(vorlage)).replace('.', ',') : '1,0';
return `<td><input type="text" inputmode="decimal" id="${prefix}Val${idx}R${ri}" value="${val}" placeholder="${ph}" title="Leer = Vorgabewert ${ph}"></td>`;
}).join('');
return `<tr><th class="lp-lph">${l}</th>${cells}</tr>`;
}).join('');
return `<table class="lp-matrix">
<thead><tr><th class="lp-lph">Leistungsphase</th>${head}</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
// collectLpRows: liest die Aufwand-Maske aus und validiert die Werte (0–1).
// Rückgabe: { aufwand, hasValue } oder null bei Eingabefehler.
function collectLpRows(prefix){
const aufwand = {};
let hasValue = false;
for(const l of AUFWAND_LPS){
const idx = l.replace(/[^\d]/g,'');
const entry = {};
let rowHasValue = false;
let invalid = false;
for(let ri = 0; ri < ROLES.length; ri++){
const r = ROLES[ri];
const input = document.getElementById(prefix + 'Val' + idx + 'R' + ri);
if(!input) continue;
const raw = input.value.trim().replace(',', '.');
if(raw === '') continue;
// Strenge Prüfung statt parseFloat: Das nahm auch "0.5.5" (→ 0.5) oder
// "1x" (→ 1) kommentarlos an.
const v = /^\d+(\.\d+)?$/.test(raw) ? parseFloat(raw) : NaN;
if(isNaN(v) || v < 0 || v > 1){
alert('Bitte Werte zwischen 0 und 1 eintragen (z. B. 0,4). (' + l + ', ' + r + ')\n\n'
+ 'Ein leeres Feld bedeutet: es gilt der Vorgabewert dieser Leistungsphase. '
+ 'Für "kein Aufwand" bitte ausdrücklich 0 eintragen.');
invalid = true;
break;
}
entry[r] = v;
rowHasValue = true;
}
if(invalid) return null;
if(rowHasValue){ aufwand[l] = entry; hasValue = true; }
}
return { aufwand, hasValue };
}
// applyLpRows: übernimmt das Ergebnis von collectLpRows auf ein Projekt.
// Die Maske ist mit den Werten der firmenweiten Vorlage vorbefüllt;
// Felder ohne Vorbefüllung, die leer bleiben, zählen bei gesetzter aktiver LP
// später als Standard-Faktor 1,0; ohne aktive LP zählt das Projekt 0.
// Die amtierende Leistungsphase wird separat über den Tab "Projekte" gesetzt.
function applyLpRows(p, lp){
if(!lp) return false;
p.aufwandProLP = lp.hasValue ? lp.aufwand : undefined;
return true;
}
// openLpModal: öffnet das Modal "Aufwand je Leistungsphase" für ein Projekt.
// Parameter: pid = Projekt-ID; gesperrte Wochen werden abgewiesen. Kein Rückgabewert.
function openLpModal(pid){
if(guardWeekLocked()) return;
const p = currentWeek().projects.find(x=>x.id===pid);
if(!p) return;
lpModalProjectId = pid;
const titleEl = document.getElementById('lpModalProject');
if(titleEl) titleEl.textContent = (p.nr ? p.nr + ' – ' : '') + (p.name || '');
const rowsEl = document.getElementById('lpModalRows');
if(rowsEl) rowsEl.innerHTML = lpRowsHtml(p, 'lp');
document.getElementById('lpModal').style.display = 'flex';
}
// closeLpModal: blendet das LP-Aufwand-Modal aus und setzt lpModalProjectId zurück.
function closeLpModal(){
document.getElementById('lpModal').style.display = 'none';
lpModalProjectId = null;
}
// saveLpModal: liest die LP-Maske aus, übernimmt sie auf das Projekt, speichert
// und rendert Board, Projekte und Auslastung neu; danach schließt sich das Modal.
function saveLpModal(){
if(guardWeekLocked()) return;
const p = currentWeek().projects.find(x=>x.id===lpModalProjectId);
if(!p) return;
const lp = collectLpRows('lp');
if(lp === null) return;
if(applyLpRows(p, lp)) save();
renderBoard(); renderProjects(); renderLoad();
closeLpModal();
}
// ---------- Neues Projekt (mit LP-Aufwand beim Anlegen) ---------- // ---------- Neues Projekt (mit LP-Aufwand beim Anlegen) ----------
// Der Dialog "Neues Projekt" ist entfallen — openNewProjModal(), // Der Dialog "Neues Projekt" ist entfallen — openNewProjModal(),
@@ -476,9 +283,6 @@ function saveLpModal(){
// und jeden davon erst nach vollständiger Eingabe freigibt. Der Grund für den // und jeden davon erst nach vollständiger Eingabe freigibt. Der Grund für den
// Wechsel: Honorar, Team und Schlagwörter mussten zuvor NACH dem Anlegen an // Wechsel: Honorar, Team und Schlagwörter mussten zuvor NACH dem Anlegen an
// drei weiteren Stellen nachgetragen werden — und wurden es meist nicht. // drei weiteren Stellen nachgetragen werden — und wurden es meist nicht.
//
// collectLpRows()/applyLpRows()/lpRowsHtml() bleiben: Sie tragen weiterhin
// die Aufwandsmaske openLpModal(), die aus dem Projektbereich aufgerufen wird.
// PROJEKT_FARBEN: Palette der Lastband-Segmente. Bewusst gedämpfte Mitteltöne // PROJEKT_FARBEN: Palette der Lastband-Segmente. Bewusst gedämpfte Mitteltöne
// und vom Ampelspektrum (grün/gelb/rot) getrennt — ein Segment sagt aus, // und vom Ampelspektrum (grün/gelb/rot) getrennt — ein Segment sagt aus,
@@ -512,236 +316,204 @@ function projektFarbe(name){
// und Balkenskala sind firmenweit einstellbar (Bereich „Einstellungen"). // und Balkenskala sind firmenweit einstellbar (Bereich „Einstellungen").
// Mittelwert: geteilt wird durch die Zahl der Sitzungen, in denen die Person // Mittelwert: geteilt wird durch die Zahl der Sitzungen, in denen die Person
// überhaupt im Teilnehmerkreis stand — nicht durch alle vorhandenen. // überhaupt im Teilnehmerkreis stand — nicht durch alle vorhandenen.
// loadProjekteOffen: Personen (Name), deren Projektliste aufgeklappt ist.
const loadProjekteOffen = new Set();
// loadProjekteUmschalten: klappt die Projekte einer Personenkarte auf/zu,
// ohne die Seite neu zu zeichnen. Parameter: knopf (trägt data-person).
function loadProjekteUmschalten(knopf){
const name = knopf.dataset.person || '';
const box = knopf.closest('.load-chips');
if(!box) return;
const offen = !box.classList.contains('ist-offen');
box.classList.toggle('ist-offen', offen);
if(offen) loadProjekteOffen.add(name); else loadProjekteOffen.delete(name);
knopf.setAttribute('aria-expanded', String(offen));
const anzahl = box.querySelectorAll('.ist-weitere').length;
knopf.textContent = offen ? 'weniger' : '+ ' + anzahl + ' weitere';
}
function renderLoad(){ function renderLoad(){
const selectedWeek = currentWeek(); const w = currentWeek();
const weeks = selectedWeek ? [selectedWeek] : []; const grid = document.getElementById('loadGrid');
if(weeks.length === 0){ if(!grid) return;
document.getElementById('loadGrid').innerHTML = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>'; if(!w){
grid.innerHTML = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>';
return; return;
} }
const grouped = peopleByRole(selectedWeek); const wochen = vorschauWochen();
const stats = {}; const grouped = peopleByRole(w);
const personen = {}; // fullName -> { person, stunden, rollen:Set }
ROLES.forEach(r=>{
grouped[r].forEach(entry=>{
stats[assignKey(entry.person.id, r)] = {stunden:0,projects:{},anbahnung:0,weeks:0};
});
});
// Je Person zählen, in wie vielen der ausgewerteten Sitzungen sie im // Je MENSCH (über alle Rollen): Stunden je Woche, Projekte, Rollen.
// Teilnehmerkreis stand. Ohne das wurde durch ALLE Sitzungen geteilt: Ein const koepfe = {};
// neuer Kollege, der seit zwei von zwanzig Sitzungen voll eingeplant ist, const kopfVon = person => {
// erschien mit 0,30 statt 3,00 — also als praktisch unbeschäftigt. const schluessel = person.fullName || person.name;
weeks.forEach(w => { return koepfe[schluessel] || (koepfe[schluessel] = {
(Array.isArray(w.people) ? w.people : []).forEach(person => { person, ids: new Set(), rollen: new Set(), verlauf: new Array(VORSCHAU_WOCHEN).fill(0),
ROLES.forEach(r => { projekte: {}, anbahnung: new Set()
const key = assignKey(person.id, r);
if(stats[key]) stats[key].weeks++;
});
});
}); });
};
ROLES.forEach(r => grouped[r].forEach(entry => {
const k = kopfVon(entry.person);
k.ids.add(entry.person.id);
k.rollen.add(r);
}));
const kopfZuId = {};
Object.values(koepfe).forEach(k => k.ids.forEach(id => { kopfZuId[id] = k; }));
// Wer keine Fachrolle hat, aber eine Aufgabe, bekommt ebenfalls eine
// Karte — sonst wäre seine Aufgabe nirgends sichtbar.
const kopfFuerId = id => {
if(kopfZuId[id]) return kopfZuId[id];
const person = (w.people || []).find(x => x.id === id);
if(!person) return null;
const k = kopfVon(person);
k.ids.add(id);
kopfZuId[id] = k;
return k;
};
// Nicht bewertbare Projekte werden MITGEZÄHLT statt übergangen: Ein Projekt // Nicht bewertbare Projekte werden ausgewiesen statt still als 0 gezählt.
// ohne amtierende Phase, ohne Honorar und ohne Größe trug bisher still 0 const luecken = {};
// bei und war von echter Unterbeschäftigung nicht zu unterscheiden. Die
// Seite weist die Lücke jetzt oben aus.
const luecken = {}; // Projektname -> Grund
let bewertbareProjekte = 0, alleProjekte = 0; let bewertbareProjekte = 0, alleProjekte = 0;
weeks.forEach(w=>{ (w.projects || []).forEach(pr => {
w.projects.forEach(pr=>{ const anbahnung = pr.group === 'In Anbahnung';
if(pr.group === 'In Anbahnung') return; const assign = pr.assign || {};
alleProjekte++;
const aktivePhase = lpActiveList(pr)[0];
const probe = berechneAuslastung(null, pr, aktivePhase, ROLES[0], 1, null);
const projektName = pr.nr ? pr.nr + ' ' + pr.name : pr.name; const projektName = pr.nr ? pr.nr + ' ' + pr.name : pr.name;
if(probe.unbewertbar){ luecken[projektName] = probe.grund; }
else bewertbareProjekte++;
// Vergebene Stunden je Projekt mitzählen: Ein Projekt kann bewertbar // Projektanzahl je Person: jede Zuordnung zählt, unabhängig davon, ob
// sein und trotzdem bei niemandem ankommen — etwa wenn es in LPH 8 // das Projekt bewertbar ist — "wer hat wie viele Projekte".
// steht (die die Bauüberwachung trägt), aber nur ein Planer zugeordnet Object.keys(assign).forEach(key => {
// ist. Die Arbeit fällt an, wird aber niemandem zugerechnet; das ist const st = assign[key];
// eine Lücke und keine Null. if(st !== 1 && st !== 2) return;
let vergeben = 0; const k = kopfZuId[key.slice(0, key.indexOf('|'))];
if(!k) return;
if(anbahnung){ k.anbahnung.add(projektName); return; }
const e = k.projekte[projektName] || (k.projekte[projektName] = { haupt: false, stunden: 0 });
if(st === 1) e.haupt = true;
});
Object.keys(pr.assign).forEach(aKey=>{ // A: Zuständige offener Aufgaben zählen als Beteiligte des Projekts,
const s = stats[aKey]; // auch wenn sie unter "Beteiligte" nicht eingetragen sind.
if(!s) return; const todos = (pr.plan && Array.isArray(pr.plan.todos)) ? pr.plan.todos : [];
const st = pr.assign[aKey]; todos.forEach(t => {
const rolle = aKey.slice(aKey.indexOf('|') + 1); if(!t || t.done || balkenPhase(t)) return;
const al = berechneAuslastung(null, pr, aktivePhase, rolle, st, pr.assign); (Array.isArray(t.assignees) ? t.assignees : []).forEach(id => {
if(al.anbahnung){ const k = kopfFuerId(id);
if(st===1 || st===2) s.anbahnung++; if(!k) return;
return; if(anbahnung){ k.anbahnung.add(projektName); return; }
} if(!k.projekte[projektName]) k.projekte[projektName] = { haupt: false, stunden: 0 };
if(al.stunden > 0){ });
vergeben += al.stunden; });
s.stunden += al.stunden; // B: Aufgaben mit Aufwand bringen ihre Stunden mit — auch in Projekten,
// Der Stundenbeitrag des einzelnen Projekts wird behalten, nicht // die (noch) nicht bewertbar oder schon fertig gerechnet sind.
// nur gezählt: Das Lastband zeigt die Summe aufgeteilt nach const aufgabenStd = aufgabenVerlaufJePerson(pr);
// Projekten. Gerechnet wird nichts Zusätzliches — derselbe Wert, Object.keys(aufgabenStd).forEach(id => {
// der ohnehin aufaddiert wird, wurde bisher nur verworfen. const k = kopfFuerId(id);
const eintrag = s.projects[pr.name] || (s.projects[pr.name] = { anzahl:0, stunden:0, nr: pr.nr, quelle: al.quelle }); if(!k) return;
eintrag.anzahl++; aufgabenStd[id].forEach((h, i) => { k.verlauf[i] += h; });
eintrag.stunden += al.stunden; const e = k.projekte[projektName];
if(!eintrag.nr && pr.nr) eintrag.nr = pr.nr; if(e) e.stunden += aufgabenStd[id].reduce((x, y) => x + y, 0);
});
if(anbahnung) return;
// Bewertet ist ein Projekt, wenn mindestens eine offene Aufgabe mit
// Aufwand und Zuständigen darin steht — sonst trägt es nichts bei, und
// das wird ausgewiesen statt still als Null gezählt.
alleProjekte++;
if(offeneAufwandsAufgaben(pr).length) bewertbareProjekte++;
else {
const offen = todos.filter(t => t && !t.done && !balkenPhase(t));
luecken[projektName] = offen.length === 0 ? 'keine offenen Aufgaben'
: offen.some(t => Number(t.aufwand) > 0) ? 'Aufgaben mit Aufwand ohne Zuständige'
: 'keine Aufgabe mit Aufwand';
} }
}); });
if(!probe.unbewertbar && !probe.fertig && vergeben === 0 && !luecken[projektName]){ // Karten vorbereiten: Kapazität je Woche (ohne Urlaub/Feiertage), Quote,
// Welche Rolle trüge die Arbeit? Die mit dem höchsten Gewicht in der // Spitze.
// Phase, in der gerade gearbeitet wird. const MAX_QUOTE = 1.4;
const phase = arbeitsPhase(pr, aktivePhase); const karten = Object.values(koepfe).map(k => {
const tragend = phase const kap = wochenKapazitaet(k.person);
? ROLES.slice().sort((a,b)=> rollenGewicht(pr, phase, b) - rollenGewicht(pr, phase, a))[0] const quoten = k.verlauf.map((h, i) => kap[i].stunden > 0 ? h / kap[i].stunden : null);
: null; let spitze = -1, spitzeIdx = -1;
luecken[projektName] = tragend quoten.forEach((q, i) => { if(q !== null && q > spitze){ spitze = q; spitzeIdx = i; } });
? 'niemand für ' + tragend + ' zugeordnet (' + phase + ')' const projektNamen = Object.keys(k.projekte);
: 'niemand zugeordnet'; return { k, kap, quoten, spitze: Math.max(0, spitze), spitzeIdx, projektNamen,
bewertbareProjekte--; haupt: projektNamen.filter(n => k.projekte[n].haupt).length };
} }).sort((a, b) => (b.projektNamen.length > 0) - (a.projektNamen.length > 0) || b.spitze - a.spitze);
});
});
// Wochenstunden: über die Sitzungen gemittelt, in denen die Person im const prozent = q => Math.round(q * 100) + ' %';
// Teilnehmerkreis stand. const ueberlastet = karten.filter(c => c.spitze >= schwelleRot());
const wochenStunden = k => stats[k].stunden / Math.max(1, stats[k].weeks || weeks.length);
// Kapazität einer Person in Stunden je Woche (aus der Benutzerverwaltung,
// sonst die firmenweite Vollzeit).
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
const kapazitaetStunden = person => kapazitaetsFaktor(person) * vollzeit;
// Summe je MENSCH über alle Rollen. Bisher fehlte sie: Wer in drei Rollen
// arbeitet, hat trotzdem nur eine Arbeitswoche — jede Rollenzeile blieb
// unter der Schwelle, die Überlastung wurde nirgends rot. Ein Mensch mit
// 0,80 Planung und 0,40 Projektleitung ist bei 1,20, und das gehört
// sichtbar an den Anfang.
const koepfe = {};
ROLES.forEach(r=>{
grouped[r].forEach(entry=>{
const schluessel = entry.person.fullName || entry.person.name;
const k = koepfe[schluessel] || (koepfe[schluessel] = { person: entry.person, stunden: 0, rollen: [] });
const std = wochenStunden(assignKey(entry.person.id, r));
k.stunden += std;
if(std > 0) k.rollen.push(r);
});
});
let html = "";
// --- Kopf: was die Zahlen abdecken ---
const luckenNamen = Object.keys(luecken); const luckenNamen = Object.keys(luecken);
const ueberlastet = Object.values(koepfe).filter(k => k.stunden / kapazitaetStunden(k.person) >= schwelleRot());
html += `<div class="load-kopf"> let html = `<div class="load-kopf">
<div class="load-kopf-zahl"> <div class="load-kopf-zahl"><strong>${bewertbareProjekte}</strong> von ${alleProjekte} Projekten mit Aufwand in Aufgaben</div>
<strong>${bewertbareProjekte}</strong> von ${alleProjekte} Projekten bewertet ${ueberlastet.length ? `<div class="load-kopf-warnung">${ueberlastet.length} ${ueberlastet.length === 1 ? 'Person ist' : 'Personen sind'} in den nächsten ${VORSCHAU_WOCHEN} Wochen über der Kapazität</div>` : ''}
</div> ${luckenNamen.length ? `<button type="button" class="secondary small" onclick="toggleLoadLuecken()">${luckenNamen.length} ohne Aufwand…</button>` : ''}
${ueberlastet.length ? `<div class="load-kopf-warnung">${ueberlastet.length} ${ueberlastet.length === 1 ? 'Person ist' : 'Personen sind'} über der Kapazität</div>` : ''}
${luckenNamen.length ? `<button type="button" class="secondary small" onclick="toggleLoadLuecken()">${luckenNamen.length} nicht bewertbar…</button>` : ''}
</div>`; </div>`;
if(luckenNamen.length){ if(luckenNamen.length){
html += `<div class="load-luecken" id="loadLuecken" style="display:none"> html += `<div class="load-luecken" id="loadLuecken" style="display:none">
<p class="hint" style="margin:0 0 8px">Diese Projekte fließen in keine Auslastung ein. Sie erscheinen nicht als Null, sondern hier — eine fehlende Angabe ist keine fehlende Arbeit.</p> <p class="hint" style="margin:0 0 8px">Diese Projekte haben keine offene Aufgabe mit Aufwand und fließen deshalb in keine Auslastung ein.</p>
${luckenNamen.sort().map(n => `<div class="load-luecke"><span>${esc(n)}</span><b>${esc(luecken[n])}</b></div>`).join('')} ${luckenNamen.sort().map(n => `<div class="load-luecke"><span>${esc(n)}</span><b>${esc(luecken[n])}</b></div>`).join('')}
</div>`; </div>`;
} }
html += `<p class="hint load-erklaerung">Auslastung der nächsten ${VORSCHAU_WOCHEN} Wochen: Gezählt werden die offenen Stunden aller Aufgaben mit Aufwand, zu gleichen Teilen auf ihre Zuständigen und auf die Arbeitstage der Aufgabe verteilt. Gestrichelt ist die volle Kapazität, schraffiert sind Wochen mit Urlaub oder anderer Abwesenheit.</p>`;
// --- Summe je Mensch --- html += '<div class="load-karten">' + karten.map(c => {
const kopfListe = Object.values(koepfe) const k = c.k;
.filter(k => k.stunden > 0) const name = k.person.fullName || k.person.name;
.sort((a,b)=> b.stunden / kapazitaetStunden(b.person) - a.stunden / kapazitaetStunden(a.person)); const anzahl = c.projektNamen.length;
if(kopfListe.length){ const jetztKap = c.kap[0].stunden;
html += `<div class="load-rolle"><div class="load-rolle-titel">Gesamt je Person</div>`; const jetzt = c.quoten[0];
kopfListe.forEach(k=>{ const jetztText = jetzt === null ? 'abwesend' : prozent(jetzt);
const kap = kapazitaetStunden(k.person); const jetztFarbe = jetzt === null ? 'var(--text-muted)' : (k.verlauf[0] > 0 ? loadAmpelColor(jetzt) : 'var(--text-subtle)');
const quote = kap > 0 ? k.stunden / kap : 0; const teilzeit = Math.abs(kapazitaetsFaktor(k.person) - 1) < 0.005 ? ''
const ampel = loadAmpelColor(quote); : `<span class="load-capacity" title="Vertragliche Wochenstunden aus der Benutzerverwaltung">${stundenFormat(kapazitaetsFaktor(k.person) * (Number(APP_SETTINGS.vollzeitStundenProWoche) || 40))} h/Woche</span>`;
const pct = Math.min(100, quote * 100); const saeulen = c.quoten.map((q, i) => {
html += `<div class="load-zeile load-zeile-summe"> const kap = c.kap[i];
<div class="load-wer"> const h = k.verlauf[i];
<div class="load-wer-name">${esc(k.person.fullName || k.person.name)}</div> const titel = 'KW ' + wochen[i].kw + ': ' + (kap.stunden > 0
<div class="load-wer-meta">${esc(k.rollen.join(' · ') || '—')}</div> ? stundenFormat(h) + ' h von ' + stundenFormat(kap.stunden) + ' h (' + prozent(q) + ')' + (kap.abwesend ? ', ' + kap.abwesend + ' Tag(e) abwesend' : '')
</div> : 'abwesend' + (h > 0 ? ', ' + stundenFormat(h) + ' h geplant' : ''));
<div class="load-band" title="Kapazität: ${stundenFormat(kap)} h/Woche"> if(q === null) return `<span class="load-saeule ist-abwesend" title="${esc(titel)}"></span>`;
<div class="load-seg load-seg-summe" style="width:${pct}%;background:${ampel}"></div> const hoehe = Math.max(h > 0 ? 3 : 0, Math.min(q, MAX_QUOTE) / MAX_QUOTE * 100);
<div class="load-kapazitaet" title="Volle Kapazität"></div> return `<span class="load-saeule${i === 0 ? ' ist-jetzt' : ''}${kap.abwesend ? ' ist-teil' : ''}" style="height:${hoehe}%;background:${loadAmpelColor(q)}" title="${esc(titel)}"></span>`;
</div>
<div class="load-wert" style="color:${ampel}">${stundenFormat(k.stunden)}<span class="load-einheit">h</span></div>
</div>`;
});
html += `</div>`;
}
// --- Je Rolle ---
const benutzteProjekte = {};
ROLES.forEach(r=>{
html += `<div class="load-rolle"><div class="load-rolle-titel">${r}</div>`;
if(grouped[r].length===0){
html += `<div class="hint">Keine Personen.</div></div>`;
return;
}
const sortiert = grouped[r].slice().sort((a,b)=>
wochenStunden(assignKey(b.person.id, r)) - wochenStunden(assignKey(a.person.id, r)));
sortiert.forEach(entry=>{
const s = stats[assignKey(entry.person.id, r)];
const kap = kapazitaetStunden(entry.person);
const nWeeks = Math.max(1, s.weeks || weeks.length);
const std = wochenStunden(assignKey(entry.person.id, r));
const quote = kap > 0 ? std / kap : 0;
// Ohne jede Zuordnung KEINE Ampelfarbe: Grün hieße "gut ausgelastet",
// die Null heißt aber "nichts zugeordnet".
const ampel = std > 0 ? loadAmpelColor(quote) : 'var(--text-subtle)';
const stelleBadge = Math.abs(kapazitaetsFaktor(entry.person) - 1) < 0.005 ? ''
: `<span class="load-capacity" title="Vertragliche Wochenstunden aus der Benutzerverwaltung">${stundenFormat(kap)} h/Woche</span>`;
const anbahnungBadge = (s.anbahnung && s.anbahnung > 0)
? `<span class="load-anbahnung">${s.anbahnung} in Anbahnung</span>` : '';
// Segmente: je Projekt eines, Breite als Anteil der KAPAZITÄT. Die
// Skala ist damit nicht mehr willkürlich (früher fest 8, während die
// Ampel bei 1,0 rot wurde — ein roter Balken war zu 12,5 % gefüllt),
// sondern: volles Band = volle Woche.
const namen = Object.keys(s.projects).sort((a,b)=> s.projects[b].stunden - s.projects[a].stunden);
const segmente = namen.map(name=>{
const eintrag = s.projects[name];
const anteilStd = eintrag.stunden / nWeeks;
const breite = kap > 0 ? Math.min(100, anteilStd / kap * 100) : 0;
const farbe = projektFarbe(name);
if(!benutzteProjekte[name]) benutzteProjekte[name] = { nr: eintrag.nr, farbe, quelle: eintrag.quelle };
const titel = (eintrag.nr ? eintrag.nr + ' · ' : '') + name
+ ' · ' + stundenFormat(anteilStd) + ' h/Woche'
+ (eintrag.quelle === 'groesse' ? ' · aus der Projektgröße' : '')
+ (eintrag.anzahl > 1 ? ' · in ' + eintrag.anzahl + ' Sitzungen' : '');
const beschriftung = breite >= 7 ? `<span>${esc(eintrag.nr || name)}</span>` : '';
return `<div class="load-seg" style="width:${breite}%;background:${farbe}" title="${esc(titel)}">${beschriftung}</div>`;
}).join(''); }).join('');
const kws = wochen.map((wo, i) => `<span${i === 0 ? ' class="ist-jetzt"' : ''}>${wo.kw}</span>`).join('');
html += `<div class="load-zeile"> const hinweis = anzahl === 0 ? '<div class="load-hinweis ist-leer">Keinem Projekt zugeordnet.</div>'
<div class="load-wer"> : c.spitze >= schwelleRot()
<div class="load-wer-name">${esc(entry.person.fullName || entry.person.name)}</div> ? `<div class="load-hinweis ist-rot">Spitze ${prozent(c.spitze)} in KW ${wochen[c.spitzeIdx].kw}: Aufgaben verschieben oder abgeben.</div>`
<div class="load-wer-meta">${esc(entry.person.name)}${stelleBadge}${anbahnungBadge}</div> : c.spitze >= schwelleGelb()
? `<div class="load-hinweis ist-gelb">Höchstens ${prozent(c.spitze)} (KW ${wochen[c.spitzeIdx].kw}): ausgelastet, aber im Rahmen.</div>`
: `<div class="load-hinweis ist-ok">Keine Überlastung in den nächsten ${VORSCHAU_WOCHEN} Wochen.</div>`;
const top = c.projektNamen.slice().sort((a, b) => k.projekte[b].stunden - k.projekte[a].stunden);
// Alle Projekte stehen im Markup; ab dem dritten sind sie eingeklappt
// und lassen sich über den Knopf aufklappen (Eriks Wunsch). Der Zustand
// je Person überlebt das Neuzeichnen (loadProjekteOffen).
const offen = loadProjekteOffen.has(name);
const chipTitel = n => n + (k.projekte[n].stunden > 0 ? ' · ' + stundenFormat(k.projekte[n].stunden) + ' h in 12 Wochen' : '');
const chips = top.map((n, i) => `<span class="load-chip${i >= 2 ? ' ist-weitere' : ''}" title="${esc(chipTitel(n))}">${esc(n)}</span>`).join('')
+ (top.length > 2 ? `<button type="button" class="load-chip ist-mehr" aria-expanded="${offen}"
data-person="${esc(name)}" onclick="loadProjekteUmschalten(this)">${offen ? 'weniger' : '+ ' + (top.length - 2) + ' weitere'}</button>` : '');
const anbahnung = k.anbahnung.size ? `, ${k.anbahnung.size} in Anbahnung` : '';
return `<article class="load-karte${anzahl === 0 ? ' ist-ohne' : ''}">
<div class="load-karte-kopf">
<span class="load-ava">${esc(k.person.name)}</span>
<span class="load-karte-wer"><b>${esc(name)}</b>
<span>${anzahl === 1 ? '1 Projekt' : anzahl + ' Projekte'}${anzahl ? `, davon ${c.haupt} in Hauptverantwortung` : ''}${anbahnung}</span>
${teilzeit}</span>
<span class="load-jetzt"><b style="color:${jetztFarbe}">${jetztText}</b><span>diese Woche${jetztKap > 0 ? ', ' + stundenFormat(k.verlauf[0]) + ' h' : ''}</span></span>
</div> </div>
<div class="load-band" title="Volles Band = ${stundenFormat(kap)} h/Woche"> <div class="load-saeulen" role="img" aria-label="${esc('Auslastung ' + name + ' der nächsten ' + VORSCHAU_WOCHEN + ' Wochen')}">
${segmente || '<span class="load-band-leer">keine Zuordnung</span>'} <span class="load-linie" style="bottom:${100 / MAX_QUOTE}%"></span>${saeulen}
</div> </div>
<div class="load-wert" style="color:${ampel}">${stundenFormat(std)}<span class="load-einheit">h</span></div> <div class="load-kws" aria-hidden="true">${kws}</div>
</div>`; ${hinweis}
}); ${chips ? `<div class="load-chips${offen ? ' ist-offen' : ''}">${chips}</div>` : ''}
html += `</div>`; </article>`;
}); }).join('') + '</div>';
const legende = Object.keys(benutzteProjekte).sort().map(name=>{ grid.innerHTML = html;
const e = benutzteProjekte[name];
return `<span class="load-legende-item"><i style="background:${e.farbe}"></i>${esc(e.nr ? e.nr + ' ' + name : name)}${e.quelle === 'groesse' ? '<em>≈</em>' : ''}</span>`;
}).join('');
if(legende){
const hatSchaetzung = Object.values(benutzteProjekte).some(e => e.quelle === 'groesse');
html += `<div class="load-legende">${legende}</div>`;
if(hatSchaetzung) html += `<p class="hint" style="margin:8px 0 0">≈ aus der Projektgröße gerechnet, nicht aus einem hinterlegten Honorar.</p>`;
}
document.getElementById('loadGrid').innerHTML=html;
} }
+318 -193
View File
@@ -4,7 +4,7 @@
// Dieses Modul stellt die drei PDF-Exporte der Anwendung bereit — jeder in // Dieses Modul stellt die drei PDF-Exporte der Anwendung bereit — jeder in
// seinem eigenen, klar abgegrenzten Abschnitt dieser Datei: // seinem eigenen, klar abgegrenzten Abschnitt dieser Datei:
// * exportWochenberichtPDF() — Wochenbericht (Übersicht + Akte je Gewerk) // * exportWochenberichtPDF() — Wochenbericht (Übersicht + Akte je Gewerk)
// * exportSandboxPlanPDF() — Planung (Balkenplan eines Sandbox-Terminplans) // * exportSandboxPlanPDF() — Terminplan (Balkenplan eines Projekts oder Planung-Plans)
// * exportProtokollPDF() — Protokolle (Besprechungsprotokoll je Projekt) // * exportProtokollPDF() — Protokolle (Besprechungsprotokoll je Projekt)
// Ein früherer, inzwischen entfernter dritter Export, exportPDF() (eine // Ein früherer, inzwischen entfernter dritter Export, exportPDF() (eine
// gewerkeübergreifende A3-Tabellenübersicht der ganzen Sitzung, samt // gewerkeübergreifende A3-Tabellenübersicht der ganzen Sitzung, samt
@@ -19,10 +19,9 @@
// PDF_PAD_X/PDF_LINE_W). // PDF_PAD_X/PDF_LINE_W).
// //
// Was unten bleibt, wird von MEHREREN der drei Exporte gebraucht (nicht // Was unten bleibt, wird von MEHREREN der drei Exporte gebraucht (nicht
// zwingend allen): Farbkonstanten, die A3-quer-Seitengeometrie // zwingend allen): Farbkonstanten, PDF_STYLES (der Terminplan bringt seine
// PDF_PAGE_W/PDF_MARGIN_X/PDF_USABLE_W (nur exportSandboxPlanPDF() — die // eigene Geometrie in PDF_TP mit, Wochenbericht/Protokoll sind A4 hochkant
// beiden anderen sind A4 hochkant, eigene Konstanten PDF_WB_PAGE_W/ // mit PDF_WB_PAGE_W/PDF_WB_MARGIN_X weiter unten), pdfStatusText()
// PDF_WB_MARGIN_X weiter unten), PDF_STYLES, pdfStatusText()
// (Kommentar-HTML -> pdfmake-Rich-Text), pdfNormalizeColor() und // (Kommentar-HTML -> pdfmake-Rich-Text), pdfNormalizeColor() und
// loadLogoPng(). // loadLogoPng().
@@ -35,14 +34,6 @@ const PDF_ASSIGN_MAIN = '#248A3D';
const PDF_ASSIGN_OPT = '#D18B00'; const PDF_ASSIGN_OPT = '#D18B00';
// --- Seitengeometrie (A3 quer, in pt) — nur von exportSandboxPlanPDF() genutzt;
// exportWochenberichtPDF()/exportProtokollPDF() sind A4 hochkant, siehe
// PDF_WB_PAGE_W/PDF_WB_MARGIN_X weiter unten ---
const PDF_PAGE_W = 1190.55; // A3 quer, vorher A4: 841.89
const PDF_MARGIN_X = 20;
const PDF_USABLE_W = PDF_PAGE_W - PDF_MARGIN_X * 2; // 1150,55
// PDF_STYLES: wiederverwendbare Textstile (Überschriften, Tabellen-/Zellformat) // PDF_STYLES: wiederverwendbare Textstile (Überschriften, Tabellen-/Zellformat)
// für die pdfmake-Dokumentdefinition // für die pdfmake-Dokumentdefinition
const PDF_STYLES = { const PDF_STYLES = {
@@ -393,6 +384,15 @@ function pdfWbGruppentitel(name, topMargin){
// Landkarte der Sitzung — was hat sich geändert, was ist offen — bevor ab // Landkarte der Sitzung — was hat sich geändert, was ist offen — bevor ab
// Seite 2 der vollständige Text folgt. Parameter: liste (aus // Seite 2 der vollständige Text folgt. Parameter: liste (aus
// berichtProjekte()), prevWeek. Rückgabe: Array von pdfmake-Content-Blöcken. // berichtProjekte()), prevWeek. Rückgabe: Array von pdfmake-Content-Blöcken.
// pdfHaken: Haken als Vektorgrafik. Das Zeichen "✓" fehlt in der
// PDF-Schrift (Roboto aus vfs_fonts.js) und erschien als leeres Kästchen —
// Eriks Meldung. Parameter: farbe, breite (Spaltenbreite für die Mitte).
function pdfHaken(farbe, breite){
return { canvas: [{ type: 'polyline', lineWidth: 1.6, lineColor: farbe, lineCap: 'round',
points: [{ x: 0, y: 4 }, { x: 3.2, y: 7.2 }, { x: 9, y: 0.5 }] }],
margin: [Math.max(0, (breite - 9) / 2 - 4), 0, 0, 0] };
}
function pdfWbUebersichtBlock(liste, prevWeek){ function pdfWbUebersichtBlock(liste, prevWeek){
const groups = {}; const groups = {};
liste.forEach(p => (groups[p.group] || (groups[p.group] = [])).push(p)); liste.forEach(p => (groups[p.group] || (groups[p.group] = [])).push(p));
@@ -407,11 +407,11 @@ function pdfWbUebersichtBlock(liste, prevWeek){
{ text: 'Projekt', style: 'th' }, { text: 'Projekt', style: 'th' },
{ text: 'Änderung', style: 'th' }, { text: 'Änderung', style: 'th' },
{ text: 'Amt. LPH', style: 'th' }, { text: 'Amt. LPH', style: 'th' },
{ text: '✓', style: 'th', alignment: 'center' } { text: 'Bespr.', style: 'th', alignment: 'center' }
]; ];
const body = [header]; const body = [header];
projekte.forEach(p => { projekte.forEach(p => {
const change = classifyProjectChange(p, prevWeek); const change = classifyProjectChange(p, prevWeek, wochenberichtGewerk);
const info = pdfWbAktuelleLphInfo(p); const info = pdfWbAktuelleLphInfo(p);
body.push([ body.push([
pdfWbChangeDot(change.bucket), pdfWbChangeDot(change.bucket),
@@ -419,10 +419,10 @@ function pdfWbUebersichtBlock(liste, prevWeek){
{ text: p.name, style: 'td', bold: true }, { text: p.name, style: 'td', bold: true },
{ text: change.bucket !== 'unchanged' ? change.reason : '', style: 'td' }, { text: change.bucket !== 'unchanged' ? change.reason : '', style: 'td' },
{ text: info ? info.lph + ' · ' + info.pct + ' %' : '–', style: 'td' }, { text: info ? info.lph + ' · ' + info.pct + ' %' : '–', style: 'td' },
{ text: p.besprochen ? '✓' : '', style: 'tdCenter', color: PDF_ASSIGN_MAIN } kommentarVon(p, wochenberichtGewerk).besprochen ? pdfHaken(PDF_ASSIGN_MAIN, 38) : { text: '', style: 'tdCenter' }
]); ]);
}); });
blocks.push({ table: { headerRows: 1, widths: [14, 40, '*', 118, 76, 18], body }, blocks.push({ table: { headerRows: 1, widths: [14, 40, '*', 118, 76, 38], body },
layout: pdfWbGridLayout(true), margin: [0, 0, 0, 4] }); layout: pdfWbGridLayout(true), margin: [0, 0, 0, 4] });
}); });
return blocks; return blocks;
@@ -436,11 +436,12 @@ function pdfWbUebersichtBlock(liste, prevWeek){
// ausdrückliches false — es gibt keinen dritten Zustand. Parameter: w // ausdrückliches false — es gibt keinen dritten Zustand. Parameter: w
// (Sitzung). Rückgabe: Array von pdfmake-Content-Blöcken. // (Sitzung). Rückgabe: Array von pdfmake-Content-Blöcken.
function pdfWbAnwesenheitBlock(w){ function pdfWbAnwesenheitBlock(w){
const people = Array.isArray(w.people) ? w.people : []; // Nur das Gewerk des Berichts — wie am Bildschirm (renderAttendance()).
const people = personenDesGewerks(w, wochenberichtGewerk);
if(!people.length){ if(!people.length){
return [ return [
pdfWbGruppentitel('Anwesenheit der Sitzung', 18), pdfWbGruppentitel('Anwesenheit ' + (wochenberichtGewerk || ''), 18),
{ text: 'Für diese Sitzung sind keine Projektbeteiligten hinterlegt.', { text: 'Für dieses Gewerk sind in dieser Sitzung keine Projektbeteiligten hinterlegt.',
fontSize: 8, italics: true, color: '#86868B' } fontSize: 8, italics: true, color: '#86868B' }
]; ];
} }
@@ -460,7 +461,7 @@ function pdfWbAnwesenheitBlock(w){
body.push(row); body.push(row);
} }
return [ return [
pdfWbGruppentitel('Anwesenheit der Sitzung', 18), pdfWbGruppentitel('Anwesenheit ' + (wochenberichtGewerk || ''), 18),
{ text: anwesend + ' von ' + people.length + ' anwesend', fontSize: 8, color: '#6E6E73', margin: [0, 0, 0, 6] }, { text: anwesend + ' von ' + people.length + ' anwesend', fontSize: 8, color: '#6E6E73', margin: [0, 0, 0, 6] },
{ table: { widths: [16, '*', 16, '*', 16, '*'], body }, layout: pdfWbGridLayout(false) } { table: { widths: [16, '*', 16, '*', 16, '*'], body }, layout: pdfWbGridLayout(false) }
]; ];
@@ -472,7 +473,7 @@ function pdfWbAnwesenheitBlock(w){
// Beteiligten-Tabelle. Parameter: p (Projekt), w (Sitzung), prevWeek // Beteiligten-Tabelle. Parameter: p (Projekt), w (Sitzung), prevWeek
// (Vorwoche-Objekt oder null), allePersonen. Rückgabe: pdfmake-Content-Block. // (Vorwoche-Objekt oder null), allePersonen. Rückgabe: pdfmake-Content-Block.
function pdfWbProjektAkte(p, w, prevWeek, allePersonen){ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
const change = classifyProjectChange(p, prevWeek); const change = classifyProjectChange(p, prevWeek, wochenberichtGewerk);
const spineColor = PDF_WB_CHANGE_COLORS[change.bucket] || PDF_WB_CHANGE_COLORS.unchanged; const spineColor = PDF_WB_CHANGE_COLORS[change.bucket] || PDF_WB_CHANGE_COLORS.unchanged;
const statusChip = auftragsstatusOf(p) === 'beauftragt' ? '' : ' · ' + auftragsstatusLabel(auftragsstatusOf(p)); const statusChip = auftragsstatusOf(p) === 'beauftragt' ? '' : ' · ' + auftragsstatusLabel(auftragsstatusOf(p));
const tags = Array.isArray(p.tags) && p.tags.length ? ' [' + p.tags.join(', ') + ']' : ''; const tags = Array.isArray(p.tags) && p.tags.length ? ' [' + p.tags.join(', ') + ']' : '';
@@ -483,7 +484,7 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
{ text: (p.nr ? p.nr + ' ' : '') + p.name, bold: true, fontSize: 10 }, { text: (p.nr ? p.nr + ' ' : '') + p.name, bold: true, fontSize: 10 },
{ text: tags + statusChip, fontSize: 7.5, color: '#6E6E73' } { text: tags + statusChip, fontSize: 7.5, color: '#6E6E73' }
], width: '*' }, ], width: '*' },
{ text: p.besprochen ? '✓ besprochen' : '', fontSize: 7, color: PDF_ASSIGN_MAIN, { text: kommentarVon(p, wochenberichtGewerk).besprochen ? '✓ besprochen' : '', fontSize: 7, color: PDF_ASSIGN_MAIN,
alignment: 'right', width: 70 } alignment: 'right', width: 70 }
], margin: [0, 0, 0, 3] ], margin: [0, 0, 0, 3]
}; };
@@ -492,11 +493,17 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
? { text: change.reason, fontSize: 7.5, bold: true, color: spineColor, margin: [0, 0, 0, 5] } ? { text: change.reason, fontSize: 7.5, bold: true, color: spineColor, margin: [0, 0, 0, 5] }
: { text: '', margin: [0, 0, 0, 1] }; : { text: '', margin: [0, 0, 0, 1] };
const kommentarInhalt = pdfStatusText(p.status || ''); // Der Kommentar des Gewerks, für das der Bericht erstellt wird.
const kommentarInhalt = pdfStatusText(kommentarVon(p, wochenberichtGewerk).status || '');
const kommentar = (kommentarInhalt.text && kommentarInhalt.text.length) const kommentar = (kommentarInhalt.text && kommentarInhalt.text.length)
? Object.assign({}, kommentarInhalt, { fontSize: 8 }) ? Object.assign({}, kommentarInhalt, { fontSize: 8 })
: { text: 'Kein Kommentar.', fontSize: 7.5, italics: true, color: '#86868B' }; : { text: 'Kein Kommentar.', fontSize: 7.5, italics: true, color: '#86868B' };
// Der zuletzt geschriebene frühere Kommentar desselben Gewerks (wie die
// Zeile "Zuletzt KW …" auf dem Bildschirm, berichtVerlaufDaten() in
// wochenbericht.js) — mit Formatierung, abgesetzt unter dem aktuellen.
const vorher = pdfWbVorigerKommentar(p, w);
const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0]; const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0];
const lphTable = pdfWbLphMiniTable(p, aktLp); const lphTable = pdfWbLphMiniTable(p, aktLp);
const beteiligteTable = pdfWbBeteiligteTable(p, allePersonen); const beteiligteTable = pdfWbBeteiligteTable(p, allePersonen);
@@ -507,13 +514,13 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
rightStack.push({ text: 'BETEILIGTE', fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 10, 0, 3] }); rightStack.push({ text: 'BETEILIGTE', fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 10, 0, 3] });
rightStack.push(beteiligteTable); rightStack.push(beteiligteTable);
if(abwesend.length){ if(abwesend.length){
rightStack.push({ text: 'Fehlt heute: ' + abwesend.join(', '), fontSize: 7, italics: true, rightStack.push({ text: 'Heute abwesend: ' + abwesend.join(', '), fontSize: 7, italics: true,
color: '#D70015', margin: [0, 6, 0, 0] }); color: '#D70015', margin: [0, 6, 0, 0] });
} }
const innerContent = { const innerContent = {
columns: [ columns: [
{ width: '*', stack: [kopf, aenderung, kommentar] }, { width: '*', stack: [kopf, aenderung, kommentar].concat(vorher ? [vorher] : []) },
{ width: 190, stack: rightStack } { width: 190, stack: rightStack }
], ],
columnGap: 12 columnGap: 12
@@ -541,6 +548,36 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
}; };
} }
// pdfWbVorigerKommentar: Block mit dem jüngsten früheren Kommentar des
// Gewerks. Steht er in der direkten Vorwoche, heißt er "Vorwoche KW …",
// sonst "Zuletzt KW …". Parameter: p (Projekt), w (Sitzung). Rückgabe:
// pdfmake-Block oder null (kein früherer Kommentar).
function pdfWbVorigerKommentar(p, w){
if(!p.projectUid || typeof berichtVerlaufDaten !== 'function') return null;
const e = berichtVerlaufDaten(p, w, wochenberichtGewerk).eintraege[0];
if(!e) return null;
const inhalt = pdfStatusText(e.html || '');
if(!inhalt.text || !inhalt.text.length) return null;
const vorwoche = findPreviousWeek(w);
const titel = (vorwoche && vorwoche.label === e.week.label ? 'VORWOCHE ' : 'ZULETZT ')
+ berichtKurzKw(e.week.label).toUpperCase()
+ (e.autor ? ' · ' + e.autor : '');
return {
table: { widths: ['*'], body: [[{
stack: [
{ text: titel, fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 0, 0, 2] },
Object.assign({}, inhalt, { fontSize: 7.5, color: '#3A3A3C' })
],
fillColor: '#F5F5F7'
}]] },
layout: {
hLineWidth: () => 0, vLineWidth: () => 0,
paddingLeft: () => 6, paddingRight: () => 6, paddingTop: () => 4, paddingBottom: () => 5
},
margin: [0, 8, 0, 0]
};
}
// exportWochenberichtPDF: Hauptfunktion. Erzeugt aus der Tagesordnung der // exportWochenberichtPDF: Hauptfunktion. Erzeugt aus der Tagesordnung der
// aktuellen Sitzung ein ablagefähiges, zweiteiliges PDF — Seite 1 die // aktuellen Sitzung ein ablagefähiges, zweiteiliges PDF — Seite 1 die
// Übersicht samt Anwesenheit, ab Seite 2 die vollständige Akte je Projekt, // Übersicht samt Anwesenheit, ab Seite 2 die vollständige Akte je Projekt,
@@ -574,7 +611,7 @@ async function exportWochenberichtPDF(){
ROLES.forEach(r => grouped[r].forEach(e => allePersonen.push(e))); ROLES.forEach(r => grouped[r].forEach(e => allePersonen.push(e)));
const logo = await loadLogoPng(); const logo = await loadLogoPng();
const ab = liste.filter(p => p.besprochen).length; const ab = liste.filter(p => kommentarVon(p, wochenberichtGewerk).besprochen).length;
const gewerkLabel = (typeof GEWERK_LABELS === 'object' && GEWERK_LABELS[wochenberichtGewerk]) || wochenberichtGewerk; const gewerkLabel = (typeof GEWERK_LABELS === 'object' && GEWERK_LABELS[wochenberichtGewerk]) || wochenberichtGewerk;
const kopf = []; const kopf = [];
@@ -640,218 +677,306 @@ async function exportWochenberichtPDF(){
} }
} }
// ----------------------------- // -----------------------------
// Export PDF — Planung (Sandbox-Terminplan) // Export PDF — Terminplan (Projekt und Planung)
// ----------------------------- // -----------------------------
// exportSandboxPlanPDF() ist der ANDERE der beiden verbleibenden PDF-Exporte // Balkenplan EINES Terminplans, A3 quer, Gestaltung „A2 — volles Raster“
// (der erste ist exportWochenberichtPDF() oben): ein klassischer Balkenplan // (Eriks Wahl aus dem Entwurfsvergleich): links eine Vorgangsliste (Vorgang,
// (Entwurf A der Design-Vergleiche, siehe CLAUDE.md "Planung — // Start, Ende), rechts die Balken über Monaten (im Wechsel hinterlegt) und
// projektunabhängige Terminplan-Sandbox") für EINEN Terminplan der // Kalenderwochen, Gitterlinien über das ganze Blatt. Gegliedert nach
// Planung-Sandbox — durchgehende Zeitachse, Balken je Aufgabe, Rauten für // Leistungsphasen wie der Terminplan am Bildschirm: jeder Phasenbalken
// Meilensteine, eine rote Linie für "heute", genau wie am Bildschirm im // (balkenPhase()) wird eine dunkelblaue Kopfzeile mit Sammelbalken und
// Terminplan des Projektbereichs. A3 quer — eine Zeitachse braucht die // Fortschritt aus dem Wochenbericht, darunter die Aufgaben, die in ihm
// Breite —, aber kein Übersichts-Raster wie eine Sitzungstabelle, sondern // beginnen (aufgabenPhase()), und die Meilensteine in seinem Zeitraum. Der
// EIN Terminplan, deshalb kein eigenes Deckblatt. // Rest steht unter „Weitere Aufgaben“. Keine Zuständigen, keine Heute-Linie
// (Eriks Vorgabe).
// //
// Es gab vor diesem Export keinerlei Gantt-Balken-PDF in dieser Anwendung — // Technik: Jede Seite wird frei positioniert (absolutePosition) — ein
// nur flache Prozent-Balken (LPH-/Auslastungs-Balken oben). Die Technik ist // Canvas für Flächen, Linien und Balken, Texte als eigene Knoten darüber.
// dieselbe (canvas-Rechtecke mit berechnetem x/w), nur nach Datum statt nach // pdfmake kann Text nicht innerhalb eines Canvas setzen. Passen nicht alle
// Prozent positioniert. Lange Zeiträume werden NICHT auf mehrere Blätter // Zeilen auf ein Blatt, geht es auf weiteren Seiten mit demselben Kopf weiter.
// aufgeteilt, sondern auf die feste Blattbreite zusammengestaucht (wie die
// Bildschirmansicht bei pxPerDay=null) — für sehr lange Pläne ist das gröber,
// aber deutlich einfacher als eine Kachelung über mehrere Seiten, und für
// eine Sandbox (kein Abgabedokument) ausreichend. Abhängigkeitslinien sind
// bewusst nicht Teil dieser ersten Fassung — sie sind im Bildschirm-Terminplan
// ohnehin sichtbar, das PDF ist in erster Linie eine mitnehmbare
// Datums-Momentaufnahme.
const PDF_PLAN_LABEL_W = 190; const PDF_TP = {
const PDF_PLAN_ROW_H = 18; W: 1190.55, H: 841.89, M: 32,
top: 84, kopfH: 34, unten: 60,
spalten: [ ['Vorgang', 280], ['Start', 60], ['Ende', 60] ],
blau: '#0f5a85', ink: '#1a1f24', grau: '#5f6a73', gitter: '#c9d1d8',
woche: '#dde3e8', monatHell: '#f4f6f8', phase: '#8fb2c9',
aufgabeRand: '#4a6f88', inArbeit: '#4a86b3', erledigt: '#2e7a50'
};
// pdfPlanDayX: x-Position (pt, relativ zum Beginn der Zeitachse) eines const PDF_TP_LPH_NAMEN = {
// Datums innerhalb der Chart-Spalte. Parameter: date (Date), rangeStart 'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
// (Date), totalDays (Zahl), chartW (pt). Rückgabe: Zahl (pt), auf den 'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
// sichtbaren Bereich begrenzt. 'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung',
function pdfPlanDayX(date, rangeStart, totalDays, chartW){ 'W+M': 'Wartung und Monitoring'
const tage = Math.max(0, Math.min(totalDays, daysBetween(rangeStart, date))); };
return chartW * tage / totalDays;
// pdfTpDatum: 'TT.MM.JJ'. Parameter: Date. Rückgabe: String.
function pdfTpDatum(d){
return String(d.getDate()).padStart(2, '0') + '.' + String(d.getMonth() + 1).padStart(2, '0') + '.'
+ String(d.getFullYear()).slice(2);
} }
// pdfPlanMonatsAchse: teilt [rangeStart, rangeEnd] in Monatsabschnitte für // pdfTpKuerzen: kürzt Text auf eine Breite (pt) bei gegebener Schriftgröße —
// die Kopfzeile der Zeitachse. Rückgabe: Array {label, x1, x2}. // grob geschätzt, Roboto ist im Mittel gut eine halbe Schriftgröße breit.
function pdfPlanMonatsAchse(rangeStart, rangeEnd, totalDays, chartW){ function pdfTpKuerzen(text, breite, groesse){
const monate = []; const max = Math.floor(breite / (groesse * 0.52));
let cur = new Date(rangeStart.getFullYear(), rangeStart.getMonth(), 1); const t = String(text || '');
let sicherheit = 0; return t.length > max ? t.slice(0, Math.max(1, max - 1)) + '…' : t;
while(cur <= rangeEnd && sicherheit++ < 60){ }
const naechster = new Date(cur.getFullYear(), cur.getMonth() + 1, 1);
const segStart = cur < rangeStart ? rangeStart : cur; // pdfTpText: frei positionierter Text in einem Kasten der Breite w.
const segEnd = naechster > rangeEnd ? addDays(rangeEnd, 1) : naechster; // opts: size, bold, italics, color, align.
monate.push({ function pdfTpText(x, y, w, text, opts){
label: segStart.toLocaleDateString('de-DE', { month: 'short', year: '2-digit' }), const o = opts || {};
x1: pdfPlanDayX(segStart, rangeStart, totalDays, chartW), w = Math.max(1, Math.min(w, PDF_TP.W - PDF_TP.M - x)); // nie über den rechten Rand
x2: pdfPlanDayX(segEnd, rangeStart, totalDays, chartW) return {
absolutePosition: { x, y },
columns: [{ width: w, text: String(text), fontSize: o.size || 9, bold: !!o.bold, italics: !!o.italics,
color: o.color || PDF_TP.ink, alignment: o.align || 'left', noWrap: true }]
};
}
// pdfTpZeilen: die Zeilen des Plans in Anzeigereihenfolge — je Phase eine
// Kopfzeile, ihre Aufgaben und Meilensteine, am Ende „Weitere Aufgaben“.
// Parameter: p (Projekt). Rückgabe: [{art:'phase'|'aufgabe'|'ms', …}].
function pdfTpZeilen(p){
const plan = p.plan || {};
const todos = (Array.isArray(plan.todos) ? plan.todos : [])
.filter(t => t.start && t.end && t.showInGantt !== false && parseDateISO(t.start) && parseDateISO(t.end));
const ms = (Array.isArray(plan.milestones) ? plan.milestones : []).filter(m => parseDateISO(m.date));
const nachStart = (a, b) => String(a.start).localeCompare(String(b.start));
const phasen = todos.filter(t => balkenPhase(t)).sort(nachStart);
const aufgaben = todos.filter(t => !balkenPhase(t));
const msPhase = m => {
const d = parseDateISO(m.date);
const k = phasen.find(b => d >= parseDateISO(b.start) && d <= parseDateISO(b.end));
return k ? balkenPhase(k) : '';
};
const zeilen = [];
const gruppe = (kopf, l) => {
const eigene = aufgaben.filter(t => aufgabenPhase(t, todos) === l).sort(nachStart);
const eigeneMs = ms.filter(m => msPhase(m) === l).sort((a, b) => String(a.date).localeCompare(String(b.date)));
if(!kopf && !eigene.length && !eigeneMs.length) return;
zeilen.push(kopf || { art: 'phase', titel: 'Weitere Aufgaben' });
eigene.forEach(t => zeilen.push({ art: 'aufgabe', titel: t.title || 'Aufgabe',
von: parseDateISO(t.start), bis: parseDateISO(t.end),
fort: t.done ? 100 : Math.max(0, Math.min(100, Number(t.progress) || 0)) }));
eigeneMs.forEach(m => zeilen.push({ art: 'ms', titel: m.name || 'Meilenstein', datum: parseDateISO(m.date) }));
};
phasen.forEach(b => {
const l = balkenPhase(b);
gruppe({ art: 'phase', titel: l + (PDF_TP_LPH_NAMEN[l] ? ' ' + PDF_TP_LPH_NAMEN[l] : ''),
von: parseDateISO(b.start), bis: parseDateISO(b.end),
fort: typeof fortschrittProzent === 'function' ? fortschrittProzent(p, l) : 0 }, l);
}); });
cur = naechster; gruppe(null, '');
} return zeilen;
return monate;
} }
// pdfPlanAchsenZeile: die Kopfzeile der Zeitachse (Monatsbeschriftungen), // pdfTpLegende: die Legende als Spalten für die Fußzeile (Zeichen + Text).
// über pdfmake columns statt canvas — Textlabel lassen sich in pdfmake nicht function pdfTpLegende(){
// frei innerhalb eines Canvas positionieren, wohl aber über Spaltenbreiten. const T = PDF_TP, sp = [];
function pdfPlanAchsenZeile(monate){ [
return { [{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.phase }, 'Leistungsphase'],
columns: [ [{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.inArbeit }, 'in Arbeit'],
{ width: PDF_PLAN_LABEL_W, text: 'Aufgabe / Meilenstein', fontSize: 7.5, bold: true, color: '#6E6E73' }, [{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.erledigt }, 'erledigt'],
...monate.map(m => ({ [{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: '#ffffff', lineColor: T.aufgabeRand, lineWidth: 0.8 }, 'offen'],
width: Math.max(2, m.x2 - m.x1), text: m.label, fontSize: 7, color: '#6E6E73' [{ type: 'polyline', closePath: true, color: T.ink, points: [{ x: 7, y: 0 }, { x: 12, y: 5 }, { x: 7, y: 10 }, { x: 2, y: 5 }] }, 'Meilenstein']
})) ].forEach(([form, text]) => {
], sp.push({ width: 20, canvas: [form] });
margin: [0, 0, 0, 3] sp.push({ width: text.length * 4.4 + 16, text, fontSize: 8, color: T.grau });
}; });
return sp;
} }
// pdfPlanBarFarbe: Balkenfarbe nach Aufgabenstand — dieselben drei Zustände, // pdfTpSeite: alle Knoten EINER Seite. Parameter: zeilen (die dieser Seite),
// die die Aufgabe tatsächlich kennt (t.done, t.progress); anders als der // rowH, x (Datum → x), range {start, ende}, kopf (Knoten des Seitenkopfs),
// Entwurf im Design-Vergleich gibt es hier keine vierte, erfundene Kategorie // ersteSeite. Rückgabe: Array von pdfmake-Knoten.
// "geplant", weil dafür kein echtes Datenfeld existiert. function pdfTpSeite(zeilen, rowH, x, range, kopf, ersteSeite){
function pdfPlanBarFarbe(t){ const T = PDF_TP, M = T.M, cx0 = M + T.spalten.reduce((s, c) => s + c[1], 0), cx1 = T.W - M;
if(t.done) return PDF_ASSIGN_MAIN; const bodyTop = T.top + T.kopfH, bodyBot = bodyTop + zeilen.length * rowH;
if((Number(t.progress) || 0) > 0) return '#0071E3'; const plus1 = d => addDays(d, 1);
return '#D2D2D7'; const flaechen = [], linien = [], balken = [], texte = [];
}
// pdfPlanZeile: eine Zeile des Balkenplans (Aufgabe ODER Meilenstein) als // Monate im Wechsel hinterlegt, mit Namen
// pdfmake columns-Objekt. Parameter: label (String), aktiv (Boolean, fett), let m = new Date(range.start.getFullYear(), range.start.getMonth(), 1), i = 0;
// canvasInhalt (Array von canvas-Primitiven für die Chart-Spalte). while(m <= range.ende && i < 120){
function pdfPlanZeile(label, kursiv, canvasInhalt){ const naechster = new Date(m.getFullYear(), m.getMonth() + 1, 1);
return { const a = Math.max(cx0, x(m)), e = Math.min(cx1, x(naechster));
columns: [ if(e > a){
{ width: PDF_PLAN_LABEL_W, text: label, fontSize: 7.5, italics: !!kursiv, if(i % 2 === 0) flaechen.push({ type: 'rect', x: a, y: T.top, w: e - a, h: bodyBot - T.top, color: T.monatHell });
color: kursiv ? '#6E6E73' : '#1D1D1F', margin: [0, (PDF_PLAN_ROW_H - 9) / 2, 4, 0] }, const name = m.toLocaleDateString('de-DE', { month: 'short' }).replace('.', '') + ' ' + String(m.getFullYear()).slice(2);
{ width: '*', canvas: canvasInhalt } if(e - a > 30) texte.push(pdfTpText(a, T.top + 4.5, e - a, name, { bold: true, align: 'center' }));
] linien.push({ type: 'line', x1: a, y1: T.top, x2: a, y2: bodyBot, lineWidth: 0.8, lineColor: T.gitter });
};
} }
m = naechster; i++;
}
// Kalenderwochen: Linie je Woche, Nummer nur so oft, wie Platz ist
const wocheB = x(addDays(range.start, 7)) - x(range.start);
const schritt = Math.max(1, Math.ceil(11 / Math.max(0.1, wocheB)));
for(let w = new Date(range.start), n = 0; w <= range.ende; w = addDays(w, 7), n++){
const a = x(w);
linien.push({ type: 'line', x1: a, y1: T.top + 17, x2: a, y2: bodyBot, lineWidth: 0.5, lineColor: T.woche });
if(n % schritt === 0) texte.push(pdfTpText(a, T.top + 23, wocheB * schritt, getWeekNumber(w), { size: 6.5, color: T.grau, align: schritt === 1 ? 'center' : 'left' }));
}
linien.push({ type: 'line', x1: cx0, y1: T.top + 17, x2: cx1, y2: T.top + 17, lineWidth: 0.6, lineColor: T.gitter });
function pdfPlanTodoZeile(t, rangeStart, totalDays, chartW, todayX){ // Tabellenkopf links
const s = parseDateISO(t.start) || rangeStart; flaechen.push({ type: 'rect', x: M, y: T.top, w: cx0 - M, h: T.kopfH, color: '#e9edf1' });
const e = parseDateISO(t.end) || s; let sx = M;
const x1 = pdfPlanDayX(s, rangeStart, totalDays, chartW); T.spalten.forEach(([name, w]) => {
const x2 = Math.max(x1 + 2, pdfPlanDayX(addDays(e, 1), rangeStart, totalDays, chartW)); texte.push(pdfTpText(sx + 6, T.top + 12, w - 8, name, { bold: true }));
const canvasInhalt = [ sx += w;
{ type: 'rect', x: x1, y: 4, w: x2 - x1, h: PDF_PLAN_ROW_H - 8, color: pdfPlanBarFarbe(t) }, if(sx < cx0) linien.push({ type: 'line', x1: sx, y1: T.top, x2: sx, y2: bodyBot, lineWidth: 0.6, lineColor: T.gitter });
{ type: 'line', x1: 0, y1: PDF_PLAN_ROW_H, x2: chartW, y2: PDF_PLAN_ROW_H, lineWidth: 0.4, lineColor: '#ECECEC' } });
const xStart = M + T.spalten[0][1], xEnde = xStart + T.spalten[1][1];
// Zeilen
zeilen.forEach((z, k) => {
const y = bodyTop + k * rowH, my = y + rowH / 2, ty = my - 4.6;
if(z.art === 'phase'){
flaechen.push({ type: 'rect', x: M, y, w: cx0 - M, h: rowH, color: T.blau });
texte.push(pdfTpText(M + 6, ty - 0.4, T.spalten[0][1] - 10, pdfTpKuerzen(z.titel, T.spalten[0][1] - 10, 9.5), { size: 9.5, bold: true, color: '#ffffff' }));
if(z.von){
texte.push(pdfTpText(xStart + 6, ty, 54, pdfTpDatum(z.von), { bold: true, color: '#ffffff' }));
texte.push(pdfTpText(xEnde + 6, ty, 54, pdfTpDatum(z.bis), { bold: true, color: '#ffffff' }));
const a = x(z.von), e = Math.max(a + 3, x(plus1(z.bis)));
balken.push({ type: 'rect', x: a, y: my - 5, w: e - a, h: 10, color: T.phase });
if(z.fort > 0) balken.push({ type: 'rect', x: a, y: my - 5, w: (e - a) * z.fort / 100, h: 10, color: T.blau });
// Prozent rechts neben dem Balken; am rechten Rand links davor —
// Text über den Seitenrand hinaus erzeugte eine leere Zusatzseite.
if(e + 45 <= T.W - M) texte.push(pdfTpText(e + 5, my - 4, 40, z.fort + ' %', { size: 8, bold: true, color: T.blau }));
else texte.push(pdfTpText(a - 45, my - 4, 40, z.fort + ' %', { size: 8, bold: true, color: T.blau, align: 'right' }));
}
} else if(z.art === 'aufgabe'){
texte.push(pdfTpText(M + 16, ty, T.spalten[0][1] - 20, pdfTpKuerzen(z.titel, T.spalten[0][1] - 20, 9)));
texte.push(pdfTpText(xStart + 6, ty, 54, pdfTpDatum(z.von)));
texte.push(pdfTpText(xEnde + 6, ty, 54, pdfTpDatum(z.bis)));
const a = x(z.von), e = Math.max(a + 3, x(plus1(z.bis)));
balken.push({ type: 'rect', x: a, y: my - 5, w: e - a, h: 10, color: '#ffffff', lineColor: T.aufgabeRand, lineWidth: 0.8 });
if(z.fort > 0) balken.push({ type: 'rect', x: a, y: my - 5, w: (e - a) * z.fort / 100, h: 10, color: z.fort >= 100 ? T.erledigt : T.inArbeit });
} else {
texte.push(pdfTpText(M + 16, ty, T.spalten[0][1] - 20, pdfTpKuerzen(z.titel, T.spalten[0][1] - 20, 9), { italics: true }));
texte.push(pdfTpText(xStart + 6, ty, 54, pdfTpDatum(z.datum)));
const mx = x(z.datum), r = 6;
balken.push({ type: 'polyline', closePath: true, color: T.ink,
points: [{ x: mx, y: my - r }, { x: mx + r, y: my }, { x: mx, y: my + r }, { x: mx - r, y: my }] });
texte.push(pdfTpText(mx - 9 - 40, my - 4, 40, pdfTpDatum(z.datum).slice(0, 6), { size: 8, align: 'right' }));
}
linien.push({ type: 'line', x1: M, y1: y + rowH, x2: cx1, y2: y + rowH, lineWidth: 0.5, lineColor: T.gitter });
});
// Rahmen
const rahmen = [
{ type: 'rect', x: M, y: T.top, w: cx1 - M, h: bodyBot - T.top, lineColor: T.ink, lineWidth: 1 },
{ type: 'line', x1: cx0, y1: T.top, x2: cx0, y2: bodyBot, lineWidth: 1, lineColor: T.ink },
{ type: 'line', x1: M, y1: bodyTop, x2: cx1, y2: bodyTop, lineWidth: 1, lineColor: T.ink }
]; ];
if(todayX !== null){
canvasInhalt.push({ type: 'line', x1: todayX, y1: 0, x2: todayX, y2: PDF_PLAN_ROW_H, lineWidth: 0.75, lineColor: '#D70015' }); const knoten = [{ canvas: [...flaechen, ...linien, ...balken, ...rahmen], absolutePosition: { x: 0, y: 0 } }];
} if(!ersteSeite) knoten[0].pageBreak = 'before';
return pdfPlanZeile(t.title || 'Aufgabe', false, canvasInhalt); return [...knoten, ...kopf, ...texte];
} }
function pdfPlanMeilensteinZeile(m, rangeStart, totalDays, chartW, todayX){ // exportSandboxPlanPDF: Einstieg vom Knopf „Ansicht → Als PDF exportieren“.
const d = parseDateISO(m.date) || rangeStart; // Findet einen Planung-Plan oder das im Projektbereich geöffnete Projekt.
const x = pdfPlanDayX(d, rangeStart, totalDays, chartW); // Parameter: planId. Keine Rückgabe.
const cy = PDF_PLAN_ROW_H / 2, r = 4.5;
const canvasInhalt = [
{ type: 'polyline', points: [{ x: x, y: cy - r }, { x: x + r, y: cy }, { x: x, y: cy + r }, { x: x - r, y: cy }],
closePath: true, color: '#0071E3' },
{ type: 'line', x1: 0, y1: PDF_PLAN_ROW_H, x2: chartW, y2: PDF_PLAN_ROW_H, lineWidth: 0.4, lineColor: '#ECECEC' }
];
if(todayX !== null){
canvasInhalt.push({ type: 'line', x1: todayX, y1: 0, x2: todayX, y2: PDF_PLAN_ROW_H, lineWidth: 0.75, lineColor: '#D70015' });
}
return pdfPlanZeile('◆ ' + (m.name || 'Meilenstein'), true, canvasInhalt);
}
// exportSandboxPlanPDF: Hauptfunktion. Erzeugt den Balkenplan-PDF-Export
// EINES Terminplans der Planung-Sandbox. Parameter: planId. Keine Rückgabe.
async function exportSandboxPlanPDF(planId){ async function exportSandboxPlanPDF(planId){
const sandbox = (db.sandboxWeek && Array.isArray(db.sandboxWeek.projects))
? db.sandboxWeek.projects.find(x => x.id === planId) : null;
const app = window.PlanApp;
const p = sandbox || (app && app.project && app.project.id === planId ? app.project : null);
return exportTerminplanPDF(p, !!sandbox, sandbox ? null : (app && app.week));
}
// exportTerminplanPDF: erzeugt das PDF. Parameter: p (Projekt), sandbox
// (Boolean, Planung-Plan), week (Sitzung des Projekts oder null).
async function exportTerminplanPDF(p, sandbox, week){
if(!window.pdfMake){ if(!window.pdfMake){
alert('PDF-Export nicht verfügbar: pdfmake wurde nicht geladen. ' alert('PDF-Export nicht verfügbar: pdfmake wurde nicht geladen. '
+ 'Bitte Internetverbindung bzw. Skript-Einbindung prüfen.'); + 'Bitte Internetverbindung bzw. Skript-Einbindung prüfen.');
return; return;
} }
const p = (db.sandboxWeek && Array.isArray(db.sandboxWeek.projects))
? db.sandboxWeek.projects.find(x => x.id === planId) : null;
if(!p){ if(!p){
alert('Dieser Terminplan wurde nicht gefunden.'); alert('Dieser Terminplan wurde nicht gefunden.');
return; return;
} }
try { try {
const plan = p.plan || {}; const T = PDF_TP, M = T.M;
const todos = (Array.isArray(plan.todos) ? plan.todos.slice() : []) const zeilen = pdfTpZeilen(p);
.sort((a, b) => (parseDateISO(a.start) || 0) - (parseDateISO(b.start) || 0)); const daten = [];
const milestones = (Array.isArray(plan.milestones) ? plan.milestones.slice() : []) zeilen.forEach(z => { if(z.von) daten.push(z.von, z.bis); if(z.datum) daten.push(z.datum); });
.sort((a, b) => (parseDateISO(a.date) || 0) - (parseDateISO(b.date) || 0)); if(!daten.length){
if(!todos.length && !milestones.length){ alert('Dieser Terminplan enthält noch keine Aufgaben mit Termin oder Meilensteine — es gibt nichts abzubilden.');
alert('Dieser Terminplan enthält noch keine Aufgaben oder Meilensteine — es gibt nichts abzubilden.');
return; return;
} }
const range = ganttRangeFromProjects([p], 7); // Zeitraum: Montag der ersten bis Sonntag der letzten Woche
const totalDays = Math.max(1, daysBetween(range.start, range.end) + 1); const min = new Date(Math.min(...daten)), max = new Date(Math.max(...daten));
const chartW = PDF_USABLE_W - PDF_PLAN_LABEL_W - 10; const start = addDays(min, -((min.getDay() + 6) % 7));
const heute = new Date(); const ende = addDays(max, 6 - ((max.getDay() + 6) % 7));
heute.setHours(0, 0, 0, 0); const range = { start, ende };
const todayX = (heute >= range.start && heute <= range.end) const cx0 = M + T.spalten.reduce((s, c) => s + c[1], 0), cx1 = T.W - M;
? pdfPlanDayX(heute, range.start, totalDays, chartW) : null; const spanne = daysBetween(start, ende) + 1;
const x = d => cx0 + (cx1 - cx0) * daysBetween(start, d) / spanne;
// Zeilenhöhe: so groß wie möglich (höchstens 30 pt), mindestens 17 pt;
// reicht das nicht, geht es auf weiteren Seiten weiter.
const platz = T.H - T.unten - T.top - T.kopfH;
const rowH = Math.max(17, Math.min(30, platz / zeilen.length));
const jeSeite = Math.max(1, Math.floor(platz / rowH));
// Seitenkopf
const logo = await loadLogoPng(); const logo = await loadLogoPng();
const kopf = []; const titel = (!sandbox && p.nr ? p.nr + ' ' : '') + (p.name || 'Terminplan');
if(logo) kopf.push({ image: logo, width: 46, margin: [0, 0, 0, 6] }); const teile = [sandbox ? 'Terminplan — Planung, unabhängig von einem laufenden Projekt' : 'Terminplan'];
kopf.push( if(!sandbox && week && week.label) teile.push('Stand ' + week.label);
{ text: p.name, fontSize: 17, bold: true, color: '#1D1D1F' }, if(Number(p.honorar) > 0) teile.push('Honorar ' + Number(p.honorar).toLocaleString('de-DE') + ' €');
{ text: 'Terminplan — Planung, unabhängig von einem laufenden Projekt', if(Array.isArray(p.beauftragteLP) && p.beauftragteLP.length) teile.push('beauftragt ' + p.beauftragteLP.join(', '));
fontSize: 9, color: '#6E6E73', margin: [0, 2, 0, 0] } // Je Seite neu gebaut: pdfmake verändert die Knoten beim Setzen, ein
); // wiederverwendeter Kopf fehlte ab Seite 2.
const honorar = Number(p.honorar) || 0; const kopf = () => {
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []; const k = [
if(honorar > 0){ pdfTpText(M, 30, 900, pdfTpKuerzen(titel, 900, 18), { size: 18, bold: true }),
kopf.push({ text: 'Honorar ' + honorar.toLocaleString('de-DE') + ' €' pdfTpText(M, 54, 900, teile.join(' · '), { size: 10, color: T.grau })
+ (beauftragt.length ? ' · ' + beauftragt.join(', ') : ''), ];
fontSize: 8, color: '#6E6E73', margin: [0, 2, 0, 0] }); if(logo) k.push({ image: logo, fit: [40, 40], absolutePosition: { x: T.W - M - 40, y: 26 } });
return k;
};
const content = [];
for(let i = 0; i < zeilen.length; i += jeSeite){
content.push(...pdfTpSeite(zeilen.slice(i, i + jeSeite), rowH, x, range, kopf(), i === 0));
} }
kopf.push({ text: 'erstellt am ' + new Date().toLocaleDateString('de-DE'),
fontSize: 7.5, color: '#6E6E73', margin: [0, 2, 0, 16] });
const content = [...kopf];
content.push(pdfPlanAchsenZeile(pdfPlanMonatsAchse(range.start, range.end, totalDays, chartW)));
todos.forEach(t => content.push(pdfPlanTodoZeile(t, range.start, totalDays, chartW, todayX)));
milestones.forEach(m => content.push(pdfPlanMeilensteinZeile(m, range.start, totalDays, chartW, todayX)));
content.push({
columns: [
{ width: 'auto', text: [{ text: '■ ', color: PDF_ASSIGN_MAIN }, { text: 'Erledigt' }], fontSize: 8 },
{ width: 'auto', text: [{ text: '■ ', color: '#0071E3' }, { text: 'In Arbeit' }], fontSize: 8 },
{ width: 'auto', text: [{ text: '■ ', color: '#D2D2D7' }, { text: 'Offen' }], fontSize: 8 },
{ width: 'auto', text: [{ text: '◆ ', color: '#0071E3' }, { text: 'Meilenstein' }], fontSize: 8 },
{ width: 'auto', text: [{ text: '┃ ', color: '#D70015' }, { text: 'heute' }], fontSize: 8 }
],
columnGap: 16, margin: [0, 12, 0, 0]
});
const fussText = 'Terminplan' + (!sandbox && p.nr ? ' ' + p.nr : '') + ' · erstellt am ' + new Date().toLocaleDateString('de-DE');
const doc = { const doc = {
pageSize: 'A3', pageSize: 'A3',
pageOrientation: 'landscape', pageOrientation: 'landscape',
pageMargins: [PDF_MARGIN_X, 30, PDF_MARGIN_X, 30], pageMargins: [M, 0, M, 36], // oben 0: Die Seite ist frei positioniert; mit Rand passte der Canvas über die ganze Höhe nicht.
defaultStyle: { font: 'Roboto', fontSize: 8, color: '#1D1D1F' }, defaultStyle: { font: 'Roboto', fontSize: 9, color: T.ink },
styles: PDF_STYLES, // Legende links und Seitenangabe rechts in der Fußzeile — als frei
// positionierter Text am Seitenende rutschte die Legende auf eine
// eigene, sonst leere Seite.
footer: (page, count) => ({ footer: (page, count) => ({
text: 'Planung · ' + p.name + ' · Seite ' + page + ' von ' + count, margin: [M, 12, M, 0],
alignment: 'right', fontSize: 6.5, color: '#6E6E73', columns: [
margin: [0, 8, PDF_MARGIN_X, 0] ...pdfTpLegende(),
{ width: '*', text: fussText + ' · Seite ' + page + ' von ' + count, alignment: 'right', fontSize: 8, color: T.grau }
]
}), }),
info: { title: 'Terminplan ' + p.name, creator: 'Projektübersicht' }, info: { title: 'Terminplan ' + (p.name || ''), creator: 'Projektübersicht' },
content: content content
}; };
const filename = 'Terminplan_Planung_' + String(p.name || 'Plan').replace(/[^\w]/g, '_') + '.pdf'; const filename = (sandbox ? 'Terminplan_Planung_' : 'Terminplan_' + (p.nr ? p.nr + '_' : ''))
+ String(p.name || 'Plan').replace(/[^\w]/g, '_') + '.pdf';
pdfMake.createPdf(doc).download(filename); pdfMake.createPdf(doc).download(filename);
} catch(err){ } catch(err){
console.error('Planung-PDF-Export fehlgeschlagen:', err); console.error('Terminplan-PDF-Export fehlgeschlagen:', err);
alert('PDF-Export fehlgeschlagen: ' + (err.message || err)); alert('PDF-Export fehlgeschlagen: ' + (err.message || err));
} }
} }
+1 -1
View File
@@ -28,7 +28,7 @@
// Parameter, keine Rückgabe. // Parameter, keine Rückgabe.
function renderPeople(){ function renderPeople(){
const people = db.globalPeople || []; const people = db.globalPeople || [];
const isAdmin = typeof currentUser !== 'undefined' && !!currentUser && currentUser.role === 'admin'; const isAdmin = hatRecht('a.benutzer'); // Teilnehmerkreis pflegen: wie Benutzerverwaltung
const w = currentWeek(); const w = currentWeek();
const inWeek = new Set((w && Array.isArray(w.people) ? w.people : []).map(p => p.id)); const inWeek = new Set((w && Array.isArray(w.people) ? w.people : []).map(p => p.id));
const locked = !w || isWeekLocked(w); const locked = !w || isWeekLocked(w);
+105 -13
View File
@@ -86,7 +86,7 @@
// aufgerufen. Mit showTodos wird deren HTML per DOM-Bearbeitung um die // aufgerufen. Mit showTodos wird deren HTML per DOM-Bearbeitung um die
// TODO- und Meilensteinzeilen aller übergebenen Projekte ergänzt und als // TODO- und Meilensteinzeilen aller übergebenen Projekte ergänzt und als
// HTML-String zurückgegeben. Parameter: projects (Array), start/end (ISO- // HTML-String zurückgegeben. Parameter: projects (Array), start/end (ISO-
// Datum), options (Optionen inkl. editable und criticalPath). Rückgabe: // Datum), options (Optionen inkl. editable). Rückgabe:
// String mit dem kompletten GANTT-HTML. // String mit dem kompletten GANTT-HTML.
window.buildGanttHtml = function(projects, start, end, options = {}){ window.buildGanttHtml = function(projects, start, end, options = {}){
if(!options.showTodos) return originalBuildGanttHtml(projects, start, end, options); if(!options.showTodos) return originalBuildGanttHtml(projects, start, end, options);
@@ -169,12 +169,11 @@
const span = daysBetween(barStart, barEnd) + 1; const span = daysBetween(barStart, barEnd) + 1;
const dragAttrs = editable ? ` data-gantt-type="todo" data-gantt-project="${esc(p.id)}" data-gantt-index="${tIdx}" data-gantt-start="${formatDateISO(s)}" data-gantt-end="${formatDateISO(e)}" draggable="false"` : ''; const dragAttrs = editable ? ` data-gantt-type="todo" data-gantt-project="${esc(p.id)}" data-gantt-index="${tIdx}" data-gantt-start="${formatDateISO(s)}" data-gantt-end="${formatDateISO(e)}" draggable="false"` : '';
const handles = editable ? `<span class="gantt-resize gantt-resize-left" data-resize="left"></span><span class="gantt-resize gantt-resize-right" data-resize="right"></span>` : ''; const handles = editable ? `<span class="gantt-resize gantt-resize-left" data-resize="left"></span><span class="gantt-resize gantt-resize-right" data-resize="right"></span>` : '';
const isCritical = options.criticalPath && options.criticalPath.includes(todo.id);
const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0)); const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0));
const conflictTitle = conflicts.length > 0 const conflictTitle = conflicts.length > 0
? '\nAchtung: ' + conflicts.map(c => `${c.name} ${c.type.toLowerCase()} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('; ') ? '\nAchtung: ' + conflicts.map(c => `${c.name} ${c.type.toLowerCase()} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('; ')
: ''; : '';
barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${isCritical ? ' critical' : ''}${conflicts.length > 0 ? ' conflict-absence' : ''}"${dragAttrs} data-todo-id="${esc(todo.id)}" data-todo-depends="${esc((todo.dependsOn || []).join(','))}" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(todo.title || 'TODO')}: ${formatDateDE(s)}–${formatDateDE(e)}${esc(conflictTitle)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`; barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${conflicts.length > 0 ? ' conflict-absence' : ''}"${dragAttrs} data-todo-id="${esc(todo.id)}" data-todo-depends="${esc((todo.dependsOn || []).join(','))}" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(todo.title || 'TODO')}: ${formatDateDE(s)}–${formatDateDE(e)}${esc(conflictTitle)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`;
} }
} }
// Geister-Balken zuerst einfügen, damit der aktuelle Balken darüber liegt. // Geister-Balken zuerst einfügen, damit der aktuelle Balken darüber liegt.
@@ -204,10 +203,22 @@
? `<span class="gantt-conflict-badge" title="${esc(conflicts.map(c => `${c.name}: ${c.type} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('\n'))}">${icon('warning')}</span>` ? `<span class="gantt-conflict-badge" title="${esc(conflicts.map(c => `${c.name}: ${c.type} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('\n'))}">${icon('warning')}</span>`
: ''; : '';
row.innerHTML = `<div class="gantt-label" data-todo-id="${esc(todo.id)}" style="padding-left:24px;font-weight:400;color:var(--text-muted)" title="${esc(todo.title || 'TODO')}"> // Ruhigere Namensspalte (Eriks Wahl "A"): Vorgänger stehen nicht mehr
// als Schildchen im Namen, sondern im Tooltip (und als Linie im
// Diagramm); rechts die Kürzel der Zuständigen.
const vorgaengerText = depBadge ? '\nVorgänger: ' + (depBadge.match(/title="Vorgänger: ([^"]*)"/) || [,''])[1] : '';
const vorgaengerKonflikt = depBadge.indexOf('conflict') !== -1;
const leute = (Array.isArray(todo.assignees) ? todo.assignees : []).map(id => {
const pers = ((app.week && app.week.people) || []).find(x => x.id === id);
return pers ? pers.name : '';
}).filter(Boolean);
const leuteHtml = leute.length
? `<span class="gantt-leute">${leute.slice(0, 3).map(k => `<span>${esc(k)}</span>`).join('')}${leute.length > 3 ? `<span>+${leute.length - 3}</span>` : ''}</span>` : '';
row.innerHTML = `<div class="gantt-label" data-todo-id="${esc(todo.id)}" style="padding-left:24px;font-weight:400;color:var(--text-muted)" title="${esc(todo.title || 'TODO')}${vorgaengerText}">
${editable ? '<span class="gantt-todo-drag">' + icon('grip') + '</span>' : ''} ${editable ? '<span class="gantt-todo-drag">' + icon('grip') + '</span>' : ''}
<span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}${depBadge}</span> <span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}</span>
${conflictBadge} ${vorgaengerKonflikt ? '<span class="gantt-vorgaenger-warn" title="Beginnt vor dem Ende eines Vorgängers">!</span>' : ''}
${conflictBadge}${leuteHtml}
</div> </div>
<div class="gantt-timeline">${barHtml}</div>`; <div class="gantt-timeline">${barHtml}</div>`;
return row; return row;
@@ -260,16 +271,73 @@
} }
if(!projectRow) return; if(!projectRow) return;
// Die Projektzeile trägt die Gesamtdauer und Meilensteine ohne eigene
// Zeile; sie heißt jetzt schlicht so, statt den Projektnamen ein
// zweites Mal zu wiederholen.
projectRow.classList.add('tp-projektzeile');
const projektLabel = projectRow.querySelector('.gantt-label');
if(projektLabel) projektLabel.textContent = 'Projekt gesamt';
let insertAfter = projectRow; let insertAfter = projectRow;
const insertedMilestoneIds = new Set(); const insertedMilestoneIds = new Set();
todos.forEach((todo, tIdx) => { // Gliederung nach Leistungsphasen (Eriks Wahl "A"): Phasenbalken
// (balkenPhase(), load.js) werden Gruppenköpfe; jede andere Aufgabe
// steht unter der Phase, in deren Balken sie beginnt. Nur die
// ANZEIGE wird umsortiert — plan.todos übernimmt die Reihenfolge erst,
// wenn jemand eine Zeile zieht (reorderPlanRows(), plan-drag.js).
const istPhase = t => typeof balkenPhase === 'function' && !!balkenPhase(t);
const hatBalken = t => t.start && t.end && t.showInGantt !== false;
const phasenKoepfe = todos.filter(t => hatBalken(t) && istPhase(t))
.sort((a, b) => String(a.start).localeCompare(String(b.start)));
const gruppeVon = t => {
if(istPhase(t)) return t.id;
const s0 = parseDateISO(t.start);
const k = s0 && phasenKoepfe.find(p => s0 >= parseDateISO(p.start) && s0 <= parseDateISO(p.end));
return k ? k.id : '';
};
let reihe = todos;
if(phasenKoepfe.length){
reihe = [];
phasenKoepfe.forEach(p => { reihe.push(p); todos.forEach(t => { if(!istPhase(t) && gruppeVon(t) === p.id) reihe.push(t); }); });
todos.forEach(t => { if(reihe.indexOf(t) === -1) reihe.push(t); });
}
const heuteIsoWert = formatDateISO(new Date());
const filter = options.filter || 'alle';
const passtFilter = t => filter === 'alle'
|| (filter === 'meine' && options.eigenePersonId && Array.isArray(t.assignees) && t.assignees.indexOf(options.eigenePersonId) !== -1)
|| (filter === 'ueberfaellig' && !t.done && t.end && t.end < heuteIsoWert);
const phasenZu = options.phasenZu || {};
let aktuelleGruppeZu = false;
let restKopfGesetzt = false;
const einfuegen = row => { insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); insertAfter = row; };
// Meilensteine ohne Aufgabenbezug: je eine eigene Zeile direkt unter
// "Projekt gesamt", nach Datum sortiert (Eriks Meldung: ohne Aufgaben
// standen alle Meilensteine übereinander in derselben Zeile).
milestones.filter(m => !m.position)
.sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')))
.forEach(m => einfuegen(buildMilestoneRow(m, milestones.indexOf(m))));
reihe.forEach(todo => {
const tIdx = todos.indexOf(todo);
if(phasenKoepfe.length && hatBalken(todo)){
if(istPhase(todo)) aktuelleGruppeZu = !!phasenZu[balkenPhase(todo)];
else if(!gruppeVon(todo) && !restKopfGesetzt){
// Aufgaben außerhalb jedes Phasenbalkens bekommen einen eigenen Kopf.
const kopf = document.createElement('div');
kopf.className = 'gantt-row tp-gruppe';
kopf.innerHTML = '<div class="gantt-label">Weitere Aufgaben</div><div class="gantt-timeline"></div>';
einfuegen(kopf);
restKopfGesetzt = true;
aktuelleGruppeZu = false;
}
}
// Meilensteine, die VOR dieser Aufgabe positioniert sind // Meilensteine, die VOR dieser Aufgabe positioniert sind
milestones.filter(m => m.position === 'before:' + todo.id).forEach(m => { milestones.filter(m => m.position === 'before:' + todo.id).forEach(m => {
const mIdx = milestones.indexOf(m); const mIdx = milestones.indexOf(m);
const row = buildMilestoneRow(m, mIdx); const row = buildMilestoneRow(m, mIdx);
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu');
insertAfter = row; einfuegen(row);
insertedMilestoneIds.add(m.id); insertedMilestoneIds.add(m.id);
}); });
@@ -292,16 +360,40 @@
// kippt. // kippt.
if(todo.start && todo.end && todo.showInGantt !== false){ if(todo.start && todo.end && todo.showInGantt !== false){
const todoRow = buildTodoRow(todo, tIdx); const todoRow = buildTodoRow(todo, tIdx);
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling); if(phasenKoepfe.length && istPhase(todo)){
insertAfter = todoRow; const ph = balkenPhase(todo);
const zu = !!phasenZu[ph];
const kinder = todos.filter(t => !istPhase(t) && hatBalken(t) && gruppeVon(t) === todo.id);
todoRow.classList.add('gantt-phase');
const label = todoRow.querySelector('.gantt-label');
if(label){
const knopf = document.createElement('button');
knopf.type = 'button';
knopf.className = 'tp-klappen';
knopf.setAttribute('aria-expanded', String(!zu));
knopf.setAttribute('aria-label', (zu ? 'Aufklappen: ' : 'Zuklappen: ') + ph);
knopf.setAttribute('onclick', `event.stopPropagation(); tpPhaseUmschalten('${ph.replace(/'/g, '')}')`);
knopf.textContent = zu ? '▶' : '▼';
label.insertBefore(knopf, label.firstChild);
const info = document.createElement('span');
info.className = 'tp-phase-info';
info.textContent = kinder.length + ' · ' + (parseInt(todo.progress, 10) || 0) + ' %';
info.title = (kinder.length === 1 ? '1 Aufgabe' : kinder.length + ' Aufgaben') + ', Phase zu ' + (parseInt(todo.progress, 10) || 0) + ' % erledigt';
label.appendChild(info);
}
} else {
if(aktuelleGruppeZu) todoRow.classList.add('gantt-zu');
if(!passtFilter(todo)) todoRow.classList.add('gantt-gefiltert');
}
einfuegen(todoRow);
} }
// Meilensteine, die NACH dieser Aufgabe positioniert sind // Meilensteine, die NACH dieser Aufgabe positioniert sind
milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => { milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => {
const mIdx = milestones.indexOf(m); const mIdx = milestones.indexOf(m);
const row = buildMilestoneRow(m, mIdx); const row = buildMilestoneRow(m, mIdx);
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu');
insertAfter = row; einfuegen(row);
insertedMilestoneIds.add(m.id); insertedMilestoneIds.add(m.id);
}); });
}); });
+18 -9
View File
@@ -130,14 +130,11 @@
const todoRowIds = rows.filter(r => r.classList.contains('gantt-todo')).map(r => r.dataset.todoId).filter(Boolean); const todoRowIds = rows.filter(r => r.classList.contains('gantt-todo')).map(r => r.dataset.todoId).filter(Boolean);
const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean); const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean);
// Sicherheitscheck: Anzahl muss stimmen. Nicht jedes Meilenstein wird als // Sicherheitscheck: Anzahl muss stimmen. Jeder Meilenstein hat eine
// eigene .gantt-milestone-row gerendert – nur die mit einer TODO-basierten // eigene .gantt-milestone-row (auch ohne Position, dann oben unter
// Position (before:/after:) sind eigene Zeilen. Meilensteine ohne Position // "Projekt gesamt").
// liegen in der Projektzeile und werden hier nicht umsortiert, deshalb nur
// die positionierten zählen.
if(todoRowIds.length !== scheduledIds.size) return; if(todoRowIds.length !== scheduledIds.size) return;
const positionedIds = milestones.filter(m => m.position && /^(before|after):/.test(m.position)).map(m => m.id); if(milestoneRowIds.length !== milestones.length) return;
if(milestoneRowIds.length !== positionedIds.length) return;
// TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden // TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden
// gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne // gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne
@@ -153,7 +150,7 @@
// Regeln: // Regeln:
// - Steht der Meilenstein direkt vor einer TODO -> before:todoId // - Steht der Meilenstein direkt vor einer TODO -> before:todoId
// - Steht er direkt nach einer TODO -> after:todoId // - Steht er direkt nach einer TODO -> after:todoId
// - Steht er ganz am Anfang (vor erster TODO) oder es gibt keine TODO -> '' (oben in Projektzeile) // - Steht er ganz am Anfang (vor erster TODO) oder es gibt keine TODO -> '' (eigene Zeile oben)
// - Steht er ganz am Ende (nach letzter TODO) -> after:lastTodoId // - Steht er ganz am Ende (nach letzter TODO) -> after:lastTodoId
rows.forEach((row, idx) => { rows.forEach((row, idx) => {
if(!row.classList.contains('gantt-milestone-row')) return; if(!row.classList.contains('gantt-milestone-row')) return;
@@ -165,7 +162,11 @@
const prevRow = rows[idx - 1]; const prevRow = rows[idx - 1];
const nextRow = rows[idx + 1]; const nextRow = rows[idx + 1];
if(prevRow && prevRow.classList.contains('gantt-todo')){ const todoDavor = rows.slice(0, idx).some(r => r.classList.contains('gantt-todo'));
if(!todoDavor){
// Vor der ersten Aufgabe: ohne Position (eigene Zeile oben).
position = '';
} else if(prevRow && prevRow.classList.contains('gantt-todo')){
position = 'after:' + prevRow.dataset.todoId; position = 'after:' + prevRow.dataset.todoId;
} else if(nextRow && nextRow.classList.contains('gantt-todo')){ } else if(nextRow && nextRow.classList.contains('gantt-todo')){
position = 'before:' + nextRow.dataset.todoId; position = 'before:' + nextRow.dataset.todoId;
@@ -449,6 +450,14 @@
const globalAbsence = globalPerson && Array.isArray(globalPerson.absences) const globalAbsence = globalPerson && Array.isArray(globalPerson.absences)
? globalPerson.absences.find(x => x.id === absenceId) : null; ? globalPerson.absences.find(x => x.id === absenceId) : null;
if(!globalAbsence) return; if(!globalAbsence) return;
// Abwesenheit einer anderen Person: Art und Notiz kennt nur sie selbst
// (loadGlobalPeople() schickt sie verdeckt). Das Verschieben legt neu an
// und bräuchte die Art — sonst wäre der Eintrag nach dem Löschen weg.
if(globalAbsence.verdeckt){
app.showToast('Abwesenheiten anderer Personen lassen sich hier nicht verschieben.');
renderPlanGantt();
return;
}
const type = globalAbsence.type || ''; const type = globalAbsence.type || '';
const note = globalAbsence.note || ''; const note = globalAbsence.note || '';
// Ursprungstermine merken: Scheitert das Speichern (Netz, fehlende // Ursprungstermine merken: Scheitert das Speichern (Netz, fehlende
+2 -2
View File
@@ -58,7 +58,7 @@
const hint = document.getElementById('filesNoProjectHint'); const hint = document.getElementById('filesNoProjectHint');
const uploadSection = document.getElementById('filesUploadSection'); const uploadSection = document.getElementById('filesUploadSection');
if(hint) hint.style.display = hasNr ? 'none' : ''; if(hint) hint.style.display = hasNr ? 'none' : '';
if(uploadSection) uploadSection.style.display = hasNr ? '' : 'none'; if(uploadSection) uploadSection.style.display = (hasNr && hatRecht('proj.dateien')) ? '' : 'none';
const fileInput = document.getElementById('fileUploadInput'); const fileInput = document.getElementById('fileUploadInput');
if(fileInput) fileInput.value = ''; if(fileInput) fileInput.value = '';
if(hasNr) loadReviewerCheckboxes(); if(hasNr) loadReviewerCheckboxes();
@@ -212,7 +212,7 @@
// Dieselbe Regel wie im Server (DELETE /project-files/:id): der Uploader, // Dieselbe Regel wie im Server (DELETE /project-files/:id): der Uploader,
// solange die Datei noch offen ist — oder ein Administrator. Vorher stand // solange die Datei noch offen ist — oder ein Administrator. Vorher stand
// der Knopf bei jeder Datei und endete für andere in einer Fehlermeldung. // der Knopf bei jeder Datei und endete für andere in einer Fehlermeldung.
const isAdmin = !!(typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin'); const isAdmin = hatRecht('proj.dateienFremd'); // Recht „Fremde Dateien löschen“
const isUploader = !!(typeof currentUser !== 'undefined' && currentUser && f.uploadedByUserId === currentUser.id); const isUploader = !!(typeof currentUser !== 'undefined' && currentUser && f.uploadedByUserId === currentUser.id);
const canDelete = isAdmin || (isUploader && f.status === 'pending'); const canDelete = isAdmin || (isUploader && f.status === 'pending');
const reviewerChips = (f.reviewers || []).map(r => const reviewerChips = (f.reviewers || []).map(r =>
+187 -76
View File
@@ -10,7 +10,7 @@
// Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter) // Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter)
// und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt, // und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt,
// offene Aufgaben und nächster Meilenstein aus denselben Plan-Daten; // 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 // siehe computeResourceLoads() weiter unten — dieselbe Rechnung wie im
// Auslastungs-Tab, nicht aus den Plan-Daten selbst). renderPlanGantt ist die // Auslastungs-Tab, nicht aus den Plan-Daten selbst). renderPlanGantt ist die
// zentrale Neu-Render-Funktion (rendert am Ende auch Auslastung und KPIs // zentrale Neu-Render-Funktion (rendert am Ende auch Auslastung und KPIs
@@ -26,7 +26,7 @@
// renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts // renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts
// neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den // 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 // Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die
// Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert. // Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert.
window.renderPlanGantt = function(){ window.renderPlanGantt = function(){
@@ -41,16 +41,23 @@
document.getElementById('planGanttStart').value = formatDateISO(start); document.getElementById('planGanttStart').value = formatDateISO(start);
document.getElementById('planGanttEnd').value = formatDateISO(end); 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 showVacation = document.getElementById('planShowVacation').checked;
const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id); const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id);
const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : [];
const ghost = collectGhostDates(); const ghost = collectGhostDates();
container.innerHTML = buildGanttHtml([app.project], start, end, { container.innerHTML = buildGanttHtml([app.project], start, end, {
showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id, 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, 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; container.scrollLeft = prevScroll;
if(typeof ganttGriffeEinsetzen === 'function') ganttGriffeEinsetzen(container);
requestAnimationFrame(() => renderGanttDependencies(container)); requestAnimationFrame(() => renderGanttDependencies(container));
renderPlanWarnings(); renderPlanWarnings();
renderResourceLoad(); renderResourceLoad();
@@ -61,6 +68,7 @@
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei // Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich. // Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
if(typeof renderPlanTasks === 'function') renderPlanTasks(); if(typeof renderPlanTasks === 'function') renderPlanTasks();
if(typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(typeof renderPlanDashboard === 'function') renderPlanDashboard(); if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
}; };
@@ -121,71 +129,68 @@
</div>`; </div>`;
}; };
// kpiTile: baut das HTML einer einzelnen Kachel für renderPlanKpis(). // bandZelle: eine Kennzahl im Lageband des Dashboards. Parameter: label,
// Parameter: label (Kachel-Titel), value (Hauptzahl), sub (Unterzeile), // value, sub (Texte), warn (Unterzeile rot). Rückgabe: HTML-String.
// warn (optional, hebt den Wert farblich hervor, z. B. bei überfälligen function bandZelle(label, value, sub, warn){
// Aufgaben). Rückgabe: HTML-String. return `<div class="plan-lage-zelle">
function kpiTile(label, value, sub, warn){
return `<div class="plan-kpi-tile${warn ? ' warn' : ''}">
<div class="kpi-label">${esc(label)}</div> <div class="kpi-label">${esc(label)}</div>
<div class="kpi-value">${esc(value)}</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>`; </div>`;
} }
// renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout, // renderPlanKpis: füllt das Lageband oben im Dashboard (#planKpiRow,
// #planKpiRow in App.html) mit vier Kennzahlen, die alle aus den // Anordnung "Lagebild"): links die laufende Leistungsphase mit Ring
// bereits vorhandenen Plan-Daten abgeleitet sind (keine neue Datenquelle): // (amtierende LPH und ihr Fortschritt aus dem Wochenbericht), daneben offene
// Gesamtfortschritt (Ø-Fortschritt aller TODOs), offene Aufgaben (inkl. // Aufgaben (mit Überfälligem), nächster Meilenstein und Team-Auslastung
// überfällige), nächster anstehender Meilenstein und Team-Auslastung // (computeResourceLoads(), dieselbe Zahl wie im Auslastungs-Tab). Der
// (Ø-Prozentwert aus computeResourceLoads(), dieselbe Zahl wie im // frühere "Gesamtfortschritt" (Ø der Aufgaben) ist entfallen — für den
// Auslastungs-Modal). Wird nach jedem GANTT-Rendering aufgerufen, damit sie // Stand eines Projekts zählt die Leistungsphase. Wird nach jedem
// nach TODO-/Meilenstein-/Zuordnungsänderungen sofort aktuell ist. // GANTT-Rendering aufgerufen, damit es sofort aktuell ist.
window.renderPlanKpis = function(){ window.renderPlanKpis = function(){
const row = document.getElementById('planKpiRow'); const row = document.getElementById('planKpiRow');
if(!row || !app.project) return; if(!row || !app.project) return;
const p = app.project;
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : []; const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : []; const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
let progressTile; const lph = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || '';
if(todos.length === 0){ const roh = lph && p.lph ? p.lph[lph] : '';
progressTile = kpiTile('Gesamtfortschritt', '–', 'Keine Aufgaben angelegt'); const pct = roh === undefined || roh === '' ? null : Math.max(0, Math.min(100, Number(roh) || 0));
} else { const phase = `<div class="plan-lage-zelle plan-lage-phase">
const avg = Math.round(todos.reduce((s, t) => s + (parseInt(t.progress, 10) || 0), 0) / todos.length); <span class="plan-lage-ring" style="--p:${pct || 0}"></span>
const doneCount = todos.filter(t => t.done).length; <div><div class="kpi-label">Laufende Phase</div>
progressTile = kpiTile('Gesamtfortschritt', avg + '%', doneCount + ' von ' + todos.length + ' Aufgaben erledigt'); <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 open = todos.filter(t => !t.done);
const overdue = open.filter(t => t.end && parseDateISO(t.end) < today); 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 openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'im Zeitplan' : 'alle erledigt');
const openTile = kpiTile('Offene Aufgaben', String(open.length), openSub, overdue.length > 0);
const upcoming = milestones const upcoming = milestones
.filter(m => m.date && parseDateISO(m.date) >= today) .filter(m => m.date && parseDateISO(m.date) >= today)
.sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date)); .sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date));
let milestoneTile; const msZelle = upcoming.length > 0
if(upcoming.length > 0){ ? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein')
milestoneTile = kpiTile('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein'); : bandZelle('Nächster Meilenstein', '–', milestones.length > 0 ? 'keine anstehenden' : 'keine angelegt');
} else {
milestoneTile = kpiTile('Nächster Meilenstein', '–', milestones.length > 0 ? 'Keine anstehenden Meilensteine' : 'Keine Meilensteine angelegt');
}
const { loads } = computeResourceLoads(); const { loads } = computeResourceLoads();
const loadValues = Object.values(loads); const loadValues = Object.values(loads);
let loadTile; let loadZelle;
if(loadValues.length === 0){ if(loadValues.length === 0){
loadTile = kpiTile('Team-Auslastung', '–', 'Keine Beteiligten zugeordnet'); loadZelle = bandZelle('Team-Auslastung', '–', 'niemand zugeordnet');
} else { } else {
const avgPct = Math.round(loadValues.reduce((s, l) => s + l.quote * 100, 0) / loadValues.length); 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]; 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 // renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die
@@ -202,7 +207,13 @@
let svg = chart.querySelector('.gantt-dependencies-svg'); let svg = chart.querySelector('.gantt-dependencies-svg');
if(svg) svg.remove(); 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; if(nodeBars.length === 0) return;
const chartRect = chart.getBoundingClientRect(); const chartRect = chart.getBoundingClientRect();
@@ -283,6 +294,97 @@
renderPlanGantt(); 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. // zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms.
// Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit). // Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit).
window.zoomPlanGantt = function(dir){ window.zoomPlanGantt = function(dir){
@@ -291,7 +393,7 @@
let end = parseDateISO(document.getElementById('planGanttEnd').value); let end = parseDateISO(document.getElementById('planGanttEnd').value);
if(!start || !end) return; if(!start || !end) return;
const totalDays = Math.max(1, daysBetween(start, end) + 1); 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 fitPx = Math.max(3, cw / totalDays);
const base = app.pxPerDay || fitPx; const base = app.pxPerDay || fitPx;
let next; let next;
@@ -308,33 +410,20 @@
// ---------- Ressourcen-Auslastung ---------- // ---------- Ressourcen-Auslastung ----------
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten // computeResourceLoads: berechnet die Auslastung der Projektbeteiligten
// NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel // NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel:
// (berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor(), // Stunden dieser Woche aus Aufgaben mit Aufwand, gleich geteilt auf die
// js/load/load.js) — Wochenstunden aus Honorar × Phasenanteil × // Zuständigen (aufgabenVerlaufJePerson(), js/load/load.js), bezogen auf die
// Restfortschritt, bezogen auf die Kapazität der Person. // 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
// Vorher zählte diese Stelle etwas ganz anderes: Aufgabentage aus den // entspannt aus, der in vier anderen Projekten ausgebucht ist.
// 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 // Gemeinsam genutzt von renderResourceLoad() (Detailkarten im
// Terminplan-Bereich) und renderPlanKpis() (Team-Auslastung-Kachel), damit // Terminplan-Bereich) und renderPlanKpis() (Team-Auslastung-Kachel), damit
// beide dieselbe Zahl zeigen. Rückgabe: { loads } — loads ist ein Objekt // beide dieselbe Zahl zeigen. Rückgabe: { loads } — loads ist ein Objekt
// Person-ID -> { person, roles, kapazitaet, stundenProjekt, stundenAndere, // Person-ID -> { person, roles, kapazitaet, stundenProjekt, stundenAndere,
// stunden (Summe), quote (stunden/kapazitaet), andereProjekte, todos // stunden (Summe), quote (stunden/kapazitaet), andereProjekte, todos
// (Titel der eigenen TODOs in DIESEM Projekt, rein informativ — zählt // (Titel der eigenen Aufgaben in DIESEM Projekt, ohne Phasenbalken),
// nicht mehr in die Stundenrechnung mit ein). // offenProjekt (offene Aufgabenstunden dieses Projekts, alle Wochen).
function computeResourceLoads(){ function computeResourceLoads(){
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : []; 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); 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 => { todos.forEach(t => {
if(!Array.isArray(t.assignees)) return; if(!Array.isArray(t.assignees) || balkenPhase(t)) return;
t.assignees.forEach(pid => { 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 => { ((app.week && app.week.projects) || []).forEach(pr => {
const aktivePhase = lpActiveList(pr)[0]; const av = typeof aufgabenVerlaufJePerson === 'function' ? aufgabenVerlaufJePerson(pr) : {};
Object.keys(pr.assign || {}).forEach(aKey => { Object.keys(av).forEach(pid => {
const pid = aKey.slice(0, aKey.indexOf('|')); if(!loads[pid] || !(av[pid][0] > 0)) return;
if(!loads[pid]) return; // nur Personen, die auch in DIESEM Projekt beteiligt sind if(pr.id === app.project.id) loads[pid].stundenProjekt += av[pid][0];
const rolle = aKey.slice(aKey.indexOf('|') + 1); else {
const al = berechneAuslastung(null, pr, aktivePhase, rolle, pr.assign[aKey], pr.assign); loads[pid].stundenAndere += av[pid][0];
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'); const label = pr.nr ? (pr.nr + ' ' + (pr.name || '')) : (pr.name || 'Projekt');
if(!loads[pid].andereProjekte.includes(label)) loads[pid].andereProjekte.push(label); if(!loads[pid].andereProjekte.includes(label)) loads[pid].andereProjekte.push(label);
} }
@@ -456,10 +562,15 @@
</div>` </div>`
: `<div class="plan-resource-other muted">keine weiteren Projekte</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"> 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> <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> <div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
${offenText}
${otherBadge} ${otherBadge}
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div> <div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
${absBadge} ${absBadge}
+10 -78
View File
@@ -1,19 +1,16 @@
// ----------------------------- // -----------------------------
// Projektplan — Verlauf über mehrere Sitzungen // Projektplan — Verlauf über mehrere Sitzungen
// ----------------------------- // -----------------------------
// Dieses Modul macht die Sitzungs-Historie eines Projekts sichtbar: die // Dieses Modul liefert die Fassungen eines Projekts über die Sitzungen
// Kompakt-Karte "Verlauf" unter dem GANTT zeigt den letzten Kommentarwechsel, // (Vorwochen-Helfer für den GANTT). Angezeigt wird der Kommentar-Verlauf
// ein Klick öffnet #planHistoryModal mit dem vollständigen Verlauf. // im Projekt-Dashboard von plan-info.js.
// //
// Der Verlauf selbst wird NICHT hier gezeichnet, sondern von // Der Verlauf in Wochenbericht und Projektliste wird von
// renderProjektVerlauf() in js/utils.js — derselben Darstellung, die auch der // renderProjektVerlauf() in js/utils.js gezeichnet. Zuvor
// Wochenbericht unter der Projektkarte und die Projektliste zeigen. Zuvor
// hatte jede dieser Stellen ihre eigene Fassung mit eigener Auswahl (hier nur // hatte jede dieser Stellen ihre eigene Fassung mit eigener Auswahl (hier nur
// Sitzungen mit Kommentarwechsel, im Bericht auch Termine und // Sitzungen mit Kommentarwechsel, im Bericht auch Termine und
// Leistungsphasen), weshalb derselbe Projektverlauf je nach Aufrufort anders // Leistungsphasen), weshalb derselbe Projektverlauf je nach Aufrufort anders
// aussah und sich widersprechen konnte. Der Dialog übergibt kein weekLabel: // aussah und sich widersprechen konnte.
// Er kennt keine "aktuelle" Fassung, die auszulassen wäre, und zeigt deshalb
// alle Sitzungen einschließlich der geöffneten.
// //
// Hintergrund: Beim wöchentlichen cloneWeek() (js/weeks.js) wird der // Hintergrund: Beim wöchentlichen cloneWeek() (js/weeks.js) wird der
// Kommentartext in die neue Sitzung kopiert und dort überschrieben. Der alte // Kommentartext in die neue Sitzung kopiert und dort überschrieben. Der alte
@@ -86,75 +83,10 @@
return null; return null;
} }
// historyEntries: die Verlaufsliste des aktuell geöffneten Projekts // Die Kompakt-Karte "Verlauf" (renderPlanHistory()) und der Dialog
// (neueste zuerst, Wiederholungen desselben Textes gefiltert). Logik in // #planHistoryModal (openHistoryModal()/closeHistoryModal()) sind entfallen:
// js/utils.js, siehe projectStatusHistory(). // Der Kommentar-Verlauf steht jetzt in der Kachel "Kommentar der Sitzung"
function historyEntries(){ // des Projekt-Dashboards (renderPlanKommentarVerlauf(), plan-info.js).
return projectStatusHistory(app.project && app.project.projectUid);
}
// renderPlanHistory: füllt die Kompakt-Karte "Verlauf" (#planHistorySummary
// in App.html) — Anzahl der Einträge und die Sitzung des letzten
// Kommentarwechsels. Wird von renderPlanPage() (plan-state.js) nach dem
// Laden aufgerufen. Keine Parameter, keine Rückgabe.
window.renderPlanHistory = function(){
const summaryEl = document.getElementById('planHistorySummary');
if(!summaryEl || !app.project) return;
// Ein Sandbox-Plan der Planung lebt in genau einer, nie geklonten Sitzung
// (SANDBOX_WEEK_LABEL) — es gibt also nie einen "Verlauf über mehrere
// Sitzungen" zu zeigen, nur immer denselben einen Schnappschuss.
if(app.isSandboxPlan && app.isSandboxPlan()){
summaryEl.innerHTML = '<span class="hint">Kein Verlauf — dieser Terminplan gehört zu keiner Sitzung.</span>';
return;
}
if(!app.project.projectUid){
summaryEl.innerHTML = '<span class="hint">Für dieses Projekt ist kein Verlauf verfügbar.</span>';
return;
}
const entries = historyEntries();
if(entries.length === 0){
summaryEl.innerHTML = '<span class="hint">Noch keine Kommentare erfasst.</span>';
return;
}
const latest = entries[0];
const versionCount = projectVersions().length;
summaryEl.innerHTML = `<div class="plan-mini-card-preview">${esc(latest.text)}</div>
<div class="plan-mini-chip-row">
<span class="plan-mini-chip">${entries.length} Eintrag${entries.length === 1 ? '' : 'e'}</span>
<span class="plan-mini-chip">zuletzt ${esc(latest.week.label)}</span>
<span class="plan-mini-chip">${versionCount} Sitzung${versionCount === 1 ? '' : 'en'}</span>
</div>`;
};
// openHistoryModal/closeHistoryModal: öffnen bzw. schließen das
// Verlaufs-Modal (#planHistoryModal). Der Inhalt wird beim Öffnen frisch
// aufgebaut, damit ein gerade im Projektinfo-Modal geänderter Kommentar
// sofort mit auftaucht.
window.openHistoryModal = function(){
const listEl = document.getElementById('planHistoryList');
if(listEl){
if(!app.project || !app.project.projectUid){
listEl.innerHTML = '<span class="hint">Dieses Projekt hat keine dauerhafte Projekt-Kennung und kann daher nicht über Sitzungen hinweg verfolgt werden.</span>';
} else {
// Dieselbe Darstellung wie im Wochenbericht und im Projektboard
// (renderProjektVerlauf(), js/utils.js). Der Dialog hatte zuvor eine
// eigene, enger gefasste Liste — sie zeigte nur Kommentartexte und
// konnte dem Verlauf an den anderen Stellen widersprechen.
//
// weekLabel bleibt leer: Im Dialog gibt es keine "aktuelle" Fassung,
// die auszulassen wäre — hier sollen ALLE Sitzungen erscheinen,
// einschließlich der gerade geöffneten.
listEl.innerHTML = renderProjektVerlauf(app.project, '', app.isLocked());
}
}
document.getElementById('planHistoryModal').style.display = 'flex';
};
window.closeHistoryModal = function(){
document.getElementById('planHistoryModal').style.display = 'none';
};
// Export für die anderen plan-Module (plan-gantt.js/plan-build-gantt.js: // Export für die anderen plan-Module (plan-gantt.js/plan-build-gantt.js:
// Geister-Balken der letzten geplanten Sitzung). // Geister-Balken der letzten geplanten Sitzung).
+10 -3
View File
@@ -94,7 +94,11 @@
} }
const stundensatz = honorarConfig.stundensatz; const stundensatz = honorarConfig.stundensatz;
// Beginnt der Plan an einem Wochenende/Feiertag, auf den nächsten
// Arbeitstag schieben — wie der Assistent "Neues Projekt". Sonst stand der
// Balken ab Samstag, gezählt wurde aber erst ab Montag.
let cursor = startDate; let cursor = startDate;
while(!isWorkday(cursor)) cursor = addDays(cursor, 1);
let summeStunden = 0, summeTage = 0, summeHonorar = 0; let summeStunden = 0, summeTage = 0, summeHonorar = 0;
const zeilen = phasen.map(lph => { const zeilen = phasen.map(lph => {
@@ -137,7 +141,7 @@
// Eingaben gelten als 0 = "kein Honorar hinterlegt". // Eingaben gelten als 0 = "kein Honorar hinterlegt".
// Parameter: val (Eingabewert). Keine Rückgabe. // Parameter: val (Eingabewert). Keine Rückgabe.
window.setHonorarBetrag = function(val){ window.setHonorarBetrag = function(val){
if(app.isLocked()) return; if(app.stammGesperrt()) return;
const v = parseFloat(String(val).replace(',', '.')); const v = parseFloat(String(val).replace(',', '.'));
app.project.honorar = Number.isFinite(v) && v > 0 ? v : 0; app.project.honorar = Number.isFinite(v) && v > 0 ? v : 0;
app.savePlan(); app.savePlan();
@@ -152,7 +156,7 @@
// Reihenfolge von LPHS gehalten, damit der erzeugte Terminplan // Reihenfolge von LPHS gehalten, damit der erzeugte Terminplan
// chronologisch aufsteigt. Parameter: lph, checked (Boolean). // chronologisch aufsteigt. Parameter: lph, checked (Boolean).
window.toggleBeauftragteLp = function(lph, checked){ window.toggleBeauftragteLp = function(lph, checked){
if(app.isLocked()) return; if(app.stammGesperrt()) return;
const p = app.project; const p = app.project;
const set = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []); const set = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
if(checked) set.add(lph); else set.delete(lph); if(checked) set.add(lph); else set.delete(lph);
@@ -260,7 +264,7 @@
function renderHonorarHint(){ function renderHonorarHint(){
const el = document.getElementById('honorarAdminHint'); const el = document.getElementById('honorarAdminHint');
if(!el) return; if(!el) return;
el.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none'; el.style.display = hatRecht('a.honorar') ? '' : 'none';
} }
// invalidateHonorarConfig: verwirft den Zwischenspeicher, damit der nächste // invalidateHonorarConfig: verwirft den Zwischenspeicher, damit der nächste
@@ -326,6 +330,9 @@
plan.todos.push({ plan.todos.push({
id: uid(), id: uid(),
title: `${z.lph} (${fmtNum(z.stunden, 0)} h)`, title: `${z.lph} (${fmtNum(z.stunden, 0)} h)`,
// Kennzeichen für die Auslastung: Die Reststunden dieser Phase
// werden auf die Wochen dieses Balkens verteilt (load.js).
lph: z.lph,
start: formatDateISO(z.start), start: formatDateISO(z.start),
end: formatDateISO(z.ende), end: formatDateISO(z.ende),
done: false, done: false,
+136 -6
View File
@@ -70,9 +70,109 @@
// amtierende Leistungsphase, LPH-Prozentliste) aus app.project auf. Wird // amtierende Leistungsphase, LPH-Prozentliste) aus app.project auf. Wird
// nach jedem Laden (renderPlanPage(), plan-state.js) sowie nach jeder // nach jedem Laden (renderPlanPage(), plan-state.js) sowie nach jeder
// Änderung hier selbst erneut aufgerufen. Keine Parameter, keine Rückgabe. // Änderung hier selbst erneut aufgerufen. Keine Parameter, keine Rückgabe.
// ---------------------------------------------------------------------------
// Kommentar-Verlauf im Projekt-Dashboard
// ---------------------------------------------------------------------------
// planKommentarDaten: sucht über alle Sitzungen dieses Projekts (bis
// einschließlich der angezeigten) den jüngsten Kommentar und listet die
// früheren je Gewerk. Ein Eintrag im Verlauf entsteht nur, wenn sich der
// Text eines Gewerks gegenüber der Sitzung davor geändert hat — ein
// unverändert mitgeführter Kommentar stünde sonst jede Woche erneut da.
// Rückgabe: { letzter: {week, k, aktuell} | null, verlauf: [{week, gewerk, html, autor, at}] }
function planKommentarDaten(p, week){
const bisKey = week && week.label ? weekSortKey(week.label) : null;
let versionen = (typeof projectVersionsForUid === 'function' && p.projectUid)
? projectVersionsForUid(p.projectUid).filter(v => !bisKey || weekSortKey(v.week.label) <= bisKey)
: [];
// Angezeigte Sitzung immer mit dem lebenden Projektobjekt (Sandbox, oder
// ein Projekt, das in db.weeks noch nicht steht).
versionen = versionen.filter(v => !week || v.week.label !== week.label);
versionen.push({ week: week || { label: '' }, project: p });
let letzter = null;
for(let i = versionen.length - 1; i >= 0; i--){
const k = kommentarVon(versionen[i].project, null);
if(statusPlainText(k.status)){
letzter = { week: versionen[i].week, k, aktuell: i === versionen.length - 1 };
break;
}
}
const gewerke = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
const verlauf = [];
gewerke.forEach(g => {
let vorher = '';
versionen.forEach(v => {
const k = kommentarVon(v.project, g);
const text = statusPlainText(k.status);
if(text && text !== vorher && !(letzter && v.week === letzter.week)){
verlauf.push({ week: v.week, gewerk: g, html: sanitizeStatusHtml(k.status), autor: k.statusAuthor || '', at: k.statusAt || '' });
}
vorher = text;
});
});
// Derselbe Text in mehreren Gewerken derselben Sitzung (Altbestand: die
// Umstellung auf Kommentare je Gewerk hat ihn in jedes kopiert) wird ein
// Eintrag mit allen Gewerken, statt zweimal untereinander zu stehen.
const zusammen = [];
verlauf.forEach(e => {
const gleich = zusammen.find(z => z.week === e.week && statusPlainText(z.html) === statusPlainText(e.html));
if(gleich){ gleich.gewerk += ', ' + e.gewerk; if(!gleich.autor) gleich.autor = e.autor; }
else zusammen.push(Object.assign({}, e));
});
zusammen.sort((a, b) => weekSortKey(b.week.label).localeCompare(weekSortKey(a.week.label))
|| String(b.at).localeCompare(String(a.at)));
return { letzter, verlauf: zusammen };
}
// "KW 43 / 2026" -> "KW 43" (das Jahr nur, wenn es nicht das laufende ist)
function kurzKw(label){
const m = /^(KW\s*\d+)\s*\/\s*(\d{4})$/.exec(String(label || '').trim());
if(!m) return label || '';
return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2];
}
const PLAN_VERLAUF_KURZ = 3;
let planVerlaufAlle = false;
let planVerlaufProjekt = null;
function renderPlanKommentarVerlauf(verlauf){
const el = document.getElementById('planStatusVerlauf');
if(!el) return;
const uid = app.project && (app.project.projectUid || app.project.id);
if(uid !== planVerlaufProjekt){ planVerlaufProjekt = uid; planVerlaufAlle = false; }
if(!verlauf.length){ el.hidden = true; el.innerHTML = ''; return; }
el.hidden = false;
const zeigen = planVerlaufAlle ? verlauf : verlauf.slice(0, PLAN_VERLAUF_KURZ);
const zeilen = zeigen.map((e, i) => `
<li class="plan-verlauf-eintrag${i === 0 ? ' ist-juengster' : ''}">
<span class="plan-verlauf-kw">${esc(kurzKw(e.week.label))}</span>
<span class="plan-verlauf-punkt" aria-hidden="true"></span>
<div class="plan-verlauf-inhalt">
<div class="plan-verlauf-text">${e.html}</div>
<div class="plan-verlauf-meta">${esc(e.gewerk)}${e.autor ? ' · ' + esc(e.autor) : ''}</div>
</div>
</li>`).join('');
const mehr = verlauf.length > PLAN_VERLAUF_KURZ
? `<button type="button" class="plan-verlauf-mehr" onclick="planVerlaufUmschalten()">${planVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + verlauf.length + ' anzeigen'}</button>`
: '';
el.innerHTML = `
<div class="plan-verlauf-kopf">
<span class="plan-verlauf-titel">Verlauf</span>
<span class="plan-verlauf-anzahl">${verlauf.length === 1 ? '1 früherer Kommentar' : verlauf.length + ' frühere Kommentare'}</span>
</div>
<ol class="plan-verlauf-liste">${zeilen}</ol>${mehr}`;
}
window.planVerlaufUmschalten = function(){
planVerlaufAlle = !planVerlaufAlle;
window.renderPlanInfo();
};
window.renderPlanInfo = function(){ window.renderPlanInfo = function(){
const p = app.project; const p = app.project;
const locked = app.isLocked(); const locked = app.stammGesperrt();
// Kommentar, Fortschritt und Zuordnung sind hier NUR ZU SEHEN. // Kommentar, Fortschritt und Zuordnung sind hier NUR ZU SEHEN.
// //
@@ -83,15 +183,42 @@
// Personen konnten dasselbe Feld an verschiedenen Stellen pflegen. // Personen konnten dasselbe Feld an verschiedenen Stellen pflegen.
// Was hier bleibt, sind die Stammdaten des Projekts (Bereich // Was hier bleibt, sind die Stammdaten des Projekts (Bereich
// "Einstellungen", siehe plan-settings.js). // "Einstellungen", siehe plan-settings.js).
// Gezeigt wird der ZULETZT GESCHRIEBENE Kommentar (Eriks Wunsch, Entwurf
// "A"): Hat die Sitzung noch keinen, der jüngste aus einer früheren — mit
// Hinweis, aus welcher. Darunter der Verlauf der früheren Kommentare.
const kom = planKommentarDaten(p, app.week);
const editEl = document.getElementById('planStatusEdit'); const editEl = document.getElementById('planStatusEdit');
if(editEl){ if(editEl){
editEl.className = 'status-edit is-ansicht'; editEl.className = 'status-edit is-ansicht';
editEl.contentEditable = 'false'; editEl.contentEditable = 'false';
const text = sanitizeStatusHtml(p.status || ''); // Alle Gewerke zusammen, je mit Gewerk davor (kommentarVon(), js/utils.js).
editEl.innerHTML = text || '<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'; const text = kom.letzter ? sanitizeStatusHtml(kom.letzter.k.status) : '';
editEl.innerHTML = text || '<span class="hint" style="margin:0">Zu diesem Projekt ist noch kein Kommentar erfasst.</span>';
} }
const hinweisEl = document.getElementById('planStatusHinweis');
if(hinweisEl){
const aelter = kom.letzter && !kom.letzter.aktuell;
hinweisEl.hidden = !aelter;
hinweisEl.innerHTML = aelter
? `In dieser Sitzung noch kein Kommentar — angezeigt wird der letzte aus <b>${esc(kurzKw(kom.letzter.week.label))}</b>.`
: '';
}
// Wer den Kommentar wann geschrieben hat (jüngster Eintrag aller Gewerke).
const autorEl = document.getElementById('planStatusAutor');
if(autorEl){
const k = kom.letzter ? kom.letzter.k : {};
autorEl.innerHTML = k.status && k.statusAuthor
? `<span class="plan-autor-ava">${esc(initialsForName(k.statusAuthor))}</span>${esc(k.statusAuthor)}`
+ (k.statusAt && typeof formatCommentStamp === 'function' ? ', ' + esc(formatCommentStamp(k.statusAt)) : '')
: '';
}
renderPlanKommentarVerlauf(kom.verlauf);
const statusWrap = document.getElementById('planStatusWrap'); const statusWrap = document.getElementById('planStatusWrap');
if(statusWrap) statusWrap.classList.add('locked-cell'); // Nicht mehr als gesperrt markieren: .locked-cell dimmt auf 55 % und legt im
// Dunkelmodus ein Grau darunter — der Kommentar war dadurch kaum lesbar.
// Dass er hier nur angezeigt wird, zeigen schon .is-ansicht und der
// Verweis "Im Wochenbericht bearbeiten".
if(statusWrap) statusWrap.classList.remove('locked-cell');
setzeBerichtVerweis('planStatusVerweis', locked); setzeBerichtVerweis('planStatusVerweis', locked);
// Gruppe — Stammdatum, bleibt hier pflegbar // Gruppe — Stammdatum, bleibt hier pflegbar
@@ -176,7 +303,7 @@
// setPlanGroup: ändert die Gruppe des Projekts. Parameter: val (String). // setPlanGroup: ändert die Gruppe des Projekts. Parameter: val (String).
// Keine Rückgabe; bei gesperrter Woche abgebrochen. // Keine Rückgabe; bei gesperrter Woche abgebrochen.
window.setPlanGroup = function(val){ window.setPlanGroup = function(val){
if(app.isLocked()) return; if(app.stammGesperrt()) return;
app.project.group = val; app.project.group = val;
app.savePlan(); app.savePlan();
}; };
@@ -185,11 +312,14 @@
// Projekts auf. Parameter: val (LPH-Name oder leer). Rendert das // Projekts auf. Parameter: val (LPH-Name oder leer). Rendert das
// Infopanel neu, damit die Markierung in der LPH-Liste sofort mitzieht. // Infopanel neu, damit die Markierung in der LPH-Liste sofort mitzieht.
window.setPlanActiveLp = function(val){ window.setPlanActiveLp = function(val){
if(app.isLocked()) return; if(app.stammGesperrt()) return;
if(val) app.project.aktuelleLP = [val]; if(val) app.project.aktuelleLP = [val];
else delete app.project.aktuelleLP; else delete app.project.aktuelleLP;
app.savePlan(); app.savePlan();
window.renderPlanInfo(); window.renderPlanInfo();
// Abschnitt "Leistungsphasen" der Einstellungen: Kennzeichen "läuft"
// und die Kurzfassung in der Unternavigation nachziehen.
if(typeof renderPlanSettings === 'function') renderPlanSettings();
}; };
// Die Schreibfunktionen setPlanLph()/setPlanAssign()/planStatusBlur() sind // Die Schreibfunktionen setPlanLph()/setPlanAssign()/planStatusBlur() sind
+293
View File
@@ -0,0 +1,293 @@
// plan-mail-eintrag.js — "Termin aus Mail" (Eriks Wunsch): Mailtext per
// Drag & Drop (markierter Text oder eine ganze .eml-Datei) oder Strg+V auf
// den Terminplan bzw. die Aufgabenliste eines Projekts; eine KI macht daraus
// Aufgaben. Der Terminplan ist nur die grafische Darstellung der
// Aufgabenliste — deshalb entstehen hier ausschließlich Aufgaben
// (plan.todos), keine Meilensteine.
//
// Nichts wird ohne Prüfung angelegt: Jeder Vorschlag öffnet das gewohnte
// Aufgaben-Panel vorausgefüllt (openTodoModal(null, prefill)), die Mail
// steht als Notiz darin (t.notiz). Mehrere Vorschläge erst als Liste zum
// Abhaken, dann nacheinander im Panel. Server: POST /plan-eintrag-aus-mail
// (speichert nichts).
(function(){
const app = window.PlanApp;
const ANBIETER_KEY = 'projuebersicht-mail-ki';
const ZIEL_ABSCHNITTE = ['terminplan', 'aufgaben'];
let tiefe = 0; // dragenter/-leave-Zähler (Kindelemente feuern mit)
let laeuft = false;
function aktiverAbschnitt(){
if(typeof currentTab === 'undefined' || currentTab !== 'plan') return '';
const s = document.querySelector('.plan-section.is-active');
return s ? s.dataset.section : '';
}
function ablageErlaubt(){
return ZIEL_ABSCHNITTE.includes(aktiverAbschnitt()) && app.project && !app.isLocked();
}
function bringtMail(dt){
if(!dt) return false;
const typen = Array.from(dt.types || []);
return typen.includes('Files') || typen.includes('text/plain') || typen.includes('text/html');
}
// ---------- Ablagefläche ----------
function flaeche(){
let el = document.getElementById('mailAblage');
if(!el){
el = document.createElement('div');
el.id = 'mailAblage';
el.className = 'mail-ablage';
el.hidden = true;
el.innerHTML = `<div class="mail-ablage-karte">${icon('mail')}
<b id="mailAblageTitel">Mail hier ablegen</b>
<span id="mailAblageText">Die KI macht daraus einen Eintrag im Terminplan. Der Mailtext geht dafür an den KI-Anbieter aus Einstellungen → KI-Zugangsdaten.</span></div>`;
document.body.appendChild(el);
}
return el;
}
function zeigeFlaeche(titel, text){
const el = flaeche();
document.getElementById('mailAblageTitel').textContent = titel;
if(text !== undefined) document.getElementById('mailAblageText').textContent = text;
el.classList.toggle('ist-beschaeftigt', laeuft);
el.hidden = false;
}
function versteckeFlaeche(){
const el = document.getElementById('mailAblage');
if(el) el.hidden = true;
}
document.addEventListener('dragenter', e => {
if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)) return;
tiefe++;
zeigeFlaeche('Mail hier ablegen', 'Die KI macht daraus einen Eintrag im Terminplan. Der Mailtext geht dafür an den KI-Anbieter aus Einstellungen → KI-Zugangsdaten.');
});
document.addEventListener('dragover', e => {
if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
document.addEventListener('dragleave', () => {
if(tiefe === 0) return;
tiefe--;
if(tiefe === 0 && !laeuft) versteckeFlaeche();
});
document.addEventListener('drop', e => {
if(tiefe === 0 && !ablageErlaubt()) return;
tiefe = 0;
if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)){ if(!laeuft) versteckeFlaeche(); return; }
e.preventDefault();
ausDataTransfer(e.dataTransfer);
});
// Strg+V als Ausweg, wenn ein Mailprogramm das Ziehen nicht unterstützt —
// nur außerhalb von Eingabefeldern, damit normales Einfügen unberührt bleibt.
document.addEventListener('paste', e => {
if(laeuft || !ablageErlaubt()) return;
const ziel = e.target;
if(ziel && (ziel.closest('input, textarea, select, [contenteditable="true"], .modal-bg'))) return;
const dt = e.clipboardData;
if(!dt) return;
const datei = Array.from(dt.files || []).find(istEml);
const text = (dt.getData('text/plain') || '').trim();
if(!datei && !text) return;
e.preventDefault();
ausDataTransfer(dt);
});
function istEml(f){ return f && /\.eml$/i.test(f.name || '') || (f && f.type === 'message/rfc822'); }
function ausDataTransfer(dt){
const dateien = Array.from(dt.files || []);
const eml = dateien.find(istEml);
if(eml){
if(eml.size > 5 * 1024 * 1024){ fehler('Die Mail ist zu groß (mehr als 5 MB).'); return; }
eml.text().then(t => auswerten({ eml: t })).catch(() => fehler('Die Mail-Datei ließ sich nicht lesen.'));
return;
}
if(dateien.some(f => /\.msg$/i.test(f.name || ''))){
fehler('Outlook-Dateien (.msg) lassen sich nicht direkt lesen. Bitte den Mailtext markieren und hineinziehen oder mit Strg+V einfügen.');
return;
}
let text = (dt.getData('text/plain') || '').trim();
if(!text){
const html = dt.getData('text/html') || '';
const div = document.createElement('div');
div.innerHTML = html.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '');
text = (div.innerText || div.textContent || '').trim();
}
if(!text){
if(dateien.length) fehler('Diese Datei ist keine Mail. Abgelegt werden können markierter Mailtext oder .eml-Dateien.');
else versteckeFlaeche();
return;
}
auswerten({ text });
}
function fehler(meldung){
laeuft = false;
versteckeFlaeche();
app.showToast(meldung, 'error');
}
// ---------- Auswerten ----------
function anbieterWaehlen(){
return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => {
const bereit = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured);
let zuletzt = '';
try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
return bereit.includes(zuletzt) ? zuletzt : (bereit[0] || '');
}).catch(() => '');
}
function projektPersonen(){
const gesehen = new Set();
const liste = [];
projectAssignees(app.project, app.week).forEach(a => {
if(gesehen.has(a.person.id)) return;
gesehen.add(a.person.id);
liste.push(a.person);
});
return liste;
}
function kontext(){
const todos = (app.project.plan && app.project.plan.todos) || [];
return {
heute: heuteIso(),
projekt: [app.project.nr, app.project.name].filter(Boolean).join(' '),
personen: projektPersonen().map(p => p.fullName || p.name).filter(Boolean),
phasen: todos.filter(t => t.lph && t.start && t.end).map(t => ({ lph: t.lph, start: t.start, ende: t.end }))
};
}
function auswerten(nutzlast){
laeuft = true;
zeigeFlaeche('Mail wird ausgewertet …', 'Die KI liest die Mail. Das dauert einige Sekunden.');
anbieterWaehlen().then(anbieter => fetch(baseUrl() + '/plan-eintrag-aus-mail', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(Object.assign({ anbieter, kontext: kontext() }, nutzlast))
}))
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
laeuft = false;
versteckeFlaeche();
if(!ok || !d.ok){ app.showToast(d.error || 'Die Mail konnte nicht ausgewertet werden.', 'error'); return; }
if(d.erkennungsart === 'chatgpt' || d.erkennungsart === 'claude'){
try { localStorage.setItem(ANBIETER_KEY, d.erkennungsart); } catch(e){ /* egal */ }
}
verarbeiten(d);
})
.catch(() => fehler('Der Server ist nicht erreichbar.'));
}
// ---------- Vorschläge ins Panel ----------
// personenZuordnen: Namen aus der KI-Antwort → Personen-IDs des Projekts
// (voller Name, sonst Nachname, sonst Kürzel; Groß-/Kleinschreibung egal).
function personenZuordnen(namen){
const leute = projektPersonen();
const ids = [];
(namen || []).forEach(n => {
const s = String(n || '').trim().toLowerCase();
if(!s) return;
const treffer = leute.find(p => String(p.fullName || '').toLowerCase() === s)
|| leute.find(p => { const nn = String(p.fullName || '').toLowerCase().split(/\s+/).pop(); return nn && (s === nn || s.split(/\s+/).pop() === nn); })
|| leute.find(p => String(p.name || '').toLowerCase() === s);
if(treffer && !ids.includes(treffer.id)) ids.push(treffer.id);
});
return ids;
}
function notizText(mail, eintrag){
const kopf = [];
const datum = mail.datum ? formatDateDE(parseDateISO(mail.datum)) : '';
kopf.push('Aus Mail' + (mail.absender ? ' von ' + mail.absender : '') + (datum ? ' vom ' + datum : ''));
if(mail.betreff) kopf.push('Betreff: ' + mail.betreff);
if(eintrag.zitat) kopf.push('Bezug: „' + eintrag.zitat + '“');
return kopf.join('\n') + '\n\n' + String(mail.text || '').slice(0, 8000);
}
function hinweisText(d){
if(d.erkennungsart === 'chatgpt' || d.erkennungsart === 'claude'){
return 'Vorschlag der KI aus der Mail — bitte prüfen und speichern.';
}
return 'Die KI war nicht verfügbar (' + (d.erkennungshinweis || 'unbekannter Fehler') + '). Termine bitte selbst eintragen.';
}
function verarbeiten(d){
const eintraege = Array.isArray(d.eintraege) ? d.eintraege : [];
if(eintraege.length === 0){
app.showToast('In der Mail steht nichts für den Terminplan.');
return;
}
if(eintraege.length === 1){ nacheinanderOeffnen(d, eintraege); return; }
auswahlZeigen(d, eintraege);
}
function nacheinanderOeffnen(d, liste){
const rest = liste.slice();
const gesamt = rest.length;
const naechster = () => {
const e = rest.shift();
if(!e){ renderPlanGantt(); return; }
const nr = gesamt - rest.length;
openTodoModal(null, {
title: e.titel,
start: e.start,
end: e.ende,
assignees: personenZuordnen(e.zustaendig),
aufwand: e.aufwandStunden,
notiz: notizText(d.mail || {}, e),
mailHinweis: (gesamt > 1 ? `Vorschlag ${nr} von ${gesamt}. ` : '') + hinweisText(d),
nachSchliessen: naechster
});
};
naechster();
}
// ---------- Mehrere Vorschläge: Auswahl ----------
function auswahlZeigen(d, eintraege){
let bg = document.getElementById('mailAuswahlModal');
if(!bg){
bg = document.createElement('div');
bg.id = 'mailAuswahlModal';
bg.className = 'modal-bg';
bg.style.display = 'none';
bg.addEventListener('click', e => { if(e.target === bg) bg.style.display = 'none'; });
document.body.appendChild(bg);
}
const zeitraum = e => {
if(!e.start) return 'ohne Termin';
const a = formatDateDE(parseDateISO(e.start)), b = formatDateDE(parseDateISO(e.ende));
return a === b ? a : a + ' – ' + b;
};
bg.innerHTML = `<div class="modal mail-auswahl" role="dialog" aria-label="Einträge aus der Mail">
<h2>${eintraege.length} Einträge in der Mail</h2>
<p class="hint">${esc(hinweisText(d))} Die ausgewählten öffnen sich nacheinander im Aufgaben-Panel.</p>
<div class="mail-auswahl-liste">${eintraege.map((e, i) => `
<label class="mail-auswahl-zeile"><input type="checkbox" checked data-i="${i}">
<span class="mail-auswahl-titel">${esc(e.titel)}</span>
<span class="mail-auswahl-datum">${esc(zeitraum(e))}</span></label>`).join('')}</div>
<div class="modal-actions">
<button type="button" class="secondary" data-aktion="abbrechen">Abbrechen</button>
<button type="button" data-aktion="weiter">Prüfen und anlegen</button>
</div></div>`;
bg.querySelector('[data-aktion="abbrechen"]').onclick = () => { bg.style.display = 'none'; };
bg.querySelector('[data-aktion="weiter"]').onclick = () => {
const gewaehlt = Array.from(bg.querySelectorAll('input[type="checkbox"]:checked')).map(cb => eintraege[Number(cb.dataset.i)]);
bg.style.display = 'none';
if(gewaehlt.length) nacheinanderOeffnen(d, gewaehlt);
};
bg.style.display = 'flex';
}
})();
+160 -6
View File
@@ -55,7 +55,7 @@
if(positionSelect){ if(positionSelect){
const currentPosition = currentMs && currentMs.position ? currentMs.position : ''; const currentPosition = currentMs && currentMs.position ? currentMs.position : '';
const defaultAfterTodo = isNew && plan.todos && plan.todos.length > 0 ? plan.todos[plan.todos.length - 1].id : null; const defaultAfterTodo = isNew && plan.todos && plan.todos.length > 0 ? plan.todos[plan.todos.length - 1].id : null;
let opts = '<option value="">Oben in der Projektzeile</option>'; let opts = '<option value="">Oben, eigene Zeile</option>';
if(plan.todos && plan.todos.length > 0){ if(plan.todos && plan.todos.length > 0){
plan.todos.forEach(t => { plan.todos.forEach(t => {
const beforeVal = 'before:' + t.id; const beforeVal = 'before:' + t.id;
@@ -69,6 +69,14 @@
} }
positionSelect.innerHTML = opts; positionSelect.innerHTML = opts;
} }
window.msLageSync();
// Seitenpanel: Überschrift und "Löschen" nur beim Bearbeiten — vorher
// stand "Löschen" auch beim Anlegen eines neuen Meilensteins.
const msTitel = document.getElementById('milestoneModalTitle');
if(msTitel) msTitel.textContent = isNew ? 'Neuer Meilenstein' : 'Meilenstein bearbeiten';
const msLoeschen = document.getElementById('milestoneEditDelete');
if(msLoeschen) msLoeschen.hidden = isNew;
const dependsOnList = document.getElementById('milestoneEditDependsOn'); const dependsOnList = document.getElementById('milestoneEditDependsOn');
if(dependsOnList){ if(dependsOnList){
@@ -110,6 +118,35 @@
document.getElementById('milestoneEditName').focus(); document.getElementById('milestoneEditName').focus();
}; };
// msLageSync/msLageSetzen: "Wo im Terminplan" als Umschalter "In der
// Projektzeile | Bei einer Aufgabe" über dem bisherigen Auswahlfeld
// (#milestoneEditPosition, Wert '' = Projektzeile, sonst before:/after:
// einer Aufgabe). Gespeichert wird weiter allein der Wert des Felds.
window.msLageSync = function(){
const sel = document.getElementById('milestoneEditPosition');
const seg = document.getElementById('milestoneEditLage');
if(!sel || !seg) return;
const beiAufgabe = !!sel.value;
const hatAufgaben = sel.options.length > 1;
seg.querySelectorAll('button').forEach(b => {
b.setAttribute('aria-pressed', String((b.dataset.lage === 'aufgabe') === beiAufgabe));
if(b.dataset.lage === 'aufgabe') b.disabled = !hatAufgaben;
});
sel.hidden = !beiAufgabe;
// Die Option "Oben, eigene Zeile" steht schon im Umschalter.
if(sel.options[0] && sel.options[0].value === '') sel.options[0].hidden = true;
};
window.msLageSetzen = function(art){
const sel = document.getElementById('milestoneEditPosition');
if(!sel) return;
if(art === 'zeile') sel.value = '';
else if(!sel.value){
const nachOptionen = Array.from(sel.options).filter(o => o.value.indexOf('after:') === 0);
if(nachOptionen.length) sel.value = nachOptionen[nachOptionen.length - 1].value;
}
window.msLageSync();
};
// closeMilestoneModal: blendet das Meilenstein-Modal aus und setzt den // closeMilestoneModal: blendet das Meilenstein-Modal aus und setzt den
// Bearbeitungsindex zurück. // Bearbeitungsindex zurück.
window.closeMilestoneModal = function(){ window.closeMilestoneModal = function(){
@@ -186,6 +223,9 @@
// todoModalSnapshot: Stand der Eingaben beim Öffnen — Vergleichsbasis für // todoModalSnapshot: Stand der Eingaben beim Öffnen — Vergleichsbasis für
// die Rückfrage beim Abbrechen (siehe todoFormSnapshot()). // die Rückfrage beim Abbrechen (siehe todoFormSnapshot()).
let todoModalSnapshot = ''; let todoModalSnapshot = '';
// todoNachSchliessen: Rückruf nach dem Schließen des Panels (gespeichert
// oder verworfen) — "Termin aus Mail" öffnet damit den nächsten Vorschlag.
let todoNachSchliessen = null;
// todoProgressMemo: zuletzt im Dialog eingestellter Fortschritt. "Offen" und // todoProgressMemo: zuletzt im Dialog eingestellter Fortschritt. "Offen" und
// "Erledigt" haben feste Werte (0/100); wer von dort zurück auf "In Arbeit" // "Erledigt" haben feste Werte (0/100); wer von dort zurück auf "In Arbeit"
@@ -358,6 +398,69 @@
list.querySelectorAll('.todo-depends-group').forEach(group => { group.hidden = !!query; }); list.querySelectorAll('.todo-depends-group').forEach(group => { group.hidden = !!query; });
}; };
// ---------------------------------------------------------------------------
// Leistungsphase als Balken (Eriks Wunsch): "+ Neu" → "Leistungsphase"
// öffnet dasselbe Panel im Phasen-Modus. Ein Balken ist NUR über das Feld
// t.lph eine Phase (balkenPhase(), load.js) — nicht mehr über seinen Titel.
// ---------------------------------------------------------------------------
const LPH_NAMEN = {
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung', 'W+M': 'Wartung und Monitoring'
};
let todoPhasenModus = false;
let todoPhaseAutoTitel = '';
const phasenTitel = l => l ? (l + (LPH_NAMEN[l] ? ' ' + LPH_NAMEN[l] : '')) : '';
// addPlanPhase: "+ Neu" → "Leistungsphase".
// Optional lph: diese Phase vorwählen (Bereich "Leistungsphasen", Kachel
// ohne Zeitraum).
window.addPlanPhase = function(lph){ openTodoModal(null, { phase: true, lph: typeof lph === 'string' ? lph : '' }); };
// todoPhaseGewechselt: Auswahl der Phase geändert — den vorgeschlagenen
// Titel mitziehen, solange niemand ihn von Hand geändert hat.
window.todoPhaseGewechselt = function(){
const sel = document.getElementById('todoEditLph');
const titel = document.getElementById('todoEditTitle');
if(!sel || !titel) return;
if(!titel.value.trim() || titel.value === todoPhaseAutoTitel){
todoPhaseAutoTitel = phasenTitel(sel.value);
titel.value = todoPhaseAutoTitel;
}
};
// todoPhasenModusSetzen: Panel für eine Leistungsphase herrichten (Auswahl
// füllen, "Wer"/"Speichern & neu" ausblenden) oder zurück zur Aufgabe.
function todoPhasenModusSetzen(an, todo, isNew, vorgabeLph){
todoPhasenModus = an;
const abs = document.getElementById('todoEditPhaseAbs');
const wer = document.getElementById('todoEditWerAbs');
const neu = document.getElementById('todoEditSaveNew');
if(abs) abs.hidden = !an;
if(wer) wer.hidden = an;
if(neu) neu.hidden = an;
const titelEl = document.getElementById('todoModalTitle');
if(titelEl) titelEl.textContent = an ? (isNew ? 'Neue Leistungsphase' : 'Leistungsphase bearbeiten') : (isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten');
if(!an) return;
const plan = app.ensurePlanLocal();
const vergeben = new Set((plan.todos || []).filter(t => t !== todo && t.lph).map(t => t.lph));
const beauftragt = Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : [];
const sel = document.getElementById('todoEditLph');
if(!sel) return;
sel.innerHTML = LPHS.map(l => {
const zusatz = vergeben.has(l) ? ' (schon im Plan)' : (beauftragt.length && !beauftragt.includes(l) ? ' (nicht beauftragt)' : '');
return `<option value="${esc(l)}"${vergeben.has(l) ? ' disabled' : ''}>${esc(phasenTitel(l) + zusatz)}</option>`;
}).join('');
const frei = l => !vergeben.has(l);
sel.value = (todo && todo.lph) || (vorgabeLph && frei(vorgabeLph) ? vorgabeLph : '') || LPHS.find(l => frei(l) && (!beauftragt.length || beauftragt.includes(l))) || LPHS.find(frei) || '';
if(isNew){
todoPhaseAutoTitel = phasenTitel(sel.value);
document.getElementById('todoEditTitle').value = todoPhaseAutoTitel;
} else {
todoPhaseAutoTitel = '';
}
}
// openTodoModal: öffnet das TODO-Modal. Parameter: index (Index der Aufgabe // openTodoModal: öffnet das TODO-Modal. Parameter: index (Index der Aufgabe
// oder null für eine neue), prefill (optional, Vorbelegung für "Speichern & // oder null für eine neue), prefill (optional, Vorbelegung für "Speichern &
// Neu": { start, end, assignees }). Befüllt Titel, Zeitraum samt sichtbarer // Neu": { start, end, assignees }). Befüllt Titel, Zeitraum samt sichtbarer
@@ -380,7 +483,18 @@
const titleEl = document.getElementById('todoModalTitle'); const titleEl = document.getElementById('todoModalTitle');
if(titleEl) titleEl.textContent = isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten'; if(titleEl) titleEl.textContent = isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten';
document.getElementById('todoEditTitle').value = isNew ? '' : (currentTodo.title || ''); document.getElementById('todoEditTitle').value = isNew ? (pre.title || '') : (currentTodo.title || '');
const notizEl = document.getElementById('todoEditNotiz');
if(notizEl) notizEl.value = isNew ? (pre.notiz || '') : (currentTodo.notiz || '');
// Hinweis über der Notiz, nur bei einem Vorschlag aus "Termin aus Mail".
const mailHinweisEl = document.getElementById('todoEditMailHinweis');
if(mailHinweisEl){
mailHinweisEl.hidden = !(isNew && pre.mailHinweis);
mailHinweisEl.textContent = (isNew && pre.mailHinweis) || '';
}
todoNachSchliessen = isNew && typeof pre.nachSchliessen === 'function' ? pre.nachSchliessen : null;
const saveNewBtn = document.getElementById('todoEditSaveNew');
if(saveNewBtn) saveNewBtn.hidden = !!todoNachSchliessen;
// Zeitraum: neben den versteckten ISO-Feldern muss auch die sichtbare // Zeitraum: neben den versteckten ISO-Feldern muss auch die sichtbare
// Anzeige gesetzt werden — sonst stünde dort der Platzhalter bzw. der // Anzeige gesetzt werden — sonst stünde dort der Platzhalter bzw. der
@@ -404,6 +518,12 @@
// Wert (dieselben Rahmendaten wie Zeitraum/Zuständige, s. saveTodoEdit()). // Wert (dieselben Rahmendaten wie Zeitraum/Zuständige, s. saveTodoEdit()).
const showInGanttEl = document.getElementById('todoEditShowInGantt'); const showInGanttEl = document.getElementById('todoEditShowInGantt');
if(showInGanttEl) showInGanttEl.checked = isNew ? (pre.showInGantt !== undefined ? pre.showInGantt : true) : currentTodo.showInGantt !== false; if(showInGanttEl) showInGanttEl.checked = isNew ? (pre.showInGantt !== undefined ? pre.showInGantt : true) : currentTodo.showInGantt !== false;
const aufwandEl = document.getElementById('todoEditAufwand');
if(aufwandEl){
const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand);
aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : '';
}
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0); const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50; todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
@@ -465,13 +585,25 @@
uniquePeople.push(a.person); uniquePeople.push(a.person);
} }
}); });
// Bereits Zuständige, die (nicht mehr) dem Projekt zugeordnet sind —
// etwa über "Aufgabe aus Kommentar" aus dem ganzen Teilnehmerkreis
// gewählt — ebenfalls als Chip zeigen. Ohne sie gingen sie beim
// Speichern stillschweigend verloren, weil saveTodoEdit() nur die
// gedrückten Chips liest.
currentAssignees.forEach(id => {
if(seenPeople.has(id)) return;
seenPeople.add(id);
const p = ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
uniquePeople.push(Object.assign({}, p, { nichtImProjekt: true }));
});
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die // Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
// Personenspalten im Projektboard. // Personenspalten im Projektboard.
assigneesDiv.innerHTML = uniquePeople.map(person => { assigneesDiv.innerHTML = uniquePeople.map(person => {
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false'; const pressed = currentAssignees.includes(person.id) ? 'true' : 'false';
const short = person.name || initialsForName(person.fullName || '') || '?'; const short = person.name || initialsForName(person.fullName || '') || '?';
const full = person.fullName || person.name || ''; const full = person.fullName || person.name || '';
return `<button type="button" class="person-chip" data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">` const fremd = person.nichtImProjekt ? ' title="Nicht (mehr) dem Projekt zugeordnet"' : '';
return `<button type="button" class="person-chip${person.nichtImProjekt ? ' ist-fremd' : ''}"${fremd} data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">`
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`; + `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`;
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>'; }).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
} }
@@ -512,8 +644,10 @@
const read = id => { const el = document.getElementById(id); return el ? el.value : ''; }; const read = id => { const el = document.getElementById(id); return el ? el.value : ''; };
const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value); const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value);
const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId); const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId);
const gantt = document.getElementById('todoEditShowInGantt');
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'), return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
currentTodoEditStatus(), read('todoEditProgress'), deps, people]); currentTodoEditStatus(), read('todoEditProgress'), deps, people,
gantt ? gantt.checked : true, read('todoEditAufwand'), read('todoEditLph'), read('todoEditNotiz')]);
} }
// closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex // closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex
@@ -527,6 +661,9 @@
document.getElementById('todoModal').style.display = 'none'; document.getElementById('todoModal').style.display = 'none';
app.currentTodoIndex = null; app.currentTodoIndex = null;
todoModalSnapshot = ''; todoModalSnapshot = '';
const danach = todoNachSchliessen;
todoNachSchliessen = null;
if(danach) setTimeout(danach, 0);
return true; return true;
}; };
@@ -573,12 +710,21 @@
const showInGanttEl = document.getElementById('todoEditShowInGantt'); const showInGanttEl = document.getElementById('todoEditShowInGantt');
const showInGantt = showInGanttEl ? showInGanttEl.checked : true; const showInGantt = showInGanttEl ? showInGanttEl.checked : true;
// Aufwand in Stunden (optional): zählt zur Auslastung der Zuständigen.
const aufwandEl = document.getElementById('todoEditAufwand');
const aufwand = aufwandEl ? Math.max(0, parseFloat(String(aufwandEl.value).replace(',', '.')) || 0) : 0;
// Phasen-Modus: welche Leistungsphase dieser Balken ist.
const lphSel = document.getElementById('todoEditLph');
const lph = todoPhasenModus && lphSel ? lphSel.value : '';
const notizEl = document.getElementById('todoEditNotiz');
const notiz = notizEl ? notizEl.value.trim() : '';
if(todoPhasenModus && !lph){ alert('Bitte eine Leistungsphase wählen.'); return; }
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.todos)) plan.todos = []; if(!Array.isArray(plan.todos)) plan.todos = [];
if(app.currentTodoIndex === null){ if(app.currentTodoIndex === null){
plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees, showInGantt }); plan.todos.push(Object.assign({ id: uid(), title, start, end, done, progress, dependsOn, assignees, showInGantt, aufwand, notiz }, lph ? { lph, assignees: [], aufwand: 0 } : {}));
} else if(plan.todos[app.currentTodoIndex]){ } else if(plan.todos[app.currentTodoIndex]){
const t = plan.todos[app.currentTodoIndex]; const t = plan.todos[app.currentTodoIndex];
t.title = title; t.title = title;
@@ -589,6 +735,9 @@
t.dependsOn = dependsOn; t.dependsOn = dependsOn;
t.assignees = assignees; t.assignees = assignees;
t.showInGantt = showInGantt; t.showInGantt = showInGantt;
t.aufwand = aufwand;
t.notiz = notiz;
if(todoPhasenModus) t.lph = lph;
} }
app.savePlan(); app.savePlan();
// Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt // Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt
@@ -633,7 +782,12 @@
if(app.isLocked()) return; if(app.isLocked()) return;
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
if(!plan.todos[idx]) return; if(!plan.todos[idx]) return;
plan.todos[idx].done = !plan.todos[idx].done; const t = plan.todos[idx];
t.done = !t.done;
// Fortschritt mitführen: Sonst blieb nach dem Zurücknehmen "100 %" stehen,
// und der Dialog las daraus wieder "fertig" (todoStatusFrom()).
if(t.done) t.progress = 100;
else if(Number(t.progress) >= 100) t.progress = 0;
app.savePlan(); app.savePlan();
renderPlanGantt(); renderPlanGantt();
}; };
+180
View File
@@ -0,0 +1,180 @@
// -----------------------------
// Projektbereich — Leistungsphasen
// -----------------------------
// Bereich "Leistungsphasen" (Eriks Wahl "B"): die Phasenbalken des
// Terminplans (Aufgaben mit t.lph, balkenPhase() in load.js) als eigener
// Bereich statt als Zeilen zwischen den Aufgaben. Oben ein Ablaufbalken über
// alle Phasen mit Heute-Linie, darunter eine Kachel je Phase: Fortschritt
// (aus dem Wochenbericht, p.lph), Zeitraum (aus dem Balken), Aufwand
// (Summe der Stunden ihrer Aufgaben) und die Aufgaben, die in ihrem
// Zeitraum beginnen — dieselbe Zuordnung wie die Gliederung im Terminplan
// (plan-build-gantt.js).
//
// Keine eigene Datenhaltung: Bearbeiten öffnet dasselbe Panel wie im
// Terminplan (openTodoModal() im Phasen-Modus), Fortschritt bleibt im
// Wochenbericht ("Ein Ort je Angabe").
//
// renderPlanPhasen() wird von renderPlanGantt() (plan-gantt.js) und beim
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) aufgerufen.
// aufgabenPhase: zu welcher Leistungsphase eine (gewöhnliche) Aufgabe gehört —
// der Phasenbalken, in dessen Zeitraum sie beginnt. Dieselbe Regel wie die
// Gliederung im Terminplan. Parameter: t (Aufgabe), todos (alle Aufgaben des
// Plans). Rückgabe: Phasenname ('LPH 5') oder ''.
function aufgabenPhase(t, todos){
if(!t || balkenPhase(t)) return '';
const s0 = parseDateISO(t.start);
if(!s0) return '';
const k = (todos || [])
.filter(b => balkenPhase(b) && b.start && b.end)
.sort((a, b) => String(a.start).localeCompare(String(b.start)))
.find(b => s0 >= parseDateISO(b.start) && s0 <= parseDateISO(b.end));
return k ? balkenPhase(k) : '';
}
(function(){
const app = window.PlanApp;
const LPH_NAMEN = {
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung',
'W+M': 'Wartung und Monitoring'
};
const kurzDatum = d => d ? d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) : '';
const langDatum = d => d ? formatDateDE(d) : '';
// phasenDaten: je Phase alles, was Ablaufbalken und Kachel brauchen.
// Rückgabe: Array, sortiert läuft → geplant → fertig, darin nach Beginn.
function phasenDaten(){
const p = app.project;
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
// Phasen: beauftragte plus alle, die einen Balken haben.
const namen = LPHS.filter(l => beauftragt.includes(l) || todos.some(t => balkenPhase(t) === l));
const amtierend = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || null;
const laufend = typeof arbeitsPhase === 'function' ? arbeitsPhase(p, amtierend) : amtierend;
return namen.map(l => {
const idx = todos.findIndex(t => balkenPhase(t) === l);
const balken = idx >= 0 ? todos[idx] : null;
const start = balken ? parseDateISO(balken.start) : null;
const ende = balken ? parseDateISO(balken.end) : null;
const prozent = typeof fortschrittProzent === 'function' ? fortschrittProzent(p, l) : 0;
const aufgaben = todos.filter(t => aufgabenPhase(t, todos) === l);
// Aufwand = Summe der Stunden ihrer Aufgaben (dieselbe Quelle wie die
// Auslastung, js/load/load.js), nicht mehr aus dem Honorar.
const stunden = Math.round(aufgaben.reduce((s, t) => s + (Number(t.aufwand) || 0), 0) * 10) / 10;
const status = prozent >= 100 ? 'fertig' : (l === laufend ? 'laeuft' : 'geplant');
return { l, idx, balken, start, ende, prozent, stunden, aufgaben, status,
beauftragt: !beauftragt.length || beauftragt.includes(l) };
}).sort((a, b) => {
const rang = { laeuft: 0, geplant: 1, fertig: 2 };
if(rang[a.status] !== rang[b.status]) return rang[a.status] - rang[b.status];
return LPHS.indexOf(a.l) - LPHS.indexOf(b.l);
});
}
// ablaufHtml: der schmale Balken über alle Phasen mit Zeitraum. Phasen
// ohne Zeitraum fehlen hier (sie stehen als Kachel mit Hinweis darunter).
function ablaufHtml(daten){
const mit = daten.filter(d => d.start && d.ende && d.ende >= d.start)
.sort((a, b) => a.start - b.start);
if(!mit.length) return '';
const von = mit[0].start.getTime();
const bis = Math.max(...mit.map(d => d.ende.getTime()));
const spanne = Math.max(1, bis - von);
const pos = t => Math.min(100, Math.max(0, (t - von) / spanne * 100));
const heute = new Date(); heute.setHours(0, 0, 0, 0);
const heutePos = heute.getTime() >= von && heute.getTime() <= bis ? pos(heute.getTime()) : null;
const segmente = mit.map(d => {
const links = pos(d.start.getTime());
const breite = Math.max(1.5, pos(d.ende.getTime() + 86400000) - links);
const titel = `${d.l}: ${langDatum(d.start)} – ${langDatum(d.ende)}, ${d.prozent} %`;
return `<button type="button" class="phasen-ablauf-seg is-${d.status}" style="left:${links}%;width:${breite}%;--fort:${d.prozent}%"
title="${esc(titel)}" onclick="phasenKachelZeigen('${esc(d.l)}')"><span>${esc(breite > 6 ? d.l : d.l.replace('LPH ', ''))}</span></button>`;
}).join('');
const heuteHtml = heutePos === null ? '' : `<span class="phasen-ablauf-heute" style="left:${heutePos}%" title="heute"></span>`;
const monat = d => d.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' });
return `<div class="plan-panel phasen-ablauf">
<div class="phasen-kopf">Ablauf</div>
<div class="phasen-ablauf-spur">${segmente}${heuteHtml}</div>
<div class="phasen-ablauf-achse"><span>${esc(monat(mit[0].start))}</span>${heutePos === null ? '' : `<span class="phasen-ablauf-heute-text" style="left:${heutePos}%">heute, ${esc(kurzDatum(heute))}</span>`}<span>${esc(monat(new Date(bis)))}</span></div>
</div>`;
}
// kachelHtml: eine Kachel je Phase.
function kachelHtml(d, locked){
const statusText = { laeuft: 'läuft', geplant: 'geplant', fertig: 'fertig' }[d.status];
const erledigt = d.aufgaben.filter(t => t.done).length;
const aufgabenText = d.aufgaben.length ? `${erledigt} von ${d.aufgaben.length} erledigt` : 'keine';
const zeitraum = d.start && d.ende
? `${d.start.getFullYear() === d.ende.getFullYear() ? kurzDatum(d.start) : langDatum(d.start)} – ${langDatum(d.ende)}`
: '<span class="phasen-leer">kein Zeitraum</span>';
const bearbeiten = locked ? ''
: (d.balken
? `<button class="secondary small" onclick="openTodoModal(${d.idx})">Bearbeiten</button>`
: `<button class="secondary small" onclick="addPlanPhase('${esc(d.l)}')">Zeitraum festlegen</button>`);
const ansehen = d.aufgaben.length
? `<button class="secondary small" onclick="phasenAufgabenZeigen('${esc(d.l)}')">Aufgaben ansehen</button>` : '';
return `<div class="phasen-kachel is-${d.status}" data-phase="${esc(d.l)}">
<div class="phasen-kachel-kopf">
<div class="phasen-ring" style="--fort:${d.prozent}%"><span>${d.prozent} %</span></div>
<div class="phasen-kachel-name">
<b>${esc(d.l)}</b>
<span>${esc(LPH_NAMEN[d.l] || '')}</span>
</div>
<span class="phasen-chip is-${d.status}">${statusText}</span>
</div>
${d.beauftragt ? '' : '<div class="phasen-hinweis">nicht beauftragt</div>'}
<div class="phasen-werte">
<span><small>Zeitraum</small><b>${zeitraum}</b></span>
<span><small>Aufwand</small><b>${d.stunden ? d.stunden.toLocaleString('de-DE') + ' h' : '<span class="phasen-leer">keine Stunden</span>'}</b></span>
<span><small>Aufgaben</small><b>${aufgabenText}</b></span>
</div>
${bearbeiten || ansehen ? `<div class="phasen-knoepfe">${bearbeiten}${ansehen}</div>` : ''}
</div>`;
}
// renderPlanPhasen: baut den Bereich neu auf. Keine Parameter.
window.renderPlanPhasen = function(){
const el = document.getElementById('planPhasenWrap');
if(!el || !app.project) return;
const locked = app.isLocked();
const daten = phasenDaten();
if(!daten.length){
el.innerHTML = `<div class="plan-panel"><div class="plan-task-empty">${locked
? 'Für dieses Projekt sind keine Leistungsphasen hinterlegt.'
: 'Noch keine Leistungsphase. Unter „Einstellungen“ beauftragen oder über „+ Leistungsphase“ bzw. „Terminplan aus Honorar“ anlegen.'}</div></div>`;
return;
}
const summeH = daten.reduce((s, d) => s + d.stunden, 0);
const enden = daten.filter(d => d.ende).map(d => d.ende.getTime());
const laufend = daten.find(d => d.status === 'laeuft');
const teile = [daten.filter(d => d.beauftragt).length + ' beauftragt'];
if(laufend) teile.push('läuft: ' + laufend.l);
if(summeH) teile.push(summeH.toLocaleString('de-DE') + ' h in Aufgaben');
if(enden.length) teile.push('Ende voraussichtlich ' + langDatum(new Date(Math.max(...enden))));
el.innerHTML = `<div class="phasen-zusammenfassung">${esc(teile.join(' · '))}<span>Fortschritt aus dem Wochenbericht</span></div>
${ablaufHtml(daten)}
<div class="phasen-raster">${daten.map(d => kachelHtml(d, locked)).join('')}</div>`;
};
// phasenKachelZeigen: Klick auf ein Segment im Ablauf — Kachel ins Bild
// holen und kurz hervorheben.
window.phasenKachelZeigen = function(l){
const k = document.querySelector(`#planPhasenWrap .phasen-kachel[data-phase="${CSS.escape(l)}"]`);
if(!k) return;
k.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
k.classList.remove('ist-markiert'); void k.offsetWidth; k.classList.add('ist-markiert');
};
// phasenAufgabenZeigen: in den Bereich Aufgaben, gefiltert auf diese Phase.
window.phasenAufgabenZeigen = function(l){
if(typeof setzeAufgabenFilter === 'function') setzeAufgabenFilter(l);
showPlanSection('aufgaben');
};
})();
+1 -1
View File
@@ -177,7 +177,7 @@
body.innerHTML = ` body.innerHTML = `
<div class="todo-depends-list" id="protokollTodosPunkteListe" style="max-height:280px"> <div class="todo-depends-list" id="protokollTodosPunkteListe" style="max-height:280px">
${erkanntePunkte.map((p, i) => { ${erkanntePunkte.map((p, i) => {
const checked = punkteAuswahl[i] !== undefined ? punkteAuswahl[i] : (p.status === 'offen'); const checked = punkteAuswahl[i] !== undefined ? punkteAuswahl[i] : ((p.status || (p.erledigt ? 'erledigt' : 'offen')) === 'offen');
punkteAuswahl[i] = checked; punkteAuswahl[i] = checked;
return `<label class="todo-depends-item"> return `<label class="todo-depends-item">
<input type="checkbox" value="${i}" ${checked ? 'checked' : ''}> <input type="checkbox" value="${i}" ${checked ? 'checked' : ''}>
+119 -32
View File
@@ -2,7 +2,7 @@
// Projektplan — Protokolle (Besprechungsprotokolle je Projekt) // Projektplan — Protokolle (Besprechungsprotokolle je Projekt)
// ----------------------------- // -----------------------------
// Bereich "Protokolle" des Projektbereichs (siehe js/plan/plan-sections.js) // Bereich "Protokolle" des Projektbereichs (siehe js/plan/plan-sections.js)
// plus eine Kompakt-Karte im Dashboard (#planProtokolleSummary, springt per // plus eine Kachel im Dashboard (#planDashProtokolle, springt per
// Klick über openProtokollePanel() in diesen Bereich — gleiches Muster wie // Klick über openProtokollePanel() in diesen Bereich — gleiches Muster wie
// #planFilesSummary/openFilesPanel() in plan-files.js). // #planFilesSummary/openFilesPanel() in plan-files.js).
// //
@@ -77,28 +77,103 @@
}); });
} }
// renderProtokolleSummary: füllt die Kompakt-Vorschau der Dashboard-Karte // renderProtokolleSummary: füllt die Dashboard-Kachel "Protokolle"
// (#planProtokolleSummary) — letztes Protokoll + Anzahl offener Punkte, // (#planDashProtokolle, Eriks Wunsch — vorher nur eine Zeile in
// gleiches Muster wie renderFilesSummary() in plan-files.js. // "Unterlagen"). Oben das letzte Protokoll, darunter dessen OFFENE Punkte
function renderProtokolleSummary(){ // (die Frage nach einer Besprechung ist "was ist noch offen?"), unten die
const el = document.getElementById('planProtokolleSummary'); // früheren Protokolle als Kurzliste. Die Punkte kommen aus einem zweiten
if(!el) return; // Abruf (GET /project-protokolle/:id) — die Liste liefert nur Zahlen.
if(!app.project || !app.project.nr){ const DASH_PROTOKOLL_PUNKTE = 5;
el.innerHTML = '<span class="hint">Keine Projektnummer.</span>'; const DASH_PROTOKOLL_FRUEHERE = 3;
return; let dashProtokollAbruf = 0;
function datumDe(iso){
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ''));
return m ? `${m[3]}.${m[2]}.${m[1]}` : String(iso || '');
} }
function renderProtokolleSummary(){
const el = document.getElementById('planDashProtokolle');
const panel = document.getElementById('planDashProtokollePanel');
if(!el) return;
// Ohne Projektnummer (Planung-Terminplan) gibt es keine Protokolle —
// die Kachel verschwindet wie der Bereich selbst.
if(panel) panel.hidden = !(app.project && app.project.nr);
if(!app.project || !app.project.nr){ el.innerHTML = ''; return; }
if(protokolleCache.length === 0){ if(protokolleCache.length === 0){
el.innerHTML = '<span class="hint">Noch keine Protokolle.</span>'; el.innerHTML = `<div class="plan-dash-leer">Noch keine Protokolle.
<button type="button" class="plan-panel-link" onclick="dashNeuesProtokoll()">+ Neues Protokoll</button></div>`;
return; return;
} }
const letztes = protokolleCache[0]; // Server liefert nach nummer DESC const letztes = protokolleCache[0]; // Server liefert nach nummer DESC
const chip = letztes.offenCount > 0 const frueher = protokolleCache.slice(1, 1 + DASH_PROTOKOLL_FRUEHERE);
? `<span class="plan-mini-chip warn">${letztes.offenCount} offen</span>` const zahlen = [
: '<span class="plan-mini-chip">Alle erledigt</span>'; letztes.offenCount ? `<span class="plan-mini-chip warn">${letztes.offenCount} offen</span>` : '<span class="plan-mini-chip">nichts offen</span>',
el.innerHTML = `<div class="plan-mini-card-preview">Nr. ${esc(letztes.nummer)} · ${esc(letztes.betreff || 'ohne Betreff')}</div> letztes.erledigtCount ? `<span class="plan-mini-chip">${letztes.erledigtCount} erledigt</span>` : '',
<div class="plan-mini-chip-row">${chip}</div>`; letztes.infoCount ? `<span class="plan-mini-chip">${letztes.infoCount} Info</span>` : ''
].join('');
el.innerHTML = `
<button type="button" class="plan-prot-kopf" onclick="dashOeffneProtokoll('${esc(letztes.id)}')">
<span class="plan-prot-nr">Nr. ${esc(letztes.nummer)}</span>
<span class="plan-prot-titel"><b>${esc(letztes.betreff || 'ohne Betreff')}</b>
<span>${esc(datumDe(letztes.datum))}${letztes.ort ? ' · ' + esc(letztes.ort) : ''}${letztes.teilnehmerCount ? ' · ' + letztes.teilnehmerCount + ' Teilnehmer' : ''}</span></span>
</button>
<div class="plan-mini-chip-row">${zahlen}</div>
<div class="plan-prot-punkte" id="planDashProtokollPunkte">${letztes.offenCount ? '<span class="hint">Lade offene Punkte…</span>' : ''}</div>
${frueher.length ? `<div class="plan-termin-gruppe">Frühere Protokolle</div>
${frueher.map(pr => `<button type="button" class="plan-prot-frueher" onclick="dashOeffneProtokoll('${esc(pr.id)}')">
<span>Nr. ${esc(pr.nummer)} · ${esc(datumDe(pr.datum))}</span>
<span class="plan-prot-frueher-titel">${esc(pr.betreff || 'ohne Betreff')}</span>
<span class="plan-prot-frueher-zahl">${pr.offenCount ? pr.offenCount + ' offen' : ''}</span>
</button>`).join('')}` : ''}`;
if(letztes.offenCount) ladeOffenePunkte(letztes.id);
} }
function ladeOffenePunkte(id){
const nr = ++dashProtokollAbruf;
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(id))
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(d => {
if(nr !== dashProtokollAbruf) return; // inzwischen anderes Projekt
const ziel = document.getElementById('planDashProtokollPunkte');
if(!ziel || !d.protokoll) return;
const punkte = d.protokoll.punkte || [];
const nummern = window.berechneProtokollNummern(punkte);
const heute = typeof heuteIso === 'function' ? heuteIso() : new Date().toISOString().slice(0, 10);
const offen = punkte.map((p, i) => Object.assign({}, p, { anzeigeNr: nummern[i] }))
.filter(p => p.status === 'offen')
// Überfälliges zuerst, dann nach Termin, ohne Termin zuletzt
.sort((a, b) => (a.termin || '9999').localeCompare(b.termin || '9999'));
const zeigen = offen.slice(0, DASH_PROTOKOLL_PUNKTE);
const rest = offen.length - zeigen.length;
ziel.innerHTML = zeigen.map(p => {
const ueber = p.termin && p.termin < heute;
const titel = p.thema || statusPlainText(p.text).slice(0, 80) || 'ohne Thema';
return `<div class="plan-termin ist-klickbar" onclick="dashOeffneProtokoll('${esc(d.protokoll.id)}')">
<span class="plan-termin-art">${esc(p.anzeigeNr)}</span>
<span class="plan-termin-text"><b>${esc(titel)}</b><span>${p.gruppe ? esc(p.gruppe) + ' · ' : ''}${p.zustaendig ? esc(p.zustaendig) : 'ohne Zuständigen'}</span></span>
<span class="plan-termin-datum${ueber ? ' ist-rot' : ''}">${p.termin ? esc(datumDe(p.termin)) + (typeof relativerTag === 'function' ? '<em>' + esc(relativerTag(p.termin)) + '</em>' : '') : '—'}</span>
</div>`;
}).join('') + (rest > 0 ? `<button type="button" class="plan-panel-link plan-prot-mehr" onclick="dashOeffneProtokoll('${esc(d.protokoll.id)}')">… ${rest} weitere offene Punkte</button>` : '');
})
.catch(err => {
if(nr !== dashProtokollAbruf) return;
const ziel = document.getElementById('planDashProtokollPunkte');
if(ziel) ziel.innerHTML = '<span class="hint">Offene Punkte konnten nicht geladen werden.</span>';
console.error('Offene Protokollpunkte laden fehlgeschlagen:', err);
});
}
// dashOeffneProtokoll: aus der Kachel direkt in DIESES Protokoll.
window.dashOeffneProtokoll = function(id){
window.openProtokollePanel();
window.oeffneProtokoll(id);
};
window.dashNeuesProtokoll = function(){
window.openProtokollePanel();
window.neuesProtokoll();
};
// openProtokollePanel: springt in den Bereich "Protokolle" — Gegenstück zu // openProtokollePanel: springt in den Bereich "Protokolle" — Gegenstück zu
// openFilesPanel()/openResourceModal(). // openFilesPanel()/openResourceModal().
window.openProtokollePanel = function(){ window.openProtokollePanel = function(){
@@ -175,7 +250,7 @@
// currentProtokoll.punkte), richtung (-1 = nach oben, +1 = nach unten). // currentProtokoll.punkte), richtung (-1 = nach oben, +1 = nach unten).
// Keine Rückgabe; rendert nur den Punkte-Block neu. // Keine Rückgabe; rendert nur den Punkte-Block neu.
window.verschiebePunkt = function(idx, richtung){ window.verschiebePunkt = function(idx, richtung){
if(!currentProtokoll || app.isLocked()) return; if(!currentProtokoll || app.protokolleGesperrt()) return;
const punkte = currentProtokoll.punkte; const punkte = currentProtokoll.punkte;
const gruppe = String(punkte[idx].gruppe || '').trim(); const gruppe = String(punkte[idx].gruppe || '').trim();
const gleicheGruppeIdx = punkte.map((p, i) => i).filter(i => String(punkte[i].gruppe || '').trim() === gruppe); const gleicheGruppeIdx = punkte.map((p, i) => i).filter(i => String(punkte[i].gruppe || '').trim() === gruppe);
@@ -197,7 +272,7 @@
// Parameter: gruppe (Name), richtung (-1 = nach vorn, +1 = nach hinten). // Parameter: gruppe (Name), richtung (-1 = nach vorn, +1 = nach hinten).
// Keine Rückgabe. // Keine Rückgabe.
window.verschiebeGruppe = function(gruppe, richtung){ window.verschiebeGruppe = function(gruppe, richtung){
if(!currentProtokoll || app.isLocked()) return; if(!currentProtokoll || app.protokolleGesperrt()) return;
const punkte = currentProtokoll.punkte; const punkte = currentProtokoll.punkte;
const namen = []; const namen = [];
punkte.forEach(p => { punkte.forEach(p => {
@@ -230,7 +305,7 @@
function renderProtokolleListe(){ function renderProtokolleListe(){
const container = document.getElementById('protokolleContainer'); const container = document.getElementById('protokolleContainer');
if(!container) return; if(!container) return;
const locked = app.isLocked(); const locked = app.protokolleGesperrt();
const anlegeBtns = locked ? '' : `<button onclick="neuesProtokoll()">+ Neues Protokoll</button> const anlegeBtns = locked ? '' : `<button onclick="neuesProtokoll()">+ Neues Protokoll</button>
<button class="secondary" onclick="oeffneProtokollImportDialog()">Protokoll importieren</button>`; <button class="secondary" onclick="oeffneProtokollImportDialog()">Protokoll importieren</button>`;
if(protokolleCache.length === 0){ if(protokolleCache.length === 0){
@@ -262,7 +337,7 @@
// "anwesend" zurückgesetzt), sonst Fallback auf projectAssignees(), sonst // "anwesend" zurückgesetzt), sonst Fallback auf projectAssignees(), sonst
// leer. Das eigentliche Fortschreiben der Punkte übernimmt der Server. // leer. Das eigentliche Fortschreiben der Punkte übernimmt der Server.
window.neuesProtokoll = async function(){ window.neuesProtokoll = async function(){
if(app.isLocked()) return; if(app.protokolleGesperrt()) return;
let teilnehmer = []; let teilnehmer = [];
try { try {
if(protokolleCache.length > 0){ if(protokolleCache.length > 0){
@@ -317,7 +392,7 @@
// firmenweiten Stand, und kann sich seit dem letzten Öffnen geändert // firmenweiten Stand, und kann sich seit dem letzten Öffnen geändert
// haben. // haben.
window.oeffneProtokollImportDialog = function(){ window.oeffneProtokollImportDialog = function(){
if(app.isLocked()) return; if(app.protokolleGesperrt()) return;
document.getElementById('protokollImportFile').value = ''; document.getElementById('protokollImportFile').value = '';
document.getElementById('protokollImportBetreff').value = ''; document.getElementById('protokollImportBetreff').value = '';
document.getElementById('protokollImportOrt').value = ''; document.getElementById('protokollImportOrt').value = '';
@@ -460,7 +535,7 @@
const container = document.getElementById('protokolleContainer'); const container = document.getElementById('protokolleContainer');
if(!container || !currentProtokoll) return; if(!container || !currentProtokoll) return;
const p = currentProtokoll; const p = currentProtokoll;
const locked = app.isLocked(); const locked = app.protokolleGesperrt();
const datumDisplay = formatDateDE(parseDateISO(p.datum)) || ''; const datumDisplay = formatDateDE(parseDateISO(p.datum)) || '';
const quelleHinweis = p.quelleDateiname const quelleHinweis = p.quelleDateiname
@@ -634,7 +709,7 @@
// aktualisiereListenZeile() oben. // aktualisiereListenZeile() oben.
function renderPunkteBlockHtml(){ function renderPunkteBlockHtml(){
const p = currentProtokoll; const p = currentProtokoll;
const locked = app.isLocked(); const locked = app.protokolleGesperrt();
const gruppen = gruppenReihenfolge(); const gruppen = gruppenReihenfolge();
const nummern = window.berechneProtokollNummern(p.punkte); const nummern = window.berechneProtokollNummern(p.punkte);
@@ -717,7 +792,7 @@
// in currentProtokoll.punkte, -1 = kein Punkt vorhanden), nummern (das // in currentProtokoll.punkte, -1 = kein Punkt vorhanden), nummern (das
// Ergebnis von berechneProtokollNummern()). Rückgabe: HTML-String. // Ergebnis von berechneProtokollNummern()). Rückgabe: HTML-String.
function renderSchreibbereichHtml(idx, nummern){ function renderSchreibbereichHtml(idx, nummern){
const locked = app.isLocked(); const locked = app.protokolleGesperrt();
if(idx < 0){ if(idx < 0){
return `<div class="protokoll-schreiben ist-leer"> return `<div class="protokoll-schreiben ist-leer">
<p>Noch kein Punkt angelegt.</p> <p>Noch kein Punkt angelegt.</p>
@@ -874,17 +949,17 @@
// ---------- Strukturelle Änderungen (Zeile hinzufügen/entfernen) ---------- // ---------- Strukturelle Änderungen (Zeile hinzufügen/entfernen) ----------
window.fuegeTeilnehmerHinzu = function(){ window.fuegeTeilnehmerHinzu = function(){
if(!currentProtokoll || app.isLocked()) return; if(!currentProtokoll || app.protokolleGesperrt()) return;
currentProtokoll.teilnehmer.push({ name: '', firma: '', anwesend: true }); currentProtokoll.teilnehmer.push({ name: '', firma: '', anwesend: true });
renderProtokollDetail(); renderProtokollDetail();
}; };
window.entferneTeilnehmer = function(idx){ window.entferneTeilnehmer = function(idx){
if(!currentProtokoll || app.isLocked()) return; if(!currentProtokoll || app.protokolleGesperrt()) return;
currentProtokoll.teilnehmer.splice(idx, 1); currentProtokoll.teilnehmer.splice(idx, 1);
renderProtokollDetail(); renderProtokollDetail();
}; };
window.fuegePunktHinzu = function(gruppe){ window.fuegePunktHinzu = function(gruppe){
if(!currentProtokoll || app.isLocked()) return; if(!currentProtokoll || app.protokolleGesperrt()) return;
// nummer wird nicht gesetzt — sie ergibt sich beim Rendern automatisch // nummer wird nicht gesetzt — sie ergibt sich beim Rendern automatisch
// aus Gruppe und Position (siehe berechneProtokollNummern() oben). // aus Gruppe und Position (siehe berechneProtokollNummern() oben).
// erstelltAm nur als Vorschau bis zum nächsten Speichern — maßgeblich ist // erstelltAm nur als Vorschau bis zum nächsten Speichern — maßgeblich ist
@@ -894,7 +969,19 @@
thema: '', text: '', zustaendig: '', termin: '', status: 'offen', thema: '', text: '', zustaendig: '', termin: '', status: 'offen',
erstelltAm: currentProtokoll.datum || '', entfaelltNaechstes: false erstelltAm: currentProtokoll.datum || '', entfaelltNaechstes: false
}; };
currentProtokoll.punkte.push(neuerPunkt); // Hinter dem letzten Punkt DERSELBEN Gruppe einfügen statt ans Ende: Die
// Nummern folgen der Reihenfolge im Array (berechneProtokollNummern()),
// die Anzeige aber gruppiert. Ein ans Ende gehängter Punkt "ohne Gruppe"
// bekam sonst eine Nummer hinter allen Gruppen (1–5, dann 7, darunter 6.1).
// Gibt es noch keinen Punkt ohne Gruppe, kommt er an den Anfang — dieser
// Abschnitt steht immer zuerst.
const liste = currentProtokoll.punkte;
const g = gruppe || '';
let letzter = -1;
liste.forEach((x, i) => { if((x.gruppe || '') === g) letzter = i; });
if(letzter >= 0) liste.splice(letzter + 1, 0, neuerPunkt);
else if(!g) liste.unshift(neuerPunkt);
else liste.push(neuerPunkt);
// Den neuen Punkt gleich im Schreibbereich öffnen und ins Thema-Feld // Den neuen Punkt gleich im Schreibbereich öffnen und ins Thema-Feld
// springen — sonst stünde er zwar links in der Liste, rechts bliebe // springen — sonst stünde er zwar links in der Liste, rechts bliebe
// aber der vorige Punkt stehen und man müsste ihn erst anklicken. // aber der vorige Punkt stehen und man müsste ihn erst anklicken.
@@ -914,7 +1001,7 @@
// (nicht mehr einem Feld am Punkt, siehe currentProtokollUmbenenneGruppe() // (nicht mehr einem Feld am Punkt, siehe currentProtokollUmbenenneGruppe()
// oben) fokussiert und markiert, damit er sich sofort überschreiben lässt. // oben) fokussiert und markiert, damit er sich sofort überschreiben lässt.
window.fuegeNeueGruppeHinzu = function(){ window.fuegeNeueGruppeHinzu = function(){
if(!currentProtokoll || app.isLocked()) return; if(!currentProtokoll || app.protokolleGesperrt()) return;
const bestehende = new Set(gruppenReihenfolge()); const bestehende = new Set(gruppenReihenfolge());
let name = 'Neue Gruppe'; let name = 'Neue Gruppe';
let n = 2; let n = 2;
@@ -927,7 +1014,7 @@
if(input){ input.focus(); input.select(); } if(input){ input.focus(); input.select(); }
}; };
window.entfernePunkt = function(idx){ window.entfernePunkt = function(idx){
if(!currentProtokoll || app.isLocked()) return; if(!currentProtokoll || app.protokolleGesperrt()) return;
// War der gelöschte Punkt der geöffnete, rückt der an seine Stelle // War der gelöschte Punkt der geöffnete, rückt der an seine Stelle
// getretene nach (bzw. der letzte, wenn es der letzte war) — sonst // getretene nach (bzw. der letzte, wenn es der letzte war) — sonst
// stünde der Schreibbereich leer da, obwohl noch Punkte vorhanden sind. // stünde der Schreibbereich leer da, obwohl noch Punkte vorhanden sind.
@@ -947,7 +1034,7 @@
// Punkte komplett (siehe server.js). Ausdrückliches Speichern, kein // Punkte komplett (siehe server.js). Ausdrückliches Speichern, kein
// Auto-Save, wie die Board-Kommentare. // Auto-Save, wie die Board-Kommentare.
window.speichereProtokoll = function(){ window.speichereProtokoll = function(){
if(!currentProtokoll || app.isLocked()) return; if(!currentProtokoll || app.protokolleGesperrt()) return;
// Errechnete Nummern vor dem Senden in die Punkte schreiben, damit die // Errechnete Nummern vor dem Senden in die Punkte schreiben, damit die
// gespeicherten Werte mit der Anzeige übereinstimmen (maßgeblich ist beim // gespeicherten Werte mit der Anzeige übereinstimmen (maßgeblich ist beim
// Anzeigen aber immer die Neuberechnung, nie dieser gespeicherte Wert — // Anzeigen aber immer die Neuberechnung, nie dieser gespeicherte Wert —
@@ -995,7 +1082,7 @@
}; };
window.loescheProtokoll = function(){ window.loescheProtokoll = function(){
if(!currentProtokoll || app.isLocked()) return; if(!currentProtokoll || app.protokolleGesperrt()) return;
if(!confirm('Protokoll Nr. ' + currentProtokoll.nummer + ' wirklich löschen?')) return; if(!confirm('Protokoll Nr. ' + currentProtokoll.nummer + ' wirklich löschen?')) return;
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), { method: 'DELETE' }) fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), { method: 'DELETE' })
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); }) .then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
+7 -115
View File
@@ -1,13 +1,11 @@
// ----------------------------- // -----------------------------
// Projektplan — Kritischer Pfad, Auto-Schedule & Mail-Link // Projektplan — Auto-Schedule & Mail-Link
// ----------------------------- // -----------------------------
// Dieses Modul berechnet über computeCriticalPath die Aufgabe(n) auf dem // Dieses Modul verschiebt bei autoScheduleTodos TODOs und Meilensteine
// kritischen Pfad (längste Vorgänger-Kette) und hebt sie im GANTT hervor, // automatisch hinter ihre Vorgänger (aufgerufen beim Ziehen eines Balkens,
// verschiebt bei autoScheduleTodos TODOs und Meilensteine automatisch hinter // plan-drag.js, und über "Auto-Schedule" im Bereich Aufgaben) und erstellt
// ihre Vorgänger und erstellt bei todoMail einen mailto-Link an alle // bei todoMail einen mailto-Link an alle zugeordneten Personen. Die frühere
// zugeordneten Personen. Die Hervorhebung übergibt renderPlanGantt als // Hervorhebung des kritischen Pfads ist auf Eriks Wunsch entfallen.
// Option criticalPath an buildGanttHtml (plan-build-gantt.js); den Schalter
// criticalPathActive hält der Zustand in window.PlanApp (plan-state.js).
// //
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js). // (plan-state.js).
@@ -15,113 +13,7 @@
(function(){ (function(){
const app = window.PlanApp; const app = window.PlanApp;
// ---------- Kritischer Pfad & Auto-Schedule ---------- // ---------- Auto-Schedule ----------
// computeCriticalPath: liefert Array der TODO-IDs, die auf einem kritischen Pfad liegen
// (längste Kette von Vorgänger-Abhängigkeiten, End-to-Start)
window.computeCriticalPath = function(project){
if(!project || !project.plan) return [];
const todos = (project.plan.todos || []).filter(t => t.start && t.end);
const milestones = (project.plan.milestones || []).filter(m => m.date);
if(todos.length === 0 && milestones.length === 0) return [];
// Einheitliche Knoten: TODOs und Meilensteine (Dauer 1)
const nodes = todos.map(t => ({ type: 'todo', id: t.id, obj: t, start: parseDateISO(t.start), end: parseDateISO(t.end), duration: Math.max(1, daysBetween(parseDateISO(t.start), parseDateISO(t.end)) + 1) }));
milestones.forEach(m => {
const d = parseDateISO(m.date);
nodes.push({ type: 'milestone', id: 'm_' + m.id, obj: m, start: d, end: d, duration: 1 });
});
const nodeMap = new Map(nodes.map(n => [n.id, n]));
// depsOf: liefert die Vorgänger-Liste eines Knotens (leeres Array, falls
// keine vorhanden).
function depsOf(n){
return Array.isArray(n.obj.dependsOn) ? n.obj.dependsOn : [];
}
// Frühester Start (ES) und frühestes Ende (EF) berechnen.
// visiting-Set schützt vor Endlos-Rekursion bei zyklischen Abhängigkeiten.
const es = {}, ef = {};
const esVisiting = new Set();
// computeES: berechnet rekursiv den frühesten Start (ES) und frühestes
// Ende (EF) eines Knotens aus seinen Vorgängern. Liefert ES oder null
// bei Zyklen (visiting-Set).
function computeES(n){
if(es[n.id] !== undefined) return es[n.id];
if(esVisiting.has(n.id)){ es[n.id] = null; ef[n.id] = null; return null; } // Zyklus
if(!n.start || !n.end){ es[n.id] = null; ef[n.id] = null; return null; }
esVisiting.add(n.id);
const deps = depsOf(n);
let maxEF = null;
deps.forEach(depId => {
const dep = nodeMap.get(depId);
if(!dep) return;
const depStart = computeES(dep);
if(!depStart) return;
const depEnd = addDays(depStart, dep.duration - 1);
if(maxEF === null || depEnd > maxEF) maxEF = depEnd;
});
esVisiting.delete(n.id);
es[n.id] = maxEF === null ? n.start : addDays(maxEF, 1);
ef[n.id] = addDays(es[n.id], n.duration - 1);
return es[n.id];
}
nodes.forEach(n => computeES(n));
const validNodes = nodes.filter(n => es[n.id]);
if(validNodes.length === 0) return [];
const maxEF = validNodes.reduce((max, n) => { const d = ef[n.id]; return !max || d > max ? d : max; }, null);
// Spätestes Ende (LF) und spätester Start (LS) rückwärts berechnen.
// Wichtig: LF eines Knotens ist der Tag VOR dem spätesten Start (LS)
// seines frühesten Nachfolgers – nicht vor dessen LF.
const lf = {}, ls = {};
const lfVisiting = new Set();
// computeLF: berechnet rekursiv den spätesten Start (LS) und das späteste
// Ende (LF) eines Knotens rückwärts aus seinen Nachfolgern. Liefert LF
// oder null bei Zyklen (visiting-Set).
function computeLF(n){
if(lf[n.id] !== undefined) return lf[n.id];
if(lfVisiting.has(n.id)){ lf[n.id] = null; ls[n.id] = null; return null; } // Zyklus
if(!n.start || !n.end){ lf[n.id] = null; ls[n.id] = null; return null; }
lfVisiting.add(n.id);
const successors = validNodes.filter(succ => depsOf(succ).includes(n.id));
let minLF = null;
successors.forEach(succ => {
if(!computeLF(succ)) return;
const succLS = ls[succ.id];
if(!succLS) return;
const candidate = addDays(succLS, -1);
if(minLF === null || candidate < minLF) minLF = candidate;
});
lfVisiting.delete(n.id);
lf[n.id] = minLF === null ? maxEF : minLF;
ls[n.id] = addDays(lf[n.id], -(n.duration - 1));
return lf[n.id];
}
validNodes.forEach(n => computeLF(n));
return validNodes.filter(n => n.type === 'todo' && es[n.id] && ls[n.id] && daysBetween(es[n.id], ls[n.id]) === 0).map(n => n.id);
};
// toggleCriticalPath: schaltet die Hervorhebung des kritischen Pfads im
// GANTT ein/aus (Wert in app.criticalPathActive), aktualisiert den
// Toolbar-Button und zeigt über einen Toast an, wie viele Aufgaben auf
// dem kritischen Pfad liegen.
window.toggleCriticalPath = function(){
app.criticalPathActive = !app.criticalPathActive;
const btn = document.getElementById('criticalPathBtn');
if(btn) btn.classList.toggle('active', app.criticalPathActive);
renderPlanGantt();
if(app.criticalPathActive){
const path = computeCriticalPath(app.project);
if(path.length === 0){
app.showToast('Kein kritischer Pfad ermittelt (zu wenig Aufgaben oder Abhängigkeiten).');
} else {
app.showToast(path.length + ' Aufgabe(n) auf dem kritischen Pfad hervorgehoben.');
}
}
};
// autoScheduleTodos: verschiebt alle TODOs und Meilensteine, die von // autoScheduleTodos: verschiebt alle TODOs und Meilensteine, die von
// anderen Elementen abhängen, automatisch auf den frühesten möglichen // anderen Elementen abhängen, automatisch auf den frühesten möglichen
+115 -43
View File
@@ -36,7 +36,7 @@
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der // PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
// Navigationsleiste. Muss zu den data-section-Attributen in // Navigationsleiste. Muss zu den data-section-Attributen in
// App.html passen. // App.html passen.
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen']; const PLAN_SECTIONS = ['dashboard', 'terminplan', 'phasen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen // showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist // Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
@@ -63,6 +63,7 @@
if(!app.project) return; if(!app.project) return;
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt(); if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
if(key === 'phasen' && typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks(); if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung(); if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle(); if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
@@ -106,53 +107,88 @@
if(nav) nav.style.display = ''; if(nav) nav.style.display = '';
} }
// upcomingEntries: sammelt die nächsten Termine des Projekts — offene // upcomingEntries: offene Aufgaben mit Endtermin und Meilensteine ab heute
// Aufgaben mit Endtermin und Meilensteine mit Datum — in einer gemeinsamen, // (vergangene Meilensteine haben kein Erledigt-Kennzeichen und gelten als
// chronologisch sortierten Liste. Überfällige Einträge stehen zuerst, damit // gewesen), chronologisch. idx verweist in plan.todos/plan.milestones, damit
// sie im Dashboard nicht untergehen. Rückgabe: Array von // ein Klick denselben Dialog öffnet wie im Terminplan.
// { kind, title, date, overdue }. // Rückgabe: Array von { art, idx, titel, datum, iso, wer, meins }.
function upcomingEntries(){ function upcomingEntries(){
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
const today = new Date(); const heute = heuteIso();
today.setHours(0, 0, 0, 0); const ich = currentUser && currentUser.personId;
const namen = {};
((app.week && app.week.people) || []).forEach(x => { namen[x.id] = x.fullName || x.name; });
const rows = []; const rows = [];
(plan.todos || []).forEach((t, idx) => {
(plan.todos || []).forEach(t => { if(t.done || !t.end) return;
if(t.done) return; const wer = (t.assignees || []).map(id => namen[id] || id);
const d = parseDateISO(t.end); rows.push({ art: 'aufgabe', idx, titel: t.title || 'Aufgabe', iso: t.end, wer,
if(!d) return; meins: !!ich && (t.assignees || []).includes(ich) });
rows.push({ kind: 'Aufgabe', title: t.title || 'Aufgabe', date: d, overdue: d < today });
}); });
(plan.milestones || []).forEach(m => { (plan.milestones || []).forEach((m, idx) => {
const d = parseDateISO(m.date); if(!m.date || m.date < heute) return;
if(!d) return; rows.push({ art: 'meilenstein', idx, titel: m.name || 'Meilenstein', iso: m.date, wer: [], meins: false });
rows.push({ kind: 'Meilenstein', title: m.name || 'Meilenstein', date: d, overdue: d < today });
}); });
rows.sort((a, b) => a.iso.localeCompare(b.iso));
rows.sort((a, b) => a.date - b.date);
return rows; return rows;
} }
// renderPlanDashboard: füllt die beiden eigenen Karten des Dashboards // PLAN_DASH_ANTEIL: wie viele Einträge je Gruppe offen dastehen — jede
// ("Nächste Termine" und "Team"). Die übrigen Inhalte des Bereichs // Gruppe bekommt ihren eigenen Anteil, sonst füllten 25 überfällige
// stammen aus bereits vorhandenen Render-Funktionen: die KPI-Kacheln aus // Aufgaben die Liste und "Diese Woche" stand ohne Einträge da. Der Rest
// renderPlanKpis() (plan-gantt.js), Kommentar und Leistungsphasen aus // steckt hinter "… weitere in Aufgaben".
// renderPlanInfo() (plan-info.js), die Kompakt-Karten aus const PLAN_DASH_ANTEIL = { ueber: 4, woche: 4, spaeter: 3 };
// renderResourceLoad()/renderProjectFiles()/renderPlanHistory().
// Keine Parameter, keine Rückgabe. // renderPlanDashboard: füllt "Termine und Aufgaben" (gegliedert nach
// Überfällig / Diese Woche / Später) und "Team" (eine Zeile je PERSON mit
// ihren Rollen als Chips — vorher stand jemand mit drei Rollen dreimal da).
// Kennzahlenband, Kommentar, Leistungsphasen und Unterlagen füllen
// renderPlanKpis()/renderPlanInfo()/renderResourceLoad()/
// renderProjectFiles(). Keine Parameter.
window.renderPlanDashboard = function(){ window.renderPlanDashboard = function(){
if(!app.project) return; if(!app.project) return;
const locked = app.isLocked();
const upcomingEl = document.getElementById('planDashUpcoming'); const upcomingEl = document.getElementById('planDashUpcoming');
if(upcomingEl){ if(upcomingEl){
const rows = upcomingEntries().slice(0, 8); const heute = heuteIso();
upcomingEl.innerHTML = rows.length === 0 const wocheEnde = isoPlusTage(7);
? '<div class="plan-task-empty">Keine offenen Termine hinterlegt.</div>' const alle = upcomingEntries();
: '<div class="plan-upcoming-list">' + rows.map(r => `<div class="plan-upcoming-item${r.overdue ? ' overdue' : ''}"> if(alle.length === 0){
<span class="plan-upcoming-date">${esc(formatDateDE(r.date))}</span> upcomingEl.innerHTML = '<div class="plan-task-empty">Keine offenen Termine. Aufgaben und Meilensteine entstehen im Terminplan.</div>';
<span>${esc(r.title)}</span> } else {
<span class="plan-upcoming-kind">${esc(r.kind)}</span> const gruppen = [
</div>`).join('') + '</div>'; { key: 'ueber', titel: 'Überfällig', klasse: 'ist-rot', treffer: r => r.art === 'aufgabe' && r.iso < heute },
{ key: 'woche', titel: 'Diese Woche', klasse: '', treffer: r => r.iso >= heute && r.iso <= wocheEnde },
{ key: 'spaeter', titel: 'Später', klasse: '', treffer: r => r.iso > wocheEnde },
];
let gezeigt = 0;
let html = '';
gruppen.forEach(g => {
const alleDerGruppe = alle.filter(g.treffer);
const sichtbar = alleDerGruppe.slice(0, PLAN_DASH_ANTEIL[g.key]);
gezeigt += sichtbar.length;
if(!alleDerGruppe.length) return;
html += `<div class="plan-termin-gruppe ${g.klasse}">${esc(g.titel)}<span>${alleDerGruppe.length}</span></div>`;
html += sichtbar.map(r => {
const klick = locked ? '' : ` onclick="${r.art === 'meilenstein' ? 'openMilestoneModal' : 'openTodoModal'}(${r.idx})"`;
const zeichen = r.art === 'meilenstein' ? '◆' : (r.meins ? 'A' : 'a');
const klasse = r.art === 'meilenstein' ? 'ist-ms' : (r.meins ? 'ist-meins' : '');
const wer = r.art === 'meilenstein' ? 'Meilenstein' : (r.wer.length ? r.wer.join(', ') : 'niemand zugewiesen');
const rot = r.art === 'aufgabe' && r.iso < heute;
return `<div class="plan-termin${locked ? '' : ' ist-klickbar'}"${klick}>
<span class="plan-termin-art ${klasse}">${zeichen}</span>
<span class="plan-termin-text"><b>${esc(r.titel)}</b><span>${esc(wer)}</span></span>
<span class="plan-termin-datum${rot ? ' ist-rot' : ''}">${esc(formatDateDE(parseDateISO(r.iso)))}<em>${esc(relativerTag(r.iso))}</em></span>
</div>`;
}).join('');
});
const verborgen = alle.length - gezeigt;
if(verborgen > 0){
html += `<button type="button" class="plan-panel-link plan-termin-mehr" onclick="showPlanSection('aufgaben')">${verborgen} weitere in „Aufgaben“</button>`;
}
upcomingEl.innerHTML = html;
}
} }
const teamEl = document.getElementById('planDashTeam'); const teamEl = document.getElementById('planDashTeam');
@@ -162,14 +198,50 @@
teamEl.innerHTML = '<div class="plan-task-empty">Diesem Projekt ist noch niemand zugeordnet. Zuordnung im Bereich „Einstellungen“.</div>'; teamEl.innerHTML = '<div class="plan-task-empty">Diesem Projekt ist noch niemand zugeordnet. Zuordnung im Bereich „Einstellungen“.</div>';
} else { } else {
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
teamEl.innerHTML = '<div class="plan-dash-team">' + assignees.map(a => { const jePerson = new Map();
const offen = (plan.todos || []).filter(t => !t.done && Array.isArray(t.assignees) && t.assignees.indexOf(a.person.id) !== -1).length; assignees.forEach(a => {
const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : ''); if(!jePerson.has(a.person.id)) jePerson.set(a.person.id, { person: a.person, rollen: [] });
return `<div class="plan-dash-team-row"> jePerson.get(a.person.id).rollen.push({ rolle: a.role, state: a.state });
<span class="board-avatar ${app.planRoleClass(a.role)} state-${a.state}" title="${esc(title)}">${esc(a.person.name)}</span> });
<span>${esc(a.person.fullName || a.person.name)}</span> const zeile = e => {
<span class="plan-upcoming-kind">${esc(a.role || '')}</span> const offen = (plan.todos || []).filter(t => !t.done && Array.isArray(t.assignees) && t.assignees.indexOf(e.person.id) !== -1).length;
<span class="plan-task-meta" style="margin-left:auto">${offen} offen</span> const haupt = e.rollen.find(r => r.state === 1) || e.rollen[0];
return `<div class="plan-team-zeile">
<span class="board-avatar ${app.planRoleClass(haupt.rolle)} state-${haupt.state}">${esc(e.person.name)}</span>
<span class="plan-team-text"><b>${esc(e.person.fullName || e.person.name)}</b>
<span class="plan-team-rollen">${e.rollen.map(r => `<span class="plan-rolle${r.state === 2 ? ' ist-neben' : ''}" title="${r.state === 2 ? 'Unterstützung' : 'Hauptverantwortung'}">${esc(r.rolle || '')}</span>`).join('')}</span></span>
<span class="plan-team-offen">${offen} offen</span>
</div>`;
};
// Nach Gewerk gruppiert (Eriks Wahl "A"): erst die Gewerke des
// Projekts in fester Reihenfolge — auch unbesetzte, mit Hinweis —,
// dann Gewerke von Beteiligten, die nicht zum Projekt gehören, zuletzt
// Personen ohne bekanntes Gewerk. Das Gewerk einer Person kommt aus
// ihrem Konto (personGewerk(), users.department).
const reihenfolge = Object.keys(DEPARTMENTS);
const projGewerke = reihenfolge.filter(g => (app.project.gewerke || []).indexOf(g) !== -1);
const gruppen = new Map(projGewerke.map(g => [g, []]));
[...jePerson.values()].forEach(e => {
const g = personGewerk(e.person) || '';
if(!gruppen.has(g)) gruppen.set(g, []);
gruppen.get(g).push(e);
});
const schluessel = [...gruppen.keys()].sort((x, y) => {
const rang = g => g === '' ? 99 : (projGewerke.indexOf(g) !== -1 ? projGewerke.indexOf(g) : 10 + reihenfolge.indexOf(g));
return rang(x) - rang(y);
});
teamEl.innerHTML = '<div class="plan-team">' + schluessel.map(g => {
const leute = gruppen.get(g);
const fremd = g !== '' && projGewerke.indexOf(g) === -1;
const titel = g === '' ? 'Ohne Gewerk' : ((DEPARTMENTS[g] && DEPARTMENTS[g].label !== g) ? DEPARTMENTS[g].label : '');
return `<div class="plan-team-gruppe">
<div class="plan-team-gewerk">
${g ? `<span class="plan-gewerk-schild">${esc(g)}</span>` : ''}
<span class="plan-gewerk-titel">${esc(titel)}${fremd ? (titel ? ' · ' : '') + 'kein Gewerk des Projekts' : ''}</span>
<span class="plan-gewerk-linie"></span>
<span class="plan-gewerk-anzahl">${leute.length === 1 ? '1 Person' : leute.length + ' Personen'}</span>
</div>
${leute.length ? leute.map(zeile).join('') : '<div class="plan-team-luecke">Noch niemand aus diesem Gewerk zugeordnet.</div>'}
</div>`; </div>`;
}).join('') + '</div>'; }).join('') + '</div>';
} }
+311 -89
View File
@@ -51,10 +51,285 @@
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) und nach // Wechsel in den Bereich (showPlanSection(), plan-sections.js) und nach
// jeder Änderung an Honorar oder beauftragten Leistungsphasen aufgerufen. // jeder Änderung an Honorar oder beauftragten Leistungsphasen aufgerufen.
// Keine Parameter, keine Rückgabe. // Keine Parameter, keine Rückgabe.
// HOAI-Bezeichnungen der Leistungsphasen für die Zeilen im Abschnitt
// "Leistungsphasen" (nur Anzeige).
const LPH_BEZEICHNUNG = {
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung'
};
// Gewählter Abschnitt der Einstellungen (Allgemein/Auftrag/Phasen/Team).
let einstTeil = 'allgemein';
window.zeigeEinstTeil = function(teil){
einstTeil = teil;
document.querySelectorAll('#planSection-einstellungen .plan-einst-reiter').forEach(b => {
const an = b.dataset.teil === teil;
b.classList.toggle('is-active', an);
b.setAttribute('aria-current', an ? 'true' : 'false');
});
document.querySelectorAll('#planSection-einstellungen .plan-einst-teil').forEach(t => {
t.hidden = t.dataset.teil !== teil;
});
};
// einstTeamPersonen: alle Personen, die im Abschnitt "Beteiligte" vorkommen
// können — jede Person der Sitzung mit mindestens einer Fachrolle
// (peopleByRole()), je mit den Rollen, die sie hat. Dazu, wer dem Projekt
// zugeordnet ist, obwohl sein Konto die Rolle inzwischen nicht mehr trägt —
// sonst wäre so jemand unsichtbar und ließe sich nicht mehr entfernen.
// Rückgabe: Array {person, rollen:Set}.
function einstTeamPersonen(){
const grouped = peopleByRole(app.week);
const map = new Map();
ROLES.forEach(r => (grouped[r] || []).forEach(entry => {
if(!map.has(entry.person.id)) map.set(entry.person.id, { person: entry.person, rollen: new Set() });
map.get(entry.person.id).rollen.add(r);
}));
const p = app.project;
const leute = (app.week && app.week.people) || [];
Object.keys((p && p.assign) || {}).forEach(key => {
if(!(p.assign[key] > 0)) return;
const [id, r] = key.split('|');
if(map.has(id)) { map.get(id).rollen.add(r); return; }
const person = leute.find(x => x.id === id);
if(person) map.set(id, { person, rollen: new Set([r]) });
});
return [...map.values()];
}
const einstName = person => person.fullName || person.name || '';
const einstNachname = person => einstName(person).split(' ').slice(-1)[0];
const einstBeteiligt = (p, id) => ROLES.some(r => (p.assign[assignKey(id, r)] || 0) > 0);
function einstGewerkRang(p){
const reihenfolge = Object.keys(DEPARTMENTS);
const projGewerke = reihenfolge.filter(g => (p.gewerke || []).indexOf(g) !== -1);
return { projGewerke, rang: g => g === '' ? 99 : (projGewerke.indexOf(g) !== -1 ? projGewerke.indexOf(g) : 10 + reihenfolge.indexOf(g)) };
}
// Zustand des Abschnitts "Beteiligte": zuletzt hinzugefügte Person (kurz
// hervorgehoben) und die zuletzt entfernte samt ihrer Zuordnung, damit
// "Rückgängig" sie wiederherstellen kann.
let einstNeu = '';
let einstEntfernt = null;
let einstToastTimer = null;
// renderEinstTeam: Besetzungsleiste, Sperrhinweis, Suchfeld und die
// Personenliste nach Gewerk (Eriks Wahl "B"). Keine Rückgabe.
function renderEinstTeam(p, locked){
if(!p.assign) p.assign = {};
const personen = einstTeamPersonen();
const { projGewerke, rang } = einstGewerkRang(p);
const sperre = document.getElementById('planEinstSperre');
if(sperre) sperre.hidden = !locked;
const sucheWrap = document.getElementById('planEinstSucheWrap');
if(sucheWrap) sucheWrap.hidden = locked;
// Besetzungsleiste: je Rolle, wer die Hauptverantwortung trägt.
const besetzung = document.getElementById('planEinstBesetzung');
if(besetzung){
besetzung.innerHTML = ROLES.map(r => {
const haupt = personen.filter(e => p.assign[assignKey(e.person.id, r)] === 1);
const neben = personen.filter(e => p.assign[assignKey(e.person.id, r)] === 2);
const fehlt = haupt.length === 0;
const text = fehlt
? (neben.length ? 'nur Unterstützung, Haupt fehlt' : 'niemand zugeordnet')
: haupt.map(e => einstNachname(e.person)).join(', ') + (neben.length ? ' + ' + neben.length + ' Unterst.' : '');
return `<div class="plan-einst-besetzt${fehlt ? ' ist-luecke' : ''}"><b>${esc(r)}</b><span>${esc(text)}</span></div>`;
}).join('');
}
const el = document.getElementById('planAssignMatrix');
if(!el) return;
const drin = personen.filter(e => einstBeteiligt(p, e.person.id));
if(!drin.length){
el.innerHTML = personen.length
? `<div class="plan-einst-leer"><b>Noch niemand zugeordnet</b><span>Über das Suchfeld Personen hinzufügen. Vorgeschlagen werden zuerst die Mitarbeiter aus den Gewerken des Projekts.</span></div>`
: '<div class="plan-einst-leer"><b>Niemand verfügbar</b><span>In dieser Sitzung ist niemand mit einer Fachrolle hinterlegt (Admin → Benutzerverwaltung).</span></div>';
} else {
const gruppen = new Map();
drin.forEach(e => {
const g = personGewerk(e.person) || '';
if(!gruppen.has(g)) gruppen.set(g, []);
gruppen.get(g).push(e);
});
el.innerHTML = [...gruppen.keys()].sort((x, y) => rang(x) - rang(y)).map(g => {
const liste = gruppen.get(g).sort((a, b) => einstName(a.person).localeCompare(einstName(b.person), 'de'));
const titel = g === '' ? 'Ohne Gewerk' : ((typeof GEWERK_LABELS === 'object' && GEWERK_LABELS[g]) || '')
+ (projGewerke.indexOf(g) === -1 ? ', kein Gewerk des Projekts' : '');
return `<div class="plan-einst-gruppe">
${g ? `<span class="plan-gewerk-schild">${esc(g)}</span>` : ''}
<span class="plan-gewerk-titel">${esc(titel)}</span>
<span class="plan-gewerk-linie"></span>
<span class="plan-gewerk-anzahl">${liste.length === 1 ? '1 Person' : liste.length + ' Personen'}</span>
</div>` + liste.map(e => {
const id = e.person.id;
const name = einstName(e.person);
const kapseln = ROLES.map(r => {
const hat = e.rollen.has(r);
const st = p.assign[assignKey(id, r)] || 0;
const art = st === 1 ? 'ist-haupt' : (st === 2 ? 'ist-neben' : '');
const titel = !hat ? `${name} hat die Rolle ${r} nicht` : `${r}: ${st === 1 ? 'Hauptverantwortung' : st === 2 ? 'Unterstützung' : 'nicht beteiligt'}`;
return `<button type="button" class="plan-einst-kapsel ${art}${hat ? '' : ' ist-fremd'}" aria-pressed="${st > 0}"
${!hat || locked ? 'disabled' : ''} title="${esc(titel)}"
onclick="planCycleAssign('${esc(id)}','${esc(r)}')">${esc(r)}</button>`;
}).join('');
return `<div class="plan-einst-zeile${einstNeu === id ? ' ist-neu' : ''}">
<span class="plan-einst-person"><span class="plan-einst-ava">${esc(e.person.name)}</span><b>${esc(name)}</b></span>
<span class="plan-einst-kapseln" role="group" aria-label="Rollen von ${esc(name)}">${kapseln}</span>
${locked ? '' : `<button type="button" class="plan-einst-weg" title="Aus dem Projekt entfernen" aria-label="${esc(name)} aus dem Projekt entfernen" onclick="einstEntfernen('${esc(id)}')">×</button>`}
</div>`;
}).join('');
}).join('');
}
const toast = document.getElementById('planEinstToast');
if(toast){
toast.hidden = !einstEntfernt || locked;
toast.innerHTML = einstEntfernt
? `<span>${esc(einstEntfernt.name)} aus dem Projekt entfernt</span><button type="button" onclick="einstRueckgaengig()">Rückgängig</button>` : '';
}
}
// einstNachAenderung: speichern und alles nachziehen, was die Zuordnung
// zeigt (dieselben Wege wie planCycleAssign()).
function einstNachAenderung(){
app.savePlan();
renderPlanSettings();
if(typeof renderBoard === 'function') renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht();
}
// einstEntfernen: nimmt eine Person mit allen Rollen aus dem Projekt und
// merkt sich die Zuordnung für "Rückgängig" (8 Sekunden).
window.einstEntfernen = function(personId){
const p = app.project;
if(!p || app.stammGesperrt()) return;
const eintrag = {};
ROLES.forEach(r => {
const key = assignKey(personId, r);
if(p.assign[key]) { eintrag[key] = p.assign[key]; delete p.assign[key]; }
});
const e = einstTeamPersonen().find(x => x.person.id === personId);
const person = e ? e.person : ((app.week.people || []).find(x => x.id === personId) || { name: personId });
einstEntfernt = { name: einstName(person), eintrag };
einstNeu = '';
clearTimeout(einstToastTimer);
einstToastTimer = setTimeout(() => { einstEntfernt = null; const t = document.getElementById('planEinstToast'); if(t) t.hidden = true; }, 8000);
einstNachAenderung();
};
window.einstRueckgaengig = function(){
const p = app.project;
if(!p || !einstEntfernt || app.stammGesperrt()) return;
Object.assign(p.assign, einstEntfernt.eintrag);
const key = Object.keys(einstEntfernt.eintrag)[0];
einstNeu = key ? key.split('|')[0] : '';
einstEntfernt = null;
clearTimeout(einstToastTimer);
einstNachAenderung();
};
// Suchfeld "Person hinzufügen": Vorschläge aus allen Personen mit
// Fachrolle, die noch nicht beteiligt sind — erst die Gewerke des
// Projekts, dann die übrigen. Gerendert wird nur die Vorschlagsliste,
// damit das Eingabefeld beim Tippen seinen Fokus behält.
function einstVorschlaege(){
const p = app.project;
const feld = document.getElementById('planEinstSuche');
const s = (feld ? feld.value : '').trim().toLowerCase();
const { projGewerke } = einstGewerkRang(p);
const frei = einstTeamPersonen()
.filter(e => !einstBeteiligt(p, e.person.id))
.filter(e => !s || einstName(e.person).toLowerCase().indexOf(s) !== -1 || String(e.person.name || '').toLowerCase().indexOf(s) !== -1)
.sort((a, b) => einstName(a.person).localeCompare(einstName(b.person), 'de'));
const eigen = frei.filter(e => projGewerke.indexOf(personGewerk(e.person)) !== -1);
const andere = frei.filter(e => projGewerke.indexOf(personGewerk(e.person)) === -1);
return [['Aus den Gewerken des Projekts', eigen], ['Andere Gewerke', andere]].filter(([, l]) => l.length);
}
window.einstSucheZeigen = function(){
const box = document.getElementById('planEinstVorschlaege');
if(!box || !app.project || app.stammGesperrt()) return;
const gruppen = einstVorschlaege();
if(!gruppen.length){
const feld = document.getElementById('planEinstSuche');
box.innerHTML = feld && feld.value.trim() ? '<div class="plan-einst-vorschlag-leer">Niemand gefunden.</div>' : '';
box.hidden = !box.innerHTML;
return;
}
box.innerHTML = gruppen.map(([titel, liste]) => `<div class="plan-einst-vorschlag-titel">${esc(titel)}</div>`
+ liste.map(e => `<button type="button" class="plan-einst-vorschlag" onmousedown="event.preventDefault()"
onclick="einstHinzufuegen('${esc(e.person.id)}')">
<span class="plan-einst-ava">${esc(e.person.name)}</span>
<span><b>${esc(einstName(e.person))}</b><span>${esc([personGewerk(e.person), ROLES.filter(r => e.rollen.has(r)).join(', ')].filter(Boolean).join(', '))}</span></span>
</button>`).join('')).join('');
box.hidden = false;
};
window.einstSucheSchliessen = function(){
setTimeout(() => { const box = document.getElementById('planEinstVorschlaege'); if(box) box.hidden = true; }, 120);
};
// Enter übernimmt den ersten Vorschlag, Esc schließt die Liste.
window.einstSucheTaste = function(event){
if(event.key === 'Escape'){ const box = document.getElementById('planEinstVorschlaege'); if(box) box.hidden = true; return; }
if(event.key !== 'Enter') return;
event.preventDefault();
const erste = einstVorschlaege()[0];
if(erste && erste[1][0]) window.einstHinzufuegen(erste[1][0].person.id);
};
// einstHinzufuegen: nimmt eine Person mit ihrer ersten Fachrolle als
// Hauptverantwortung auf; weitere Rollen setzt man über die Kapseln.
window.einstHinzufuegen = function(personId){
const p = app.project;
if(!p || app.stammGesperrt()) return;
const e = einstTeamPersonen().find(x => x.person.id === personId);
if(!e) return;
const rolle = ROLES.find(r => e.rollen.has(r));
if(!rolle) return;
if(!p.assign) p.assign = {};
p.assign[assignKey(personId, rolle)] = 1;
einstNeu = personId;
einstEntfernt = null;
const feld = document.getElementById('planEinstSuche');
if(feld) feld.value = '';
const box = document.getElementById('planEinstVorschlaege');
if(box) box.hidden = true;
einstNachAenderung();
};
// renderEinstUebersicht: die Kurzfassung unter jedem Abschnitt der
// Unternavigation und welcher Abschnitt sichtbar ist.
function renderEinstUebersicht(){
const p = app.project;
if(!p) return;
const sandbox = app.isSandboxPlan();
const setze = (id, text) => { const el = document.getElementById(id); if(el) el.textContent = text; };
const gewerke = Array.isArray(p.gewerke) ? p.gewerke : [];
const amtierend = Array.isArray(p.aktuelleLP) && p.aktuelleLP[0] ? p.aktuelleLP[0] : '';
setze('einstSumAllgemein', sandbox ? [p.name || '', amtierend].filter(Boolean).join(' · ')
: [p.nr || 'ohne Nummer', gewerke.join(', ') || 'kein Gewerk', amtierend].filter(Boolean).join(' · '));
const honorar = Number(p.honorar) || 0;
const honorarText = honorar > 0 ? honorar.toLocaleString('de-DE') + ' €' : 'kein Honorar';
const status = (typeof AUFTRAGSSTATUS !== 'undefined' && typeof auftragsstatusOf === 'function')
? (AUFTRAGSSTATUS.find(s => s.key === auftragsstatusOf(p)) || {}).label : '';
setze('einstSumAuftrag', sandbox ? honorarText : [status, honorarText].filter(Boolean).join(' · '));
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP.length : 0;
const laeuft = Array.isArray(p.aktuelleLP) && p.aktuelleLP[0] ? ' · läuft: ' + p.aktuelleLP[0] : '';
setze('einstSumPhasen', `${beauftragt} von ${LPHS.length} beauftragt${laeuft}`);
const ids = new Set(Object.keys(p.assign || {}).filter(k => p.assign[k] > 0).map(k => k.split('|')[0]));
const unbesetzt = gewerke.filter(g => !einstTeamPersonen().some(e => ids.has(e.person.id) && personGewerk(e.person) === g));
setze('einstSumTeam', (ids.size === 1 ? '1 Person' : ids.size + ' Personen') + (unbesetzt.length ? ' · ' + unbesetzt.join(', ') + ' unbesetzt' : ''));
window.zeigeEinstTeil(einstTeil);
}
window.renderPlanSettings = function(){ window.renderPlanSettings = function(){
const p = app.project; const p = app.project;
if(!p) return; if(!p) return;
const locked = app.isLocked(); const locked = app.stammGesperrt();
const sandbox = app.isSandboxPlan(); const sandbox = app.isSandboxPlan();
const lockHint = document.getElementById('planSettingsLockHint'); const lockHint = document.getElementById('planSettingsLockHint');
@@ -62,16 +337,20 @@
// Felder, die nur bei einem echten Projekt Bedeutung haben (Auftrags- // Felder, die nur bei einem echten Projekt Bedeutung haben (Auftrags-
// Workflow, Dateien-Zuordnung über die Nummer, Gewerke-Filter des // Workflow, Dateien-Zuordnung über die Nummer, Gewerke-Filter des
// Wochenberichts, Auslastungs-Ersatzgröße) — für die Planung-Sandbox // Wochenberichts) — für die Planung-Sandbox
// ausgeblendet, siehe CLAUDE.md "Planung — projektunabhängige // ausgeblendet, siehe CLAUDE.md "Planung — projektunabhängige
// Terminplan-Sandbox". Honorar + beauftragte Leistungsphasen (Grundlage // Terminplan-Sandbox". Honorar + beauftragte Leistungsphasen (Grundlage
// des Terminplan-Rechners) und die Beteiligten-Matrix bleiben sichtbar. // des Terminplan-Rechners) und die Beteiligten-Matrix bleiben sichtbar.
['settingsProjNr', 'settingsAuftragsstatus', 'settingsGewerke', 'settingsGroesse'] ['settingsProjNr', 'settingsAuftragsstatus', 'settingsGewerke']
.forEach(id => { .forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
const row = el && el.closest('.form-row'); const row = el && el.closest('.form-row');
if(row) row.style.display = sandbox ? 'none' : ''; if(row) row.style.display = sandbox ? 'none' : '';
}); });
['settingsProjNrHinweis'].forEach(id => {
const el = document.getElementById(id);
if(el) el.hidden = sandbox;
});
// Projektnummer und -name: das fokussierte Feld nicht überschreiben // Projektnummer und -name: das fokussierte Feld nicht überschreiben
// (dieselbe Vorsichtsmaßnahme wie beim Honorar weiter unten). // (dieselbe Vorsichtsmaßnahme wie beim Honorar weiter unten).
@@ -86,25 +365,6 @@
nameEl.disabled = locked; nameEl.disabled = locked;
} }
// Projektgröße: Sie ist der Ersatz für ein fehlendes Honorar, deshalb
// steht unter dem Regler, ob sie gerade überhaupt zählt. Eine Zahl, die
// wirkungslos ist, ohne dass man es sieht, wäre schlimmer als keine.
const groesseEl = document.getElementById('settingsGroesse');
if(groesseEl){
const g = Number(p.groesse) || 0;
if(document.activeElement !== groesseEl) groesseEl.value = g > 0 ? g : '';
groesseEl.disabled = locked;
const hinweisEl = document.getElementById('settingsGroesseHinweis');
if(hinweisEl){
const honorar = Number(p.honorar) || 0;
hinweisEl.textContent = honorar > 0
? 'Ein Honorar ist hinterlegt — die Auslastung rechnet damit, die Größe bleibt unberücksichtigt.'
: (g > 0
? 'Kein Honorar hinterlegt: Die Auslastung rechnet mit dieser Größe. 0 = keine Angabe, 1 = großes Projekt.'
: 'Ohne Honorar und ohne Größe fließt dieses Projekt in keine Auslastung ein. 0 = keine Angabe, 1 = großes Projekt.');
}
}
// Schlagwörter: dieselbe Chip-Liste wie im früheren Projekte-Tab. // Schlagwörter: dieselbe Chip-Liste wie im früheren Projekte-Tab.
const tagsEl = document.getElementById('settingsProjTags'); const tagsEl = document.getElementById('settingsProjTags');
if(tagsEl){ if(tagsEl){
@@ -124,11 +384,12 @@
const gewerkeEl = document.getElementById('settingsGewerke'); const gewerkeEl = document.getElementById('settingsGewerke');
if(gewerkeEl && typeof GEWERK_LABELS === 'object'){ if(gewerkeEl && typeof GEWERK_LABELS === 'object'){
const gesetzt = new Set(Array.isArray(p.gewerke) ? p.gewerke : []); const gesetzt = new Set(Array.isArray(p.gewerke) ? p.gewerke : []);
gewerkeEl.innerHTML = Object.keys(GEWERK_LABELS).map(g => `<label class="beauftragte-lp-item" title="${esc(GEWERK_LABELS[g])}"> // Als Umschalt-Knöpfe (Einstellungen "C"): angetippt = gesetzt.
<input type="checkbox" value="${esc(g)}" ${gesetzt.has(g) ? 'checked' : ''} ${locked ? 'disabled' : ''} gewerkeEl.innerHTML = Object.keys(GEWERK_LABELS).map(g => {
onchange="togglePlanGewerk('${esc(g)}', this.checked)"> const an = gesetzt.has(g);
${esc(g)} return `<button type="button" class="plan-einst-chip${an ? ' an' : ''}" aria-pressed="${an}" title="${esc(GEWERK_LABELS[g])}"
</label>`).join(''); ${locked ? 'disabled' : ''} onclick="togglePlanGewerk('${esc(g)}', ${!an})">${esc(g)}</button>`;
}).join('');
} }
// Auftragsstatus: Das Auswahlfeld kommt fertig aus js/auftrag.js, damit // Auftragsstatus: Das Auswahlfeld kommt fertig aus js/auftrag.js, damit
@@ -139,8 +400,6 @@
statusEl.innerHTML = auftragsstatusSelectHtml(p, { disabled: locked }); statusEl.innerHTML = auftragsstatusSelectHtml(p, { disabled: locked });
} }
const aufwandBtn = document.getElementById('settingsAufwandBtn');
if(aufwandBtn) aufwandBtn.disabled = locked || typeof openLpModal !== 'function';
// Honorar: das fokussierte Feld nicht überschreiben, sonst springt beim // Honorar: das fokussierte Feld nicht überschreiben, sonst springt beim
// Tippen der Cursor (gleiche Vorsichtsmaßnahme wie im Honorar-Dialog). // Tippen der Cursor (gleiche Vorsichtsmaßnahme wie im Honorar-Dialog).
@@ -152,36 +411,32 @@
honorarEl.disabled = locked; honorarEl.disabled = locked;
} }
// Beauftragte Leistungsphasen: dieselbe Kreuzchen-Liste wie im // Beauftragte Leistungsphasen: je Phase eine Zeile mit Umschalter
// Honorar-Dialog, gespeichert über toggleBeauftragteLp() (plan-honorar.js). // "Beauftragt | Nicht beauftragt", die laufende Phase trägt "läuft".
// Gespeichert über toggleBeauftragteLp() (plan-honorar.js).
const lpEl = document.getElementById('settingsBeauftragteLp'); const lpEl = document.getElementById('settingsBeauftragteLp');
if(lpEl){ if(lpEl){
const gesetzt = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []); const gesetzt = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
lpEl.innerHTML = LPHS.map(l => `<label class="beauftragte-lp-item"> const laeuft = Array.isArray(p.aktuelleLP) ? p.aktuelleLP[0] : '';
<input type="checkbox" value="${esc(l)}" ${gesetzt.has(l) ? 'checked' : ''} ${locked ? 'disabled' : ''} lpEl.innerHTML = LPHS.map(l => {
onchange="toggleBeauftragteLp('${esc(l)}', this.checked)"> const an = gesetzt.has(l);
${esc(l)} return `<div class="plan-einst-phase${an ? ' ist-an' : ''}">
</label>`).join(''); <span class="plan-einst-phase-name"><b>${esc(l)}</b><span>${esc(LPH_BEZEICHNUNG[l] || '')}</span>${l === laeuft ? '<em>läuft</em>' : ''}</span>
<span class="plan-einst-seg" role="group" aria-label="${esc(l)}">
<button type="button" class="${an ? 'an' : ''}" ${locked ? 'disabled' : ''} onclick="toggleBeauftragteLp('${esc(l)}', true)">Beauftragt</button>
<button type="button" class="${an ? '' : 'an'}" ${locked ? 'disabled' : ''} onclick="toggleBeauftragteLp('${esc(l)}', false)">Nicht beauftragt</button>
</span>
</div>`;
}).join('');
} }
// Beteiligte — Stammdatum, hier editierbar (siehe Kopfkommentar). Nicht // Beteiligte — Stammdatum, hier editierbar (siehe Kopfkommentar). Nicht
// zugeordnete Personen erscheinen blass, damit erkennbar bleibt, wer zur // zugeordnete Personen erscheinen blass, damit erkennbar bleibt, wer zur
// Auswahl stünde; ein Klick schaltet den Zustand durch (planCycleAssign()). // Auswahl stünde; ein Klick schaltet den Zustand durch (planCycleAssign()).
const assignMatrix = document.getElementById('planAssignMatrix'); const assignMatrix = document.getElementById('planAssignMatrix');
if(assignMatrix){ if(assignMatrix) renderEinstTeam(p, locked);
const grouped = peopleByRole(app.week);
if(!p.assign) p.assign = {}; renderEinstUebersicht();
assignMatrix.innerHTML = ROLES.map(r => {
const entries = grouped[r] || [];
const chips = entries.length ? entries.map(entry => {
const state = p.assign[assignKey(entry.person.id, r)] || 0;
const click = locked ? '' : `onclick="planCycleAssign('${entry.person.id}','${r}')"`;
const title = (entry.person.fullName || entry.person.name) + ' · ' + r;
return `<span class="board-avatar ${app.planRoleClass(r)} state-${state}${locked ? ' locked-cell' : ''}" ${click} title="${esc(title)}">${esc(entry.person.name)}</span>`;
}).join('') : `<span class="hint" style="margin:0">Niemand hinterlegt</span>`;
return `<div class="board-role-row"><span class="board-role-label ${app.planRoleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`;
}).join('');
}
}; };
// planCycleAssign: wechselt die Zuordnung einer Person zu DIESEM Projekt // planCycleAssign: wechselt die Zuordnung einer Person zu DIESEM Projekt
@@ -191,7 +446,7 @@
// Rückgabe. // Rückgabe.
window.planCycleAssign = function(personId, role){ window.planCycleAssign = function(personId, role){
const p = app.project; const p = app.project;
if(!p || app.isLocked()) return; if(!p || app.stammGesperrt()) return;
if(!p.assign) p.assign = {}; if(!p.assign) p.assign = {};
const key = assignKey(personId, role); const key = assignKey(personId, role);
const cur = p.assign[key] || 0; const cur = p.assign[key] || 0;
@@ -215,7 +470,7 @@
// Dateien nicht mehr; deshalb die Rückfrage. Parameter: val. Keine Rückgabe. // Dateien nicht mehr; deshalb die Rückfrage. Parameter: val. Keine Rückgabe.
window.setPlanProjNr = function(val){ window.setPlanProjNr = function(val){
const p = app.project; const p = app.project;
if(!p || app.isLocked()) return; if(!p || app.stammGesperrt()) return;
const neuVal = (val || '').trim(); const neuVal = (val || '').trim();
if(neuVal === (p.nr || '')) return; if(neuVal === (p.nr || '')) return;
if(p.nr && !confirm('Die Projektnummer "' + p.nr + '" wird zu "' + neuVal + '".\n\n' if(p.nr && !confirm('Die Projektnummer "' + p.nr + '" wird zu "' + neuVal + '".\n\n'
@@ -231,7 +486,7 @@
// setPlanProjName: ändert die Projektbezeichnung. Parameter: val. // setPlanProjName: ändert die Projektbezeichnung. Parameter: val.
window.setPlanProjName = function(val){ window.setPlanProjName = function(val){
const p = app.project; const p = app.project;
if(!p || app.isLocked()) return; if(!p || app.stammGesperrt()) return;
p.name = (val || '').trim(); p.name = (val || '').trim();
app.savePlan(); app.savePlan();
// Die Bezeichnung steht an drei weiteren Stellen: in der Kopfzeile des // Die Bezeichnung steht an drei weiteren Stellen: in der Kopfzeile des
@@ -249,28 +504,12 @@
renderPlanSettings(); renderPlanSettings();
}; };
// setPlanGroesse: übernimmt die Projektgröße (0–1). Gespeichert wird erst
// beim Loslassen des Reglers, nicht bei jedem Zwischenwert — sonst liefe
// ein Zug über die Skala als zwanzig Speichervorgänge auf den Server.
// Parameter: val. Keine Rückgabe.
window.setPlanGroesse = function(val){
const p = app.project;
if(!p || app.isLocked()) return;
const n = Math.max(0, Math.min(1, parseFloat(val) || 0));
if(n === (Number(p.groesse) || 0)) return;
p.groesse = n;
app.savePlan();
renderPlanSettings();
// Die Auslastung rechnet mit diesem Wert.
if(typeof renderLoad === 'function' && document.getElementById('loadGrid')) renderLoad();
};
// planTagKeydown: übernimmt ein Schlagwort mit Enter. Parameter: event. // planTagKeydown: übernimmt ein Schlagwort mit Enter. Parameter: event.
window.planTagKeydown = function(event){ window.planTagKeydown = function(event){
if(event.key !== 'Enter') return; if(event.key !== 'Enter') return;
event.preventDefault(); event.preventDefault();
const p = app.project; const p = app.project;
if(!p || app.isLocked()) return; if(!p || app.stammGesperrt()) return;
const tag = (event.target.value || '').trim(); const tag = (event.target.value || '').trim();
if(!tag) return; if(!tag) return;
if(!Array.isArray(p.tags)) p.tags = []; if(!Array.isArray(p.tags)) p.tags = [];
@@ -283,7 +522,7 @@
// planRemoveTag: entfernt ein Schlagwort. Parameter: tag. // planRemoveTag: entfernt ein Schlagwort. Parameter: tag.
window.planRemoveTag = function(tag){ window.planRemoveTag = function(tag){
const p = app.project; const p = app.project;
if(!p || app.isLocked() || !Array.isArray(p.tags)) return; if(!p || app.stammGesperrt() || !Array.isArray(p.tags)) return;
p.tags = p.tags.filter(t => t !== tag); p.tags = p.tags.filter(t => t !== tag);
app.savePlan(); app.savePlan();
renderPlanSettings(); renderPlanSettings();
@@ -296,7 +535,7 @@
// GEWERK_LABELS (js/projekt-wizard.js), damit sie nicht bei jeder Änderung // GEWERK_LABELS (js/projekt-wizard.js), damit sie nicht bei jeder Änderung
// durcheinandergerät. Parameter: gewerk, checked (Boolean). // durcheinandergerät. Parameter: gewerk, checked (Boolean).
window.togglePlanGewerk = function(gewerk, checked){ window.togglePlanGewerk = function(gewerk, checked){
if(app.isLocked()) return; if(app.stammGesperrt()) return;
const p = app.project; const p = app.project;
const set = new Set(Array.isArray(p.gewerke) ? p.gewerke : []); const set = new Set(Array.isArray(p.gewerke) ? p.gewerke : []);
if(checked) set.add(gewerk); else set.delete(gewerk); if(checked) set.add(gewerk); else set.delete(gewerk);
@@ -304,21 +543,4 @@
app.savePlan(); app.savePlan();
renderPlanSettings(); renderPlanSettings();
}; };
// planOpenAufwand: öffnet die Aufwands-Gewichtungsmatrix. Der Dialog selbst
// liegt in js/load/load.js (openLpModal) und wird hier nur aufgerufen —
// er gehörte immer zum Projekt, stand aber bisher nur im Projekte-Tab.
window.planOpenAufwand = function(){
const p = app.project;
if(!p || typeof openLpModal !== 'function') return;
// openLpModal() sucht das Projekt in currentWeek(). Zeigt der
// Projektbereich eine ANDERE Sitzung, findet es dort nichts und täte
// stillschweigend nichts — deshalb hier der ausdrückliche Hinweis.
const w = typeof currentWeek === 'function' ? currentWeek() : null;
if(!w || !Array.isArray(w.projects) || !w.projects.some(x => x.id === p.id)){
app.showToast('Der Aufwand lässt sich nur in der laufenden Sitzung bearbeiten.');
return;
}
openLpModal(p.id);
};
})(); })();
+26 -13
View File
@@ -36,7 +36,6 @@ window.PlanApp = {
week: null, // planWeek week: null, // planWeek
project: null, // planProject project: null, // planProject
pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen) pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen)
criticalPathActive: false, // Hervorhebung des kritischen Pfads im GANTT aktiv
// Drag-Zustände // Drag-Zustände
todoDrag: null, // Drag-and-Drop von TODO/Meilenstein-Zeilen todoDrag: null, // Drag-and-Drop von TODO/Meilenstein-Zeilen
ganttDrag: null, // Balken/Meilenstein/Urlaub verschieben ganttDrag: null, // Balken/Meilenstein/Urlaub verschieben
@@ -122,6 +121,9 @@ window.PlanApp = {
app.projNr = ''; app.projNr = '';
app.projName = app.project.name || ''; app.projName = app.project.name || '';
renderPlanPage(); renderPlanPage();
// showTab() hat die Adresse schon vor diesem Aufruf geschrieben, als
// der Name noch fehlte (/Projektplan/?…) — jetzt mit Namen nachziehen.
if(typeof updateRouteUrl === 'function') updateRouteUrl('plan');
return true; return true;
} }
@@ -177,10 +179,21 @@ window.PlanApp = {
return app.project.plan; return app.project.plan;
} }
// isLocked: liefert true, wenn die aktuelle Woche gesperrt ist (dann sind // isLocked: liefert true, wenn der TERMINPLAN nicht bearbeitet werden darf
// Bearbeitungen im Plan gesperrt). Rückgabe: Boolescher Wert. // — gesperrte Sitzung oder fehlendes Recht „Terminplan bearbeiten“ (bei
// einem Planung-Plan: „Planung“; Rollen & Rechte). Rückgabe: Boolean.
function isLocked(){ function isLocked(){
return !!app.week.locked; return !!app.week.locked || !hatRecht(isSandboxPlan() ? 'wz.planung' : 'proj.plan');
}
// stammGesperrt: dasselbe für Projekteinstellungen, Gruppe, amtierende
// Leistungsphase und Honorar (Recht „Stammdaten und Beteiligte“).
function stammGesperrt(){
return !!app.week.locked || !hatRecht(isSandboxPlan() ? 'wz.planung' : 'proj.stamm');
}
// protokolleGesperrt: Protokolle (Recht „Protokolle bearbeiten“), in einer
// gesperrten Sitzung wie bisher gesperrt.
function protokolleGesperrt(){
return !!app.week.locked || !hatRecht('proj.protokolle');
} }
// isSandboxPlan: liefert true, wenn der aktuell geöffnete Plan zur // isSandboxPlan: liefert true, wenn der aktuell geöffnete Plan zur
@@ -238,8 +251,6 @@ window.PlanApp = {
// Bereichs), lädt bei vorhandener Projektnummer die Protokollliste. // Bereichs), lädt bei vorhandener Projektnummer die Protokollliste.
if(typeof initPlanProtokoll === 'function') initPlanProtokoll(); if(typeof initPlanProtokoll === 'function') initPlanProtokoll();
// Verlauf über alle Sitzungen (Kommentar-Historie) — siehe js/plan/plan-history.js.
renderPlanHistory();
// Einstellungen-Bereich (Honorar, beauftragte Leistungsphasen, // Einstellungen-Bereich (Honorar, beauftragte Leistungsphasen,
// Projektangaben) — siehe js/plan/plan-settings.js. // Projektangaben) — siehe js/plan/plan-settings.js.
@@ -247,10 +258,12 @@ window.PlanApp = {
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten // Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche: // Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
// Terminplan (…Btn) und Aufgaben (…BtnList), siehe App.html. // Terminplan (…Btn), Aufgaben (…BtnList) und Leistungsphasen (…BtnPhasen),
// siehe App.html.
const locked = isLocked(); const locked = isLocked();
['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn', 'autoScheduleBtn', ['addMilestoneBtn', 'addPhaseBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn',
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList'] 'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList',
'honorarBtnPhasen', 'addPhaseBtnPhasen']
.forEach(id => { .forEach(id => {
const btn = document.getElementById(id); const btn = document.getElementById(id);
if(btn) btn.style.display = locked ? 'none' : ''; if(btn) btn.style.display = locked ? 'none' : '';
@@ -263,10 +276,8 @@ window.PlanApp = {
document.querySelectorAll('#planNav .plan-nav-item[data-section="dateien"], #planNav .plan-nav-item[data-section="leistung"], #planNav .plan-nav-item[data-section="protokolle"]') document.querySelectorAll('#planNav .plan-nav-item[data-section="dateien"], #planNav .plan-nav-item[data-section="leistung"], #planNav .plan-nav-item[data-section="protokolle"]')
.forEach(btn => { btn.style.display = sandbox ? 'none' : ''; }); .forEach(btn => { btn.style.display = sandbox ? 'none' : ''; });
// Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gibt es nur für // Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gilt für
// Sandbox-Pläne der Planung — ein echtes Projekt hat kein Äquivalent. // Planung-Pläne UND echte Projekte, der Knopf ist immer sichtbar.
const sandboxPdfBtn = document.getElementById('sandboxPdfBtn');
if(sandboxPdfBtn) sandboxPdfBtn.style.display = sandbox ? '' : 'none';
// GANTT inkl. TODOs (rendert am Ende auch Aufgabenliste und Dashboard mit) // GANTT inkl. TODOs (rendert am Ende auch Aufgabenliste und Dashboard mit)
fitPlanGanttRange(); fitPlanGanttRange();
@@ -394,6 +405,8 @@ window.PlanApp = {
// (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können. // (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können.
app.ensurePlanLocal = ensurePlanLocal; app.ensurePlanLocal = ensurePlanLocal;
app.isLocked = isLocked; app.isLocked = isLocked;
app.stammGesperrt = stammGesperrt;
app.protokolleGesperrt = protokolleGesperrt;
app.isSandboxPlan = isSandboxPlan; app.isSandboxPlan = isSandboxPlan;
app.removeDependencyRefs = removeDependencyRefs; app.removeDependencyRefs = removeDependencyRefs;
app.renderProjectAssignees = renderProjectAssignees; app.renderProjectAssignees = renderProjectAssignees;
+36 -2
View File
@@ -75,6 +75,18 @@
return e < today; return e < today;
} }
// Phasenfilter der Aufgabenliste: '' = alle, 'LPH 5', oder '-' = ohne
// Phase. Gilt nur für das Projekt, für das er gesetzt wurde.
let filter = { projekt: null, phase: '' };
const filterPhase = () => filter.projekt === app.project ? filter.phase : '';
// setzeAufgabenFilter: von außen (Bereich Leistungsphasen) oder über die
// Filterknöpfe. Parameter: phase ('' | 'LPH x' | '-').
window.setzeAufgabenFilter = function(phase){
filter = { projekt: app.project, phase: phase || '' };
window.renderPlanTasks();
};
// renderPlanTasks: baut die Aufgaben- und die Meilensteinliste des Bereichs // renderPlanTasks: baut die Aufgaben- und die Meilensteinliste des Bereichs
// "Aufgaben" neu auf und schreibt die Zähler in die Kartenüberschriften. // "Aufgaben" neu auf und schreibt die Zähler in die Kartenüberschriften.
// Keine Parameter, keine Rückgabe. // Keine Parameter, keine Rückgabe.
@@ -85,7 +97,25 @@
// ---- Aufgaben ---- // ---- Aufgaben ----
const todoEl = document.getElementById('planTodoList'); const todoEl = document.getElementById('planTodoList');
const todos = Array.isArray(plan.todos) ? plan.todos : []; const alleTodos = Array.isArray(plan.todos) ? plan.todos : [];
// Phasenbalken stehen im eigenen Bereich "Leistungsphasen"
// (plan-phasen.js), nicht in der Aufgabenliste. idx bleibt der Index in
// plan.todos, den die Dialoge erwarten.
const eintraege = alleTodos.map((t, idx) => ({ t, idx, phase: aufgabenPhase(t, alleTodos) }))
.filter(e => !balkenPhase(e.t));
const todos = eintraege.map(e => e.t);
const fp = filterPhase();
const sichtbar = eintraege.filter(e => !fp || (fp === '-' ? !e.phase : e.phase === fp));
const filterEl = document.getElementById('planTodoFilter');
if(filterEl){
const phasen = LPHS.filter(l => eintraege.some(e => e.phase === l));
const knopf = (wert, text) => `<button type="button" class="plan-task-filter-btn${fp === wert ? ' is-active' : ''}" onclick="setzeAufgabenFilter('${esc(wert)}')">${esc(text)}</button>`;
filterEl.innerHTML = phasen.length
? '<span>Phase:</span>' + knopf('', 'Alle') + phasen.map(l => knopf(l, l)).join('')
+ (eintraege.some(e => !e.phase) ? knopf('-', 'ohne Phase') : '')
: '';
}
if(todoEl){ if(todoEl){
// In gesperrten Sitzungen sind die Anlege-Schaltflächen ausgeblendet // In gesperrten Sitzungen sind die Anlege-Schaltflächen ausgeblendet
// (renderPlanPage(), plan-state.js) — dann darf der Leertext auch nicht // (renderPlanPage(), plan-state.js) — dann darf der Leertext auch nicht
@@ -95,7 +125,9 @@
: 'Noch keine Aufgaben. Über „+ Aufgabe“, „+ Vorlage“ oder „+ Terminplan“ anlegen.'; : 'Noch keine Aufgaben. Über „+ Aufgabe“, „+ Vorlage“ oder „+ Terminplan“ anlegen.';
todoEl.innerHTML = todos.length === 0 todoEl.innerHTML = todos.length === 0
? '<div class="plan-task-empty">' + leerTextTodos + '</div>' ? '<div class="plan-task-empty">' + leerTextTodos + '</div>'
: todos.map((t, idx) => { : sichtbar.length === 0
? '<div class="plan-task-empty">Keine Aufgaben in dieser Auswahl.</div>'
: sichtbar.map(({ t, idx, phase }) => {
const status = todoStatus(t); const status = todoStatus(t);
const range = dateRangeText(t); const range = dateRangeText(t);
const overdue = isOverdue(t); const overdue = isOverdue(t);
@@ -109,6 +141,8 @@
${check} ${check}
<span class="plan-task-dot ${status}"></span> <span class="plan-task-dot ${status}"></span>
<span class="plan-task-title">${esc(t.title || 'Aufgabe')}</span> <span class="plan-task-title">${esc(t.title || 'Aufgabe')}</span>
${t.notiz ? `<span class="plan-task-notiz" title="${esc(String(t.notiz).slice(0, 300))}">${icon('fileText')}</span>` : ''}
${phase ? `<span class="plan-task-phase" title="Liegt im Zeitraum dieser Leistungsphase">${esc(phase)}</span>` : ''}
${assigneeAvatars(t.assignees)} ${assigneeAvatars(t.assignees)}
${progress} ${progress}
<span class="plan-task-meta${overdue ? ' overdue' : ''}" title="${range ? '' : 'Ohne Termin — erscheint nicht im Terminplan'}">${range ? esc(range) : '<span class="plan-task-no-schedule">ohne Termin</span>'}</span> <span class="plan-task-meta${overdue ? ' overdue' : ''}" title="${range ? '' : 'Ohne Termin — erscheint nicht im Terminplan'}">${range ? esc(range) : '<span class="plan-task-no-schedule">ohne Termin</span>'}</span>
+127 -20
View File
@@ -4,7 +4,8 @@
// In einem Ingenieurbüro wiederholt sich der Ablauf einer Leistungsphase über // In einem Ingenieurbüro wiederholt sich der Ablauf einer Leistungsphase über
// alle Projekte hinweg. Dieses Modul macht daraus wiederverwendbare Vorlagen: // alle Projekte hinweg. Dieses Modul macht daraus wiederverwendbare Vorlagen:
// Der Button "+ Vorlage" in der GANTT-Toolbar öffnet #lphTemplateModal, dort // Der Button "+ Vorlage" in der GANTT-Toolbar öffnet #lphTemplateModal, dort
// wählt man eine Leistungsphase und ein Startdatum, hakt die gewünschten // wählt man Gewerk, Leistungsphase und Startdatum (Vorlagen gibt es je
// Gewerk), hakt die gewünschten
// Schritte an und fügt sie mit einem Klick als TODOs in den Projektplan ein. // Schritte an und fügt sie mit einem Klick als TODOs in den Projektplan ein.
// //
// Der Honorar-Terminplan (js/plan/plan-honorar.js) ist ein eigener Dialog mit // Der Honorar-Terminplan (js/plan/plan-honorar.js) ist ein eigener Dialog mit
@@ -16,7 +17,7 @@
// lph_templates): GET für alle angemeldeten Nutzer, POST/DELETE nur für // lph_templates): GET für alle angemeldeten Nutzer, POST/DELETE nur für
// Admins — das Einfügen darf jeder, das Pflegen der firmenweiten Vorlagen // Admins — das Einfügen darf jeder, das Pflegen der firmenweiten Vorlagen
// nicht. Die Pflege-Maske selbst steht seit dem Umbau NICHT mehr in diesem // nicht. Die Pflege-Maske selbst steht seit dem Umbau NICHT mehr in diesem
// Dialog, sondern im Bereich „Einstellungen" (js/settings/settings.js) — // Dialog, sondern im Admin-Bereich „Aufgaben-Vorlagen" (js/admin/admin.js) —
// hier bleibt für Administratoren nur ein Verweis dorthin. // hier bleibt für Administratoren nur ein Verweis dorthin.
// //
// WICHTIG: Eingefügte Aufgaben sind danach ganz normale plan_todos ohne // WICHTIG: Eingefügte Aufgaben sind danach ganz normale plan_todos ohne
@@ -46,13 +47,28 @@
if(app.isLocked()) return; if(app.isLocked()) return;
const lphSelect = document.getElementById('templateLphSelect'); const lphSelect = document.getElementById('templateLphSelect');
if(lphSelect && !lphSelect.options.length){ // Bei JEDEM Öffnen neu vorbelegen: Der Projektbereich zeigt nacheinander
// verschiedene Projekte im selben Dokument — sonst blieb die amtierende
// Leistungsphase des zuerst geöffneten Projekts vorgewählt.
if(lphSelect){
// Vorbelegung: die amtierende Leistungsphase des Projekts, sonst LPH 1 // Vorbelegung: die amtierende Leistungsphase des Projekts, sonst LPH 1
const aktLp = Array.isArray(app.project.aktuelleLP) ? app.project.aktuelleLP[0] : ''; const aktLp = Array.isArray(app.project.aktuelleLP) ? app.project.aktuelleLP[0] : '';
lphSelect.innerHTML = TEMPLATE_LPHS.map(l => lphSelect.innerHTML = TEMPLATE_LPHS.map(l =>
`<option value="${esc(l)}" ${l === aktLp ? 'selected' : ''}>${esc(l)}</option>` `<option value="${esc(l)}" ${l === aktLp ? 'selected' : ''}>${esc(l)}</option>`
).join(''); ).join('');
} }
// Gewerk vorbelegen: das eigene, wenn das Projekt es hat, sonst das
// erste Gewerk des Projekts, sonst das eigene (Vorlagen gibt es je
// Gewerk, Eriks Wunsch).
const gewSelect = document.getElementById('templateGewerkSelect');
if(gewSelect){
const projGew = Array.isArray(app.project.gewerke) ? app.project.gewerke : [];
const eigen = currentUser && Object.keys(DEPARTMENTS).includes(currentUser.department) ? currentUser.department : '';
const vorwahl = (eigen && projGew.includes(eigen)) ? eigen : (projGew[0] || eigen || Object.keys(DEPARTMENTS)[0]);
gewSelect.innerHTML = Object.keys(DEPARTMENTS).map(d =>
`<option value="${esc(d)}" ${d === vorwahl ? 'selected' : ''}>${esc(d)}${projGew.includes(d) ? '' : ' (nicht im Projekt)'}</option>`
).join('');
}
// Startdatum vorbelegen: heute // Startdatum vorbelegen: heute
const startInput = document.getElementById('templateStart'); const startInput = document.getElementById('templateStart');
const startDisplay = document.getElementById('templateStartDisplay'); const startDisplay = document.getElementById('templateStartDisplay');
@@ -62,6 +78,7 @@
if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today)); if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today));
} }
templatePhaseVorbelegen();
document.getElementById('lphTemplateModal').style.display = 'flex'; document.getElementById('lphTemplateModal').style.display = 'flex';
loadTemplates(); loadTemplates();
}; };
@@ -88,12 +105,88 @@
}); });
} }
// templatePhaseVorbelegen: Hat das Projekt einen Phasenbalken der gewählten
// Leistungsphase, übernehmen Startdatum und „Dauer der Phase“ seinen
// Zeitraum (in Arbeitstagen). Sonst bleibt das Startdatum, die Dauer ist
// von Hand anzugeben. Keine Parameter.
window.templatePhaseVorbelegen = function(){
const lph = (document.getElementById('templateLphSelect') || {}).value || '';
const todos = (app.ensurePlanLocal().todos || []);
const balken = todos.find(t => typeof balkenPhase === 'function' && balkenPhase(t) === lph && t.start && t.end);
const tageEl = document.getElementById('templatePhasenTage');
const hinweis = document.getElementById('templatePhasenHinweis');
if(balken){
const s = parseDateISO(balken.start), e = parseDateISO(balken.end);
const startInput = document.getElementById('templateStart');
const startDisplay = document.getElementById('templateStartDisplay');
if(startInput) startInput.value = balken.start;
if(startDisplay) startDisplay.value = formatDateDE(s);
if(tageEl) tageEl.value = Math.max(1, workdaysBetween(s, e));
if(hinweis) hinweis.textContent = 'aus dem Phasenbalken ' + lph + ' (' + formatDateDE(s) + ' – ' + formatDateDE(e) + ')';
} else {
if(tageEl) tageEl.value = '';
if(hinweis) hinweis.textContent = 'Kein Phasenbalken für ' + lph + ' — Dauer bitte angeben.';
}
};
// templatePlan: Termine der Schritte nach ihrem Anteil (Eriks Wunsch, z. B.
// Siemon-Tabelle): Jeder Schritt mit Anteil bekommt Anteil ÷ Summe aller
// Anteile der Phase an deren Dauer, nacheinander ab dem Startdatum in der
// gepflegten Reihenfolge (Arbeitstage, ohne Wochenenden/Feiertage). Die
// Grenzen werden über die laufende Summe gerundet, damit sich keine
// Rundungsfehler aufaddieren. Schritte ohne Anteil: Start/Dauer der
// Vorlage, ohne Dauer ohne Termin. Parameter: entries (alle Schritte der
// Phase), startDate, phasenTage. Rückgabe: Map id → {s, e} oder null.
function templatePlan(entries, startDate, phasenTage){
const out = new Map();
const summe = entries.reduce((a, t) => a + (Number(t.anteil) || 0), 0);
let kum = 0;
entries.forEach(t => {
const ant = Number(t.anteil) || 0;
if(ant > 0 && summe > 0){
if(!startDate || !(phasenTage > 0)){ out.set(t.id, null); return; }
const von = Math.round(phasenTage * kum / summe);
const bis = Math.round(phasenTage * (kum + ant) / summe);
kum += ant;
const s = addWorkdays(startDate, von);
const e = addWorkdays(s, Math.max(1, bis - von) - 1);
out.set(t.id, { s, e, anteil: ant / summe });
} else if(t.enthaltenIn){
out.set(t.id, null); // zweiter Durchgang unten
} else if(t.durationDays > 0 && startDate){
const s = addDays(startDate, t.offsetDays);
out.set(t.id, { s, e: addDays(s, t.durationDays - 1) });
} else {
out.set(t.id, null);
}
});
// „in a) enthalten“ (Siemon): zeitgleich mit den genannten Schritten —
// vom frühesten Beginn bis zum spätesten Ende. Der Buchstabe eines
// Schritts steht am Ende seines Titels („… (a)“).
const buchstabe = t => (String(t.title).match(/\(([a-z])\)\s*$/i) || [])[1];
entries.filter(t => t.enthaltenIn).forEach(t => {
const wirte = t.enthaltenIn.split(',')
.map(b => entries.find(x => !x.enthaltenIn && (buchstabe(x) || '').toLowerCase() === b))
.map(x => x && out.get(x.id)).filter(Boolean);
if(!wirte.length) return;
out.set(t.id, {
s: new Date(Math.min(...wirte.map(w => w.s.getTime()))),
e: new Date(Math.max(...wirte.map(w => w.e.getTime()))),
enthaltenIn: t.enthaltenIn
});
});
return out;
}
// templatesForSelectedLph: die Vorlagen-Schritte der gerade gewählten // templatesForSelectedLph: die Vorlagen-Schritte der gerade gewählten
// Leistungsphase, in ihrer gepflegten Reihenfolge. Rückgabe: Array. // Leistungsphase, in ihrer gepflegten Reihenfolge. Rückgabe: Array.
function templatesForSelectedLph(){ function templatesForSelectedLph(){
const lphSelect = document.getElementById('templateLphSelect'); const lphSelect = document.getElementById('templateLphSelect');
const gewSelect = document.getElementById('templateGewerkSelect');
const lph = lphSelect ? lphSelect.value : ''; const lph = lphSelect ? lphSelect.value : '';
return templateCache.filter(t => t.lph === lph); const g = gewSelect ? gewSelect.value : '';
// Vorlagen ohne Gewerk (Altbestand) gelten für alle Gewerke
return templateCache.filter(t => t.lph === lph && (!t.gewerk || t.gewerk === g));
} }
// renderTemplateList: baut die Auswahlliste der Schritte (Checkbox je // renderTemplateList: baut die Auswahlliste der Schritte (Checkbox je
@@ -107,18 +200,23 @@
const entries = templatesForSelectedLph(); const entries = templatesForSelectedLph();
const startIso = (document.getElementById('templateStart') || {}).value || ''; const startIso = (document.getElementById('templateStart') || {}).value || '';
const startDate = parseDateISO(startIso); const startDate = parseDateISO(startIso);
const phasenTage = parseInt((document.getElementById('templatePhasenTage') || {}).value, 10) || 0;
const plan = templatePlan(entries, startDate, phasenTage);
if(entries.length === 0){ if(entries.length === 0){
listEl.innerHTML = '<span class="hint">Für diese Leistungsphase ist noch keine Vorlage hinterlegt.</span>'; listEl.innerHTML = '<span class="hint">Für dieses Gewerk und diese Leistungsphase ist noch keine Vorlage hinterlegt.</span>';
} else { } else {
listEl.innerHTML = entries.map(t => { listEl.innerHTML = entries.map(t => {
// Vorschau der konkreten Termine (nur wenn ein Startdatum gesetzt ist) // Vorschau der konkreten Termine (nur wenn ein Startdatum gesetzt ist)
let dateHint = `Tag +${t.offsetDays}, ${t.durationDays} Tag${t.durationDays === 1 ? '' : 'e'}`; const termin = plan.get(t.id);
if(startDate){ let dateHint;
const s = addDays(startDate, t.offsetDays); if(termin) dateHint = formatDateDE(termin.s) + '–' + formatDateDE(termin.e)
const e = addDays(s, Math.max(0, t.durationDays - 1)); + (termin.anteil ? ' · ' + String(t.anteil).replace('.', ',') + ' %' : '')
dateHint = formatDateDE(s) + '–' + formatDateDE(e); + (termin.enthaltenIn ? ' · in ' + termin.enthaltenIn.split(',').map(x => x + ')').join(' + ') : '');
} else if(t.enthaltenIn) dateHint = 'in ' + t.enthaltenIn.split(',').map(x => x + ')').join(' + ') + ' — Dauer der Phase fehlt';
else if(Number(t.anteil) > 0) dateHint = String(t.anteil).replace('.', ',') + ' % — Dauer der Phase fehlt';
else if(t.durationDays > 0) dateHint = `Tag +${t.offsetDays}, ${t.durationDays} Tag${t.durationDays === 1 ? '' : 'e'}`;
else dateHint = 'ohne Termin';
return `<label class="todo-depends-item"> return `<label class="todo-depends-item">
<input type="checkbox" value="${esc(t.id)}" checked> <input type="checkbox" value="${esc(t.id)}" checked>
<span style="flex:1">${esc(t.title)}</span> <span style="flex:1">${esc(t.title)}</span>
@@ -131,13 +229,13 @@
}; };
// renderTemplateHint: blendet für Administratoren den Verweis auf den // renderTemplateHint: blendet für Administratoren den Verweis auf den
// Bereich „Einstellungen" ein. Das Anlegen und Löschen von Schritten stand // Admin-Bereich „Aufgaben-Vorlagen" ein. Das Anlegen und Löschen von Schritten stand
// bis zum Umbau hier im Dialog; es liegt jetzt in js/settings/settings.js. // bis zum Umbau hier im Dialog; es liegt jetzt in js/admin/admin.js.
// Dieser Dialog FÜGT Vorlagen nur noch ein. Keine Parameter, keine Rückgabe. // Dieser Dialog FÜGT Vorlagen nur noch ein. Keine Parameter, keine Rückgabe.
function renderTemplateHint(){ function renderTemplateHint(){
const el = document.getElementById('templateAdminHint'); const el = document.getElementById('templateAdminHint');
if(!el) return; if(!el) return;
el.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none'; el.style.display = hatRecht('a.vorlagen') ? '' : 'none';
} }
// applyTemplate: erzeugt aus den angehakten Vorlagen-Schritten neue TODOs im // applyTemplate: erzeugt aus den angehakten Vorlagen-Schritten neue TODOs im
@@ -149,7 +247,6 @@
if(app.isLocked()) return; if(app.isLocked()) return;
const startIso = (document.getElementById('templateStart') || {}).value || ''; const startIso = (document.getElementById('templateStart') || {}).value || '';
const startDate = parseDateISO(startIso); const startDate = parseDateISO(startIso);
if(!startDate){ alert('Bitte ein Startdatum wählen.'); return; }
const listEl = document.getElementById('templateTaskList'); const listEl = document.getElementById('templateTaskList');
const checkedIds = listEl const checkedIds = listEl
@@ -157,16 +254,26 @@
: []; : [];
if(checkedIds.length === 0){ alert('Bitte mindestens einen Schritt auswählen.'); return; } if(checkedIds.length === 0){ alert('Bitte mindestens einen Schritt auswählen.'); return; }
const entries = templatesForSelectedLph().filter(t => checkedIds.includes(t.id)); const alle = templatesForSelectedLph();
const entries = alle.filter(t => checkedIds.includes(t.id));
// Ein Startdatum braucht es nur für Schritte mit Anteil oder Dauer —
// ohne beides „ohne Termin“ (Aufgabe nur in der Liste, nicht im Gantt).
if(!startDate && entries.some(t => t.durationDays > 0 || Number(t.anteil) > 0 || t.enthaltenIn)){ alert('Bitte ein Startdatum wählen.'); return; }
const phasenTage = parseInt((document.getElementById('templatePhasenTage') || {}).value, 10) || 0;
if(!phasenTage && entries.some(t => Number(t.anteil) > 0 || t.enthaltenIn)){ alert('Bitte die Dauer der Phase in Arbeitstagen angeben — die Schritte bekommen ihren Anteil davon.'); return; }
// Anteile beziehen sich auf ALLE Schritte der Phase: Abgewählte lassen
// ihre Lücke, die übrigen behalten ihren Platz im Ablauf.
const termine = templatePlan(alle, startDate, phasenTage);
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
entries.forEach(t => { entries.forEach(t => {
const s = addDays(startDate, t.offsetDays); const termin = termine.get(t.id);
const e = addDays(s, Math.max(0, t.durationDays - 1)); const s = termin ? termin.s : null;
const e = termin ? termin.e : null;
plan.todos.push({ plan.todos.push({
id: uid(), id: uid(),
title: t.title, title: t.title,
start: formatDateISO(s), start: s ? formatDateISO(s) : '',
end: formatDateISO(e), end: e ? formatDateISO(e) : '',
done: false, done: false,
progress: 0, progress: 0,
dependsOn: [], dependsOn: [],
+4 -1
View File
@@ -106,7 +106,7 @@ function erstellePlanungTerminplan(){
id: uid(), projectUid: uid(), id: uid(), projectUid: uid(),
nr: '', name, group: '', gewerke: [], tags: [], nr: '', name, group: '', gewerke: [], tags: [],
status: '', order: plaene.length, lph: {}, assign: {}, plan: null, status: '', order: plaene.length, lph: {}, assign: {}, plan: null,
auftragsstatus: 'beauftragt', honorar: 0, groesse: 0, auftragsstatus: 'beauftragt', honorar: 0,
beauftragteLP: [], aktuelleLP: [], besprochen: false, beauftragteLP: [], aktuelleLP: [], besprochen: false,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
@@ -126,5 +126,8 @@ function erstellePlanungTerminplan(){
if(typeof showPlanSection === 'function') showPlanSection('einstellungen'); if(typeof showPlanSection === 'function') showPlanSection('einstellungen');
const honorarEl = document.getElementById('settingsHonorar'); const honorarEl = document.getElementById('settingsHonorar');
if(honorarEl) honorarEl.focus(); if(honorarEl) honorarEl.focus();
} else if(typeof showPlanSection === 'function'){
// "Leer beginnen" führt direkt in den (leeren) Terminplan.
showPlanSection('terminplan');
} }
} }
-1
View File
@@ -169,7 +169,6 @@ function renderProjects(){
${locked ? '' : `<input type="text" class="tag-input" placeholder="+ Tag" onkeydown="handleProjectTagKeydown(event,'${p.id}')">`} ${locked ? '' : `<input type="text" class="tag-input" placeholder="+ Tag" onkeydown="handleProjectTagKeydown(event,'${p.id}')">`}
</div> </div>
<div class="project-card-footer"> <div class="project-card-footer">
<button class="secondary small" onclick="openLpModal('${p.id}')" ${locked ? 'disabled' : ''}>LP-Aufwand</button>
<button class="danger small" onclick="delProjectFromList('${p.id}')" ${locked || !darfProjektlisteAendern() ? 'disabled' : ''}>Löschen</button> <button class="danger small" onclick="delProjectFromList('${p.id}')" ${locked || !darfProjektlisteAendern() ? 'disabled' : ''}>Löschen</button>
</div> </div>
</div>`; </div>`;
+35 -87
View File
@@ -9,7 +9,7 @@
// Die Schritte: // Die Schritte:
// //
// 1. PROJEKT Nummer, Bezeichnung, GEWERKE, Gruppe, Schlagwörter // 1. PROJEKT Nummer, Bezeichnung, GEWERKE, Gruppe, Schlagwörter
// 2. AUFTRAG Auftragsstatus, Honorar ODER Projektgröße, beauftragte // 2. AUFTRAG Auftragsstatus, Honorar (freiwillig), beauftragte
// Leistungsphasen // Leistungsphasen
// 3. STAND amtierende Leistungsphase // 3. STAND amtierende Leistungsphase
// 4. TEAM Beteiligte je Rolle — eingeschränkt auf die Gewerke des // 4. TEAM Beteiligte je Rolle — eingeschränkt auf die Gewerke des
@@ -20,10 +20,10 @@
// //
// Warum ein Assistent: Honorar, Team und Schlagwörter mussten zuvor NACH dem // Warum ein Assistent: Honorar, Team und Schlagwörter mussten zuvor NACH dem
// Anlegen an drei weiteren Stellen nachgetragen werden — und wurden es meist // Anlegen an drei weiteren Stellen nachgetragen werden — und wurden es meist
// nicht. Genau daher rühren die Projekte, die in der Auslastung als "nicht // nicht.
// bewertbar" erscheinen.
// //
// JEDER SCHRITT IST PFLICHT: "Weiter" bleibt gesperrt, solange etwas fehlt, // Pflichtangaben sperren "Weiter", solange etwas fehlt (das Honorar ist
// freiwillig, Eriks Vorgabe 10/2026),
// und unter dem Knopf steht, was fehlt. Ein Assistent, der unvollständige // und unter dem Knopf steht, was fehlt. Ein Assistent, der unvollständige
// Projekte durchlässt, löst das Problem nicht, das er lösen soll. // Projekte durchlässt, löst das Problem nicht, das er lösen soll.
// //
@@ -64,7 +64,7 @@ function openNewProjWizard(){
schritt: 0, schritt: 0,
fertig: null, // angelegtes Projekt, solange der Abschluss steht fertig: null, // angelegtes Projekt, solange der Abschluss steht
nr: '', name: '', gewerke: [], gruppe: 'Aktive Projekte', tags: [], nr: '', name: '', gewerke: [], gruppe: 'Aktive Projekte', tags: [],
status: AUFTRAGSSTATUS_VORGABE, grundlage: 'honorar', honorar: '', groesse: '', status: AUFTRAGSSTATUS_VORGABE, honorar: '',
beauftragt: [], amtierend: '', beauftragt: [], amtierend: '',
team: {}, fremde: [], team: {}, fremde: [],
planErzeugen: true, planStart: heuteIso(), planPersonen: '', planErzeugen: true, planStart: heuteIso(), planPersonen: '',
@@ -133,25 +133,13 @@ function wizardHonorar(){
const n = parseFloat(String(wizard.honorar).replace(',', '.')); const n = parseFloat(String(wizard.honorar).replace(',', '.'));
return (!Number.isFinite(n) || n <= 0) ? 0 : n; return (!Number.isFinite(n) || n <= 0) ? 0 : n;
} }
function wizardGroesse(){ // wizardBasis: der Geldbetrag für den Terminplan — allein das Honorar.
const n = parseFloat(String(wizard.groesse).replace(',', '.')); // Es ist beim Anlegen freiwillig (Eriks Vorgabe); ohne Honorar entsteht
if(!Number.isFinite(n) || n <= 0) return 0; // kein Terminplan, er lässt sich später im Projektbereich erzeugen.
return Math.min(1, n);
}
// wizardBasis: der Geldbetrag, mit dem gerechnet wird. Bei der Projektgröße
// über das firmenweite Referenzhonorar — der Betrag wird bewusst NICHT
// angezeigt: Die Größe ist eine Schätzung, und eine daneben stehende
// Eurosumme gäbe ihr eine Genauigkeit, die sie nicht hat.
function wizardBasis(){ function wizardBasis(){
if(wizard.grundlage === 'honorar'){
const h = wizardHonorar(); const h = wizardHonorar();
return h > 0 ? { betrag: h, quelle: 'Honorar' } : { betrag: 0, quelle: null }; return h > 0 ? { betrag: h, quelle: 'Honorar' } : { betrag: 0, quelle: null };
} }
const g = wizardGroesse();
const referenz = Number(APP_SETTINGS.referenzHonorar) || 250000;
return g > 0 ? { betrag: g * referenz, quelle: 'Projektgröße' } : { betrag: 0, quelle: null };
}
// wizardPlanPersonen: wie viele gleichzeitig an einer Phase arbeiten. // wizardPlanPersonen: wie viele gleichzeitig an einer Phase arbeiten.
// Vorgabe ist die Größe des Teams; überschreibbar, weil selten alle // Vorgabe ist die Größe des Teams; überschreibbar, weil selten alle
@@ -168,7 +156,7 @@ function wizardPlanPersonen(){
// aus dem Honorar gerechnet werden kann). // aus dem Honorar gerechnet werden kann).
function wizardPlanZeilen(){ function wizardPlanZeilen(){
const b = wizardBasis(); const b = wizardBasis();
if(wizard.grundlage !== 'honorar' || b.betrag <= 0) return []; if(b.betrag <= 0) return [];
const phasen = wizard.beauftragt.length ? wizard.beauftragt : AUFWAND_LPS; const phasen = wizard.beauftragt.length ? wizard.beauftragt : AUFWAND_LPS;
const summe = phasen.reduce((s, l) => s + (Number(HONORAR_ANTEILE[l]) || 0), 0); const summe = phasen.reduce((s, l) => s + (Number(HONORAR_ANTEILE[l]) || 0), 0);
if(summe <= 0) return []; if(summe <= 0) return [];
@@ -202,9 +190,8 @@ function wizardPlanZeilen(){
// Klartexten; leer = der Schritt ist vollständig. // Klartexten; leer = der Schritt ist vollständig.
// //
// Der Assistent lässt niemanden weiter, solange hier etwas steht: Genau die // Der Assistent lässt niemanden weiter, solange hier etwas steht: Genau die
// Lücken, die sonst entstehen (kein Honorar, keine Phase, niemand // Lücken, die sonst entstehen (keine Phase, niemand zugeordnet), fielen
// zugeordnet), sind der Grund, warum Projekte später in der Auslastung nicht // später im Wochenbericht und im Projektbereich auf.
// bewertbar sind.
function wizardFehlt(key){ function wizardFehlt(key){
const fehlt = []; const fehlt = [];
if(key === 'projekt'){ if(key === 'projekt'){
@@ -213,8 +200,6 @@ function wizardFehlt(key){
if(!wizard.gewerke.length) fehlt.push('mindestens ein Gewerk'); if(!wizard.gewerke.length) fehlt.push('mindestens ein Gewerk');
} }
if(key === 'auftrag'){ if(key === 'auftrag'){
if(wizard.grundlage === 'honorar' && wizardHonorar() <= 0) fehlt.push('Honorar');
if(wizard.grundlage === 'groesse' && wizardGroesse() <= 0) fehlt.push('Projektgröße');
if(!wizard.beauftragt.length) fehlt.push('mindestens eine beauftragte Leistungsphase'); if(!wizard.beauftragt.length) fehlt.push('mindestens eine beauftragte Leistungsphase');
} }
if(key === 'stand'){ if(key === 'stand'){
@@ -224,7 +209,7 @@ function wizardFehlt(key){
if(wizardTeamAnzahl() === 0) fehlt.push('mindestens eine beteiligte Person'); if(wizardTeamAnzahl() === 0) fehlt.push('mindestens eine beteiligte Person');
} }
if(key === 'terminplan'){ if(key === 'terminplan'){
if(wizard.grundlage === 'honorar' && wizard.planErzeugen && !wizard.planStart) fehlt.push('Beginn'); if(wizardHonorar() > 0 && wizard.planErzeugen && !wizard.planStart) fehlt.push('Beginn');
} }
return fehlt; return fehlt;
} }
@@ -317,36 +302,20 @@ function wizardProjektHtml(){
} }
function wizardAuftragHtml(){ function wizardAuftragHtml(){
const perHonorar = wizard.grundlage === 'honorar';
return ` return `
<div class="form-row"> <div class="form-row">
<label>Auftragsstatus</label> <label>Auftragsstatus</label>
<select onchange="wizardSetz('status', this.value)"> <select onchange="wizardSetz('status', this.value)">
${auftragsstatusOptionsHtml(wizard.status)} ${auftragsstatusOptionsHtml(wizard.status)}
</select> </select>
<span class="hint">Anfrage und Angebot wandern in die Gruppe „In Anbahnung" und zählen nicht zur Auslastung.</span> <span class="hint">Anfrage und Angebot wandern in die Gruppe „In Anbahnung".</span>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Grundlage der Rechnung</label> <label>Honorar (netto, €)</label>
<div class="wiz-chips">
<button type="button" class="wiz-chip" aria-pressed="${perHonorar}" onclick="wizardSetz('grundlage','honorar')">Vereinbartes Honorar</button>
<button type="button" class="wiz-chip" aria-pressed="${!perHonorar}" onclick="wizardSetz('grundlage','groesse')">Geschätzte Projektgröße</button>
</div>
</div>
<div class="wiz-zeile">
<div class="form-row">
<label>Honorar (netto, €) ${perHonorar ? '<span class="wiz-pflicht">*</span>' : ''}</label>
<input type="number" id="wizHonorar" min="0" step="1000" value="${esc(wizard.honorar)}" placeholder="z. B. 480000" <input type="number" id="wizHonorar" min="0" step="1000" value="${esc(wizard.honorar)}" placeholder="z. B. 480000"
${perHonorar ? `oninput="wizardSetz('honorar', this.value)"` : 'disabled'}> oninput="wizardSetz('honorar', this.value)">
<span class="hint" id="wizHinweisHonorar">${wizardHinweisHonorar()}</span> <span class="hint" id="wizHinweisHonorar">${wizardHinweisHonorar()}</span>
</div> </div>
<div class="form-row">
<label>Projektgröße (0,00 – 1,00) ${!perHonorar ? '<span class="wiz-pflicht">*</span>' : ''}</label>
<input type="number" id="wizGroesse" min="0" max="1" step="0.05" value="${esc(wizard.groesse)}" placeholder="z. B. 0,40"
${!perHonorar ? `oninput="wizardSetz('groesse', this.value)"` : 'disabled'}>
<span class="hint" id="wizHinweisGroesse">${wizardHinweisGroesse()}</span>
</div>
</div>
<div class="form-row"> <div class="form-row">
<label>Beauftragte Leistungsphasen <span class="wiz-pflicht">*</span></label> <label>Beauftragte Leistungsphasen <span class="wiz-pflicht">*</span></label>
<div class="wiz-chips"> <div class="wiz-chips">
@@ -358,18 +327,13 @@ function wizardAuftragHtml(){
</div>`; </div>`;
} }
// Die beiden Hinweistexte stehen als eigene Funktionen da, weil sie beim // Der Hinweistext steht als eigene Funktion da, weil er beim Tippen
// Tippen mitlaufen müssen, ohne dass das Eingabefeld neu gebaut wird — sonst // mitläuft, ohne dass das Eingabefeld neu gebaut wird — sonst springt der
// springt der Cursor ans Feldende. // Cursor ans Feldende.
function wizardHinweisHonorar(){ function wizardHinweisHonorar(){
return wizard.grundlage === 'honorar' return wizardHonorar() > 0
? 'Grundlage für Leistungsstand, Auslastung und Terminplan.' ? 'Grundlage für Leistungsstand und Terminplan.'
: 'Gesperrt — es wird mit der Projektgröße gerechnet.'; : 'Freiwillig. Ohne Honorar entsteht kein Terminplan; es lässt sich später in den Projekteinstellungen nachtragen.';
}
function wizardHinweisGroesse(){
return wizard.grundlage === 'groesse'
? 'Grober Umfang des Projekts. 0 = keine Angabe, 1 = großes Projekt.'
: 'Gesperrt — es wird mit dem Honorar gerechnet.';
} }
function wizardStandHtml(){ function wizardStandHtml(){
@@ -382,8 +346,7 @@ function wizardStandHtml(){
<button type="button" class="wiz-chip" aria-pressed="${wizard.amtierend === l}" <button type="button" class="wiz-chip" aria-pressed="${wizard.amtierend === l}"
onclick="wizardSetz('amtierend','${esc(l)}')">${esc(l)}</button>`).join('')} onclick="wizardSetz('amtierend','${esc(l)}')">${esc(l)}</button>`).join('')}
</div> </div>
<span class="hint">Woran gerade gearbeitet wird. Ohne diese Angabe erschiene das Projekt <span class="hint">Woran gerade gearbeitet wird.</span>
in der Auslastung als nicht bewertbar.</span>
</div>`; </div>`;
} }
@@ -443,14 +406,11 @@ function wizardTeamHtml(){
} }
function wizardTerminplanHtml(){ function wizardTerminplanHtml(){
// Aus der Projektgröße lässt sich kein Terminplan rechnen: Sie ist eine // Ohne Honorar kein Terminplan — er lässt sich später im Projektbereich
// Schätzung des Umfangs, kein vereinbarter Betrag. Termine, die daraus // erzeugen („Terminplan aus Honorar").
// entstünden, sähen im Gantt so verbindlich aus wie errechnete. if(wizardHonorar() <= 0){
if(wizard.grundlage !== 'honorar'){
return `<div class="wiz-sperre"> return `<div class="wiz-sperre">
<b>Kein Terminplan aus der Projektgröße.</b> <b>Kein Terminplan ohne Honorar.</b>
<p>Die Größe ist eine Schätzung des Umfangs, kein vereinbarter Betrag. Termine, die daraus
entstünden, sähen im Terminplan so verbindlich aus wie errechnete — ohne es zu sein.</p>
<p>Sobald das Honorar feststeht, lässt sich der Terminplan im Projektbereich jederzeit <p>Sobald das Honorar feststeht, lässt sich der Terminplan im Projektbereich jederzeit
nachträglich erzeugen („Terminplan aus Honorar").</p> nachträglich erzeugen („Terminplan aus Honorar").</p>
</div>`; </div>`;
@@ -537,9 +497,7 @@ function wizardPruefenHtml(){
${zeile('Gruppe', esc(wizard.gruppe))} ${zeile('Gruppe', esc(wizard.gruppe))}
${zeile('Schlagwörter', wizard.tags.length ? esc(wizard.tags.join(', ')) : 'keine', !wizard.tags.length)} ${zeile('Schlagwörter', wizard.tags.length ? esc(wizard.tags.join(', ')) : 'keine', !wizard.tags.length)}
${zeile('Auftragsstatus', esc(auftragsstatusLabel(wizard.status)))} ${zeile('Auftragsstatus', esc(auftragsstatusLabel(wizard.status)))}
${zeile('Grundlage', wizard.grundlage === 'honorar' ${zeile('Honorar', wizardHonorar() > 0 ? eurFormat(wizardHonorar()) : 'nicht angegeben', !(wizardHonorar() > 0))}
? eurFormat(wizardHonorar()) + ' Honorar'
: 'Projektgröße ' + faktorFormat(wizardGroesse()))}
${zeile('Beauftragte Phasen', esc(wizard.beauftragt.join(', ')))} ${zeile('Beauftragte Phasen', esc(wizard.beauftragt.join(', ')))}
${zeile('Amtierende Phase', esc(wizard.amtierend))} ${zeile('Amtierende Phase', esc(wizard.amtierend))}
${zeile('Team', esc(team))} ${zeile('Team', esc(team))}
@@ -547,14 +505,12 @@ function wizardPruefenHtml(){
const p = wizardPersonen().find(x => x.id === id); const p = wizardPersonen().find(x => x.id === id);
return p ? p.kuerzel + (p.gewerk ? ' (' + p.gewerk + ')' : '') : id; return p ? p.kuerzel + (p.gewerk ? ' (' + p.gewerk + ')' : '') : id;
}).join(', '))) : ''} }).join(', '))) : ''}
${zeile('Terminplan', wizard.grundlage !== 'honorar' ${zeile('Terminplan', !(wizardHonorar() > 0)
? 'nicht möglich — Grundlage ist die Projektgröße' ? 'nicht möglich — kein Honorar angegeben'
: (!wizard.planErzeugen ? 'wird nicht erzeugt' : (!wizard.planErzeugen ? 'wird nicht erzeugt'
: (plan.length ? plan.length + ' Phasen bis ' + formatWeekDate(isoVon(plan[plan.length - 1].ende)) : 'nicht errechenbar')), : (plan.length ? plan.length + ' Phasen bis ' + formatWeekDate(isoVon(plan[plan.length - 1].ende)) : 'nicht errechenbar')),
wizard.grundlage !== 'honorar' || !wizard.planErzeugen || !plan.length)} !(wizardHonorar() > 0) || !wizard.planErzeugen || !plan.length)}
</div> </div>`;
<p class="hint" style="margin-top:14px">Die Gewichtung des Aufwands je Phase und Rolle übernimmt die
firmenweite Vorlage; sie lässt sich später im Projektbereich anpassen.</p>`;
} }
function wizardAbschlussHtml(){ function wizardAbschlussHtml(){
@@ -585,13 +541,8 @@ function wizardSetz(feld, wert){
if(wert === 'anfrage' || wert === 'angebot') wizard.gruppe = 'In Anbahnung'; if(wert === 'anfrage' || wert === 'angebot') wizard.gruppe = 'In Anbahnung';
else if(wizard.gruppe === 'In Anbahnung') wizard.gruppe = 'Aktive Projekte'; else if(wizard.gruppe === 'In Anbahnung') wizard.gruppe = 'Aktive Projekte';
} }
if(feld === 'grundlage'){
// Das nun gesperrte Feld leeren: Ein Wert, der dasteht und nicht mehr
// zählt, führt in die Irre.
if(wert === 'honorar') wizard.groesse = ''; else wizard.honorar = '';
}
// Freitextfelder nicht neu bauen — sonst springt der Cursor ans Feldende. // Freitextfelder nicht neu bauen — sonst springt der Cursor ans Feldende.
if(feld === 'nr' || feld === 'name' || feld === 'honorar' || feld === 'groesse' || feld === 'planPersonen'){ if(feld === 'nr' || feld === 'name' || feld === 'honorar' || feld === 'planPersonen'){
wizardNurAbhaengiges(); wizardNurAbhaengiges();
return; return;
} }
@@ -624,8 +575,6 @@ function wizardNurAbhaengiges(){
const hh = document.getElementById('wizHinweisHonorar'); const hh = document.getElementById('wizHinweisHonorar');
if(hh) hh.textContent = wizardHinweisHonorar(); if(hh) hh.textContent = wizardHinweisHonorar();
const hg = document.getElementById('wizHinweisGroesse');
if(hg) hg.textContent = wizardHinweisGroesse();
const tab = document.getElementById('wizPlanTabelle'); const tab = document.getElementById('wizPlanTabelle');
if(tab) tab.innerHTML = wizardPlanTabelleHtml(); if(tab) tab.innerHTML = wizardPlanTabelleHtml();
@@ -725,8 +674,7 @@ function wizardAnlegen(){
status: '', statusState: undefined, order: nextOrder, status: '', statusState: undefined, order: nextOrder,
lph: {}, assign: {}, plan: null, lph: {}, assign: {}, plan: null,
auftragsstatus: auftragsstatusOf({ auftragsstatus: wizard.status }), auftragsstatus: auftragsstatusOf({ auftragsstatus: wizard.status }),
honorar: wizard.grundlage === 'honorar' ? wizardHonorar() : 0, honorar: wizardHonorar(),
groesse: wizard.grundlage === 'groesse' ? wizardGroesse() : 0,
beauftragteLP: wizard.beauftragt.slice(), beauftragteLP: wizard.beauftragt.slice(),
aktuelleLP: [wizard.amtierend], aktuelleLP: [wizard.amtierend],
}; };
@@ -744,13 +692,13 @@ function wizardAnlegen(){
todos: zeilen.map((z, i) => ({ todos: zeilen.map((z, i) => ({
id: uid(), id: uid(),
title: z.lph, title: z.lph,
lph: z.lph,
start: isoVon(z.start), start: isoVon(z.start),
end: isoVon(z.ende), end: isoVon(z.ende),
done: false, done: false,
progress: 0, progress: 0,
assignees: [], assignees: [],
deps: [], dependsOn: [],
order: i,
})), })),
milestones: [], milestones: [],
}; };
+28 -1
View File
@@ -31,11 +31,16 @@ function baseUrl(){
// lokalen Daten beruhen; meldet der Server 409 (conflict), wird ein Error // lokalen Daten beruhen; meldet der Server 409 (conflict), wird ein Error
// mit `err.conflict = true` und `err.updatedAt` geworfen, damit der Aufrufer // mit `err.conflict = true` und `err.updatedAt` geworfen, damit der Aufrufer
// den Konflikt anzeigen kann. // den Konflikt anzeigen kann.
// CLIENT_ID: zufällige Kennung dieses Browser-Tabs. Geht bei jedem Speichern
// als X-Client-Id mit und bei GET /ereignisse als ?client= — so bekommt der
// Tab seine eigenen Änderungen nicht als "fremdes" Live-Ereignis zurück.
const CLIENT_ID = Math.random().toString(36).slice(2) + Date.now().toString(36);
function sendWeekToServer(week){ function sendWeekToServer(week){
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar')); if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
return fetch(baseUrl() + '/save-week',{ return fetch(baseUrl() + '/save-week',{
method:'POST', method:'POST',
headers:{'Content-Type':'application/json; charset=utf-8'}, headers:{'Content-Type':'application/json; charset=utf-8', 'X-Client-Id': CLIENT_ID},
body:JSON.stringify({ body:JSON.stringify({
week, week,
baseUpdatedAt:(week && week.updatedAt) ? week.updatedAt : undefined baseUpdatedAt:(week && week.updatedAt) ? week.updatedAt : undefined
@@ -58,6 +63,28 @@ function sendWeekToServer(week){
}); });
} }
// sendProjectsToServer: speichert nur einzelne Projekte einer Sitzung
// (POST /save-projects, siehe js/store.js syncAllToServer()). Parameter:
// label, projekte (Array von Projekt-Objekten; ihr updatedAt ist die Basis
// für die Konfliktprüfung je Projekt). Rückgabe: Promise mit
// {ok, updatedAt, projekte:{id: updatedAt}}; bei 409 ein Error mit
// conflict=true, wie sendWeekToServer().
function sendProjectsToServer(label, projekte){
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
return fetch(baseUrl() + '/save-projects', {
method: 'POST',
headers: {'Content-Type':'application/json; charset=utf-8', 'X-Client-Id': CLIENT_ID},
body: JSON.stringify({ label, projects: projekte.map(p => ({ project: p, baseUpdatedAt: p.updatedAt || '' })) })
}).then(r => r.json().catch(() => null).then(json => {
if(r.ok && json) return json;
const err = new Error((json && json.error) || ('Server HTTP ' + r.status));
if(json && json.conflict){ err.conflict = true; err.projekte = json.projekte || []; }
err.status = r.status;
err.label = label;
throw err;
}));
}
// deleteWeekFromServer: fordert das Löschen einer Woche über POST /delete-week an // deleteWeekFromServer: fordert das Löschen einer Woche über POST /delete-week an
// Parameter: label (String, das Wochen-Label). Rückgabe: Promise mit Server-Antwort. // Parameter: label (String, das Wochen-Label). Rückgabe: Promise mit Server-Antwort.
// Bei einer Fehler-Antwort (z. B. 403 „Keine Berechtigung“) trägt der geworfene // Bei einer Fehler-Antwort (z. B. 403 „Keine Berechtigung“) trägt der geworfene
+14 -1
View File
@@ -52,7 +52,12 @@ function renderSettingsAccount(){
<span id="settingsEmailError" class="settings-warn" hidden></span> <span id="settingsEmailError" class="settings-warn" hidden></span>
</span> </span>
</div>` </div>`
+ row('Rolle', currentUser.role === 'admin' ? 'Administrator' : 'Mitglied') + row('Rolle', currentUser.rolleName || currentUser.role)
// Glas-Darstellung (public/glas.css) je Browser abschaltbar — etwa auf
// einem langsamen Rechner, dem die Unschärfe zu viel Leistung kostet.
+ row('Darstellung', `<label class="settings-glas-schalter"><input type="checkbox" id="settingsGlas"
${document.documentElement.classList.contains('glas') ? 'checked' : ''} onchange="setGlasDarstellung(this.checked)">
Glas-Darstellung (Transparenz und Unschärfe)</label>`)
// Ohne Personen-Zuordnung bleibt „Meine Aufgaben" leer, weil die Aufgaben // Ohne Personen-Zuordnung bleibt „Meine Aufgaben" leer, weil die Aufgaben
// über plan_todo_assignees.person_id zugeordnet werden (siehe // über plan_todo_assignees.person_id zugeordnet werden (siehe
// GET /my-dashboard). Deshalb steht der Hinweis hier und nicht nur dort. // GET /my-dashboard). Deshalb steht der Hinweis hier und nicht nur dort.
@@ -64,6 +69,14 @@ function renderSettingsAccount(){
: ''); : '');
} }
// setGlasDarstellung: schaltet die Glas-Darstellung in diesem Browser ein
// oder aus (Klasse html.glas, gemerkt in localStorage). Wirkt sofort, ohne
// Neuladen. Parameter: an (Boolean). Keine Rückgabe.
function setGlasDarstellung(an){
document.documentElement.classList.toggle('glas', !!an);
try { localStorage.setItem('projuebersicht-glas', an ? 'an' : 'aus'); } catch(e){ /* nur Komfort */ }
}
// submitEmailChange: schickt die eigene E-Mail-Adresse an POST /change-email. // submitEmailChange: schickt die eigene E-Mail-Adresse an POST /change-email.
// Anders als der Passwortwechsel braucht es kein aktuelles Passwort zur // Anders als der Passwortwechsel braucht es kein aktuelles Passwort zur
// Bestätigung — die Route ist ohnehin an die eigene, per Session // Bestätigung — die Route ist ohnehin an die eigene, per Session
+501 -21
View File
@@ -112,6 +112,43 @@ function syncAllToServer(){
// weekChangeCounter): Nur wenn er bei der Antwort unverändert ist, sind // weekChangeCounter): Nur wenn er bei der Antwort unverändert ist, sind
// wirklich alle Änderungen dieser Woche beim Server angekommen. // wirklich alle Änderungen dieser Woche beim Server angekommen.
const sentGeneration = weekChangeCounter.get(label) || 0; const sentGeneration = weekChangeCounter.get(label) || 0;
// Nur die geänderten Projekte schicken, wenn sich an der Sitzung selbst
// nichts geändert hat (geaenderteProjekte(), siehe unten). Sonst — oder
// ohne bekannten Server-Stand — wie bisher die ganze Sitzung.
const delta = geaenderteProjekte(wochenBasis.get(label), w);
if(delta && delta.length === 0){
// Nichts zu senden (z. B. eine Änderung, die wieder zurückgenommen wurde).
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
return;
}
if(delta){
const gesendeteProjekte = delta.map(p => JSON.parse(JSON.stringify(p)));
jobs.push(sendProjectsToServer(label, gesendeteProjekte)
.then(res => {
console.log('Projekte gespeichert:', label, gesendeteProjekte.length);
const basis = wochenBasis.get(label);
gesendeteProjekte.forEach(gp => {
const rev = (res.projekte || {})[gp.id] || res.updatedAt;
gp.updatedAt = rev;
const lokal = (w.projects || []).find(x => x.id === gp.id);
if(lokal) lokal.updatedAt = rev;
// Basis nur für die gesendeten Projekte nachziehen — fremde
// Änderungen, die während des Speicherns eintrafen, stehen dort schon.
if(basis){
const i = basis.projects.findIndex(x => x.id === gp.id);
if(i >= 0) basis.projects[i] = gp;
}
});
revisionUebernehmen(w, res.updatedAt);
konfliktVersuche.delete(label);
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
})
.catch(err => { problem = speicherFehler(label, w, err) || problem; }));
return;
}
// Genau dieser Stand geht an den Server — nach Erfolg ist er die neue
// Basis für ein späteres Zusammenführen (wochenBasis).
const gesendet = wocheKopie(w);
jobs.push(sendWeekToServer(w) jobs.push(sendWeekToServer(w)
.then(res => { .then(res => {
if(res && res.ok){ if(res && res.ok){
@@ -119,28 +156,21 @@ function syncAllToServer(){
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves // Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
// nicht fälschlich als Konflikt gemeldet werden. // nicht fälschlich als Konflikt gemeldet werden.
if(res.updatedAt) w.updatedAt = res.updatedAt; if(res.updatedAt) w.updatedAt = res.updatedAt;
gesendet.updatedAt = w.updatedAt;
// Das ganze Speichern setzt am Server JEDES Projekt der Sitzung auf
// diese Revision (projects.updated_at) — hier nachziehen, sonst
// scheiterte das nächste Speichern einzelner Projekte als Konflikt.
(w.projects || []).forEach(x => { x.updatedAt = w.updatedAt; });
(gesendet.projects || []).forEach(x => { x.updatedAt = w.updatedAt; });
wochenBasis.set(label, gesendet);
konfliktVersuche.delete(label);
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label); if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
} else { } else {
problem = 'Sitzung "' + w.label + '" konnte nicht gespeichert werden.'; problem = 'Sitzung "' + w.label + '" konnte nicht gespeichert werden.';
console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler'); console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler');
} }
}) })
.catch(err => { .catch(err => { problem = speicherFehler(label, w, err) || problem; }));
if(err && err.conflict){
dirtyWeeks.delete(label);
handleWeekConflict(w, err);
} else if(err && err.status === 401){
// Sitzung am Server abgelaufen: Anmeldefenster zeigen, statt still
// weiterzuarbeiten und alles Weitere zu verlieren.
problem = 'Nicht mehr angemeldet — bitte neu anmelden, sonst gehen Änderungen verloren.';
if(typeof showLoginOverlay === 'function') showLoginOverlay();
console.error('Speichern abgelehnt (nicht angemeldet):', w.label);
} else {
problem = 'Speichern fehlgeschlagen (' + (err && err.message ? err.message : 'unbekannt') + ') — neuer Versuch in 20 Sekunden.';
console.error('Sitzung konnte nicht gespeichert werden:', w.label, (err && err.message) || err);
// Label bleibt dirty -> der nächste Sync versucht es erneut.
}
}));
}); });
Promise.all(jobs).then(() => { Promise.all(jobs).then(() => {
@@ -151,6 +181,58 @@ function syncAllToServer(){
}); });
} }
// speicherFehler: gemeinsame Fehlerbehandlung beider Speicherwege. Rückgabe:
// Text für das Fehlerband oder '' (Konflikt: wird zusammengeführt).
function speicherFehler(label, w, err){
if(err && err.conflict){
// Jemand anderes hat die Sitzung bzw. das Projekt inzwischen gespeichert:
// nicht verwerfen, sondern zusammenführen und erneut speichern.
zusammenfuehrenUndSpeichern(label, w, err);
return '';
}
if(err && err.status === 403){
// Fehlendes Recht (Rollen & Rechte): Ein erneuter Versuch hilft nicht.
// Änderung verwerfen, Hinweis zeigen und den Stand vom Server holen.
dirtyWeeks.delete(label);
zeigeHinweis((err.message || 'Keine Berechtigung.') + ' Die Änderung wurde nicht gespeichert.');
reloadWeekFromServer(label);
return '';
}
if(err && err.status === 401){
// Sitzung am Server abgelaufen: Anmeldefenster zeigen, statt still
// weiterzuarbeiten und alles Weitere zu verlieren.
if(typeof showLoginOverlay === 'function') showLoginOverlay();
console.error('Speichern abgelehnt (nicht angemeldet):', label);
return 'Nicht mehr angemeldet — bitte neu anmelden, sonst gehen Änderungen verloren.';
}
console.error('Sitzung konnte nicht gespeichert werden:', label, (err && err.message) || err);
// Label bleibt dirty -> der nächste Sync versucht es erneut.
return 'Speichern fehlgeschlagen (' + (err && err.message ? err.message : 'unbekannt') + ') — neuer Versuch in 20 Sekunden.';
}
// geaenderteProjekte: vergleicht eine Sitzung mit ihrem zuletzt bekannten
// Server-Stand. Rückgabe: die geänderten Projekte (Array, evtl. leer), wenn
// sich NUR Projektinhalte geändert haben; null, wenn die ganze Sitzung
// gespeichert werden muss — Teilnehmer, Anwesenheit, Sperre, Datum, ein
// angelegtes, gelöschtes oder umsortiertes Projekt, oder kein Server-Stand.
function geaenderteProjekte(basis, w){
if(!basis || !w) return null;
const rahmen = x => { const k = Object.assign({}, x); delete k.projects; delete k.updatedAt; return k; };
if(!gleich(rahmen(basis), rahmen(w))) return null;
const bp = basis.projects || [], wp = w.projects || [];
if(bp.length !== wp.length || bp.some((p, i) => p.id !== wp[i].id)) return null;
return wp.filter((p, i) => !gleich(p, bp[i]));
}
// revisionUebernehmen: setzt die Revision einer Sitzung (lokal und Basis),
// aber nie zurück — Antworten und Ereignisse können sich überholen.
function revisionUebernehmen(w, rev){
if(!w || !rev) return;
if(!w.updatedAt || rev > w.updatedAt) w.updatedAt = rev;
const basis = wochenBasis.get(w.label);
if(basis && (!basis.updatedAt || rev > basis.updatedAt)) basis.updatedAt = rev;
}
// markWeekDirty: markiert eine bestimmte Woche (label) als geändert – für // markWeekDirty: markiert eine bestimmte Woche (label) als geändert – für
// Aktionen, die Wochen AUFßER der aktuellen betreffen (z. B. Auto-Lock). // Aktionen, die Wochen AUFßER der aktuellen betreffen (z. B. Auto-Lock).
function markWeekDirty(label){ function markWeekDirty(label){
@@ -159,6 +241,400 @@ function markWeekDirty(label){
weekChangeCounter.set(label, (weekChangeCounter.get(label) || 0) + 1); weekChangeCounter.set(label, (weekChangeCounter.get(label) || 0) + 1);
} }
// ===========================================================================
// Gleichzeitiges Arbeiten: Zusammenführen statt Verwerfen
// ---------------------------------------------------------------------------
// Eine Sitzung wird immer als Ganzes gespeichert (POST /save-week mit
// Revisionsstand). Hat jemand anderes sie inzwischen gespeichert, antwortet
// der Server mit 409. Früher verwarf der Browser daraufhin die eigenen
// Änderungen mit einem alert() — bei zwei Personen in derselben Sitzung
// passierte das ständig, auch wenn sie an verschiedenen Projekten arbeiteten.
//
// Jetzt merkt sich der Browser je Sitzung den Stand, den er zuletzt vom
// Server kennt (wochenBasis). Bei einem 409 holt er den aktuellen Stand und
// führt drei Fassungen zusammen (dreiwegeZusammenfuehren(): Basis, eigene,
// fremde): Was nur eine Seite geändert hat, wird übernommen — bis hinunter zu
// einzelnen Feldern, Projekten, Aufgaben/Meilensteinen (über ihre id),
// Kommentaren je Gewerk und der Anwesenheit je Person. Nur wenn beide
// dasselbe Feld unterschiedlich geändert haben, gilt die eigene (jüngste)
// Fassung, und ein kurzer Hinweis nennt das Projekt. Danach wird erneut
// gespeichert. Das Ergebnis wird IN die vorhandenen Objekte geschrieben
// (inPlaceUebernehmen()), damit z. B. der Projektbereich, der eine Referenz
// auf sein Projekt hält (window.PlanApp), weiter auf dasselbe Objekt zeigt.
//
// Zusätzlich gleicht wochenLiveAbgleich() alle 20 Sekunden ab: Hat sich eine
// Sitzung am Server geändert und gibt es hier nichts Ungespeichertes, werden
// die fremden Änderungen übernommen — außer während jemand gerade tippt oder
// ein Dialog offen ist (dann beim nächsten Durchlauf).
// ===========================================================================
const wochenBasis = new Map(); // label -> zuletzt bekannter Server-Stand
const konfliktVersuche = new Map(); // label -> Zahl der Zusammenführungen in Folge
function wocheKopie(w){ return JSON.parse(JSON.stringify(w)); }
function basisMerken(w){ if(w && w.label) wochenBasis.set(w.label, wocheKopie(w)); }
function istObjekt(x){ return !!x && typeof x === 'object' && !Array.isArray(x); }
function istIdListe(a){ return Array.isArray(a) && a.every(x => istObjekt(x) && x.id !== undefined && x.id !== null && x.id !== ''); }
// gleich: inhaltlicher Vergleich, unabhängig von der Reihenfolge der Felder
// (inPlaceUebernehmen() lässt vorhandene Felder an ihrer Stelle und hängt
// neue an — JSON.stringify allein hielte dann Gleiches für verschieden, und
// zwei Browser schickten sich über die Live-Ereignisse endlos dieselben
// Projekte zu). undefined-Felder zählen wie fehlende.
function stabilerText(x){
if(Array.isArray(x)) return '[' + x.map(v => v === undefined ? 'null' : stabilerText(v)).join(',') + ']';
if(x && typeof x === 'object'){
return '{' + Object.keys(x).filter(k => x[k] !== undefined).sort()
.map(k => JSON.stringify(k) + ':' + stabilerText(x[k])).join(',') + '}';
}
return x === undefined ? 'null' : JSON.stringify(x);
}
function gleich(a, b){ return a === b || stabilerText(a) === stabilerText(b); }
// dreiwegeZusammenfuehren: Basis, eigene und fremde Fassung → Ergebnis.
// konflikte sammelt die Stellen, an denen beide Seiten dasselbe
// unterschiedlich geändert haben (dort gewinnt die eigene Fassung).
// Parameter: basis, eigen, fremd, pfad (Text für Hinweise), konflikte (Array).
function dreiwegeZusammenfuehren(basis, eigen, fremd, pfad, konflikte){
if(gleich(eigen, fremd)) return eigen;
if(basis !== undefined && gleich(eigen, basis)) return fremd;
if(basis !== undefined && gleich(fremd, basis)) return eigen;
if(istObjekt(eigen) && istObjekt(fremd)){
const b = istObjekt(basis) ? basis : {};
const ergebnis = {};
new Set([...Object.keys(fremd), ...Object.keys(eigen)]).forEach(k => {
const inE = k in eigen, inF = k in fremd, inB = k in b;
if(inE && inF){ ergebnis[k] = dreiwegeZusammenfuehren(inB ? b[k] : undefined, eigen[k], fremd[k], pfad, konflikte); return; }
if(inE){ // fremd fehlt: dort gelöscht (wenn in der Basis) oder hier neu
if(inB && gleich(eigen[k], b[k])) return; // fremd hat gelöscht, hier unverändert
if(inB) konflikte.push(pfad); // hier geändert, dort gelöscht → behalten
ergebnis[k] = eigen[k]; return;
}
// eigen fehlt
if(inB && gleich(fremd[k], b[k])) return; // hier gelöscht, dort unverändert
if(inB){ konflikte.push(pfad); return; } // hier gelöscht, dort geändert → gelöscht lassen
ergebnis[k] = fremd[k]; // dort neu
});
return ergebnis;
}
if(istIdListe(eigen) && istIdListe(fremd)){
const bListe = istIdListe(basis) ? basis : [];
const bMap = new Map(bListe.map(x => [String(x.id), x]));
const eMap = new Map(eigen.map(x => [String(x.id), x]));
const fMap = new Map(fremd.map(x => [String(x.id), x]));
// Reihenfolge: hat nur diese Seite umsortiert, gilt ihre, sonst die fremde;
// Neues der anderen Seite wird an seiner Stelle angehängt.
const ids = l => l.map(x => String(x.id));
const eigeneReihe = !gleich(ids(eigen), ids(bListe)) && gleich(ids(fremd), ids(bListe));
const reihe = eigeneReihe ? ids(eigen) : ids(fremd);
(eigeneReihe ? ids(fremd) : ids(eigen)).forEach(id => { if(!reihe.includes(id)) reihe.push(id); });
const ergebnis = [];
reihe.forEach(id => {
const e = eMap.get(id), f = fMap.get(id), b = bMap.get(id);
const name = (e || f || {}).name || (e || f || {}).title || id;
const unterpfad = pfad ? pfad : name;
if(e && f){ ergebnis.push(dreiwegeZusammenfuehren(b, e, f, unterpfad, konflikte)); return; }
if(e){ if(b && gleich(e, b)) return; if(b) konflikte.push(unterpfad); ergebnis.push(e); return; }
if(f){ if(b && gleich(f, b)) return; if(b){ konflikte.push(unterpfad); return; } ergebnis.push(f); }
});
return ergebnis;
}
konflikte.push(pfad);
return eigen;
}
// inPlaceUebernehmen: schreibt quelle in das bestehende Objekt ziel — Objekte
// mit gleicher id (Projekte, Aufgaben, Meilensteine, Personen) bleiben
// dieselben Objekte, damit offene Ansichten ihre Referenzen behalten.
function inPlaceUebernehmen(ziel, quelle){
Object.keys(ziel).forEach(k => { if(!(k in quelle)) delete ziel[k]; });
Object.keys(quelle).forEach(k => {
const z = ziel[k], q = quelle[k];
if(istObjekt(z) && istObjekt(q)) inPlaceUebernehmen(z, q);
else if(istIdListe(z) && istIdListe(q)){
const alt = new Map(z.map(x => [String(x.id), x]));
const neu = q.map(x => { const o = alt.get(String(x.id)); if(o){ inPlaceUebernehmen(o, x); return o; } return x; });
z.length = 0; neu.forEach(x => z.push(x));
} else ziel[k] = q;
});
}
// wocheVomServer: holt den aktuellen Stand EINER Sitzung (über /load-all,
// die Sandbox steckt dort in sandboxPlans). Rückgabe: Promise<Woche|null>.
// Seit GET /week nur diese eine Sitzung statt aller über /load-all.
function wocheVomServer(label){
return fetch(baseUrl() + '/week?label=' + encodeURIComponent(label))
.then(r => {
if(r.status === 404) return null;
if(!r.ok) throw new Error('Server HTTP ' + r.status);
return r.json().then(data => {
const w = data && data.week;
if(!w) return null;
if(!Array.isArray(w.people)) w.people = [];
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
if(label === SANDBOX_WEEK_LABEL) return { label, projects: w.projects, updatedAt: w.updatedAt || '' };
return w;
});
});
}
// zusammenfuehren: führt eine lokale Woche mit einem frischen Server-Stand
// zusammen und schreibt das Ergebnis in die lokale Woche. Rückgabe: Array
// der Projekte/Stellen mit echtem Konflikt.
function zusammenfuehren(lokal, frisch){
const basis = wochenBasis.get(lokal.label);
const ohneRev = w => { const k = wocheKopie(w); delete k.updatedAt; return k; };
const konflikte = [];
let ergebnis;
if(lokal.label === SANDBOX_WEEK_LABEL){
// Die Sandbox: nur die Terminpläne zusammenführen, der Rest ist lokal gebaut.
ergebnis = Object.assign({}, ohneRev(lokal), {
projects: dreiwegeZusammenfuehren(basis ? basis.projects : undefined, wocheKopie(lokal.projects), frisch.projects, '', konflikte)
});
} else {
ergebnis = dreiwegeZusammenfuehren(basis ? ohneRev(basis) : undefined, ohneRev(lokal), ohneRev(frisch), '', konflikte);
}
inPlaceUebernehmen(lokal, ergebnis);
lokal.updatedAt = frisch.updatedAt;
const serverStand = lokal.label === SANDBOX_WEEK_LABEL ? Object.assign(wocheKopie(lokal), { projects: wocheKopie(frisch.projects) }) : wocheKopie(frisch);
wochenBasis.set(lokal.label, serverStand);
return [...new Set(konflikte.filter(Boolean))];
}
// zusammenfuehrenUndSpeichern: Antwort auf einen 409 beim Speichern.
function zusammenfuehrenUndSpeichern(label, lokal, err){
const n = (konfliktVersuche.get(label) || 0) + 1;
konfliktVersuche.set(label, n);
if(n > 3){
// Ungewöhnlich: dreimal in Folge überholt. Dann der alte, sichere Weg.
konfliktVersuche.delete(label);
dirtyWeeks.delete(label);
handleWeekConflict(lokal, err);
return;
}
wocheVomServer(label).then(frisch => {
if(!frisch){
// Die Sitzung wurde am Server gelöscht — hier nicht wiederbeleben.
dirtyWeeks.delete(label);
handleWeekConflict(lokal, err);
return;
}
const konflikte = zusammenfuehren(lokal, frisch);
markWeekDirty(label);
ansichtAuffrischen();
if(konflikte.length){
zeigeHinweis('Gleichzeitig geändert: ' + konflikte.slice(0, 3).join(', ')
+ (konflikte.length > 3 ? ' …' : '') + '. Ihre Fassung wurde übernommen.');
}
scheduleSessionSync();
}).catch(e => {
console.error('Zusammenführen fehlgeschlagen:', e);
showSyncProblem('Speichern fehlgeschlagen — neuer Versuch in 20 Sekunden.');
scheduleSyncRetry();
});
}
// ansichtAuffrischen: zeichnet die offene Ansicht nach übernommenen Daten neu.
function ansichtAuffrischen(){
if(typeof renderAll === 'function') renderAll();
if(typeof currentTab !== 'undefined' && currentTab === 'plan'){
if(typeof renderPlanGantt === 'function') renderPlanGantt();
} else if(typeof currentTab !== 'undefined' && currentTab === 'wand'){
if(typeof renderWandboard === 'function') renderWandboard();
}
}
// zeigeHinweis: kurze, nicht blockierende Meldung unten rechts (8 s).
let hinweisTimer = null;
function zeigeHinweis(text){
let el = document.getElementById('syncHinweis');
if(!el){
el = document.createElement('div');
el.id = 'syncHinweis';
el.className = 'sync-problem-bar sync-hinweis';
document.body.appendChild(el);
}
el.textContent = text;
el.style.display = '';
clearTimeout(hinweisTimer);
hinweisTimer = setTimeout(() => { el.style.display = 'none'; }, 8000);
}
// wirdGeradeBearbeitet: tippt jemand oder ist ein Dialog offen? Dann keine
// fremden Änderungen einspielen — ein Neuzeichnen würde Eingaben verwerfen.
function wirdGeradeBearbeitet(){
const a = document.activeElement;
if(a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName))) return true;
return [...document.querySelectorAll('.modal-bg')].some(m => m.style.display && m.style.display !== 'none');
}
// wochenLiveAbgleich: übernimmt Änderungen anderer, ohne dass jemand neu
// laden muss. Läuft alle 20 Sekunden; die Wandtafel lädt ohnehin selbst.
let liveAbgleichLaeuft = false;
function wochenLiveAbgleich(){
if(liveAbgleichLaeuft || !currentUser || !hatRecht('app')) return;
if(syncInFlight || dirtyWeeks.size > 0 || wirdGeradeBearbeitet()) return;
if(!Array.isArray(db.weeks) || db.weeks.length === 0) return;
liveAbgleichLaeuft = true;
fetch(baseUrl() + '/week-revisionen')
.then(r => r.ok ? r.json() : null)
.then(data => {
if(!data || !data.ok) return;
const rev = data.revisionen || {};
const lokal = db.weeks.concat(db.sandboxWeek ? [db.sandboxWeek] : []);
const geaendert = lokal.filter(w => w.label in rev && rev[w.label] && rev[w.label] !== (w.updatedAt || ''));
if(!geaendert.length) return;
return Promise.all(geaendert.map(w => wocheVomServer(w.label).then(frisch => ({ w, frisch })))).then(liste => {
// Zwischenzeitlich doch etwas bearbeitet? Dann beim nächsten Mal.
if(dirtyWeeks.size > 0 || syncInFlight || wirdGeradeBearbeitet()) return;
liste.forEach(({ w, frisch }) => { if(frisch) zusammenfuehren(w, frisch); });
ansichtAuffrischen();
});
})
.catch(() => { /* still — der nächste Durchlauf versucht es wieder */ })
.finally(() => { liveAbgleichLaeuft = false; });
}
// Nur noch Rückfall: Änderungen kommen sofort über die Live-Ereignisse
// (liveVerbinden(), unten). Der Abgleich fängt ab, was während einer
// Verbindungsunterbrechung verpasst wurde, und läuft zusätzlich direkt nach
// jedem Wiederverbinden.
setInterval(wochenLiveAbgleich, 60000);
// ===========================================================================
// Live-Ereignisse (GET /ereignisse, Server-Sent Events)
// ---------------------------------------------------------------------------
// Sobald jemand speichert, meldet der Server es allen anderen Browsern —
// bei einzelnen Projekten gleich mit deren neuem Stand, sodass nur diese
// übertragen werden. Eigene ungespeicherte Änderungen an demselben Projekt
// bleiben erhalten (Dreiwege-Zusammenführung wie beim Speicherkonflikt).
// Während jemand tippt oder ein Dialog offen ist, werden die Daten
// übernommen, aber erst danach neu gezeichnet (ansichtNachBearbeitung()).
// ===========================================================================
let liveQuelle = null;
let liveGetrennt = false;
let liveNeuzeichnenAusstehend = false;
function liveVerbinden(){
if(liveQuelle || typeof EventSource === 'undefined' || !currentUser) return;
liveQuelle = new EventSource(baseUrl() + '/ereignisse?client=' + encodeURIComponent(CLIENT_ID));
liveQuelle.onmessage = ev => {
let d; try { d = JSON.parse(ev.data); } catch(e){ return; }
liveEreignis(d);
};
liveQuelle.onopen = () => {
// Nach einer Unterbrechung nachholen, was in der Zeit passiert ist.
if(liveGetrennt){ liveGetrennt = false; wochenLiveAbgleich(); }
};
liveQuelle.onerror = () => { liveGetrennt = true; }; // EventSource verbindet selbst neu
}
function liveTrennen(){
if(liveQuelle){ liveQuelle.close(); liveQuelle = null; }
}
// Beim Verlassen der Seite die Verbindung sauber schließen.
window.addEventListener('pagehide', liveTrennen);
function liveEreignis(d){
if(!d || !d.typ) return;
if(d.typ === 'projekte') return liveProjekte(d);
if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht);
if(d.typ === 'neu') return liveAllesNeu();
if(d.typ === 'personen') return personenNachladen();
}
// personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer
// Abwesenheits-Änderung, hier oder in einem anderen Browser) und neu zeichnen.
// Rückgabe: Promise.
function personenNachladen(){
return fetch(baseUrl() + '/personen', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(d => {
if(!d || !Array.isArray(d.people)) return;
db.globalPeople = d.people;
ansichtNachBearbeitung();
})
.catch(err => console.error('Personen nachladen fehlgeschlagen:', err));
}
// liveProjekte: übernimmt die mitgeschickten Projekte einer Sitzung.
function liveProjekte(d){
const w = d.label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === d.label);
if(!w) return;
const basis = wochenBasis.get(d.label);
const konflikte = [];
let eigeneOffen = false;
for(const fp of (d.projekte || [])){
const lp = (w.projects || []).find(x => x.id === fp.id);
if(!lp) return liveWocheNachladen(d.label); // Projekt hier unbekannt: ganze Sitzung holen
const bp = basis ? (basis.projects || []).find(x => x.id === fp.id) : undefined;
if(bp && gleich(lp, bp)){
inPlaceUebernehmen(lp, JSON.parse(JSON.stringify(fp)));
} else {
// Hier gibt es noch ungespeicherte Änderungen an diesem Projekt.
const erg = dreiwegeZusammenfuehren(bp, JSON.parse(JSON.stringify(lp)), fp, lp.name || '', konflikte);
inPlaceUebernehmen(lp, erg);
lp.updatedAt = fp.updatedAt;
eigeneOffen = true;
}
if(basis){
const i = basis.projects.findIndex(x => x.id === fp.id);
if(i >= 0) basis.projects[i] = JSON.parse(JSON.stringify(fp));
}
}
revisionUebernehmen(w, d.updatedAt);
if(eigeneOffen){ markWeekDirty(d.label); scheduleSessionSync(); }
if(konflikte.length){
zeigeHinweis('Gleichzeitig geändert: ' + [...new Set(konflikte.filter(Boolean))].slice(0, 3).join(', ') + '. Ihre Fassung wurde übernommen.');
}
ansichtNachBearbeitung();
}
// liveWocheNachladen: eine ganze Sitzung hat sich geändert — nur sie holen.
function liveWocheNachladen(label, geloescht){
if(geloescht || !label) return liveAllesNeu();
// Während eines eigenen Speicherns nicht dazwischenfunken; kurz danach.
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === label);
if(!w) return liveAllesNeu();
wocheVomServer(label).then(frisch => {
if(!frisch) return liveAllesNeu();
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
const hatteEigene = dirtyWeeks.has(label);
const konflikte = zusammenfuehren(w, frisch);
if(hatteEigene){ markWeekDirty(label); scheduleSessionSync(); }
if(konflikte.length){
zeigeHinweis('Gleichzeitig geändert: ' + konflikte.slice(0, 3).join(', ') + '. Ihre Fassung wurde übernommen.');
}
ansichtNachBearbeitung();
}).catch(e => console.error('Live-Nachladen fehlgeschlagen:', e));
}
// liveAllesNeu: neue Sitzung, gelöschte Sitzung oder eingespieltes Backup —
// alles neu laden, aber erst, wenn hier nichts mehr ungespeichert ist.
function liveAllesNeu(){
if(dirtyWeeks.size > 0 || syncInFlight || wirdGeradeBearbeitet()){
setTimeout(liveAllesNeu, 2000);
return;
}
load(true);
}
// ansichtNachBearbeitung: neu zeichnen — sofort, oder sobald niemand mehr
// tippt bzw. kein Dialog mehr offen ist (ein Neuzeichnen verwürfe Eingaben).
function ansichtNachBearbeitung(){
if(!wirdGeradeBearbeitet()){ liveNeuzeichnenAusstehend = false; ansichtAuffrischen(); return; }
if(liveNeuzeichnenAusstehend) return;
liveNeuzeichnenAusstehend = true;
const pruefen = () => {
if(!liveNeuzeichnenAusstehend) return;
if(wirdGeradeBearbeitet()){ setTimeout(pruefen, 1500); return; }
liveNeuzeichnenAusstehend = false;
ansichtAuffrischen();
};
setTimeout(pruefen, 1500);
}
// handleWeekConflict: wird aufgerufen, wenn der Server eine zwischenzeitlich // handleWeekConflict: wird aufgerufen, wenn der Server eine zwischenzeitlich
// geänderte Woche meldet (409). Nur diese eine Woche wird vom Server neu // geänderte Woche meldet (409). Nur diese eine Woche wird vom Server neu
// geladen; andere lokal geänderte Wochen (dirtyWeeks) und die Personenliste // geladen; andere lokal geänderte Wochen (dirtyWeeks) und die Personenliste
@@ -177,11 +653,8 @@ function handleWeekConflict(week, err){
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von // Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
// syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten. // syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten.
function reloadWeekFromServer(label){ function reloadWeekFromServer(label){
fetch(baseUrl() + '/load-all') wocheVomServer(label)
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }) .then(fresh => {
.then(data => {
const fresh = (Array.isArray(data.weeks) ? data.weeks : [])
.find(w => w && w.label === label);
const idx = db.weeks.findIndex(w => w.label === label); const idx = db.weeks.findIndex(w => w.label === label);
if(idx >= 0){ if(idx >= 0){
if(fresh){ if(fresh){
@@ -190,6 +663,7 @@ function reloadWeekFromServer(label){
if(!Array.isArray(fresh.projects)) fresh.projects = []; if(!Array.isArray(fresh.projects)) fresh.projects = [];
if(!fresh.attendance) fresh.attendance = {}; if(!fresh.attendance) fresh.attendance = {};
db.weeks[idx] = fresh; db.weeks[idx] = fresh;
basisMerken(fresh);
} else { } else {
db.weeks.splice(idx, 1); db.weeks.splice(idx, 1);
} }
@@ -309,6 +783,11 @@ function load(silent){
return w; return w;
}); });
// Server-Stand als Basis für späteres Zusammenführen merken.
wochenBasis.clear();
db.weeks.forEach(basisMerken);
basisMerken(db.sandboxWeek);
if(db.weeks.length === 0){ if(db.weeks.length === 0){
console.warn('Server lieferte 0 Sitzungen. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob die SQLite-Datenbank sessions/projektuebersicht.db existiert.'); console.warn('Server lieferte 0 Sitzungen. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob die SQLite-Datenbank sessions/projektuebersicht.db existiert.');
seed(); seed();
@@ -327,6 +806,7 @@ function load(silent){
// wenn der Tab per Deep-Link-URL (z. B. "/Projekte") gewählt wurde. // wenn der Tab per Deep-Link-URL (z. B. "/Projekte") gewählt wurde.
renderAll(); renderAll();
showTab(currentTab); showTab(currentTab);
liveVerbinden();
}) })
.catch(err => { .catch(err => {
console.error('Laden vom Server fehlgeschlagen:', err); console.error('Laden vom Server fehlgeschlagen:', err);
+10 -7
View File
@@ -23,15 +23,17 @@
// Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der // Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der
// Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom // Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom
// Dashboard ins Projektboard. // Dashboard ins Projektboard.
const TAB_PANELS = ['board','wochenbericht','load','people','planung','planchecker','dashboard','wand','settings','admin','plan']; const TAB_PANELS = ['board','wochenbericht','load','abwesenheit','people','planung','planchecker','dashboard','wand','settings','admin','plan'];
function showTab(t){ function showTab(t){
// Der Bereich „Admin" steht nur Administratoren offen. Ohne diese Weiche // Weichen nach Rollen & Rechte (UI-Gate, die Routen dahinter prüft der
// zeigte ein weitergereichter Deep-Link „/Admin" einem Mitglied eine leere // Server mit requireRecht()): „Admin“ nur mit einem Admin-Recht, die
// Seite — die Karten darin rendert renderAdminArea() für Nicht-Admins gar // Wandtafel nur mit „Wandtafel anzeigen“, und eine Rolle ohne „Anwendung
// nicht erst. (Ein UI-Gate, mehr nicht: Die Routen dahinter sind // nutzen“ (früher das Kiosk-Konto) landet immer auf der Wandtafel.
// serverseitig mit requireRole('admin') abgesichert.) if(currentUser && !hatRecht('app') && hatRecht('wand')) t = 'wand';
if(t === 'admin' && !(currentUser && currentUser.role === 'admin')) t = DEFAULT_TAB; if(t === 'admin' && !hatAdminRecht()) t = DEFAULT_TAB;
if(t === 'abwesenheit' && currentUser && !darfIrgendeineAbwesenheit()) t = DEFAULT_TAB;
if(t === 'wand' && currentUser && !hatRecht('wand')) t = DEFAULT_TAB;
if(t !== currentTab) previousTab = currentTab; if(t !== currentTab) previousTab = currentTab;
currentTab = t; currentTab = t;
saveUiState(); saveUiState();
@@ -58,6 +60,7 @@ function showTab(t){
if(t==='board' && currentWeek()) renderBoard(); if(t==='board' && currentWeek()) renderBoard();
if(t==='wochenbericht') renderWochenbericht(); if(t==='wochenbericht') renderWochenbericht();
if(t==='load') renderLoad(); if(t==='load') renderLoad();
if(t==='abwesenheit'){ abwBeimOeffnen(); renderAbwesenheiten(); }
if(t==='people') renderPeople(); if(t==='people') renderPeople();
if(t==='planung') renderPlanung(); if(t==='planung') renderPlanung();
if(t==='planchecker') renderPlanChecker(); if(t==='planchecker') renderPlanChecker();
+365 -12
View File
@@ -309,6 +309,104 @@ function statusFocus(el, pid){
currentStatusPid = pid; currentStatusPid = pid;
} }
// ---------- Kommentar und "besprochen" je Gewerk ----------
//
// ELT, HLS und ARCH halten ihre Besprechung getrennt (Eriks Wunsch: "Die
// Kommentare und Anwesenheiten von ELT brauchen nicht in HLS oder Arch zu
// sein" — und "auch wenn das Projekt in ELT besprochen ist, heißt das nicht,
// dass HLS das Projekt auch besprochen hat"). Ein Projekt mit mehreren
// Gewerken trägt deshalb je Gewerk einen eigenen Sitzungskommentar samt
// Verfasser/Zeitstempel und einen eigenen Besprochen-Haken:
// p.kommentare = { ELT: { status, statusAuthor, statusAt, besprochen }, … }
// (Tabelle project_kommentare, server.js). Die früheren Felder
// p.status/statusAuthor/statusAt/besprochen werden nicht mehr gelesen oder
// geschrieben — alles läuft über die drei Helfer unten.
// kommentarGewerk: das Gewerk, in dessen Zusammenhang gerade gelesen bzw.
// geschrieben wird — im Wochenbericht das dort gewählte, überall sonst null
// (= alle Gewerke zusammen, nur zur Anzeige). Keine Parameter.
function kommentarGewerk(){
if(typeof currentTab !== 'undefined' && currentTab === 'wochenbericht'
&& typeof wochenberichtGewerk !== 'undefined' && wochenberichtGewerk) return wochenberichtGewerk;
return null;
}
// kommentarVon: der Kommentar eines Projekts für ein Gewerk. Ohne Gewerk die
// gewerkübergreifende Anzeige: die Kommentare aller Gewerke untereinander,
// jeweils mit dem Gewerk davor (bei nur einem Gewerk ohne Beschriftung),
// Verfasser/Zeit des jüngsten, besprochen nur, wenn ALLE Gewerke des
// Projekts es abgehakt haben. Parameter: p (Projekt), g (Gewerk oder
// null/undefined). Rückgabe: { status, statusAuthor, statusAt, besprochen }.
function kommentarVon(p, g){
const alle = (p && p.kommentare) || {};
if(g){
const e = alle[g] || {};
return { status: e.status || '', statusAuthor: e.statusAuthor || '', statusAt: e.statusAt || '', besprochen: !!e.besprochen };
}
const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); });
const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== '');
const gewerke = Array.isArray(p && p.gewerke) ? p.gewerke : [];
let status = '';
if(mitText.length === 1 && gewerke.length <= 1) status = alle[mitText[0]].status;
// Jeder Kommentar in einem eigenen Block: Die Klartext-Vorschau
// (statusPlainText/statusPreviewText) trennt nur an Blockgrenzen — ohne
// eigenen Block klebten die Gewerke dort aneinander ("…fettHLS: …").
else status = mitText.map(k => `<div><b>${esc(k)}:</b></div><div>${alle[k].status}</div>`).join('');
const juengster = mitText.map(k => alle[k]).sort((a, b) => String(b.statusAt || '').localeCompare(String(a.statusAt || '')))[0] || {};
const pruefen = gewerke.length ? gewerke : Object.keys(alle);
const besprochen = pruefen.length > 0 && pruefen.every(k => alle[k] && alle[k].besprochen);
return { status, statusAuthor: juengster.statusAuthor || '', statusAt: juengster.statusAt || '', besprochen };
}
// kommentarAnzeigeHtml: der Kommentar zur ANZEIGE, bereinigt. Mit Gewerk g
// nur dessen Text; ohne Gewerk die Texte aller Gewerke, bei mehreren je mit
// dem Gewerk als eigener, dezenter Beschriftung (.kommentar-gewerk) —
// Systemtext, nicht Teil des Kommentars (anders als die fette "ELT:"-Zeile,
// die kommentarVon() für Klartext-Vorschauen einfügt). Rückgabe: HTML ('' ohne Text).
function kommentarAnzeigeHtml(p, g){
if(g) return sanitizeStatusHtml(kommentarVon(p, g).status || '');
const alle = (p && p.kommentare) || {};
const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); });
const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== '');
if(mitText.length === 0) return '';
if(mitText.length === 1) return sanitizeStatusHtml(alle[mitText[0]].status);
return mitText.map(k => `<div class="kommentar-gewerk">${esc(k)}</div><div>${sanitizeStatusHtml(alle[k].status)}</div>`).join('');
}
// kommentarSetzen: ändert den Kommentar-Eintrag eines Gewerks (nur die in
// patch genannten Felder). Parameter: p (Projekt), g (Gewerk), patch
// (Objekt). Keine Rückgabe.
function kommentarSetzen(p, g, patch){
if(!p || !g) return;
p.kommentare = p.kommentare || {};
p.kommentare[g] = Object.assign(kommentarVon(p, g), patch || {});
}
// personGewerk: das Gewerk einer Person aus dem Teilnehmerkreis einer
// Sitzung. week.people kennt es selbst nicht; es kommt aus dem Konto
// (db.globalPeople, users.department) — gesucht über die Kennung, ersatzweise
// über den vollen Namen (Altbestand mit abweichender Kennung, siehe
// kapazitaetsFaktor()). Parameter: person. Rückgabe: Gewerk oder ''.
function personGewerk(person){
const liste = (typeof db !== 'undefined' && Array.isArray(db.globalPeople)) ? db.globalPeople : [];
const treffer = liste.find(x => x.id === person.id)
|| (person.fullName ? liste.find(x => x.fullName === person.fullName) : null);
return (treffer && treffer.gewerk) || '';
}
// personenDesGewerks: der Teilnehmerkreis einer Sitzung, eingeschränkt auf
// ein Gewerk — für die Anwesenheit, die je Gewerk getrennt geführt wird.
// Personen ohne bekanntes Gewerk bleiben überall sichtbar, damit niemand
// unauffindbar wird. Ohne Gewerk alle. Parameter: w (Woche), g.
// Rückgabe: Array.
function personenDesGewerks(w, g){
const leute = Array.isArray(w && w.people) ? w.people : [];
if(!g) return leute;
return leute.filter(person => { const pg = personGewerk(person); return !pg || pg === g; });
}
// statusValueFromHtml: normalisiert den HTML-Inhalt eines Status-Editors. // statusValueFromHtml: normalisiert den HTML-Inhalt eines Status-Editors.
// Liefert bei reinem Leerraum '' zurück, sonst den unveränderten HTML-String // Liefert bei reinem Leerraum '' zurück, sonst den unveränderten HTML-String
// (Formatierung bleibt erhalten). Parameter: `html`, Rückgabe: String. // (Formatierung bleibt erhalten). Parameter: `html`, Rückgabe: String.
@@ -345,6 +443,17 @@ function formatStatus(command, value){
// der Sitzung aller anderen ausführen. parseFromString() erzeugt dagegen ein // der Sitzung aller anderen ausführen. parseFromString() erzeugt dagegen ein
// inertes Dokument: kein Laden, kein Handler. Nachgemessen — mit innerHTML // inertes Dokument: kein Laden, kein Handler. Nachgemessen — mit innerHTML
// feuerte der Handler, mit DOMParser nicht, bei identischem Ergebnisbaum. // feuerte der Handler, mit DOMParser nicht, bei identischem Ergebnisbaum.
// fontFarbeErlaubt: nur echte Hex-Farben, und keine Grau-/Schwarz-/Weißtöne —
// eingefügter Text bringt oft color="#1a1a1a" mit, das im Dunkelmodus
// unsichtbar wäre und ohnehin nichts hervorhebt. Rückgabe: Boolean.
function fontFarbeErlaubt(wert){
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(wert || '').trim());
if(!m) return false;
const h = m[1].length === 3 ? m[1].replace(/./g, c => c + c) : m[1];
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
return Math.max(r, g, b) - Math.min(r, g, b) > 40;
}
function sanitizeStatusHtml(html){ function sanitizeStatusHtml(html){
if(!html) return ''; if(!html) return '';
// Die Bereinigung läuft vollständig IM inerten Dokument ab: Würden die // Die Bereinigung läuft vollständig IM inerten Dokument ab: Würden die
@@ -386,6 +495,10 @@ function sanitizeStatusHtml(html){
} }
for(let j = child.attributes.length - 1; j >= 0; j--){ for(let j = child.attributes.length - 1; j >= 0; j--){
const attr = child.attributes[j].name; const attr = child.attributes[j].name;
// <font color="…"> (ältere Kommentare, Browser-Farbbefehl) behält
// seine Farbe — vorher fiel sie hier still weg, und farbige
// Bestandskommentare erschienen überall einfarbig.
if(tag === 'FONT' && attr.toLowerCase() === 'color' && fontFarbeErlaubt(child.getAttribute(attr))) continue;
if(!allowedAttrs.has(attr.toLowerCase())) child.removeAttribute(attr); if(!allowedAttrs.has(attr.toLowerCase())) child.removeAttribute(attr);
} }
if(child.hasAttribute('style')){ if(child.hasAttribute('style')){
@@ -474,9 +587,239 @@ function richtextWickeln(feld, tagName, styleAttr){
sel.addRange(neueRange); sel.addRange(neueRange);
feld.dispatchEvent(new Event('input', { bubbles: true })); feld.dispatchEvent(new Event('input', { bubbles: true }));
} }
function richtextFett(feld){ richtextWickeln(feld, 'b'); }
function richtextKursiv(feld){ richtextWickeln(feld, 'i'); } // ---------- Auszeichnung wieder entfernen (Umschalten) ----------
function richtextFarbe(feld, farbe){ richtextWickeln(feld, 'span', 'color:' + farbe); } //
// Eriks Wunsch: Ein zweiter Klick auf denselben Knopf nimmt die Auszeichnung
// wieder zurück — rot markierter Text wird durch erneutes Klicken auf Rot
// wieder schwarz, ebenso Fett/Kursiv und die übrigen Farben. Vorher wickelte
// jeder Klick nur eine weitere Hülle um den Text, rückgängig machen ließ sich
// nichts.
//
// Eine Auszeichnungsart ist ein kleines Objekt { trifft(el), entferne(el) }:
// trifft() erkennt ein Element dieser Art, entferne() nimmt NUR diese Art
// heraus — ein <span style="color:red;font-weight:bold"> verliert beim
// Entfernen der Farbe nicht auch das Fett.
// richtextFarbWert: normalisiert eine Farbangabe auf die Schreibweise des
// Browsers ("rgb(215, 0, 21)"), damit '#d70015' und der gespeicherte Wert
// vergleichbar sind. Parameter: farbe (String). Rückgabe: String.
function richtextFarbWert(farbe){
const t = document.createElement('span');
t.style.color = farbe || '';
return t.style.color;
}
// richtextAuspacken: ersetzt ein Element durch seine Kinder.
function richtextAuspacken(el){
if(el.parentNode) el.replaceWith(...Array.from(el.childNodes));
}
// richtextStilEntfernen: nimmt eine CSS-Eigenschaft aus dem style-Attribut
// und packt ein dann bedeutungsloses <span>/<font> aus.
function richtextStilEntfernen(el, prop){
el.style.removeProperty(prop);
if(!el.getAttribute('style')) el.removeAttribute('style');
const tag = el.tagName;
if((tag === 'SPAN' || tag === 'FONT') && el.attributes.length === 0) richtextAuspacken(el);
}
const RICHTEXT_ART_FETT = {
trifft: el => el.tagName === 'B' || el.tagName === 'STRONG'
|| /^(bold|[6-9]00)$/.test((el.style && el.style.fontWeight) || ''),
entferne: el => (el.tagName === 'B' || el.tagName === 'STRONG') ? richtextAuspacken(el) : richtextStilEntfernen(el, 'font-weight')
};
const RICHTEXT_ART_KURSIV = {
trifft: el => el.tagName === 'I' || el.tagName === 'EM',
entferne: el => richtextAuspacken(el)
};
// richtextArtFarbe: eine bestimmte Farbe (farbe gesetzt) oder jede Farbe
// (farbe leer — zum Umfärben: erst die alte Farbe entfernen, dann die neue
// setzen, statt Hüllen ineinander zu schachteln).
function richtextArtFarbe(farbe){
const ziel = farbe ? richtextFarbWert(farbe) : '';
const farbeVon = el => (el.style && el.style.color) || (el.tagName === 'FONT' ? richtextFarbWert(el.getAttribute('color')) : '');
return {
trifft: el => { const f = farbeVon(el); return !!f && (!ziel || f === ziel); },
entferne: el => {
if(el.tagName === 'FONT') el.removeAttribute('color');
richtextStilEntfernen(el, 'color');
}
};
}
// richtextHatArt: steht in einem Element (zwischen node und feld) eine
// Auszeichnung dieser Art?
function richtextHatArt(node, feld, art){
for(let n = node.nodeType === 1 ? node : node.parentNode; n && n !== feld; n = n.parentNode){
if(n.nodeType === 1 && art.trifft(n)) return true;
}
return false;
}
// richtextAuswahl: die aktuelle, nicht leere Markierung innerhalb von feld
// oder null.
function richtextAuswahl(feld){
if(!feld) return null;
const sel = window.getSelection();
if(!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
if(range.collapsed || !feld.contains(range.commonAncestorContainer)) return null;
return range;
}
// richtextIstAktiv: trägt JEDER markierte (sichtbare) Text die Auszeichnung?
// Nur dann schaltet ein Klick sie ab; ist sie nur teilweise vorhanden, wird
// sie auf die ganze Markierung ausgedehnt — wie in jeder Textverarbeitung.
function richtextIstAktiv(feld, range, art){
const wurzel = range.commonAncestorContainer;
const texte = [];
if(wurzel.nodeType === 3) texte.push(wurzel);
else {
const walker = document.createTreeWalker(wurzel, NodeFilter.SHOW_TEXT);
while(walker.nextNode()) if(range.intersectsNode(walker.currentNode)) texte.push(walker.currentNode);
}
const relevant = texte.filter(t => t.textContent.trim() !== '');
return relevant.length > 0 && relevant.every(t => richtextHatArt(t, feld, art));
}
// richtextLeereHuellenEntfernen: Beim Aufteilen bleiben leere <b>/<span>
// zurück — unsichtbar, aber sie würden mitgespeichert.
function richtextLeereHuellenEntfernen(feld){
feld.querySelectorAll('b, strong, i, em, u, span, font').forEach(el => {
if(el.textContent === '' && !el.querySelector('br')) el.remove();
});
}
// richtextArtEntfernen: entfernt eine Auszeichnungsart von der Markierung.
// Der markierte Teil wird herausgelöst und von dieser Art befreit; liegt er
// INNERHALB eines solchen Elements (rot markiertes Wort mitten in einem roten
// Satz), wird dieses Element um ihn herum aufgeteilt, sodass Text davor und
// dahinter seine Auszeichnung behält. Die Markierung bleibt danach auf
// demselben Text stehen.
function richtextArtEntfernen(feld, range, art){
const mitte = document.createElement('span');
mitte.appendChild(range.extractContents());
// Auszeichnungen dieser Art INNERHALB der Markierung
Array.from(mitte.querySelectorAll('*')).reverse().forEach(el => { if(art.trifft(el)) art.entferne(el); });
range.insertNode(mitte);
// Umgebende Elemente dieser Art um die Markierung herum aufteilen
for(;;){
let huelle = null;
for(let n = mitte.parentNode; n && n !== feld; n = n.parentNode){
if(n.nodeType === 1 && art.trifft(n)){ huelle = n; break; }
}
if(!huelle) break;
const vorher = document.createRange();
vorher.setStart(huelle, 0); vorher.setEndBefore(mitte);
const vorderteil = vorher.extractContents();
const nachher = document.createRange();
nachher.setStartAfter(mitte); nachher.setEnd(huelle, huelle.childNodes.length);
const hinterteil = nachher.extractContents();
if(vorderteil.textContent !== '' || vorderteil.querySelector && vorderteil.querySelector('br')){
const k = huelle.cloneNode(false); k.appendChild(vorderteil); huelle.before(k);
}
if(hinterteil.textContent !== '' || hinterteil.querySelector && hinterteil.querySelector('br')){
const k = huelle.cloneNode(false); k.appendChild(hinterteil); huelle.after(k);
}
art.entferne(huelle);
}
const kinder = Array.from(mitte.childNodes);
richtextAuspacken(mitte);
richtextLeereHuellenEntfernen(feld);
const sel = window.getSelection();
const neu = document.createRange();
const erstes = kinder.find(k => k.parentNode);
const letztes = kinder.slice().reverse().find(k => k.parentNode);
if(erstes && letztes){
neu.setStartBefore(erstes); neu.setEndAfter(letztes);
sel.removeAllRanges(); sel.addRange(neu);
}
}
// richtextUmschalten: setzt die Auszeichnung, oder nimmt sie zurück, wenn die
// ganze Markierung sie schon trägt. ersetzt (optional) ist eine Art, die
// vorher entfernt wird — für Farben: Blau auf rotem Text färbt um, statt eine
// blaue Hülle in die rote zu legen.
function richtextUmschalten(feld, art, wickeln, ersetzt){
const range = richtextAuswahl(feld);
if(!range) return;
if(richtextIstAktiv(feld, range, art)){
richtextArtEntfernen(feld, range, art);
} else {
if(ersetzt) richtextArtEntfernen(feld, range, ersetzt);
else richtextArtEntfernen(feld, range, art); // keine doppelten Hüllen
wickeln();
}
feld.dispatchEvent(new Event('input', { bubbles: true }));
}
function richtextFett(feld){
richtextUmschalten(feld, RICHTEXT_ART_FETT, () => richtextWickeln(feld, 'b'));
}
function richtextKursiv(feld){
richtextUmschalten(feld, RICHTEXT_ART_KURSIV, () => richtextWickeln(feld, 'i'));
}
function richtextFarbe(feld, farbe){
richtextUmschalten(feld, richtextArtFarbe(farbe),
() => richtextWickeln(feld, 'span', 'color:' + farbe), richtextArtFarbe(''));
}
// richtextFormatLoeschen: setzt Text auf das Standardformat zurück — kein
// Fett, kein Kursiv, keine Farbe. Mit Markierung nur diese, ohne Markierung
// das ganze Feld. Zeilenumbrüche und Absätze bleiben erhalten, nur die
// Auszeichnung fällt weg. Parameter: feld (Element). Keine Rückgabe.
function richtextFormatLoeschen(feld){
if(!feld || feld.getAttribute('contenteditable') !== 'true') return;
const saeubern = wurzel => {
Array.from(wurzel.querySelectorAll('b, strong, i, em, u, span, font')).reverse().forEach(richtextAuspacken);
wurzel.querySelectorAll('[style]').forEach(el => el.removeAttribute('style'));
};
const range = richtextAuswahl(feld);
if(range){
// Alle Arten nacheinander von der Markierung nehmen — teilt umgebende
// Hüllen sauber auf, statt den Text davor/dahinter mit zu entformatieren.
[RICHTEXT_ART_FETT, RICHTEXT_ART_KURSIV, richtextArtFarbe(''),
{ trifft: el => el.tagName === 'U', entferne: richtextAuspacken }]
.forEach(art => { const r = richtextAuswahl(feld); if(r) richtextArtEntfernen(feld, r, art); });
} else {
saeubern(feld);
}
feld.normalize();
feld.dispatchEvent(new Event('input', { bubbles: true }));
}
// richtextNeueZeileMitStrich: Enter beginnt die neue Zeile mit "- " (Eriks
// Wunsch für den Wochenbericht-Kommentar — Stichpunkte sind dort die Regel).
// Der Strich ist gewöhnlicher Text: Wer eine Zeile ohne ihn will, löscht ihn
// mit der Rücktaste. Umschalt+Enter bleibt beim Browser und fügt eine Zeile
// ganz ohne Strich ein.
// Parameter: e (keydown-Event), feld (Element). Rückgabe: true, wenn das
// Ereignis behandelt wurde.
function richtextNeueZeileMitStrich(e, feld){
if(e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return false;
const sel = window.getSelection();
if(!sel || sel.rangeCount === 0 || !feld.contains(sel.getRangeAt(0).commonAncestorContainer)) return false;
e.preventDefault();
const range = sel.getRangeAt(0);
range.deleteContents();
const br = document.createElement('br');
// Nach dem <br> steht immer Text (der Strich) — ein <br> am Feldende
// würde der Browser sonst nicht als neue Zeile anzeigen.
// Geschütztes Leerzeichen: Ein gewöhnliches am Zeilenende verwirft der
// Browser beim Weitertippen, aus "- Text" würde "-Text".
const text = document.createTextNode('-\u00a0');
range.insertNode(text);
range.insertNode(br);
const neu = document.createRange();
neu.setStart(text, text.textContent.length);
neu.collapse(true);
sel.removeAllRanges(); sel.addRange(neu);
feld.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
// richtextFeldHtml: liefert ein contenteditable-Feld mit Platzhaltertext // richtextFeldHtml: liefert ein contenteditable-Feld mit Platzhaltertext
// (:empty:before + data-placeholder, siehe styles.css) als Ersatz für ein // (:empty:before + data-placeholder, siehe styles.css) als Ersatz für ein
@@ -579,7 +922,7 @@ document.addEventListener('selectionchange', function(){
const markiert = !!(bereich && feld.contains(bereich.commonAncestorContainer)); const markiert = !!(bereich && feld.contains(bereich.commonAncestorContainer));
leiste.classList.toggle('hat-auswahl', markiert); leiste.classList.toggle('hat-auswahl', markiert);
const hinweis = leiste.querySelector('.richtext-hinweis'); const hinweis = leiste.querySelector('.richtext-hinweis');
if(hinweis) hinweis.textContent = markiert ? '✓ Markiert — jetzt formatieren' : 'Erst Text markieren'; if(hinweis) hinweis.textContent = markiert ? '✓ Markiert — jetzt formatieren' : 'Erst Text markieren · erneut klicken hebt auf';
}); });
}); });
@@ -619,7 +962,10 @@ function richtextToolbarHtml(targetSelector){
</span> </span>
<span class="richtext-divider"></span> <span class="richtext-divider"></span>
<span class="richtext-farben">${farbButtons}</span> <span class="richtext-farben">${farbButtons}</span>
<span class="richtext-hinweis">Erst Text markieren</span> <span class="richtext-divider"></span>
<button type="button" class="richtext-btn richtext-btn-loeschen" title="Formatierung entfernen — die Markierung, ohne Markierung den ganzen Text"
onmousedown="event.preventDefault()" onclick="richtextFormatLoeschen(${ziel})">Standard</button>
<span class="richtext-hinweis">Erst Text markieren · erneut klicken hebt auf</span>
</div>`; </div>`;
} }
@@ -672,18 +1018,22 @@ function statusPlainText(html){
// wurden, tragen den Text der Vorwoche unverändert mit. Parameter: uid // wurden, tragen den Text der Vorwoche unverändert mit. Parameter: uid
// (String). Rückgabe: Array von { week, text, html, author, at, isFirst }, // (String). Rückgabe: Array von { week, text, html, author, at, isFirst },
// neueste zuerst. // neueste zuerst.
function projectStatusHistory(uid){ function projectStatusHistory(uid, g){
// Gewerk: ausdrücklich übergeben, sonst das des Zusammenhangs (im
// Wochenbericht das gewählte, siehe kommentarGewerk()).
const gewerk = g === undefined ? kommentarGewerk() : g;
const versions = projectVersionsForUid(uid); const versions = projectVersionsForUid(uid);
const entries = []; const entries = [];
let lastText = null; let lastText = null;
versions.forEach((v, idx) => { versions.forEach((v, idx) => {
const text = statusPlainText(v.project.status); const k = kommentarVon(v.project, gewerk);
const text = statusPlainText(k.status);
const entry = { const entry = {
week: v.week, week: v.week,
text, text,
html: sanitizeStatusHtml(v.project.status || ''), html: sanitizeStatusHtml(k.status || ''),
author: v.project.statusAuthor || '', author: k.statusAuthor || '',
at: v.project.statusAt || '', at: k.statusAt || '',
isFirst: idx === 0 isFirst: idx === 0
}; };
if(idx === 0){ if(idx === 0){
@@ -829,7 +1179,10 @@ function renderProjektVerlauf(p, weekLabel, locked){
</div>`; </div>`;
}).join(''); }).join('');
const text = statusPlainText(v.project.status); const text = statusPlainText(kommentarVon(v.project, kommentarGewerk()).status);
// Angezeigt wird der Kommentar MIT Formatierung (Farben, fett, Absätze),
// das Gewerk als eigene Beschriftung; text dient nur Tooltip und Prüfung.
const html = text ? kommentarAnzeigeHtml(v.project, kommentarGewerk()) : '';
const termine = verlaufTermine(v.project); const termine = verlaufTermine(v.project);
const termineDavor = davor ? verlaufTermine(davor.project) : { ende:'', meilenstein:'' }; const termineDavor = davor ? verlaufTermine(davor.project) : { ende:'', meilenstein:'' };
const beteiligte = verlaufBeteiligte(v); const beteiligte = verlaufBeteiligte(v);
@@ -863,7 +1216,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
<span class="timeline-kw">${esc(v.week.label)}</span> <span class="timeline-kw">${esc(v.week.label)}</span>
<span class="timeline-datum">${esc(v.week.date ? formatWeekDate(v.week.date) : '')}</span> <span class="timeline-datum">${esc(v.week.date ? formatWeekDate(v.week.date) : '')}</span>
</header> </header>
<div class="timeline-text${text ? '' : ' timeline-leer'}" title="${esc(text)}">${text ? esc(text) : 'Kein Kommentar'}</div> <div class="timeline-text${text ? '' : ' timeline-leer'}" title="${esc(text)}">${text ? html : 'Kein Kommentar'}</div>
${uebernehmen} ${uebernehmen}
<div class="timeline-lph-list">${lphHtml}</div> <div class="timeline-lph-list">${lphHtml}</div>
${zeilen.length ? `<div class="timeline-termine">${zeilen.join('')}</div>` : ''} ${zeilen.length ? `<div class="timeline-termine">${zeilen.join('')}</div>` : ''}
+230 -205
View File
@@ -29,7 +29,7 @@
// betroffene Projekt. Jede Kachel/jedes Ereignis ist eine reine Projektion // betroffene Projekt. Jede Kachel/jedes Ereignis ist eine reine Projektion
// bereits geladener Daten (dasselbe db aus GET /load-all, das jeder andere // bereits geladener Daten (dasselbe db aus GET /load-all, das jeder andere
// Tab auch nutzt) — keine eigene Server-Route, keine schreibenden Aktionen. // Tab auch nutzt) — keine eigene Server-Route, keine schreibenden Aktionen.
// Wiederverwendet werden berechneAuslastung()/loadAmpelColor()/ // Wiederverwendet werden aufgabenVerlaufJePerson()/loadAmpelColor()/
// kapazitaetsFaktor()/stundenFormat()/lpActiveList()/schwelleGelb()/ // kapazitaetsFaktor()/stundenFormat()/lpActiveList()/schwelleGelb()/
// schwelleRot() aus js/load/load.js, // schwelleRot() aus js/load/load.js,
// classifyProjectChange()/findPreviousWeek()/springZumWochenbericht() aus // classifyProjectChange()/findPreviousWeek()/springZumWochenbericht() aus
@@ -78,7 +78,6 @@
const WAND_GEWERKE = ['ELT', 'HLS', 'ARCH']; const WAND_GEWERKE = ['ELT', 'HLS', 'ARCH'];
const WAND_REFRESH_MS = 60000; // Hintergrund-Aktualisierung const WAND_REFRESH_MS = 60000; // Hintergrund-Aktualisierung
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 // 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 // in der Projektliste unterhalb der Kacheln scrollen ("ich kann mich vor die
// Tafel stellen und scrollen"). Nach dieser Zeit ohne jede Berührung kehrt // Tafel stellen und scrollen"). Nach dieser Zeit ohne jede Berührung kehrt
@@ -192,9 +191,30 @@ function wandKehreZurueck(){
// überrascht zu werden. Läuft über ein eigenes 1-Sekunden-Intervall statt // überrascht zu werden. Läuft über ein eigenes 1-Sekunden-Intervall statt
// über wandIdleTimer selbst, da der nur EIN MAL (beim Auslösen) feuert. // über wandIdleTimer selbst, da der nur EIN MAL (beim Auslösen) feuert.
// Keine Parameter, keine Rückgabe. // Keine Parameter, keine Rückgabe.
// wandImGrundzustand: ganz oben gescrollt und keine Übersicht offen — dann
// gibt es nichts zurückzusetzen, und der Rücksprung-Zähler ruht (Eriks
// Wunsch: "Der Rücksprung-Timer muss nur laufen, wenn die Seite nicht nach
// ganz oben gescrollt ist"). Rückgabe: Boolean.
function wandImGrundzustand(){
const c = document.getElementById('wandContent');
return !wandUebersicht && (!c || c.scrollTop <= 2);
}
function wandCountdownTick(){ function wandCountdownTick(){
const uhr = document.getElementById('wtUhr');
if(uhr) uhr.textContent = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const el = document.getElementById('wandIdleCountdown'); const el = document.getElementById('wandIdleCountdown');
if(!el) return; if(!el) return;
// Im Grundzustand ruht die Uhr: ausblenden und die volle Minute
// vorhalten — sobald jemand scrollt oder eine Übersicht öffnet, beginnt
// sie dort bei 60 s (das Scrollen meldet wandAktivitaetErkannt() ohnehin).
if(document.body.classList.contains('wandtafel-aktiv') && wandImGrundzustand()){
clearTimeout(wandIdleTimer);
wandIdleTimer = setTimeout(wandBeiUntaetigkeitZuruecksetzen, WAND_IDLE_MS);
wandIdleDeadline = Date.now() + WAND_IDLE_MS;
el.textContent = '';
return;
}
const rest = Math.max(0, Math.ceil((wandIdleDeadline - Date.now()) / 1000)); const rest = Math.max(0, Math.ceil((wandIdleDeadline - Date.now()) / 1000));
el.textContent = rest > 0 ? ('Rücksprung in ' + rest + ' s') : ''; el.textContent = rest > 0 ? ('Rücksprung in ' + rest + ' s') : '';
} }
@@ -209,6 +229,7 @@ function wandCountdownTick(){
// laufen, nicht nach dem ersten Rücksprung stehen bleiben, solange niemand // laufen, nicht nach dem ersten Rücksprung stehen bleiben, solange niemand
// die Tafel berührt. Keine Parameter, keine Rückgabe. // die Tafel berührt. Keine Parameter, keine Rückgabe.
function wandBeiUntaetigkeitZuruecksetzen(){ function wandBeiUntaetigkeitZuruecksetzen(){
if(wandImGrundzustand()){ wandAktivitaetErkannt(); return; }
wandUebersicht = null; wandUebersicht = null;
renderWandboard({ resetScroll: true }); renderWandboard({ resetScroll: true });
wandAktivitaetErkannt(); wandAktivitaetErkannt();
@@ -305,17 +326,18 @@ function renderWandboard(opts){
} }
container.classList.add('wand-scrollable'); container.classList.add('wand-scrollable');
container.innerHTML = '<div class="wand-ambient">' // Entwurf "Wandtafel in Glas" (Eriks Wahl): Kopf → vier Kennzahlen →
+ wandDashKopfHtml(w) // links die Gewerke (Auslastung + Besprochen je Zeile), rechts die
+ wandKpiReiheHtml(w) // nächsten fünf Arbeitstage → Hinweis auf die Projektliste darunter.
+ '<div class="dash-grid">' container.innerHTML = '<div class="wand-ambient wt">'
+ wandPanel('Auslastung / Gewerk', wandAuslastungInhalt(w)) + wtKopfHtml(w)
+ wandPanel('Besprochen / Gewerk', wandBesprochenInhalt(w)) + wtKpiHtml(w)
+ wandPanel('Aktuell', wandAktuellInhalt(w)) + '<div class="wt-felder">' + wtGewerkeHtml(w) + wtTageHtml(w) + '</div>'
+ '</div>' + wtFussHtml(w)
+ '</div>' + '</div>'
+ wandProjektlisteHtml(w); + wandProjektlisteHtml(w);
container.scrollTop = vorherigerScroll; container.scrollTop = vorherigerScroll;
wtSpaltenEinpassen();
} }
// wandGesamtKennzahlen: zwei über alle Gewerke hinweg zusammengefasste // wandGesamtKennzahlen: zwei über alle Gewerke hinweg zusammengefasste
@@ -342,61 +364,6 @@ function wandGesamtKennzahlen(w){
}; };
} }
// 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 // wandProjektlisteHtml: die vollständige Projektliste unterhalb der
// Kennzahl-Kacheln — auf Eriks ausdrücklichen Wunsch ("Die Projektliste soll // Kennzahl-Kacheln — auf Eriks ausdrücklichen Wunsch ("Die Projektliste soll
// so aussehen wie die Originale") keine eigene, vereinfachte Darstellung, // so aussehen wie die Originale") keine eigene, vereinfachte Darstellung,
@@ -552,16 +519,10 @@ function wandAuslastungJeGewerk(w){
if(gewerke.length === 0) return; if(gewerke.length === 0) return;
gewerke.forEach(g => { ergebnis[g].gesamt++; }); gewerke.forEach(g => { ergebnis[g].gesamt++; });
const aktivePhase = lpActiveList(pr)[0]; // Stunden dieser Woche allein aus Aufgaben mit Aufwand (load.js).
const probe = berechneAuslastung(null, pr, aktivePhase, ROLES[0], 1, null); const av = aufgabenVerlaufJePerson(pr);
if(!probe.unbewertbar) gewerke.forEach(g => { ergebnis[g].bewertbar++; }); if(offeneAufwandsAufgaben(pr).length) gewerke.forEach(g => { ergebnis[g].bewertbar++; });
const projektStunden = Object.values(av).reduce((s, v) => s + (v[0] || 0), 0);
let projektStunden = 0;
Object.keys(pr.assign || {}).forEach(aKey => {
const rolle = aKey.slice(aKey.indexOf('|') + 1);
const al = berechneAuslastung(null, pr, aktivePhase, rolle, pr.assign[aKey], pr.assign);
if(al.stunden > 0) projektStunden += al.stunden;
});
gewerke.forEach(g => { gewerke.forEach(g => {
ergebnis[g].stunden += projektStunden; ergebnis[g].stunden += projektStunden;
if(projektStunden > 0) ergebnis[g].projekte.push({ id: pr.id, name: pr.name, nr: pr.nr, stunden: projektStunden }); if(projektStunden > 0) ergebnis[g].projekte.push({ id: pr.id, name: pr.name, nr: pr.nr, stunden: projektStunden });
@@ -571,35 +532,6 @@ function wandAuslastungJeGewerk(w){
return ergebnis; return ergebnis;
} }
// 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);
return WAND_GEWERKE.map(g => {
const d = daten[g];
const label = (DEPARTMENTS[g] && DEPARTMENTS[g].label) || g;
const klick = ' onclick="wandZeigeUebersicht(\'auslastung\',\'' + g + '\')"';
if(d.bewertbar === 0 || d.kapazitaet <= 0){
return '<div class="wand-meter-zeile ist-klickbar"' + klick + '>'
+ '<span class="wand-meter-label">' + esc(label) + '</span>'
+ '<div class="wand-meter"><span class="wand-meter-leer">keine Daten</span></div>'
+ '<span class="wand-meter-wert">—</span></div>';
}
const quote = d.stunden / d.kapazitaet;
const ampel = loadAmpelColor(quote);
const breite = Math.min(100, quote * 100);
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:' + 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('');
}
// wandUebersichtAuslastung: alle Projekte, die zu einem Gewerk beitragen, // wandUebersichtAuslastung: alle Projekte, die zu einem Gewerk beitragen,
// nach Stunden absteigend — Antippen springt ins Projekt (Abschnitt // nach Stunden absteigend — Antippen springt ins Projekt (Abschnitt
// "Leistung", der dort ohnehin schon errechnete Honorar/Fortschritt-Bezug // "Leistung", der dort ohnehin schon errechnete Honorar/Fortschritt-Bezug
@@ -631,7 +563,8 @@ function wandBesprochenDaten(w){
WAND_GEWERKE.forEach(g => { WAND_GEWERKE.forEach(g => {
const relevante = (w.projects || []).filter(p => const relevante = (w.projects || []).filter(p =>
Array.isArray(p.gewerke) && p.gewerke.includes(g) && p.group !== 'In Anbahnung'); Array.isArray(p.gewerke) && p.gewerke.includes(g) && p.group !== 'In Anbahnung');
const offeneProjekte = relevante.filter(p => !p.besprochen); // Je Gewerk sein eigener Haken (kommentarVon(), js/utils.js).
const offeneProjekte = relevante.filter(p => !kommentarVon(p, g).besprochen);
ergebnis[g] = { ergebnis[g] = {
besprochen: relevante.length - offeneProjekte.length, besprochen: relevante.length - offeneProjekte.length,
gesamt: relevante.length, gesamt: relevante.length,
@@ -641,31 +574,6 @@ function wandBesprochenDaten(w){
return ergebnis; return ergebnis;
} }
// 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)). 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);
return WAND_GEWERKE.map(g => {
const d = daten[g];
const label = (DEPARTMENTS[g] && DEPARTMENTS[g].label) || g;
if(d.gesamt === 0){
return '<div class="wand-meter-zeile"><span class="wand-meter-label">' + esc(label) + '</span>'
+ '<span class="wand-meter-leer">keine Projekte</span></div>';
}
const quote = d.besprochen / d.gesamt;
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(--dash-ink-soft)"></div></div>'
+ '<span class="wand-meter-wert">' + d.besprochen + ' von ' + d.gesamt + '</span></div>';
}).join('');
}
// wandUebersichtBesprochen: die noch nicht besprochenen Projekte eines // wandUebersichtBesprochen: die noch nicht besprochenen Projekte eines
// Gewerks — Antippen springt NICHT in den Projektbereich, sondern in den // Gewerks — Antippen springt NICHT in den Projektbereich, sondern in den
// Wochenbericht (springZumWochenbericht(), js/plan/plan-info.js): Der // Wochenbericht (springZumWochenbericht(), js/plan/plan-info.js): Der
@@ -780,87 +688,204 @@ function wandUebersichtAbwesend(w){
// ---------- Ereignis-Feed (Panel "Aktuell") ---------- // ---------- Ereignis-Feed (Panel "Aktuell") ----------
// wandAktuelleEreignisse: sammelt drei Ereignisarten aus allen Projekten der // ===========================================================================
// Sitzung — neue/aktualisierte Kommentare (classifyProjectChange()), frisch // Normalansicht "Wandtafel in Glas" (Eriks Wahl aus dem Entwurf): gleicher
// überfällige Aufgaben und anstehende Meilensteine — als EINE, nach // Hintergrund wie die App (Lichtflächen + Logo, styles.css, Block
// zeitlicher Nähe zu heute sortierte Liste (|Tage bis Datum| aufsteigend, // "WANDTAFEL IN GLAS"), Schrift Outfit wie die Kalenderwoche der Login-Seite.
// nicht nach Datum selbst: Ein Ereignis von gestern und eines von morgen // Jedes Element ist für sich antippbar und führt in dieselben Übersichten
// sind beide "gerade jetzt relevant", eines von vor zwei Wochen oder in zwei // bzw. Projekte wie zuvor.
// 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 // wtKopfHtml: Logo + "Dashboard", Sitzung, heute Abwesende (antippbar →
// Text) sowie pid/abschnitt für den Sprung ins // Übersicht "abwesend"), große Uhr mit Datum (#wtUhr, sekündlich von
// Projekt. Parameter: w. Rückgabe: Array aus {art, projekt (HTML, bereits // wandCountdownTick() nachgeführt). Parameter: w. Rückgabe: HTML.
// escaped), beschreibung (HTML, bereits escaped), zeit, pid, abschnitt}. function wtKopfHtml(w){
function wandAktuelleEreignisse(w){ const d = wandHeuteDaten(w);
const jetzt = new Date();
const abw = d.abwesend.length
? '<div class="wt-abw" onclick="wandZeigeUebersicht(\'abwesend\')" title="Heute abwesend"><span>Heute abwesend</span>'
+ d.abwesend.slice(0, 6).map(p => '<span class="wt-ava">' + esc(initialsFor(p.name)) + '</span>').join('')
+ (d.abwesend.length > 6 ? '<span class="wt-ava">+' + (d.abwesend.length - 6) + '</span>' : '') + '</div>'
: '<div class="wt-abw ist-leer">Heute alle da</div>';
return '<header class="wt-feld wt-kopf">'
+ '<div class="wt-marke"><img src="/logo.svg" alt=""><b>Dashboard</b></div>'
+ '<span class="wt-sitzung">' + esc(w.label) + '</span>'
+ abw
+ '<div class="wt-uhr"><span id="wtUhr">' + esc(jetzt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })) + '</span>'
+ '<small>' + esc(jetzt.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })) + '</small></div>'
+ '</header>';
}
// wtAmpel: Farbe zur Auslastungsquote — dieselben Schwellen wie überall
// (schwelleGelb()/schwelleRot(), js/load/load.js).
function wtAmpel(quote){
// Eine Null bekommt KEINE Ampelfarbe (dieselbe Regel wie im Auslastungs-
// Tab): Grün hieße "gut ausgelastet", die Null heißt "nichts erfasst".
if(quote === null || quote === undefined || quote <= 0) return 'var(--wt-tinte-3)';
return quote >= schwelleRot() ? 'var(--wt-rot)' : quote >= schwelleGelb() ? 'var(--wt-gelb)' : 'var(--wt-gruen)';
}
// wtKpiHtml: Auslastung gesamt und Besprochen mit Ring, offene Aufgaben
// und Überfälliges (beide antippbar wie bisher). Parameter: w. Rückgabe: HTML.
function wtKpiHtml(w){
const g = wandGesamtKennzahlen(w);
const heute = wandHeuteDaten(w);
const bespr = wandBesprochenDaten(w);
let ja = 0, n = 0;
WAND_GEWERKE.forEach(x => { ja += bespr[x].besprochen; n += bespr[x].gesamt; });
const ring = (pct, farbe) => '<span class="wt-ring" style="--p:' + Math.max(0, Math.min(100, pct)) + ';--c:' + farbe + '"></span>';
const ausl = g.auslastung === null ? '—' : Math.round(g.auslastung * 100) + '<small>%</small>';
const ueber = heute.ueberfaelligeListe.length;
return '<section class="wt-kpis">'
+ '<div class="wt-feld wt-kpi">' + ring(g.auslastung === null ? 0 : g.auslastung * 100, wtAmpel(g.auslastung))
+ '<div><div class="wt-wert">' + ausl + '</div><div class="wt-lab"><b>Auslastung</b>alle Gewerke</div></div></div>'
+ '<div class="wt-feld wt-kpi">' + ring(n ? ja / n * 100 : 0, 'var(--wt-gruen)')
+ '<div><div class="wt-wert">' + ja + '<small>/' + n + '</small></div><div class="wt-lab"><b>Besprochen</b>diese Woche</div></div></div>'
+ '<div class="wt-feld wt-kpi ist-klickbar" onclick="wandZeigeUebersicht(\'offen\')">'
+ '<div><div class="wt-wert">' + heute.offen + '</div><div class="wt-lab"><b>Offene Aufgaben</b>in allen Projekten</div></div></div>'
+ '<div class="wt-feld wt-kpi ist-klickbar' + (ueber ? ' ist-rot' : '') + '" onclick="wandZeigeUebersicht(\'ueberfaellig\')">'
+ '<div><div class="wt-wert">' + ueber + '</div><div class="wt-lab"><b>Überfällig</b>' + (ueber ? 'Frist gerissen' : 'nichts gerissen') + '</div></div></div>'
+ '</section>';
}
// wtGewerkeHtml: je Gewerk eine Zeile mit Auslastung (Balken + Prozent,
// antippbar → Übersicht "auslastung") und Besprochen-Stand (ein Kästchen je
// Projekt, antippbar → Übersicht "besprochen"). Parameter: w. Rückgabe: HTML.
function wtGewerkeHtml(w){
const ausl = wandAuslastungJeGewerk(w);
const bespr = wandBesprochenDaten(w);
const personen = {};
(Array.isArray(w.people) ? w.people : []).forEach(p => {
const g = typeof personGewerk === 'function' ? personGewerk(p) : '';
if(g) personen[g] = (personen[g] || 0) + 1;
});
const zeilen = WAND_GEWERKE.map(g => {
const a = ausl[g], b = bespr[g];
const quote = (a.bewertbar > 0 && a.kapazitaet > 0) ? a.stunden / a.kapazitaet : null;
const farbe = wtAmpel(quote);
const auslWert = quote === null ? '—' : Math.round(quote * 100) + ' %';
const kaestchen = b.gesamt
? Array.from({ length: b.gesamt }, (_, i) => '<i class="' + (i < b.besprochen ? 'ja' : '') + '"></i>').join('')
: '<span class="wt-leer">keine Projekte</span>';
return '<div class="wt-gw">'
+ '<span class="wt-gew">' + esc(g) + '<small>' + (personen[g] || 0) + ' Personen</small></span>'
+ '<div class="wt-gw-werte">'
+ '<div class="wt-gw-zeile ist-klickbar" onclick="wandZeigeUebersicht(\'auslastung\',\'' + g + '\')">'
+ '<span class="wt-gw-lab">Auslastung</span>'
+ '<span class="wt-balken"><i style="width:' + (quote === null ? 0 : Math.min(100, quote * 100)) + '%;background:' + farbe + '"></i></span>'
+ '<span class="wt-zahl" style="color:' + farbe + '">' + auslWert + '</span></div>'
+ '<div class="wt-gw-zeile ist-klickbar" onclick="wandZeigeUebersicht(\'besprochen\',\'' + g + '\')">'
+ '<span class="wt-gw-lab">Besprochen</span>'
+ '<span class="wt-punkte">' + kaestchen + '</span>'
+ '<span class="wt-zahl klein">' + (b.gesamt ? b.besprochen + '/' + b.gesamt : '—') + '</span></div>'
+ '</div></div>';
}).join('');
return '<div class="wt-feld wt-panel wt-gewerke"><h2>Gewerke <span>Auslastung und Wochenbericht</span></h2>' + zeilen + '</div>';
}
// WT_TAGE / WT_JE_TAG: wie viele Arbeitstage die Tagesansicht zeigt und wie
// viele Einträge je Tag offen dastehen (der Rest als "+ n weitere").
const WT_TAGE = 5;
const WT_JE_TAG = 8;
// wtTageDaten: die nächsten WT_TAGE Arbeitstage ab heute (Wochenenden und
// gepflegte Feiertage übersprungen, isWorkday()) mit den Meilensteinen aller
// Projekte der Sitzung.
// Parameter: w. Rückgabe: Array von { iso, datum: Date, heute, eintraege }.
function wtTageDaten(w){
const heute = heuteIso(); const heute = heuteIso();
const prevWeek = (typeof findPreviousWeek === 'function') ? findPreviousWeek(w) : null; const tage = [];
const ereignisse = []; let d = parseDateISO(heute);
if(!isWorkday(d)) { while(!isWorkday(d)) d = addDays(d, 1); }
while(tage.length < WT_TAGE){
if(isWorkday(d)) tage.push({ iso: formatDateISO(d), datum: new Date(d), heute: formatDateISO(d) === heute, eintraege: [] });
d = addDays(d, 1);
}
const jeTag = {}; tage.forEach(t => { jeTag[t.iso] = t; });
const erster = tage[0];
// Termine an Wochenenden und Feiertagen zwischen den Spalten gehören zum
// Arbeitstag DAVOR (ein Termin am Samstag ist praktisch einer am Freitag).
// Ohne diese Zuordnung fiel ein solcher Eintrag aus der Tafel heraus, weil
// es für sein Datum keine Spalte gibt. Der Eintrag nennt dann das
// eigentliche Datum.
const spalteFuer = {};
const WT = ['So','Mo','Di','Mi','Do','Fr','Sa'];
for(let x = parseDateISO(erster.iso), ende = parseDateISO(tage[tage.length - 1].iso); x <= ende; x = addDays(x, 1)){
const iso = formatDateISO(x);
spalteFuer[iso] = jeTag[iso] || tage.filter(t => t.iso < iso).pop() || erster;
}
const hinweisFuer = iso => jeTag[iso] ? '' : 'am ' + WT[parseDateISO(iso).getDay()] + ' ' + parseDateISO(iso).getDate() + '.' + (parseDateISO(iso).getMonth() + 1) + '. · ';
(Array.isArray(w.projects) ? w.projects : []).forEach(pr => { (Array.isArray(w.projects) ? w.projects : []).forEach(pr => {
if(pr.group === 'In Anbahnung') return; const projekt = (pr.nr ? pr.nr + ' ' : '') + (pr.name || '');
const projekt = esc(pr.nr || '') + (pr.nr ? ' ' : '') + esc(pr.name || ''); const plan = pr.plan || {};
// Eriks Wunsch: In den Tagesspalten stehen NUR Meilensteine. Aufgaben
if(typeof classifyProjectChange === 'function'){ // (offen/überfällig) sind über die Kennzahlen oben erreichbar, die heute
const klass = classifyProjectChange(pr, prevWeek); // Abwesenden über die Kopfleiste.
if(klass.bucket === 'status' || klass.bucket === 'new'){ (plan.milestones || []).forEach(m => {
const vorschauRoh = pr.status ? statusPreviewText(pr.status) : ''; if(m.date && m.date >= heute && spalteFuer[m.date]) spalteFuer[m.date].eintraege.push({ art: 'ms', titel: m.name || 'Meilenstein', projekt: hinweisFuer(m.date) + projekt, pid: pr.id, abschnitt: 'terminplan', sort: m.date });
const vorschau = vorschauRoh.slice(0, 70);
ereignisse.push({
art: 'neu',
datum: pr.statusAt || heute,
pid: pr.id, abschnitt: 'dashboard', projekt,
beschreibung: (klass.bucket === 'new' ? 'Neues Projekt' : 'Neuer Kommentar')
+ (vorschau ? ': „' + esc(vorschau) + (vorschauRoh.length > 70 ? '…' : '') + '"' : ''),
}); });
} });
tage.forEach(t => t.eintraege.sort((a, b) => String(a.sort).localeCompare(String(b.sort))));
return tage;
} }
const todos = (pr.plan && Array.isArray(pr.plan.todos)) ? pr.plan.todos : []; // wtTageHtml: die Spalten der nächsten Tage, nur mit Meilensteinen (Eriks
todos.forEach(t => { // Wunsch). Ein Eintrag springt in den Terminplan seines Projekts.
if(t.done || !t.end || t.end >= heute) return; // Parameter: w. Rückgabe: HTML.
ereignisse.push({ function wtTageHtml(w){
art: 'ueberfaellig', const tage = wtTageDaten(w);
datum: t.end, const WT = ['So','Mo','Di','Mi','Do','Fr','Sa'];
pid: pr.id, abschnitt: 'aufgaben', projekt, const gruppe = {};
beschreibung: '„' + esc(t.title || 'Aufgabe') + '" überfällig', const spalten = tage.map(t => {
}); const sichtbar = t.eintraege.slice(0, WT_JE_TAG);
}); const rest = t.eintraege.length - sichtbar.length;
let letzteGruppe = null;
const meilensteine = (pr.plan && Array.isArray(pr.plan.milestones)) ? pr.plan.milestones : []; const eintraege = sichtbar.map(e => {
meilensteine.forEach(m => { let kopf = '';
if(!m.date || m.date < heute) return; if(t.heute && gruppe[e.art] && e.art !== letzteGruppe){ kopf = '<div class="wt-gruppe wt-' + e.art + '">' + gruppe[e.art] + '</div>'; letzteGruppe = e.art; }
ereignisse.push({ const klick = e.pid ? ' onclick="wandSpringeZuProjekt(\'' + esc(e.pid) + '\',\'' + e.abschnitt + '\')"' : '';
art: 'termin', return kopf + '<div class="wt-ein wt-' + e.art + (e.pid ? ' ist-klickbar' : '') + '"' + klick + '><i></i><div><b>' + esc(e.titel) + '</b><span>' + esc(e.projekt) + '</span></div></div>';
datum: m.date, }).join('');
pid: pr.id, abschnitt: 'terminplan', projekt, // "+ n weitere" steht fest am unteren Rand; wtSpaltenEinpassen() zählt
beschreibung: 'Meilenstein „' + esc(m.name || 'Meilenstein') + '"', // nach dem Zeichnen noch die Einträge hinzu, die nicht in die Höhe passen.
}); const mehr = '<div class="wt-mehr" data-rest="' + rest + '"' + (t.heute ? ' onclick="wandZeigeUebersicht(\'ueberfaellig\')"' : '') + (rest > 0 ? '' : ' hidden') + '>+ ' + rest + ' weitere</div>';
}); const titel = t.heute ? 'Heute' : WT[t.datum.getDay()];
}); const datum = (t.heute ? WT[t.datum.getDay()] + ' ' : '') + t.datum.getDate() + '.' + (t.datum.getMonth() + 1) + '.';
return '<div class="wt-tag' + (t.heute ? ' ist-heute' : '') + '"><div class="wt-tag-kopf"><b>' + titel + '</b><span>' + datum + '</span></div>'
ereignisse.forEach(e => { e.zeit = relativerTag(e.datum); }); + '<div class="wt-tag-liste">' + (eintraege || '<div class="wt-leer">Kein Meilenstein.</div>') + '</div>' + mehr + '</div>';
ereignisse.sort((a, b) => Math.abs(tageBis(a.datum)) - Math.abs(tageBis(b.datum))); }).join('');
return ereignisse; return '<div class="wt-feld wt-panel wt-tage"><h2>Die nächsten Tage <span>Meilensteine aller Projekte</span></h2>'
+ '<div class="wt-tage-raster">' + spalten + '</div>'
+ '</div>';
} }
// wandAktuellInhalt: der Inhalt des Panels "Aktuell" (wandPanel() rahmt // wtSpaltenEinpassen: blendet in jeder Tagesspalte die Einträge aus, die
// ihn) — eine flache, chronologisch sortierte Liste (wandAktuelleEreignisse() // nicht mehr in die Höhe passen (die Spaltenhöhe hängt vom Bildschirm ab,
// ist bereits nach zeitlicher Nähe zu heute sortiert), gedeckelt auf // zweizeilige Titel brauchen mehr Platz), und zählt sie zu "+ n weitere".
// WAND_FEED_MAX Einträge. Ein farbiger Punkt zeigt die Art // Läuft nach jedem Zeichnen der Normalansicht. Keine Parameter.
// (Neu/Überfällig/Termin). Jeder Eintrag bleibt für sich antippbar und function wtSpaltenEinpassen(){
// springt direkt ins zugehörige Projekt. Parameter: w. Rückgabe: document.querySelectorAll('#wandContent .wt-tag').forEach(tag => {
// HTML-String. const liste = tag.querySelector('.wt-tag-liste');
function wandAktuellInhalt(w){ const mehr = tag.querySelector('.wt-mehr');
const ereignisse = wandAktuelleEreignisse(w).slice(0, WAND_FEED_MAX); if(!liste || !mehr) return;
if(ereignisse.length === 0){ let versteckt = 0;
return '<div class="wand-leer-klein">Nichts Neues diese Woche.</div>'; const eintraege = [...liste.querySelectorAll('.wt-ein')];
while(liste.scrollHeight > liste.clientHeight + 1 && eintraege.length){
const e = eintraege.pop();
e.hidden = true; versteckt++;
// Eine Gruppenüberschrift ohne sichtbaren Eintrag danach mit ausblenden.
const vor = e.previousElementSibling;
if(vor && vor.classList.contains('wt-gruppe') && (!vor.nextElementSibling || vor.nextElementSibling.hidden)) vor.hidden = true;
} }
return ereignisse.map(e => const rest = (Number(mehr.dataset.rest) || 0) + versteckt;
'<div class="dash-feed-eintrag" onclick="wandSpringeZuProjekt(\'' + esc(e.pid) + '\',\'' + esc(e.abschnitt) + '\')">' mehr.textContent = '+ ' + rest + ' weitere';
+ '<span class="dash-feed-punkt ' + e.art + '"></span>' mehr.hidden = rest === 0;
+ '<div class="dash-feed-text">' + e.projekt + ' — ' + e.beschreibung });
+ '<span class="dash-feed-zeit">' + esc(e.zeit) + '</span></div>'
+ '</div>'
).join('');
} }
// wtFussHtml: Hinweis auf die Projektliste darunter (zum Wischen).
function wtFussHtml(w){
const n = (w.projects || []).length;
return '<div class="wt-feld wt-fuss"><span class="wt-pfeil">↓</span><span>Wischen für alle ' + n + ' Projekte</span></div>';
}
+246 -105
View File
@@ -56,7 +56,11 @@ function lphSignature(p){
// ihr (z. B. gerade erst angelegtes Projekt) gilt es als neu. Parameter: p // ihr (z. B. gerade erst angelegtes Projekt) gilt es als neu. Parameter: p
// (Projekt der aktuellen Sitzung), prevWeek (Vorwoche-Objekt oder null). // (Projekt der aktuellen Sitzung), prevWeek (Vorwoche-Objekt oder null).
// Rückgabe: { bucket: 'new'|'status'|'progress'|'unchanged', reason: String }. // Rückgabe: { bucket: 'new'|'status'|'progress'|'unchanged', reason: String }.
function classifyProjectChange(p, prevWeek){ function classifyProjectChange(p, prevWeek, gewerk){
// Kommentare werden je Gewerk verglichen (kommentarVon(), js/utils.js) —
// ein neuer ELT-Kommentar ist für die HLS-Besprechung keine Neuigkeit.
// Ohne ausdrückliches Gewerk gilt das des Zusammenhangs.
const g = gewerk === undefined ? kommentarGewerk() : gewerk;
const prev = (prevWeek && p.projectUid) const prev = (prevWeek && p.projectUid)
? prevWeek.projects.find(x => x.projectUid === p.projectUid) ? prevWeek.projects.find(x => x.projectUid === p.projectUid)
: null; : null;
@@ -64,8 +68,10 @@ function classifyProjectChange(p, prevWeek){
if(!prev){ if(!prev){
return { bucket: 'new', reason: 'Neues Projekt' }; return { bucket: 'new', reason: 'Neues Projekt' };
} }
const statusNow = (p.status || '').trim(); const kNow = kommentarVon(p, g);
const statusPrev = (prev.status || '').trim(); const kPrev = kommentarVon(prev, g);
const statusNow = (kNow.status || '').trim();
const statusPrev = (kPrev.status || '').trim();
// Nur ein tatsächlich vorhandener, geänderter Text zählt als neuer // Nur ein tatsächlich vorhandener, geänderter Text zählt als neuer
// Kommentar. Seit cloneWeek() (js/weeks.js) den Text der Vorwoche nicht // Kommentar. Seit cloneWeek() (js/weeks.js) den Text der Vorwoche nicht
// mehr mitkopiert, startet jede duplizierte Sitzung mit leerem Kommentar — // mehr mitkopiert, startet jede duplizierte Sitzung mit leerem Kommentar —
@@ -81,7 +87,7 @@ function classifyProjectChange(p, prevWeek){
// wortgleich den Text der Vorwoche, setzt dabei aber einen neuen // wortgleich den Text der Vorwoche, setzt dabei aber einen neuen
// Zeitstempel. Ohne diese Prüfung landete das Projekt unter "Unverändert", // Zeitstempel. Ohne diese Prüfung landete das Projekt unter "Unverändert",
// obwohl diese Woche ausdrücklich etwas dazu festgehalten wurde. // obwohl diese Woche ausdrücklich etwas dazu festgehalten wurde.
if(statusNow !== '' && p.statusAt && p.statusAt !== (prev.statusAt || '')){ if(statusNow !== '' && kNow.statusAt && kNow.statusAt !== (kPrev.statusAt || '')){
return { bucket: 'status', reason: 'Kommentar bestätigt' }; return { bucket: 'status', reason: 'Kommentar bestätigt' };
} }
if(lphSignature(p) !== lphSignature(prev)){ if(lphSignature(p) !== lphSignature(prev)){
@@ -166,9 +172,12 @@ function renderAttendance(){
const w = currentWeek(); const w = currentWeek();
if(!w){ container.innerHTML = ''; return; } if(!w){ container.innerHTML = ''; return; }
const locked = isWeekLocked(w); // Ohne Recht „Anwesenheit erfassen“ (Rollen & Rechte) nur Anzeige.
const locked = isWeekLocked(w) || !hatRecht('wb.anwesenheit');
const att = w.attendance || {}; const att = w.attendance || {};
const people = Array.isArray(w.people) ? w.people : []; // Nur die Personen des gewählten Gewerks — jedes Gewerk erfasst die
// Anwesenheit seiner eigenen Besprechung (personenDesGewerks(), js/utils.js).
const people = personenDesGewerks(w, wochenberichtGewerk);
// Fokus-Modus (Eriks Wahl "C"): die Anwesenheit ist ein aufklappbarer // Fokus-Modus (Eriks Wahl "C"): die Anwesenheit ist ein aufklappbarer
// Abschnitt wie Leistungsphasen/Beteiligte, kein dauerhaft sichtbarer Block // Abschnitt wie Leistungsphasen/Beteiligte, kein dauerhaft sichtbarer Block
// mehr — Zustand in berichtDisclosureState (js/board/board.js). // mehr — Zustand in berichtDisclosureState (js/board/board.js).
@@ -177,7 +186,7 @@ function renderAttendance(){
if(people.length === 0){ if(people.length === 0){
container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}> container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}>
<summary><span>Anwesenheit</span><span class="chev">${icon('chevronRight')}</span></summary> <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> <div class="bericht-disclosure-body"><span class="hint" style="margin:0">Für dieses Gewerk sind in dieser Sitzung keine Projektbeteiligten hinterlegt.</span></div>
</details>`; </details>`;
return; return;
} }
@@ -213,7 +222,7 @@ function renderAttendance(){
}).join(''); }).join('');
container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}> 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> <summary><span>Anwesenheit ${esc(wochenberichtGewerk || "")}</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> <div class="bericht-disclosure-body">${actions}<div class="attendance-grid">${tiles}</div></div>
</details>`; </details>`;
} }
@@ -243,7 +252,7 @@ function setAllAttendance(present){
const w = currentWeek(); const w = currentWeek();
if(!w) return; if(!w) return;
if(!w.attendance) w.attendance = {}; if(!w.attendance) w.attendance = {};
(w.people || []).forEach(p => { w.attendance[p.id] = !!present; }); personenDesGewerks(w, wochenberichtGewerk).forEach(p => { w.attendance[p.id] = !!present; });
save(); save();
renderAttendance(); renderAttendance();
} }
@@ -352,8 +361,7 @@ function aktuellesBerichtProjekt(w){
// Parameter: uid (project_uid). Keine Rückgabe. // Parameter: uid (project_uid). Keine Rückgabe.
function waehleBerichtProjekt(uid){ function waehleBerichtProjekt(uid){
berichtAuswahl = uid; berichtAuswahl = uid;
vorwocheRadIndex = 0; // neues Projekt beginnt wieder bei der jüngsten Vorwoche berichtVerlaufAlle = false; // neues Projekt zeigt wieder nur die jüngsten Einträge
vorwocheRadScrollAkku = 0; // kein halb aufgelaufenes Scrollen vom vorherigen Projekt übernehmen
renderWochenbericht(); renderWochenbericht();
const detail = document.getElementById('berichtDetail'); const detail = document.getElementById('berichtDetail');
if(detail) detail.scrollIntoView({ block: 'nearest' }); if(detail) detail.scrollIntoView({ block: 'nearest' });
@@ -374,23 +382,39 @@ function waehleBerichtProjekt(uid){
// das noch offen ist. Der Motor des Meetings — ohne ihn sucht man nach jedem // das noch offen ist. Der Motor des Meetings — ohne ihn sucht man nach jedem
// Projekt von Hand die nächste Zeile. Parameter: pid. Keine Rückgabe. // Projekt von Hand die nächste Zeile. Parameter: pid. Keine Rückgabe.
function besprochenUndWeiter(pid){ function besprochenUndWeiter(pid){
if(guardWeekLocked()) return; if(guardWeekLocked() || !hatRecht('wb.besprochen')) return;
const w = currentWeek(); const w = currentWeek();
const p = (w.projects || []).find(x => x.id === pid); const p = (w.projects || []).find(x => x.id === pid);
if(!p) return; if(!p) return;
p.besprochen = true; kommentarSetzen(p, wochenberichtGewerk, { besprochen: true });
save(); save();
const liste = berichtProjekte(w); const liste = berichtProjekte(w);
const idx = liste.findIndex(x => x.id === pid); const idx = liste.findIndex(x => x.id === pid);
// Erst hinter der aktuellen Stelle suchen, dann von vorn — so landet man // Erst hinter der aktuellen Stelle suchen, dann von vorn — so landet man
// auch dann beim nächsten offenen Punkt, wenn man mittendrin eingestiegen ist. // auch dann beim nächsten offenen Punkt, wenn man mittendrin eingestiegen ist.
const offen = liste.slice(idx + 1).find(x => !x.besprochen) const istAb = x => kommentarVon(x, wochenberichtGewerk).besprochen;
|| liste.find(x => !x.besprochen); const offen = liste.slice(idx + 1).find(x => !istAb(x))
|| liste.find(x => !istAb(x));
berichtAuswahl = offen ? offen.projectUid : p.projectUid; berichtAuswahl = offen ? offen.projectUid : p.projectUid;
renderWochenbericht(); renderWochenbericht();
} }
// berichtNaechstes: das Projekt direkt nach pid in der sichtbaren Liste
// (dieselbe Reihenfolge und Suche wie die Pfeiltaste ↓), sonst null.
function berichtNaechstes(w, pid){
const liste = berichtProjekte(w).filter(passtZurSuche);
const idx = liste.findIndex(x => x.id === pid);
return idx >= 0 ? (liste[idx + 1] || null) : null;
}
// berichtWeiter: springt zum nächsten Projekt, OHNE es abzuhaken — für ein
// Projekt, das heute nicht besprochen wird. Parameter: pid. Keine Rückgabe.
function berichtWeiter(pid){
const naechstes = berichtNaechstes(currentWeek(), pid);
if(naechstes) waehleBerichtProjekt(naechstes.projectUid || null);
}
// setzeAlleBesprochen: setzt alle Haken der Sitzung zurück bzw. hakt alles ab. // setzeAlleBesprochen: setzt alle Haken der Sitzung zurück bzw. hakt alles ab.
// Parameter: wert (Boolean). Keine Rückgabe. // Parameter: wert (Boolean). Keine Rückgabe.
function setzeAlleBesprochen(wert){ function setzeAlleBesprochen(wert){
@@ -402,7 +426,9 @@ function setzeAlleBesprochen(wert){
// ANDEREN Gewerke als besprochen, die in dieser Besprechung nie zu sehen // ANDEREN Gewerke als besprochen, die in dieser Besprechung nie zu sehen
// waren (widerspricht "Wochenbericht — Gewerke-Trennung": Vollständigkeit // waren (widerspricht "Wochenbericht — Gewerke-Trennung": Vollständigkeit
// gilt innerhalb des gewählten Gewerks, nicht über alle hinweg). // gilt innerhalb des gewählten Gewerks, nicht über alle hinweg).
berichtProjekte(w).forEach(p => { p.besprochen = !!wert; }); // Nur der Haken DIESES Gewerks — die anderen Gewerke besprechen das
// Projekt in ihrer eigenen Sitzung.
berichtProjekte(w).forEach(p => kommentarSetzen(p, wochenberichtGewerk, { besprochen: !!wert }));
save(); save();
renderWochenbericht(); renderWochenbericht();
} }
@@ -410,20 +436,34 @@ function setzeAlleBesprochen(wert){
// ---------- Abwesenheit ---------- // ---------- Abwesenheit ----------
// abwesendeBeteiligte: die einem Projekt zugeordneten Personen, die heute // abwesendeBeteiligte: die einem Projekt zugeordneten Personen, die heute
// fehlen. "Heute" ist der Anwesenheitsstand dieser Sitzung (w.attendance) — // laut ihrer EINGETRAGENEN Abwesenheit fehlen (Urlaub, Krank, Fortbildung …
// er wird am Fuß des Berichts ohnehin erfasst. Punkte, deren Zuständige // aus der Benutzerverwaltung, db.globalPeople[].absences) — Eriks Wunsch.
// fehlen, vertagt man im Meeting besser. // Früher wurde stattdessen die Anwesenheitsliste am Fuß des Berichts
// Parameter: p (Projekt), w (Woche). Rückgabe: Array von Personennamen. // ausgewertet: Wer dort nicht abgehakt war, "fehlte" — auch wenn er nur
// nicht in dieser Besprechung saß, und umgekehrt fehlte ein Urlauber nicht,
// solange niemand die Anwesenheit erfasst hatte. Punkte, deren Zuständige
// abwesend sind, vertagt man im Meeting besser.
// Zugeordnet = in irgendeiner Rolle (Haupt oder Unterstützung) im Projekt.
// Die Person der Sitzung wird über ihre Kennung, ersatzweise den vollen
// Namen, ihrem Konto zugeordnet (wie personGewerk(), js/utils.js).
// Parameter: p (Projekt), w (Woche). Rückgabe: Array von Texten
// "Name (Urlaub bis 28.10.)".
function abwesendeBeteiligte(p, w){ function abwesendeBeteiligte(p, w){
const att = w.attendance || {}; const heute = heuteIso();
// Solange niemand als anwesend markiert ist, ist die Anwesenheit schlicht const konten = Array.isArray(db.globalPeople) ? db.globalPeople : [];
// noch nicht erfasst — dann wäre "alle fehlen" eine Falschmeldung.
if(!Object.values(att).some(v => v)) return [];
const namen = []; const namen = [];
(w.people || []).forEach(person => { (w.people || []).forEach(person => {
const rollen = Array.isArray(person.role) ? person.role : [person.role]; const rollen = Array.isArray(person.role) ? person.role : [person.role];
const zugeordnet = rollen.some(r => (p.assign || {})[assignKey(person.id, r)] > 0); const zugeordnet = rollen.some(r => (p.assign || {})[assignKey(person.id, r)] > 0);
if(zugeordnet && !att[person.id] && namen.indexOf(person.name) === -1) namen.push(person.name); if(!zugeordnet) return;
const konto = konten.find(x => x.id === person.id)
|| (person.fullName ? konten.find(x => x.fullName === person.fullName) : null);
const a = konto && (konto.absences || []).find(x => x.start && x.end && x.start <= heute && heute <= x.end);
if(!a) return;
const name = person.fullName || person.name;
const bis = a.end > heute ? ' bis ' + formatWeekDate(a.end).replace(/\d{4}$/, '') : '';
const text = name + ' (' + (a.type || 'abwesend') + bis + ')';
if(!namen.some(n => n.startsWith(name + ' ('))) namen.push(text);
}); });
return namen; return namen;
} }
@@ -487,13 +527,13 @@ function renderWochenbericht(){
if(locked){ if(locked){
html += `<div class="lock-notice"> html += `<div class="lock-notice">
Diese Sitzung ist gesperrt. Bearbeiten nur nach Entsperren möglich. Diese Sitzung ist gesperrt. Bearbeiten nur nach Entsperren möglich.
<button type="button" class="secondary small" onclick="unlockCurrentWeek()">Entsperren</button> ${hatRecht('s.entsperren') ? '<button type="button" class="secondary small" onclick="unlockCurrentWeek()">Entsperren</button>' : ''}
</div>`; </div>`;
} }
if(liste.length === 0){ if(liste.length === 0){
html += renderBerichtKopf(w, liste, prevWeek, locked); html += renderBerichtKopf(w, liste, prevWeek, locked);
html += `<div class="hint">In dieser Sitzung ist noch kein Projekt angelegt.</div>`; html += `<div class="hint">Für ${esc(wochenberichtGewerk)} steht in dieser Sitzung kein Projekt auf der Tagesordnung.</div>`;
container.innerHTML = html; container.innerHTML = html;
renderAttendance(); renderAttendance();
return; return;
@@ -560,7 +600,7 @@ function renderBerichtKopfNeu(){
// renderBerichtKopf: Gewerke-Wahl, Fortschrittsring und das Sprungfeld. // renderBerichtKopf: Gewerke-Wahl, Fortschrittsring und das Sprungfeld.
// Parameter: w, liste, prevWeek, locked. Rückgabe: HTML. // Parameter: w, liste, prevWeek, locked. Rückgabe: HTML.
function renderBerichtKopf(w, liste, prevWeek, locked){ function renderBerichtKopf(w, liste, prevWeek, locked){
const ab = liste.filter(p => p.besprochen).length; const ab = liste.filter(p => kommentarVon(p, wochenberichtGewerk).besprochen).length;
const pct = liste.length ? Math.round(ab / liste.length * 100) : 0; const pct = liste.length ? Math.round(ab / liste.length * 100) : 0;
// Der Vergleichs-Hinweis ("Vergleich mit KW …") steht nicht mehr als eigene // Der Vergleichs-Hinweis ("Vergleich mit KW …") steht nicht mehr als eigene
// Zeile da, sondern als Titel-Tooltip am Fortschritt — dieselbe Angabe, // Zeile da, sondern als Titel-Tooltip am Fortschritt — dieselbe Angabe,
@@ -569,7 +609,7 @@ function renderBerichtKopf(w, liste, prevWeek, locked){
? `Vergleich mit ${esc(prevWeek.label)}${prevWeek.date ? ' (' + esc(formatWeekDate(prevWeek.date)) + ')' : ''}.` ? `Vergleich mit ${esc(prevWeek.label)}${prevWeek.date ? ' (' + esc(formatWeekDate(prevWeek.date)) + ')' : ''}.`
: 'Dies ist die früheste vorhandene Sitzung — kein Vergleich zur Vorwoche möglich.'; : 'Dies ist die früheste vorhandene Sitzung — kein Vergleich zur Vorwoche möglich.';
const resetBtn = locked ? '' : (ab > 0 const resetBtn = (locked || !hatRecht('wb.besprochen')) ? '' : (ab > 0
? `<button type="button" class="bericht-reset-link" onclick="setzeAlleBesprochen(false)">Zurücksetzen</button>` ? `<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>`); : `<button type="button" class="bericht-reset-link" onclick="setzeAlleBesprochen(true)">Alle abhaken</button>`);
@@ -627,7 +667,7 @@ function renderBerichtListe(){
const gruppenkopf = gruppe !== gruppeVorher ? `<div class="bericht-rail-gruppe">${esc(gruppe)}</div>` : ''; const gruppenkopf = gruppe !== gruppeVorher ? `<div class="bericht-rail-gruppe">${esc(gruppe)}</div>` : '';
gruppeVorher = gruppe; gruppeVorher = gruppe;
const name = (p.nr ? p.nr + ' · ' : '') + p.name; const name = (p.nr ? p.nr + ' · ' : '') + p.name;
return `${gruppenkopf}<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${p.besprochen ? ' is-ab' : ''}" return `${gruppenkopf}<div class="bericht-rail-item${istAktiv ? ' is-aktiv' : ''}${kommentarVon(p, wochenberichtGewerk).besprochen ? ' is-ab' : ''}"
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}" role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}"
aria-current="${istAktiv ? 'true' : 'false'}"> aria-current="${istAktiv ? 'true' : 'false'}">
<span class="bericht-rail-num">${String(position).padStart(2, '0')}</span> <span class="bericht-rail-num">${String(position).padStart(2, '0')}</span>
@@ -651,6 +691,9 @@ function renderBerichtListe(){
// "Besprochen & weiter". Parameter: p, w, prevWeek, locked. Rückgabe: HTML. // "Besprochen & weiter". Parameter: p, w, prevWeek, locked. Rückgabe: HTML.
function renderBerichtDetail(p, w, prevWeek, locked){ function renderBerichtDetail(p, w, prevWeek, locked){
if(!p) return ''; if(!p) return '';
// Kommentar und Fortschritt nur mit Recht „Kommentar und Fortschritt
// bearbeiten“, sonst wie gesperrt als Anzeige (Rollen & Rechte).
const kLocked = locked || !hatRecht('wb.kommentar');
const grouped = peopleByRole(w); const grouped = peopleByRole(w);
const allPeople = []; const allPeople = [];
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry))); ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
@@ -669,89 +712,179 @@ function renderBerichtDetail(p, w, prevWeek, locked){
</div> </div>
<h1 class="bericht-h1">${esc(p.name)}</h1> <h1 class="bericht-h1">${esc(p.name)}</h1>
<div class="bericht-sub">${subText}</div> <div class="bericht-sub">${subText}</div>
${fehlen.length ? `<span class="bericht-abwesend-hinweis">Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''} ${fehlen.length ? `<span class="bericht-abwesend-hinweis">Heute abwesend: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''}
${renderVorwocheRad(p, w)} ${renderBoardDrawer(p, allPeople, kLocked, w.label, Object.assign({}, BERICHT_OPTS, { ohneVerlauf: true }))}
${renderBoardDrawer(p, allPeople, locked, w.label, BERICHT_OPTS)} <div class="bericht-detail-fuss">
${locked ? '' : `<div class="bericht-detail-fuss"> ${!locked && hatRecht('proj.plan') ? `<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
Aufgabe aus Kommentar… Aufgabe aus Kommentar…
</button> </button>` : ''}
<span class="bericht-fuss-spacer"></span> <span class="bericht-fuss-spacer"></span>
<button type="button" onclick="besprochenUndWeiter('${esc(p.id)}')">Besprochen &amp; weiter →</button> <button type="button" class="secondary" onclick="berichtWeiter('${esc(p.id)}')"${berichtNaechstes(w, p.id) ? '' : ' disabled title="Letztes Projekt der Liste"'}>Weiter →</button>
</div>`}`; ${!locked && hatRecht('wb.besprochen') ? `<button type="button" onclick="besprochenUndWeiter('${esc(p.id)}')">Besprochen &amp; weiter →</button>` : ''}
</div>
${renderBerichtVerlauf(p, w, kLocked)}`;
} }
// vorwocheRadIndex: Position im Kommentar-Verlauf des GERADE GEÖFFNETEN // ---------- Verlauf der Vorwochen (Eriks Wahl "B") ----------
// Projekts (0 = jüngste Vorwoche, höher = weiter zurück). Ein einzelner //
// modulweiter Stand genügt, da immer nur ein Projekt im Fokus steht; // Löst das Scrollrad über dem Kommentarfeld (renderVorwocheRad()) und den
// waehleBerichtProjekt() setzt ihn beim Projektwechsel zurück. // aufklappbaren Kachel-Streifen (renderProjektVerlauf(), bleibt für
let vorwocheRadIndex = 0; // Projektliste/Projektbereich) im Wochenbericht ab. Darstellung wie im
// Projekt-Dashboard (renderPlanKommentarVerlauf(), plan-info.js): eine
// senkrechte Zeitleiste, hier als eigener Abschnitt über die volle Breite
// unter der Bearbeitungsmaske. Über dem Kommentarfeld bleibt nur die schmale
// Zeile "Zuletzt KW …" (renderBerichtZuletzt()).
//
// Ein Eintrag entsteht nur, wenn sich der Kommentar DES GEWÄHLTEN GEWERKS
// gegenüber der Sitzung davor geändert hat; Sitzungen dazwischen ohne neuen
// Text stehen als grauer Vermerk am Eintrag. Geänderte Leistungsphasen seit
// dem vorigen Eintrag erscheinen als Chips ("LPH 5: 60 → 75 %"), statt in
// jedem Eintrag alle Werte zu wiederholen. Keine neue Datenhaltung — jede
// Sitzung hält ihre eigene Kopie des Projekts (projectVersionsForUid()).
// renderVorwocheRad: löst renderVorwochenzeile() ab, die nur die const BERICHT_VERLAUF_KURZ = 3;
// UNMITTELBARE Vorwoche zeigte. Eriks Rückmeldung zum Entwurfsvergleich: mit // berichtVerlaufAlle: "Alle N anzeigen" aufgeklappt; waehleBerichtProjekt()
// dem Mausrad (oder den beiden Pfeilen) durch MEHRERE vergangene Kommentare // setzt es beim Projektwechsel zurück.
// blättern können, nicht nur eine Vorwoche sehen. Nutzt let berichtVerlaufAlle = false;
// projectStatusHistory() (js/utils.js) — denselben, bereits für den
// Verlaufs-Dialog geprüften Verlauf (nur Sitzungen mit tatsächlich // berichtKurzKw: "KW 43 / 2026" -> "KW 43" (Jahr nur, wenn nicht das laufende)
// geändertem Text, neueste zuerst), abzüglich der aktuellen Sitzung selbst function berichtKurzKw(label){
// (die steht ja bereits im Eingabefeld darunter). Parameter: p, w (aktuelle const m = /^(KW\s*\d+)\s*\/\s*(\d{4})$/.exec(String(label || '').trim());
// Sitzung). Rückgabe: HTML (leer ohne Verlauf). if(!m) return label || '';
function renderVorwocheRad(p, w){ return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2];
}
// berichtVerlaufDaten: die früheren Kommentare eines Projekts für ein Gewerk.
// Parameter: p (Projekt der Sitzung w), w (aktuelle Sitzung), g (Gewerk oder
// null). Rückgabe: { sitzungen, eintraege: [{week, html, autor, at, ruhe, chips}] },
// neueste zuerst.
function berichtVerlaufDaten(p, w, g){
const bis = weekSortKey(w.label);
const versionen = projectVersionsForUid(p.projectUid)
.filter(v => weekSortKey(v.week.label).localeCompare(bis) < 0);
const roh = [];
let vorher = '';
versionen.forEach((v, i) => {
const k = kommentarVon(v.project, g);
const text = statusPlainText(k.status);
if(text && text !== vorher){
roh.push({ i, week: v.week, project: v.project, html: sanitizeStatusHtml(k.status || ''),
autor: k.statusAuthor || '', at: k.statusAt || '' });
}
vorher = text;
});
const eintraege = roh.map((e, n) => {
// Sitzungen nach diesem Eintrag bis zum nächsten (bzw. bis zur aktuellen)
const bisIdx = n + 1 < roh.length ? roh[n + 1].i : versionen.length;
const still = versionen.slice(e.i + 1, bisIdx).map(v => berichtKurzKw(v.week.label));
const ruhe = still.length === 0 ? ''
: (still.length === 1 ? still[0] : still[0] + ' bis ' + still[still.length - 1]) + ' ohne neuen Kommentar';
// LPH-Änderungen gegenüber dem vorigen Eintrag (beim ältesten gegenüber
// der Sitzung direkt davor, sofern es eine gibt)
const basis = n > 0 ? roh[n - 1].project : (e.i > 0 ? versionen[e.i - 1].project : null);
const chips = [];
if(basis){
const alt = basis.lph || {}, neu = e.project.lph || {};
LPHS.forEach(l => {
const a = alt[l] === undefined || alt[l] === '' ? '' : String(alt[l]);
const b = neu[l] === undefined || neu[l] === '' ? '' : String(neu[l]);
if(a !== b && b !== '') chips.push(l + ': ' + (a || '0') + ' → ' + b + ' %');
});
}
return { week: e.week, html: e.html, autor: e.autor, at: e.at, ruhe, chips };
}).reverse();
return { sitzungen: versionen.length, eintraege };
}
// berichtVerlaufDatum: ISO-Zeitstempel -> "20.10." (leer, wenn unbrauchbar)
function berichtVerlaufDatum(at){
const d = at ? new Date(at) : null;
if(!d || isNaN(d)) return '';
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
// renderBerichtZuletzt: NICHT MEHR AUFGERUFEN (Eriks Wunsch — der Kommentar
// der Vorwoche steht seit der Übernahme beim Sitzungswechsel ohnehin im
// Feld). Früher: die schmale Zeile über dem Kommentarfeld — der
// jüngste frühere Kommentar dieses Gewerks. Rückgabe: HTML (leer ohne Verlauf).
function renderBerichtZuletzt(p, w){
if(!p.projectUid) return ''; if(!p.projectUid) return '';
const verlauf = projectStatusHistory(p.projectUid).filter(e => e.week.label !== w.label); const e = berichtVerlaufDaten(p, w, wochenberichtGewerk).eintraege[0];
if(verlauf.length === 0) return ''; if(!e) return '';
const i = Math.max(0, Math.min(verlauf.length - 1, vorwocheRadIndex)); // Mit Formatierung (fett, kursiv, Farbe, Zeilen) wie im Verlauf darunter —
const eintrag = verlauf[i]; // e.html ist bereits über sanitizeStatusHtml() bereinigt.
const punkte = verlauf.map((_, idx) => `<span class="dot${idx === i ? ' aktiv' : ''}"></span>`).join(''); return `<div class="bericht-zuletzt">
return `<div class="bericht-vorwoche-rad" title="Mit dem Mausrad durch den Verlauf blättern" onwheel="vorwocheRadScroll(event)"> <span class="bericht-zuletzt-kw">Zuletzt ${esc(berichtKurzKw(e.week.label))}</span>
<button type="button" class="bericht-vorwoche-nav" onclick="vorwocheRadSchritt(1)" ${i >= verlauf.length - 1 ? 'disabled' : ''} title="Weiter zurück">‹</button> <div class="bericht-zuletzt-text">${e.html}</div>
<div class="bericht-vorwoche-inhalt">
<span class="bericht-vorwoche-label">${esc(eintrag.week.label)}</span>
<span class="bericht-vorwoche-text">${esc(eintrag.text)}</span>
</div>
<button type="button" class="bericht-vorwoche-nav" onclick="vorwocheRadSchritt(-1)" ${i <= 0 ? 'disabled' : ''} title="Neuer">›</button>
<div class="bericht-vorwoche-dots">${punkte}</div>
</div>`; </div>`;
} }
// vorwocheRadSchritt: bewegt vorwocheRadIndex um delta (geklemmt auf den // renderBerichtVerlauf: der Abschnitt "Verlauf der Vorwochen" unter der
// vorhandenen Verlauf) und zeichnet den Bericht neu — derselbe vollständige // Bearbeitungsmaske. Parameter: p, w, locked. Rückgabe: HTML.
// Neuaufbau, den auch editLph() auslöst; ein offener function renderBerichtVerlauf(p, w, locked){
// Kommentar-Editor übersteht das über dasselbe Fokus-Wiederherstellungsnetz if(!p || !p.projectUid) return '';
// (pendingCommentFocus, js/board/board.js), eine aufgeklappte Anwesenheit const g = wochenberichtGewerk;
// über berichtDisclosureState. Parameter: delta (1 = weiter zurück, -1 = const { sitzungen, eintraege } = berichtVerlaufDaten(p, w, g);
// neuer). Keine Rückgabe. const gText = g || 'alle Gewerke';
function vorwocheRadSchritt(delta){ let inhalt;
vorwocheRadScrollAkku = 0; // ein gezielter Klick verwirft ein halb aufgelaufenes Scrollen if(eintraege.length === 0){
const w = currentWeek(); inhalt = `<div class="hint" style="margin:0">${sitzungen === 0
const p = aktuellesBerichtProjekt(w); ? 'Dieses Projekt erscheint zum ersten Mal, es gibt noch keine frühere Sitzung.'
if(!w || !p) return; : 'In den früheren Sitzungen gibt es noch keinen Kommentar für ' + esc(gText) + '.'}</div>`;
const max = projectStatusHistory(p.projectUid).filter(e => e.week.label !== w.label).length - 1; } else {
vorwocheRadIndex = Math.max(0, Math.min(max, vorwocheRadIndex + delta)); const zeigen = berichtVerlaufAlle ? eintraege : eintraege.slice(0, BERICHT_VERLAUF_KURZ);
renderWochenbericht(); inhalt = `<ol class="bv-liste">${zeigen.map((e, i) => {
const datum = berichtVerlaufDatum(e.at);
const wer = [e.autor, datum].filter(Boolean).join(', ');
// onmousedown + preventDefault wie beim bisherigen "übernehmen": sonst
// verliert das Kommentarfeld zuerst den Fokus und speichert dazwischen.
const knopf = locked ? '<span></span>' :
`<button type="button" class="bv-uebernehmen"
onmousedown="event.preventDefault();berichtVerlaufUebernehmen('${esc(e.week.label)}')"
title="Diesen Kommentar ins Kommentarfeld übernehmen">übernehmen</button>`;
return `<li class="bv-zeile${i === 0 ? ' ist-juengster' : ''}">
<span class="bv-kw"><b>${esc(berichtKurzKw(e.week.label))}</b>${wer ? `<span>${esc(wer)}</span>` : ''}</span>
<span class="bv-punkt" aria-hidden="true"></span>
<span class="bv-inhalt">
<span class="bv-text">${e.html}</span>
${e.ruhe ? `<span class="bv-ruhe">${esc(e.ruhe)}</span>` : ''}
</span>
<span class="bv-chips">${e.chips.map(c => `<span class="bv-chip">${esc(c)}</span>`).join('')}</span>
${knopf}
</li>`;
}).join('')}</ol>`;
}
const mehr = eintraege.length > BERICHT_VERLAUF_KURZ
? `<button type="button" class="bv-mehr" onclick="berichtVerlaufUmschalten()">${berichtVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + eintraege.length + ' anzeigen'}</button>`
: '';
const anzahl = eintraege.length === 1 ? '1 Kommentar' : eintraege.length + ' Kommentare';
return `<section class="bericht-verlauf" id="berichtVerlauf" aria-label="Verlauf der Vorwochen">
<div class="bv-kopf">
<h2 class="bv-titel">Verlauf der Vorwochen</h2>
<span class="bv-anzahl">${esc(gText)}, ${anzahl} in ${sitzungen === 1 ? '1 Sitzung' : sitzungen + ' Sitzungen'}</span>
<span class="bericht-fuss-spacer"></span>
${mehr}
</div>
${inhalt}
</section>`;
} }
// vorwocheRadScrollAkku/-SCHWELLE: jeder einzelne wheel-Tick löste zunächst // berichtVerlaufUmschalten: "Alle N anzeigen" / "Weniger anzeigen". Zeichnet
// sofort einen Schritt aus — auf einem Trackpad erzeugt schon ein kurzes // nur den Verlauf neu, damit ein angefangener Kommentar unberührt bleibt.
// Antippen mehrere Ticks mit kleinem deltaY, wodurch ein einziges Scrollen function berichtVerlaufUmschalten(){
// mehrere Wochen auf einmal übersprang (Erik: "Empfindlichkeit beim Scrollen berichtVerlaufAlle = !berichtVerlaufAlle;
// etwas verlangsamen"). Jetzt wird deltaY aufsummiert, bis die Schwelle const w = currentWeek();
// überschritten ist — eine einzelne Mausrad-Rastung (meist ~100) überschreitet const p = w && aktuellesBerichtProjekt(w);
// sie sofort und bleibt damit bei einem Schritt je Rastung, ein kurzes const el = document.getElementById('berichtVerlauf');
// Trackpad-Antippen dagegen oft nicht. Erster Richtwert, bei Bedarf if(!p || !el) return;
// nachjustieren. el.outerHTML = renderBerichtVerlauf(p, w, isWeekLocked(w));
let vorwocheRadScrollAkku = 0; }
const VORWOCHE_RAD_SCHWELLE = 50;
// vorwocheRadScroll: Mausrad-/Trackpad-Bedienung desselben Scrollrads — // berichtVerlaufUebernehmen: schreibt den Kommentar jener Sitzung in das
// runter (deltaY positiv, aufsummiert) blättert weiter zurück, hoch blättert // Kommentarfeld der aktuellen (takeCommentFromHistory(), js/board/board.js).
// zu neueren Einträgen. e.preventDefault() verhindert, dass dieselbe Bewegung function berichtVerlaufUebernehmen(weekLabel){
// zusätzlich die Seite scrollt. Parameter: e (WheelEvent). Keine Rückgabe. const feld = document.querySelector('#berichtDetail .comment-block .comment-field');
function vorwocheRadScroll(e){ if(feld) takeCommentFromHistory(feld, weekLabel);
e.preventDefault();
vorwocheRadScrollAkku += e.deltaY;
if(Math.abs(vorwocheRadScrollAkku) < VORWOCHE_RAD_SCHWELLE) return;
vorwocheRadSchritt(vorwocheRadScrollAkku > 0 ? 1 : -1);
} }
// ---------- Aufgabe aus dem Kommentar ---------- // ---------- Aufgabe aus dem Kommentar ----------
@@ -783,7 +916,7 @@ function openTodoAusKommentar(pid){
// daraus reinen Text mit erhaltenen Zeilenumbrüchen, aus dem unten die // daraus reinen Text mit erhaltenen Zeilenumbrüchen, aus dem unten die
// erste Zeile als Titelvorschlag gezogen wird. // erste Zeile als Titelvorschlag gezogen wird.
const feld = document.querySelector('#wochenbericht .comment-field'); const feld = document.querySelector('#wochenbericht .comment-field');
const text = feld ? statusTextMitZeilen(feld.innerHTML) : statusPlainText(p.status); const text = feld ? statusTextMitZeilen(feld.innerHTML) : statusPlainText(kommentarVon(p, wochenberichtGewerk).status);
const ersteZeile = String(text || '').split('\n').map(z => z.replace(/^[-•*\s]+/, '').trim()).find(z => z) || ''; const ersteZeile = String(text || '').split('\n').map(z => z.replace(/^[-•*\s]+/, '').trim()).find(z => z) || '';
document.getElementById('todoKommentarTitel').value = ersteZeile.slice(0, 120); document.getElementById('todoKommentarTitel').value = ersteZeile.slice(0, 120);
@@ -832,6 +965,13 @@ function saveTodoAusKommentar(){
return; return;
} }
const ende = document.getElementById('todoKommentarEnde').value; const ende = document.getElementById('todoKommentarEnde').value;
// Fälligkeit vor heute ergäbe einen Balken mit negativer Breite (Start ist
// immer heute) — lieber gleich ablehnen.
if(ende && ende < formatDateISO(new Date())){
fehler.textContent = 'Das Fälligkeitsdatum liegt in der Vergangenheit.';
fehler.hidden = false;
return;
}
const personen = Array.from(document.querySelectorAll('#todoKommentarPersonen input:checked')).map(i => i.value); const personen = Array.from(document.querySelectorAll('#todoKommentarPersonen input:checked')).map(i => i.value);
if(!p.plan) p.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 }; if(!p.plan) p.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 };
@@ -870,8 +1010,9 @@ function openBerichtProtokoll(){
if(prevWeek) zeilen.push('Vergleich mit ' + prevWeek.label); if(prevWeek) zeilen.push('Vergleich mit ' + prevWeek.label);
zeilen.push(''); zeilen.push('');
const anwesend = (w.people || []).filter(x => (w.attendance || {})[x.id]).map(x => x.fullName || x.name); const leute = personenDesGewerks(w, wochenberichtGewerk);
const fehlend = (w.people || []).filter(x => !(w.attendance || {})[x.id]).map(x => x.fullName || x.name); const anwesend = leute.filter(x => (w.attendance || {})[x.id]).map(x => x.fullName || x.name);
const fehlend = leute.filter(x => !(w.attendance || {})[x.id]).map(x => x.fullName || x.name);
if(anwesend.length){ if(anwesend.length){
zeilen.push('Anwesend: ' + anwesend.join(', ')); zeilen.push('Anwesend: ' + anwesend.join(', '));
if(fehlend.length) zeilen.push('Entschuldigt: ' + fehlend.join(', ')); if(fehlend.length) zeilen.push('Entschuldigt: ' + fehlend.join(', '));
@@ -884,7 +1025,7 @@ function openBerichtProtokoll(){
if(info.bucket === 'unchanged') return; // nur, was sich geändert hat if(info.bucket === 'unchanged') return; // nur, was sich geändert hat
anzahl++; anzahl++;
zeilen.push('— ' + (p.nr ? p.nr + ' ' : '') + p.name + ' [' + info.reason + ']'); zeilen.push('— ' + (p.nr ? p.nr + ' ' : '') + p.name + ' [' + info.reason + ']');
const text = statusPlainText(p.status); const text = statusPlainText(kommentarVon(p, wochenberichtGewerk).status);
if(text) zeilen.push(' ' + text); if(text) zeilen.push(' ' + text);
// Aufgaben, die in dieser Sitzung entstanden sind, gehören ins Protokoll: // Aufgaben, die in dieser Sitzung entstanden sind, gehören ins Protokoll:
// Sie sind das Ergebnis der Besprechung. // Sie sind das Ergebnis der Besprechung.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 216"><path fill="#94b0cc" fill-rule="evenodd" d="M151.22 23.16L175.56 23.13L176.19 23.38L176.41 23.77L176.61 24.70L176.50 25.31L176.50 42.44L176.63 43.19L176.28 44.34L175.88 44.69L175.31 44.88L151.69 44.88L151.04 44.65L150.60 43.96L150.50 43.56L150.50 24.31L150.70 23.51ZM93.72 50.41L100.44 50.38L106.17 51.14L110.47 52.21L111.90 52.79L113.75 53.31L114.29 53.65L115.88 54.19L118.21 55.23L119.04 55.77L120.09 56.23L123.71 58.23L126.69 60.25L129.44 62.38L132.68 65.39L135.75 68.94L137.90 71.91L139.65 74.79L141.81 79.13L143.69 84.25L144.74 88.33L145.75 95.19L145.75 96.81L145.88 97.44L145.75 103.44L144.74 110.42L144.26 111.95L143.79 114.22L142.91 116.97L141.69 120.25L141.22 121.04L140.68 122.49L140.22 123.16L139.78 124.34L137.90 127.59L136.50 129.69L134.75 132.06L132.63 134.56L130.69 136.63L128.44 138.63L125.94 140.63L122.71 142.78L120.99 143.80L116.88 145.81L111.63 147.69L107.30 148.74L106.26 148.68L105.25 147.69L98.10 137.59L97.89 136.80L97.89 136.20L99.34 130.40L99.79 129.10L100.21 127.03L101.20 124.26L105.96 121.78L109.63 119.31L110.69 118.63L113.88 115.69L115.63 113.56L117.78 110.09L118.79 107.72L119.86 103.92L120.13 101.56L120.13 98.69L119.99 97.20L119.74 95.70L118.91 92.78L117.80 90.14L116.78 88.29L114.63 85.31L112.74 83.33L110.31 81.38L108.59 80.22L106.96 79.35L104.22 78.21L101.05 77.39L99.55 77.14L97.56 77.00L94.94 77.13L90.15 78.21L87.54 79.22L85.54 80.22L83.06 82.00L81.54 83.35L79.38 85.81L78.22 87.54L77.10 89.66L76.31 91.50L75.14 95.70L74.84 97.53L74.40 97.96L73.44 98.13L48.94 98.13L48.19 97.88L47.89 97.42L48.14 94.45L49.34 89.03L51.19 83.38L53.10 79.04L55.23 75.16L57.13 72.19L60.25 68.06L63.00 65.06L66.31 62.00L68.44 60.25L73.04 57.10L76.41 55.23L77.59 54.77L78.63 54.19L80.13 53.69L81.40 53.09L82.88 52.69L83.63 52.31L88.33 51.14ZM42.98 50.79L43.63 50.81L44.31 51.25L45.63 53.06L47.15 55.54L47.63 56.06L49.15 58.54L49.63 59.06L51.15 61.54L51.77 62.29L51.99 63.08L51.99 63.92L51.26 66.58L50.71 69.53L50.26 70.83L49.66 73.85L49.00 75.69L48.52 76.09L44.90 77.09L42.41 78.10L40.19 79.38L37.81 81.25L35.25 83.94L34.10 85.54L32.23 88.91L31.31 91.13L30.14 95.95L30.00 97.19L30.00 101.94L30.26 104.05L31.19 107.63L32.10 109.84L33.23 111.96L35.13 114.69L37.06 116.75L39.66 118.78L42.91 120.65L45.65 121.79L49.08 122.74L51.31 123.00L54.80 122.86L58.85 121.79L61.34 120.65L64.69 118.50L66.71 116.78L67.88 115.69L70.50 112.69L72.90 109.34L75.38 105.06L75.94 104.38L76.50 104.06L100.31 104.00L101.35 104.21L101.71 104.47L101.74 105.55L100.66 110.10L99.79 113.10L97.78 118.34L95.65 122.59L93.63 125.94L90.75 129.94L87.69 133.38L84.19 136.75L81.69 138.75L78.59 140.90L75.71 142.65L74.16 143.35L73.74 143.68L68.88 145.81L67.03 146.34L65.97 146.79L61.80 147.86L56.17 148.74L54.69 148.88L48.44 148.88L46.95 148.74L40.83 147.74L39.73 147.34L37.40 146.79L34.25 145.69L33.46 145.22L32.16 144.78L30.96 144.10L30.14 143.80L25.06 140.75L22.19 138.63L20.19 136.88L19.74 136.32L18.20 134.99L15.25 131.69L12.22 127.59L9.22 122.59L7.21 118.35L6.21 115.85L5.21 112.72L4.26 108.67L4.01 107.05L3.63 102.94L3.63 101.44L3.38 99.69L3.63 97.69L3.88 93.44L4.26 90.45L4.74 88.80L5.21 86.28L5.79 84.72L6.21 83.03L8.10 78.29L10.20 74.14L11.22 72.41L14.25 68.06L17.13 64.69L19.56 62.25L22.94 59.38L27.64 56.20L31.75 54.06L36.52 52.21L40.45 51.14ZM225.47 104.41L249.56 104.38L250.55 104.64L250.86 105.33L250.88 106.31L249.66 112.60L249.21 113.78L248.79 115.60L248.31 116.63L247.79 118.35L247.22 119.41L246.78 120.71L244.80 124.61L241.63 129.56L240.25 131.19L239.88 131.81L237.38 134.69L235.19 136.88L232.94 138.88L230.56 140.75L228.06 142.38L227.61 142.80L224.46 144.65L222.13 145.81L217.75 147.69L215.78 148.21L214.63 148.69L211.95 149.26L210.53 149.71L209.47 149.59L208.50 148.56L206.25 145.69L205.63 144.69L201.38 139.19L200.69 137.88L200.63 137.19L201.14 134.57L201.74 132.55L202.34 129.78L202.79 128.60L203.34 126.03L204.00 124.44L204.47 124.04L208.22 122.91L211.09 121.65L214.34 119.78L216.94 117.88L219.75 114.81L220.90 113.09L222.78 109.59L223.19 108.25L223.79 106.97L224.20 105.39L224.51 104.95Z"/><path fill="#116896" fill-rule="evenodd" d="M197.72 50.16L202.06 50.13L204.06 50.25L209.30 51.01L214.75 52.31L217.35 53.21L222.21 55.23L227.46 58.10L230.81 60.38L234.61 63.45L238.75 67.56L240.75 69.94L243.90 74.41L245.78 77.66L247.81 82.13L248.31 83.75L248.69 84.50L249.34 87.10L249.66 87.90L250.74 93.45L250.99 96.42L250.68 97.36L250.49 97.55L249.80 97.74L248.19 97.88L226.69 97.75L225.39 97.55L224.56 96.50L223.79 93.03L222.65 90.41L220.75 87.31L218.13 84.19L216.06 82.25L212.96 79.97L209.84 78.22L206.30 77.01L201.81 76.38L200.31 76.38L194.95 77.14L191.39 78.33L190.71 78.78L189.54 79.22L186.31 81.25L185.14 82.20L183.00 84.31L181.38 86.19L180.22 87.91L179.35 89.41L178.78 90.84L178.19 91.88L177.31 94.50L176.89 96.70L176.50 100.56L176.50 151.44L176.50 194.31L176.40 194.71L175.71 195.28L175.31 195.38L151.69 195.38L151.01 195.18L150.56 194.88L150.31 194.50L150.26 52.70L150.72 51.66L151.56 51.25L175.43 51.26L175.96 51.48L176.40 52.04L176.Line truncated

After

Width:  |  Height:  |  Size: 5.0 KiB

+695 -21
View File
@@ -133,27 +133,6 @@
.modal-head h2 { margin: 0; } .modal-head h2 { margin: 0; }
.modal-kbd { font-size: 11px; color: var(--text-subtle); white-space: nowrap; } .modal-kbd { font-size: 11px; color: var(--text-subtle); white-space: nowrap; }
/* Etwas breiter als die 500 px der Standard-Maske, damit die vier
Schaltflächen der Fußzeile in eine Zeile passen (gleiche Ausnahme wie
#lpModal/#newProjModal in styles.css). */
#todoModal .modal { max-width: 560px; }
/* Fußzeile bleibt stehen, wenn die Abhängigkeiten aufgeklappt sind */
.modal-footer {
position: sticky;
bottom: -28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin: 20px -28px -28px;
padding: 16px 28px;
border-top: 1px solid var(--line-soft);
background: var(--surface-alt);
border-radius: 0 0 var(--r-xl) var(--r-xl);
}
/* Validierungshinweis direkt am Feld statt im alert() */ /* Validierungshinweis direkt am Feld statt im alert() */
/* .field-error steht in styles.css: der Passwort-Dialog (js/auth.js) nutzt /* .field-error steht in styles.css: der Passwort-Dialog (js/auth.js) nutzt
sie auch ausserhalb des Projektbereichs. */ sie auch ausserhalb des Projektbereichs. */
@@ -210,6 +189,9 @@
color: var(--accent); color: var(--accent);
} }
.person-chip[aria-pressed="true"]:hover { background: var(--accent-soft); } .person-chip[aria-pressed="true"]:hover { background: var(--accent-soft); }
/* Zuständige, die dem Projekt (nicht mehr) zugeordnet sind (openTodoModal()):
gestrichelt wie die "fremden" Personen im Assistenten. */
.person-chip.ist-fremd { border-style: dashed; }
.person-chip[aria-pressed="true"] .person-chip-ini { background: var(--accent); color: #fff; } .person-chip[aria-pressed="true"] .person-chip-ini { background: var(--accent); color: #fff; }
/* Hover-Fläche im Status-Segment: eigene Abstufung, weil das Segment auf /* Hover-Fläche im Status-Segment: eigene Abstufung, weil das Segment auf
@@ -1115,3 +1097,695 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); }
sich jetzt mit derselben Dauer wie der Pfeil der Projektliste sich jetzt mit derselben Dauer wie der Pfeil der Projektliste
(.board-chev), statt mit einem eigenen Wert. */ (.board-chev), statt mit einem eigenen Wert. */
.pt-chevron { transition: transform var(--dur) var(--ease-out); } .pt-chevron { transition: transform var(--dur) var(--ease-out); }
/* ===========================================================================
Projekt-Dashboard "Lagebild" (Eriks Wahl aus drei Anordnungen):
Lageband oben (#planKpiRow), darunter zwei Spalten, die sich unabhängig
füllen — links Kommentar + Termine, rechts Phasen, Team, Unterlagen.
=========================================================================== */
.plan-lage-band {
display: grid; grid-template-columns: minmax(0, 1.35fr) repeat(3, minmax(0, 1fr));
background: var(--surface); border: 1px solid var(--line-soft); border-radius: var(--r-lg);
box-shadow: var(--sh-1); padding: 4px;
}
.plan-lage-zelle { padding: 12px 16px; min-width: 0; }
.plan-lage-zelle + .plan-lage-zelle { border-left: 1px solid var(--line-soft); }
.plan-lage-zelle .kpi-label { font-size: 11px; font-weight: 650; letter-spacing: .05em; text-transform: uppercase; color: var(--text-subtle); }
.plan-lage-zelle .kpi-value { font-size: 22px; font-weight: 600; letter-spacing: -.01em; margin-top: 2px; font-variant-numeric: tabular-nums; }
.plan-lage-zelle .kpi-sub { font-size: 12px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plan-lage-zelle .kpi-sub.ist-rot { color: var(--red); font-weight: 600; }
.plan-lage-phase { display: flex; align-items: center; gap: 14px; }
.plan-lage-ring {
flex: none; width: 54px; height: 54px; border-radius: 50%;
background: conic-gradient(var(--accent) calc(var(--p) * 1%), color-mix(in srgb, var(--text-subtle) 18%, transparent) 0);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 7px), #000 calc(100% - 6px));
mask: radial-gradient(farthest-side, transparent calc(100% - 7px), #000 calc(100% - 6px));
}
.plan-lage-raster { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(0, 1fr); gap: 14px; align-items: start; margin-top: 14px; }
.plan-lage-spalte { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.plan-lage-spalte .plan-panel { box-shadow: var(--sh-1); }
@media (max-width: 1000px) {
.plan-lage-band { grid-template-columns: 1fr 1fr; }
.plan-lage-zelle + .plan-lage-zelle { border-left: 0; }
.plan-lage-raster { grid-template-columns: 1fr; }
}
.plan-panel-title .plan-panel-link, .plan-unterlage-kopf .plan-panel-link { margin-left: auto; }
.plan-panel-link {
appearance: none; border: 0; background: none !important; box-shadow: none !important; padding: 0;
color: var(--accent); font: inherit; font-size: 12px; font-weight: 550; cursor: pointer;
}
.plan-panel-link:hover { text-decoration: underline; }
.plan-panel-link:active { transform: none; }
.plan-kommentar-fuss { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
.plan-kommentar-autor { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-subtle); }
.plan-kommentar-autor:empty { display: none; }
.plan-autor-ava { width: 24px; height: 24px; border-radius: 50%; display: inline-grid; place-items: center; font-size: 9.5px; font-weight: 700; background: var(--accent-soft); color: var(--accent); }
.plan-kommentar-fuss .plan-verweis-row { margin-left: auto; }
.plan-termin-gruppe { display: flex; gap: 6px; align-items: baseline; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--text-subtle); padding: 10px 0 2px; }
.plan-termin-gruppe:first-child { padding-top: 0; }
.plan-termin-gruppe span { font-weight: 500; letter-spacing: 0; }
.plan-termin-gruppe.ist-rot { color: var(--red); }
.plan-termin { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 8px 6px; margin: 0 -6px; border-top: 1px solid var(--line-soft); border-radius: 8px; }
.plan-termin-gruppe + .plan-termin { border-top: 0; }
.plan-termin.ist-klickbar { cursor: pointer; }
.plan-termin.ist-klickbar:hover { background: var(--surface-sunk); }
.plan-termin-art { width: 22px; height: 22px; border-radius: 7px; display: grid; place-items: center; font-size: 10px; font-weight: 700; background: var(--surface-sunk); color: var(--text-muted); }
.plan-termin-art.ist-meins { background: var(--accent-soft); color: var(--accent); }
.plan-termin-art.ist-ms { background: color-mix(in srgb, #6d4bb3 14%, transparent); color: #6d4bb3; }
.plan-termin-text { min-width: 0; display: flex; flex-direction: column; }
.plan-termin-text b { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plan-termin-text span { font-size: 11.5px; color: var(--text-subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plan-termin-datum { text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.plan-termin-datum em { display: block; font-style: normal; font-size: 11px; color: var(--text-subtle); }
.plan-termin-datum.ist-rot, .plan-termin-datum.ist-rot em { color: var(--red); }
.plan-termin-mehr { margin-top: 8px; }
.plan-team-zeile { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 7px 0; border-top: 1px solid var(--line-soft); }
.plan-team-zeile:first-child { border-top: 0; padding-top: 0; }
.plan-team-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.plan-team-text b { font-weight: 550; }
.plan-team-rollen { display: flex; flex-wrap: wrap; gap: 4px; }
.plan-rolle { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); }
.plan-rolle.ist-neben { background: transparent; border: 1px dashed var(--line); color: var(--text-muted); }
.plan-team-offen { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
.plan-unterlage { padding: 9px 6px; margin: 0 -6px; border-top: 1px solid var(--line-soft); border-radius: 8px; cursor: pointer; }
.plan-unterlage:first-child { border-top: 0; padding-top: 2px; }
.plan-unterlage:hover { background: var(--surface-sunk); }
.plan-unterlage-kopf { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
.plan-unterlage-kopf b { font-weight: 600; }
@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .plan-termin-art.ist-ms { color: #c3a6ff; } }
html[data-theme="dark"] .plan-termin-art.ist-ms { color: #c3a6ff; }
/* Kommentar der Sitzung im Projekt-Dashboard größer (Eriks Wunsch): Das
allgemeine .status-edit ist auf die enge Zeile des Boards ausgelegt
(11px). Hier ist er das wichtigste Lesefeld des Lagebilds. */
#planSection-dashboard #planStatusEdit {
font-size: 14.5px;
line-height: 1.6;
padding: 14px 16px;
min-height: 160px;
border-radius: var(--r-md);
border-style: solid;
}
/* Kommentar der Sitzung mit Verlauf (Eriks Wahl "A"): gezeigt wird der
zuletzt geschriebene Kommentar; stammt er aus einer früheren Sitzung,
sagt das der Hinweis darüber. Darunter eine Zeitleiste der früheren
Kommentare je Gewerk (renderPlanKommentarVerlauf(), plan-info.js). */
.plan-kommentar-hinweis {
margin-bottom: 10px; padding: 8px 12px; border-radius: var(--r-sm, 8px);
font-size: 12.5px; line-height: 1.45;
color: var(--text);
background: color-mix(in srgb, var(--amber) 16%, transparent);
border: 1px solid color-mix(in srgb, var(--amber) 40%, transparent);
}
.plan-kommentar-verlauf { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
.plan-verlauf-kopf { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.plan-verlauf-titel { font-size: 11.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted); }
.plan-verlauf-anzahl { font-size: 12px; color: var(--text-subtle); }
.plan-verlauf-liste { list-style: none; margin: 0; padding: 0; }
.plan-verlauf-eintrag { display: grid; grid-template-columns: 52px 12px minmax(0, 1fr); column-gap: 10px; }
.plan-verlauf-kw { font-size: 12px; font-weight: 600; color: var(--text); padding-top: 1px; white-space: nowrap; }
.plan-verlauf-punkt { position: relative; }
.plan-verlauf-punkt::before {
content: ""; position: absolute; left: 1.5px; top: 5px;
width: 9px; height: 9px; border-radius: 50%;
background: color-mix(in srgb, var(--accent) 45%, transparent);
}
.plan-verlauf-eintrag.ist-juengster .plan-verlauf-punkt::before { background: var(--accent); }
.plan-verlauf-punkt::after {
content: ""; position: absolute; left: 5px; top: 17px; bottom: 0; width: 2px; background: var(--line);
}
.plan-verlauf-eintrag:last-child .plan-verlauf-punkt::after { display: none; }
.plan-verlauf-inhalt { padding-bottom: 14px; min-width: 0; }
.plan-verlauf-text { font-size: 13.5px; line-height: 1.5; color: var(--text); overflow-wrap: anywhere; }
.plan-verlauf-text p { margin: 0; }
.plan-verlauf-meta { font-size: 11.5px; color: var(--text-subtle); margin-top: 3px; }
.plan-verlauf-mehr {
border: 1px solid var(--line); background: var(--surface); color: var(--accent);
border-radius: 999px; padding: 6px 14px; font: inherit; font-size: 12.5px; cursor: pointer;
}
.plan-verlauf-mehr:hover { background: var(--surface-alt); }
/* Dashboard-Kachel "Protokolle" (Eriks Wunsch, renderProtokolleSummary()
in plan-protokoll.js): letztes Protokoll, seine offenen Punkte in der
Zeilenform der Termine, darunter die früheren als Kurzliste. */
#planDashProtokollePanel .plan-termin { grid-template-columns: auto minmax(0, 1fr) auto; }
#planDashProtokollePanel .plan-termin-art { width: auto; min-width: 34px; padding: 0 6px; box-sizing: border-box; }
.plan-prot-kopf {
display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
font: inherit; color: var(--text); background: var(--surface-sunk);
border: 1px solid var(--line-soft); border-radius: var(--r-md, 12px);
padding: 10px 12px; cursor: pointer;
}
.plan-prot-kopf:hover { border-color: var(--accent); }
.plan-prot-nr {
flex: none; font-size: 12px; font-weight: 700; color: var(--accent);
background: var(--accent-soft); border-radius: 8px; padding: 4px 8px;
}
.plan-prot-titel { min-width: 0; display: flex; flex-direction: column; }
.plan-prot-titel b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plan-prot-titel span { font-size: 12px; color: var(--text-subtle); }
.plan-prot-punkte { margin-top: 8px; }
.plan-prot-punkte:empty { display: none; }
.plan-prot-mehr { display: block; margin-top: 6px; }
.plan-prot-frueher {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: baseline;
width: 100%; text-align: left; font: inherit; font-size: 12.5px; color: var(--text-muted);
background: none; border: 0; border-top: 1px solid var(--line-soft); padding: 7px 6px; cursor: pointer; border-radius: 8px;
}
.plan-termin-gruppe + .plan-prot-frueher { border-top: 0; }
.plan-prot-frueher:hover { background: var(--surface-sunk); }
.plan-prot-frueher-titel { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plan-prot-frueher-zahl { color: var(--amber); font-size: 12px; white-space: nowrap; }
.plan-dash-leer { display: flex; align-items: center; gap: 10px; color: var(--text-subtle); font-size: 13px; }
/* Kachel "Team" nach Gewerk gruppiert (Eriks Wahl "A", plan-sections.js):
je Gewerk eine Überschrift mit Personenzahl; ein Gewerk des Projekts ohne
Beteiligte zeigt einen Hinweis statt zu verschwinden. */
.plan-team-gruppe + .plan-team-gruppe { margin-top: 6px; }
.plan-team-gewerk { display: flex; align-items: center; gap: 8px; padding: 6px 0 4px; }
.plan-team-gruppe:first-child .plan-team-gewerk { padding-top: 0; }
.plan-gewerk-schild { font-size: 10.5px; font-weight: 700; letter-spacing: .04em; padding: 2px 8px; border-radius: 6px; background: var(--accent-soft); color: var(--accent); }
.plan-gewerk-titel { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
.plan-gewerk-linie { flex: 1; height: 1px; background: var(--line-soft); }
.plan-gewerk-anzahl { font-size: 12px; color: var(--text-subtle); white-space: nowrap; }
.plan-team-gewerk + .plan-team-zeile { border-top: 0; }
.plan-team-luecke { font-size: 12.5px; color: var(--amber); padding: 2px 0 6px; }
/* Einstellungen mit Unternavigation (Eriks Wahl "C", plan-settings.js):
links vier Abschnitte mit Kurzfassung, rechts immer genau einer. */
.plan-einst { display: flex; gap: 14px; align-items: flex-start; margin-top: 4px; }
.plan-einst-nav {
flex: none; width: 260px; position: sticky; top: 90px;
display: flex; flex-direction: column; gap: 4px; padding: 8px;
background: var(--surface); border: 1px solid var(--line-soft); border-radius: var(--r-lg);
}
.plan-einst-reiter {
display: flex; flex-direction: column; align-items: flex-start; gap: 3px; text-align: left;
min-height: 56px; padding: 9px 14px; border: 0; border-radius: 12px;
background: transparent; color: var(--text); font: inherit; cursor: pointer;
}
.plan-einst-reiter b { font-size: 14px; font-weight: 600; }
.plan-einst-reiter span { font-size: 12px; color: var(--text-muted); line-height: 1.35; }
.plan-einst-reiter:hover { background: var(--surface-sunk); }
.plan-einst-reiter.is-active { background: var(--surface-sunk); box-shadow: inset 3px 0 0 var(--accent); }
.plan-einst-inhalt { flex: 1; min-width: 0; padding: 22px 26px; }
.plan-einst-titel { margin: 0 0 16px; font-size: 18px; font-weight: 650; }
.plan-einst-teil .form-row { max-width: 640px; }
.plan-einst-paar { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 0 12px; max-width: 640px; }
.plan-einst-paar > .form-row { min-width: 0; margin-bottom: 6px; }
.plan-einst-teil[data-teil="auftrag"] .plan-einst-paar { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 460px; }
.plan-einst-hint { margin: 0 0 16px; max-width: 640px; line-height: 1.45; }
.form-row .plan-einst-hint { margin: 2px 0 0; }
.plan-einst-schmal { max-width: 320px; }
.plan-einst-chips { display: flex; gap: 8px; flex-wrap: wrap; }
.plan-einst-chip {
min-height: 36px; padding: 0 16px; border-radius: 999px; font: inherit; font-size: 13px; cursor: pointer;
border: 1px solid var(--line); background: var(--surface); color: var(--text);
}
.plan-einst-chip.an { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
/* Umschalter (Leistungsphasen, Beteiligte) */
.plan-einst-seg { display: inline-flex; gap: 2px; padding: 3px; border-radius: 999px; background: var(--surface-sunk); }
.plan-einst-seg button {
border: 0; background: transparent; color: var(--text-muted); font: inherit; font-size: 12.5px;
min-height: 30px; padding: 0 12px; border-radius: 999px; cursor: pointer; white-space: nowrap;
}
.plan-einst-seg button.an { background: var(--surface); color: var(--accent); font-weight: 600; box-shadow: 0 1px 4px rgba(15,40,60,.12); }
.plan-einst-seg button:disabled { cursor: default; }
.plan-einst-phasen { display: flex; flex-direction: column; max-width: 760px; }
.plan-einst-phase { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px; align-items: center; padding: 9px 0; border-top: 1px solid var(--line-soft); }
.plan-einst-phase-name { display: flex; align-items: center; gap: 12px; min-width: 0; }
.plan-einst-phase-name b { width: 52px; flex: none; font-size: 13px; }
.plan-einst-phase-name span { font-size: 14px; color: var(--text-muted); }
.plan-einst-phase.ist-an .plan-einst-phase-name span { color: var(--text); }
.plan-einst-phase-name em { font-style: normal; font-size: 11px; font-weight: 700; color: var(--accent); background: var(--accent-soft); border-radius: 999px; padding: 2px 8px; }
@media (max-width: 900px) {
.plan-einst { flex-direction: column; }
.plan-einst-nav { width: auto; position: static; flex-direction: row; overflow-x: auto; }
.plan-einst-reiter { flex: none; }
}
/* Zahlenfelder (Honorar, Projektgröße) wie die Textfelder daneben — die
allgemeine Feldregel in styles.css kennt type=number nicht; sie dort
aufzunehmen träfe auch kompakte Zahlenfelder anderer Bereiche. */
.plan-einst-teil input[type=number] {
padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--r-md);
font: inherit; font-size: 13px; color: var(--text); background: var(--surface);
}
.plan-einst-teil input[type=number]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,113,227,.18); }
/* Dunkel ist der Akzent hell — weiße Schrift darauf wäre kaum lesbar. */
@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .plan-einst-chip.an { color: #0b141b; } }
html[data-theme="dark"] .plan-einst-chip.an { color: #0b141b; }
/* Beteiligte als Personenliste (Eriks Wahl "B", renderEinstTeam() in
plan-settings.js): Besetzungsleiste, Suchfeld mit Vorschlägen, eine Zeile
je Person mit Rollen-Kapseln (umrandet = aus, voll = Haupt, gestrichelt
= Unterstützung), Entfernen mit "Rückgängig". */
.plan-einst-teil[data-teil="team"] { position: relative; }
.plan-einst-team-kopfzeile { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 8px 20px; }
.plan-einst-team-kopfzeile .plan-einst-hint { margin-bottom: 14px; }
.plan-einst-legende { display: flex; gap: 14px; font-size: 12px; color: var(--text-muted); margin-bottom: 16px; }
.plan-einst-legende span { display: inline-flex; align-items: center; gap: 6px; }
.plan-einst-legende i { width: 24px; height: 14px; border-radius: 999px; box-sizing: border-box; }
.plan-einst-legende i.ist-haupt { background: var(--accent); }
.plan-einst-legende i.ist-neben { border: 1.5px dashed var(--accent); background: var(--accent-soft); }
.plan-einst-sperre { padding: 10px 14px; border-radius: 12px; background: var(--surface-sunk); font-size: 13px; margin-bottom: 14px; }
.plan-einst-besetzung { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-bottom: 16px; }
.plan-einst-besetzt { display: flex; flex-direction: column; gap: 3px; min-width: 0; padding: 10px 12px; border-radius: 12px; background: var(--surface-sunk); border: 1px solid transparent; }
.plan-einst-besetzt b { font-size: 12px; font-weight: 650; color: var(--text-muted); }
.plan-einst-besetzt span { font-size: 12.5px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plan-einst-besetzt.ist-luecke { background: color-mix(in srgb, var(--amber) 10%, transparent); border: 1px dashed var(--amber); }
.plan-einst-besetzt.ist-luecke b, .plan-einst-besetzt.ist-luecke span { color: var(--amber); }
.plan-einst-suche { position: relative; display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; max-width: 640px; }
.plan-einst-suche[hidden] { display: none; }
.plan-einst-suche label { font-size: 12px; font-weight: 500; color: var(--text-muted); }
.plan-einst-suche input { height: 40px; box-sizing: border-box; font-size: 14px; }
.plan-einst-vorschlaege {
position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 20; max-height: 320px; overflow: auto;
padding: 6px; border-radius: 14px; background: var(--surface); border: 1px solid var(--line);
box-shadow: 0 16px 40px rgba(15,40,60,.18);
}
html.glas .plan-einst-vorschlaege { background: var(--glas-dicht); }
.plan-einst-vorschlag-titel { font-size: 11.5px; font-weight: 650; color: var(--text-muted); padding: 8px 10px 4px; }
.plan-einst-vorschlag-leer { font-size: 13px; color: var(--text-muted); padding: 10px; }
.plan-einst-vorschlag {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; min-height: 48px; padding: 4px 10px;
border: 0; border-radius: 10px; background: transparent; color: var(--text); font: inherit; cursor: pointer;
}
.plan-einst-vorschlag:hover, .plan-einst-vorschlag:focus-visible { background: var(--surface-sunk); }
.plan-einst-vorschlag > span:last-child { display: flex; flex-direction: column; min-width: 0; }
.plan-einst-vorschlag b { font-size: 13.5px; font-weight: 600; }
.plan-einst-vorschlag > span:last-child > span { font-size: 12px; color: var(--text-muted); }
.plan-einst-ava {
width: 34px; height: 34px; flex: none; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; background: var(--accent); color: #fff;
}
.plan-einst-gruppe { display: flex; align-items: center; gap: 10px; padding: 16px 0 6px; }
.plan-einst-zeile {
display: grid; grid-template-columns: minmax(200px, 1fr) auto 36px; gap: 10px 14px; align-items: center;
padding: 9px 10px; margin: 0 -10px; border-radius: 12px; border-top: 1px solid var(--line-soft);
transition: background-color var(--dur-lg, .2s) var(--ease-out, ease);
}
.plan-einst-gruppe + .plan-einst-zeile { border-top-color: transparent; }
.plan-einst-zeile.ist-neu { background: var(--accent-soft); }
.plan-einst-person { display: flex; align-items: center; gap: 12px; min-width: 0; }
.plan-einst-person b { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.plan-einst-kapseln { display: flex; flex-wrap: wrap; gap: 6px; }
.plan-einst-kapsel {
min-height: 36px; padding: 0 14px; border-radius: 999px; box-sizing: border-box; white-space: nowrap;
border: 1px solid var(--line); background: transparent; color: var(--text-muted); font: inherit; font-size: 12.5px; cursor: pointer;
transition: transform var(--dur, .15s) var(--ease-out, ease), background-color var(--dur, .15s);
}
.plan-einst-kapsel:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
.plan-einst-kapsel:active:not(:disabled) { transform: scale(var(--press, .97)); }
.plan-einst-kapsel.ist-haupt { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.plan-einst-kapsel.ist-neben { border: 1.5px dashed var(--accent); background: var(--accent-soft); color: var(--text); font-weight: 600; }
.plan-einst-kapsel.ist-fremd { border-color: transparent; opacity: .5; text-decoration: line-through; }
.plan-einst-kapsel:disabled { cursor: default; }
.plan-einst-weg { width: 36px; height: 36px; border-radius: 50%; border: 0; background: transparent; color: var(--text-muted); font-size: 18px; cursor: pointer; }
.plan-einst-weg:hover { background: var(--surface-sunk); color: var(--red); }
.plan-einst-leer { display: flex; flex-direction: column; gap: 6px; padding: 20px; border-radius: 14px; border: 1px dashed var(--line); margin-top: 8px; }
.plan-einst-leer b { font-size: 14px; font-weight: 600; }
.plan-einst-leer span { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
.plan-einst-toast {
position: sticky; bottom: 16px; margin: 16px auto 0; width: max-content; max-width: 100%;
display: flex; align-items: center; gap: 14px; padding: 8px 8px 8px 16px; border-radius: 999px;
background: var(--text); color: var(--bg); font-size: 13px; box-shadow: 0 16px 40px rgba(15,40,60,.25);
}
.plan-einst-toast[hidden] { display: none; }
.plan-einst-toast button { min-height: 32px; padding: 0 14px; border-radius: 999px; border: 0; background: color-mix(in srgb, var(--bg) 18%, transparent); color: inherit; font: inherit; font-weight: 600; cursor: pointer; }
/* Dunkel ist der Akzent hell — Schrift darauf dunkel. */
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) .plan-einst-kapsel.ist-haupt,
html:not([data-theme="light"]) .plan-einst-ava { color: #0b141b; }
}
html[data-theme="dark"] .plan-einst-kapsel.ist-haupt,
html[data-theme="dark"] .plan-einst-ava { color: #0b141b; }
@media (max-width: 900px) {
.plan-einst-besetzung { grid-template-columns: minmax(0, 1fr); }
.plan-einst-zeile { grid-template-columns: minmax(0, 1fr) 36px; }
.plan-einst-kapseln { grid-column: 1 / -1; order: 3; }
}
/* Gesperrte Sitzung: Die allgemeine :disabled-Regel (styles.css, 45 %)
machte "Haupt" und "aus" kaum unterscheidbar — die Kapseln sollen den
Stand weiter klar zeigen, nur nicht mehr klickbar sein. Fremde Rollen
bleiben bewusst blass. */
.plan-einst-kapsel:disabled:not(.ist-fremd) { opacity: 1; cursor: default; }
/* Leistungsphasen: kräftigere Hervorhebung (Eriks Wunsch). Vorher war die
gewählte Seite nur eine weiße Kapsel mit Akzentschrift auf hellgrauem
Grund — "Beauftragt" und "Nicht beauftragt" waren kaum auseinander-
zuhalten. Jetzt: beauftragt = gefüllte Akzentkapsel und die ganze Zeile
leicht getönt; nicht beauftragt = neutrale Kapsel, Zeile zurückgenommen;
"läuft" gefüllt. */
.plan-einst-phase { padding: 9px 10px; margin: 0 -10px; border-radius: 12px; }
.plan-einst-phase.ist-an { background: var(--accent-soft); border-top-color: transparent; }
.plan-einst-phase.ist-an + .plan-einst-phase { border-top-color: transparent; }
.plan-einst-phase .plan-einst-seg { background: var(--surface-sunk); border: 1px solid var(--line-soft); }
.plan-einst-phase .plan-einst-seg button { color: var(--text-muted); }
.plan-einst-phase .plan-einst-seg button.an:first-child { background: var(--accent); color: #fff; box-shadow: 0 1px 4px rgba(15,40,60,.18); }
.plan-einst-phase .plan-einst-seg button.an:last-child { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(15,40,60,.12); }
.plan-einst-phase:not(.ist-an) .plan-einst-phase-name b { color: var(--text-muted); }
.plan-einst-phase.ist-an .plan-einst-phase-name b { color: var(--accent); }
.plan-einst-phase-name em { background: var(--accent); color: #fff; }
.plan-einst-phase .plan-einst-seg button:disabled { opacity: 1; }
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) .plan-einst-phase .plan-einst-seg button.an:first-child,
html:not([data-theme="light"]) .plan-einst-phase-name em { color: #0b141b; }
}
html[data-theme="dark"] .plan-einst-phase .plan-einst-seg button.an:first-child,
html[data-theme="dark"] .plan-einst-phase-name em { color: #0b141b; }
.plan-einst-phase + .plan-einst-phase { margin-top: 2px; }
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) .plan-einst-phase .plan-einst-seg button.an:last-child { background: rgba(255,255,255,.16); }
}
html[data-theme="dark"] .plan-einst-phase .plan-einst-seg button.an:last-child { background: rgba(255,255,255,.16); }
/* Seitenpanel für Aufgabe und Meilenstein (Eriks Wahl "B"): rechts am
Rand, volle Höhe, der Terminplan bleibt links sichtbar (Hintergrund nur
leicht abgedunkelt, nicht verschwommen). Kopf und Fußleiste stehen fest,
nur der Inhalt scrollt. */
.modal-bg.als-panel {
justify-content: flex-end; align-items: stretch;
background: rgba(15,35,50,.14);
-webkit-backdrop-filter: none; backdrop-filter: none;
}
html.glas .modal-bg.als-panel { background: rgba(15,35,50,.14); -webkit-backdrop-filter: none; backdrop-filter: none; }
@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .modal-bg.als-panel { background: rgba(0,0,0,.32); } }
html[data-theme="dark"] .modal-bg.als-panel { background: rgba(0,0,0,.32); }
.modal-bg.als-panel > .modal.pd {
width: 440px; max-width: 100%; height: 100%; max-height: none; padding: 0;
border-radius: var(--r-xl) 0 0 var(--r-xl);
display: flex; flex-direction: column; overflow: hidden;
box-shadow: -14px 0 44px rgba(15,40,60,.22);
animation: pd-rein var(--dur-lg, .2s) var(--ease-out, ease);
}
@keyframes pd-rein { from { transform: translateX(32px); opacity: 0; } to { transform: none; opacity: 1; } }
.pd-kopf { padding: 18px 22px 14px; border-bottom: 1px solid var(--line-soft); display: flex; flex-direction: column; gap: 10px; }
.pd-art { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 650; color: var(--text-muted); }
.pd-art .modal-kbd { margin-left: auto; font-weight: 400; }
.pd-kasten { width: 11px; height: 11px; border-radius: 3px; background: var(--accent); }
.pd-raute { width: 10px; height: 10px; background: var(--meilenstein, #6d4bb3); transform: rotate(45deg); margin: 0 1px; }
.pd-zu { width: 34px; height: 34px; border-radius: 50%; border: 0; background: transparent; color: var(--text-muted); font-size: 20px; line-height: 1; padding: 0; cursor: pointer; }
.pd-zu:hover { background: var(--surface-sunk); color: var(--text); }
.pd-kopf .pd-titel { width: 100%; box-sizing: border-box; font-size: 17px; font-weight: 600; padding: 9px 12px; }
.pd-inhalt { flex: 1; min-height: 0; overflow: auto; padding: 2px 22px 16px; }
.pd-abs { display: flex; flex-direction: column; gap: 8px; padding: 14px 0; border-bottom: 1px solid var(--line-soft); }
.pd-abs:last-child { border-bottom: 0; }
.pd-abs > h3 { margin: 0; font-size: 12px; font-weight: 650; color: var(--text-muted); }
.pd-abs .range-picker-display, .pd-abs select { width: 100%; box-sizing: border-box; }
.pd-abs .field-inline { display: flex; align-items: center; gap: 8px; }
.pd-abs .field-inline .range-picker-display { flex: 1; }
.pd-haken { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 400; }
.pd-hint { margin: 0; }
.pd-seg { width: 100%; }
.pd-seg button { flex: 1; justify-content: center; min-height: 32px; }
.pd-seg button:disabled { opacity: .45; }
.pd-liste { max-height: 300px; overflow: auto; }
.pd-abs .fold { margin: 0; }
.pd-fuss {
display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap;
padding: 14px 22px calc(14px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--line-soft); background: var(--surface-alt);
}
.pd-loeschen { margin-right: auto; background: transparent; border: 0; color: var(--red); padding: 8px 10px; font-weight: 500; }
.pd-loeschen:hover { background: color-mix(in srgb, var(--red) 10%, transparent); }
.pd-loeschen[hidden] { display: none; }
/* Aufwand einer Aufgabe in Stunden (zählt zur Auslastung). */
.pd-aufwand { display: flex; align-items: center; gap: 10px; font-size: 13px; font-weight: 400; margin-top: 4px; }
.pd-aufwand > span:first-child { font-weight: 600; color: var(--text-muted); font-size: 12px; }
.pd-aufwand > span:last-child { color: var(--text-muted); font-size: 12px; }
.pd-aufwand input { width: 96px; padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--r-md); font: inherit; font-size: 13px; color: var(--text); background: var(--surface); text-align: right; }
.pd-aufwand input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,113,227,.18); }
/* Terminplan aufgeräumt (Eriks Wahl "A", plan-gantt.js / plan-build-gantt.js):
eine Leiste statt zwei, Menüs "Mehr" und "+ Neu", Gliederung nach
Leistungsphasen mit zuklappbaren Köpfen, Filter, Hinweis "Ohne Termin". */
.tp-leiste {
gap: 10px; padding: 8px 10px; margin-bottom: 12px; border-radius: var(--r-lg);
background: var(--surface); border: 1px solid var(--line-soft);
}
.tp-luecke { flex: 1; }
.tp-seg button { min-height: 30px; }
.tp-zeitraum { width: 200px; }
.tp-menue { position: relative; }
.tp-menue > summary {
list-style: none; cursor: pointer; display: inline-flex; align-items: center; min-height: 34px; padding: 0 14px;
border-radius: 999px; border: 1px solid var(--line); background: var(--surface); color: var(--text); font-size: 13px; user-select: none;
}
.tp-menue > summary::-webkit-details-marker { display: none; }
.tp-neu > summary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.tp-menue-liste {
position: absolute; right: 0; top: calc(100% + 6px); z-index: 60; min-width: 250px; padding: 6px;
border-radius: 14px; background: var(--surface); border: 1px solid var(--line-soft); box-shadow: 0 16px 40px rgba(15,40,60,.22);
display: flex; flex-direction: column;
}
html.glas .tp-menue-liste { background: var(--glas-dicht); }
.tp-menue-liste.nach-rechts { right: auto; left: 0; }
.tp-menue-liste button, .tp-menue-liste label {
display: flex; align-items: center; gap: 10px; width: 100%; min-height: 38px; padding: 0 10px; box-sizing: border-box;
border: 0; border-radius: 9px; background: transparent; color: var(--text); font: inherit; font-size: 13.5px; font-weight: 400; text-align: left; cursor: pointer;
}
.tp-menue-liste button:hover, .tp-menue-liste label:hover { background: var(--surface-sunk); }
.tp-menue-liste button.active { color: var(--accent); font-weight: 600; }
.tp-menue-liste hr { border: 0; border-top: 1px solid var(--line-soft); margin: 6px 4px; width: auto; }
.tp-sym-aufgabe { width: 10px; height: 10px; border-radius: 3px; background: var(--accent); flex: none; }
.tp-sym-ms { width: 9px; height: 9px; background: var(--meilenstein, #7a3fb8); transform: rotate(45deg); flex: none; margin: 0 1px; }
@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .tp-neu > summary { color: #0b141b; } }
html[data-theme="dark"] .tp-neu > summary { color: #0b141b; }
/* Projektzeile ruhiger */
#planGanttContainer .tp-projektzeile .gantt-label { font-weight: 500; color: var(--text-muted); font-size: 12px; }
/* Phasenköpfe */
#planGanttContainer .gantt-row.gantt-phase { background: var(--surface-sunk); }
#planGanttContainer .gantt-row.gantt-phase .gantt-label { background: var(--surface-sunk); padding-left: 6px !important; font-weight: 650 !important; color: var(--text) !important; gap: 6px; }
#planGanttContainer .gantt-row.gantt-phase .gantt-bar.todo { top: 50%; height: 10px; margin-top: -5px; border-radius: 999px; }
.tp-klappen { width: 24px; height: 24px; flex: none; border: 0; border-radius: 6px; background: transparent; color: var(--text-muted); font-size: 10px; cursor: pointer; padding: 0; }
.tp-klappen:hover { background: var(--surface); color: var(--text); }
.tp-phase-info { margin-left: auto; font-size: 11.5px; font-weight: 400; color: var(--text-muted); white-space: nowrap; }
#planGanttContainer .gantt-row.tp-gruppe .gantt-label { font-weight: 650; color: var(--text-muted); font-size: 12px; background: var(--surface-sunk); }
#planGanttContainer .gantt-row.tp-gruppe { background: var(--surface-sunk); min-height: 30px; }
/* Unter einem Phasenkopf eingerückt */
#planGanttContainer .gantt-row.gantt-todo:not(.gantt-phase) .gantt-label,
#planGanttContainer .gantt-row.gantt-milestone-row .gantt-label { padding-left: 30px !important; }
#planGanttContainer .gantt-row.gantt-zu,
#planGanttContainer .gantt-row.gantt-gefiltert { display: none; }
#planGanttContainer .gantt-row.gantt-todo.done .gantt-todo-title { text-decoration: line-through; opacity: .7; }
/* Kürzel der Zuständigen rechts in der Namensspalte */
.gantt-label .gantt-leute { margin-left: auto; display: inline-flex; flex: none; padding-left: 6px; }
.gantt-label .gantt-leute span {
min-width: 20px; height: 20px; padding: 0 3px; box-sizing: border-box; border-radius: 999px; margin-left: -4px;
display: inline-flex; align-items: center; justify-content: center; font-size: 8.5px; font-weight: 700;
background: var(--surface-sunk); color: var(--text); border: 1.5px solid var(--surface);
}
.gantt-label .gantt-todo-title { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.gantt-vorgaenger-warn {
flex: none; width: 16px; height: 16px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 800; background: color-mix(in srgb, var(--amber) 20%, transparent); color: var(--amber);
}
/* Heute deutlicher */
#planGanttContainer .gantt-grid-today { border-left-width: 2px; opacity: 1; }
/* Hinweis auf Aufgaben ohne Termin */
.tp-ohne {
display: flex; align-items: center; gap: 8px; width: 100%; margin-top: 8px; padding: 10px 14px; box-sizing: border-box;
border: 1px dashed var(--line); border-radius: var(--r-md); background: transparent; color: var(--text-muted); font: inherit; font-size: 13px; text-align: left; cursor: pointer;
}
.tp-ohne:hover { border-color: var(--accent); color: var(--text); }
.tp-ohne[hidden] { display: none; }
.tp-ohne-zahl { background: var(--surface-sunk); border-radius: 999px; padding: 1px 8px; font-weight: 600; color: var(--text); }
.tp-ohne-hinweis { margin-left: auto; font-size: 12px; }
#planGanttContainer .gantt-row.gantt-phase .gantt-todo-title { flex: 1 1 auto; min-width: 70px; }
#planGanttContainer .gantt-row.gantt-phase .tp-phase-info { flex: none; }
/* Die Glas-Darstellung gibt Knöpfen einen Rahmen — in Umschaltern und
Menüs soll es keinen geben; das Menü deckt den Plan dahinter ab. */
#planSection-terminplan .tp-seg button,
#planSection-terminplan .tp-menue-liste button { border: 0 !important; box-shadow: none; }
#planSection-terminplan .tp-seg button[aria-pressed="true"] { box-shadow: var(--sh-1); }
#planSection-terminplan .tp-menue-liste { -webkit-backdrop-filter: blur(24px) saturate(160%); backdrop-filter: blur(24px) saturate(160%); }
html.glas #planSection-terminplan .tp-menue-liste { background: var(--glas-dicht); }
@media (prefers-color-scheme: light) { html.glas:not([data-theme="dark"]) #planSection-terminplan .tp-menue-liste { background: rgba(255,255,255,.96); } }
html.glas[data-theme="light"] #planSection-terminplan .tp-menue-liste { background: rgba(255,255,255,.96); }
/* Getönte Namenszellen (Projektzeile, Phasenköpfe, Gruppen) liegen auf
einem deckenden Grund — sonst schienen beim waagerechten Scrollen die
Balken durch. --gantt-label-grund setzt glas.css; ohne Glas ist
--surface ohnehin deckend. */
#planGanttContainer .gantt-row.gantt-project.highlighted .gantt-label {
background: linear-gradient(var(--accent-soft), var(--accent-soft)), var(--gantt-label-grund, var(--surface));
}
#planGanttContainer .gantt-row.gantt-phase .gantt-label,
#planGanttContainer .gantt-row.tp-gruppe .gantt-label {
background: linear-gradient(var(--surface-sunk), var(--surface-sunk)), var(--gantt-label-grund, var(--surface));
}
.tp-sym-phase { width: 14px; height: 6px; border-radius: 999px; background: var(--accent); flex: none; opacity: .75; }
#todoEditPhaseAbs select { width: 100%; }
/* ===================== Bereich Leistungsphasen =====================
Eriks Wahl "B": Ablaufbalken über alle Phasen, darunter eine Kachel je
Phase (renderPlanPhasen(), js/plan/plan-phasen.js). Text auf Akzent-
flächen in festem Weiß — unter Glas ist var(--surface) durchscheinend. */
.phasen-zusammenfassung {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px;
font-size: 13px; color: var(--text-muted); margin: 0 2px 12px;
}
.phasen-zusammenfassung span { font-size: 12px; color: var(--text-subtle); }
.phasen-kopf { font-size: 11px; font-weight: 650; letter-spacing: .02em; color: var(--text-muted); text-transform: uppercase; margin-bottom: 8px; }
.phasen-ablauf { margin-bottom: 16px; }
.phasen-ablauf-spur { position: relative; height: 30px; }
.phasen-ablauf-seg {
position: absolute; top: 0; bottom: 0; min-width: 0;
display: flex; align-items: center; padding: 0 8px; overflow: hidden;
border: 0; border-radius: var(--r-md); cursor: pointer;
font: inherit; font-size: 12px; font-weight: 600; white-space: nowrap;
color: var(--text); background: var(--surface-sunk);
}
.phasen-ablauf-seg + .phasen-ablauf-seg { box-shadow: -2px 0 0 var(--surface); }
.phasen-ablauf-seg.is-fertig { background: var(--green); color: #fff; }
.phasen-ablauf-seg.is-laeuft {
color: #fff;
background: linear-gradient(90deg, var(--accent) var(--fort), color-mix(in srgb, var(--accent) 45%, transparent) var(--fort));
}
.phasen-ablauf-seg span { overflow: hidden; text-overflow: ellipsis; }
.phasen-ablauf-heute { position: absolute; top: -4px; bottom: -4px; border-left: 2px solid var(--red); pointer-events: none; }
.phasen-ablauf-achse { position: relative; display: flex; justify-content: space-between; margin-top: 6px; font-size: 11.5px; color: var(--text-muted); }
.phasen-ablauf-heute-text { position: absolute; top: 0; transform: translateX(-50%); color: var(--red); font-weight: 600; white-space: nowrap; }
.phasen-raster { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
.phasen-kachel {
display: flex; flex-direction: column; gap: 12px;
background: var(--surface); border: 1px solid var(--line-soft);
border-radius: var(--r-lg); padding: 14px 16px;
}
.phasen-kachel.is-laeuft { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.phasen-kachel.is-fertig { opacity: .8; }
.phasen-kachel.ist-markiert { animation: phasen-markiert 1.2s var(--ease-out, ease-out); }
@keyframes phasen-markiert { 0%, 40% { box-shadow: 0 0 0 3px var(--accent); } 100% { box-shadow: none; } }
.phasen-kachel-kopf { display: flex; align-items: flex-start; gap: 12px; }
.phasen-ring {
--ring: var(--text-subtle);
width: 52px; height: 52px; border-radius: 50%; flex: none;
display: flex; align-items: center; justify-content: center;
background: conic-gradient(var(--ring) var(--fort), var(--surface-sunk) 0);
}
.phasen-kachel.is-laeuft .phasen-ring { --ring: var(--accent); }
.phasen-kachel.is-fertig .phasen-ring { --ring: var(--green); }
.phasen-ring span {
width: 42px; height: 42px; border-radius: 50%; background: var(--surface-alt);
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums;
}
.phasen-kachel-name { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.phasen-kachel-name b { font-size: 15px; }
.phasen-kachel-name span { font-size: 12.5px; color: var(--text-muted); }
.phasen-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 999px; background: var(--surface-sunk); color: var(--text-muted); white-space: nowrap; }
.phasen-chip.is-laeuft { background: var(--accent); color: #fff; }
.phasen-chip.is-fertig { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); }
.phasen-hinweis { font-size: 12px; color: var(--amber); margin-top: -4px; }
.phasen-werte { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; font-size: 12.5px; }
.phasen-werte > span { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.phasen-werte small { font-size: 11.5px; color: var(--text-muted); }
.phasen-werte b { font-weight: 600; font-variant-numeric: tabular-nums; }
.phasen-leer { color: var(--text-subtle); font-weight: 400; font-style: italic; }
.phasen-knoepfe { display: flex; flex-wrap: wrap; gap: 8px; }
/* Aufgabenliste: Phasen-Kennzeichen je Aufgabe und Filter */
.plan-task-phase {
flex-shrink: 0; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px;
background: var(--accent-soft); color: var(--accent); white-space: nowrap;
}
.plan-task-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: -2px 0 10px; font-size: 12px; color: var(--text-muted); }
.plan-task-filter:empty { display: none; }
.plan-task-filter > span { margin-right: 2px; }
.plan-task-filter-btn {
min-height: 28px; padding: 0 12px; border-radius: 999px; font: inherit; font-size: 12px;
border: 1px solid var(--line); background: var(--surface); color: var(--text); cursor: pointer;
}
.plan-task-filter-btn.is-active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 600; }
@media (max-width: 600px) { .phasen-werte { grid-template-columns: 1fr 1fr; } }
/* Leistungsphasen farblich von Aufgaben getrennt (Eriks Wunsch): Phasen-
balken in Blauviolett (bewusst bläulicher als das Lila der Meilensteine), Aufgaben bleiben im Akzentblau, erledigte grün,
Meilensteine lila — keine Farbe ist doppelt belegt. */
:root { --gantt-phase: #6a5fc7; }
@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) { --gantt-phase: #a59ef2; } }
html[data-theme="dark"] { --gantt-phase: #a59ef2; }
#planGanttContainer .gantt-row.gantt-phase .gantt-bar.todo,
#planGanttContainer .gantt-row.gantt-phase .gantt-bar.todo.done { background: var(--gantt-phase); }
.tp-sym-phase { background: var(--gantt-phase); opacity: 1; }
/* Aufgaben-Vorlagen je Gewerk (Admin): Gewerk-Umschalter, Spaltenkopf und
bearbeitbare Zeilen — gleiche Spalten wie die Zeile „+ Schritt". */
.vorlagen-wahl { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin: 10px 0 12px; }
.vorlagen-gewerke { display: inline-flex; gap: 4px; padding: 3px; border-radius: 999px; background: var(--surface-sunk); }
.vorlagen-gewerk { border: 0; background: transparent; color: var(--text-muted); font: inherit; font-size: 13px; font-weight: 600;
padding: 6px 14px; border-radius: 999px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.vorlagen-gewerk span { font-size: 11px; font-weight: 500; opacity: .75; }
.vorlagen-gewerk.is-active { background: var(--surface); color: var(--text); box-shadow: var(--sh-1); }
.vorlagen-kopf, .vorlagen-zeile, .vorlagen-neu {
display: grid !important; grid-template-columns: minmax(0, 1fr) 80px 90px 90px 120px; gap: 8px; align-items: center;
}
.vorlagen-kopf { font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted); padding: 0 6px 4px; }
.vorlagen-zeile input { width: 100%; box-sizing: border-box; min-width: 0; }
.vorlagen-neu input[type=text], .vorlagen-neu input[type=number] { width: 100%; min-width: 0; flex: none; box-sizing: border-box; }
.vorlagen-aktionen { display: inline-flex; gap: 4px; justify-content: flex-end; align-items: center; }
#planSection-terminplan .vorlagen-gewerk, #admin .vorlagen-gewerk { border: 0 !important; }
@media (max-width: 640px){
.vorlagen-kopf { display: none !important; }
.vorlagen-zeile, .vorlagen-neu { grid-template-columns: 1fr 1fr; }
.vorlagen-zeile input[type=text], .vorlagen-neu input[type=text] { grid-column: 1 / -1; }
.template-head-grid { grid-template-columns: 1fr; }
}
.vorlagen-summe { font-size: 12.5px; color: var(--text-muted); padding: 6px 6px 0; }
.vorlagen-summe b { color: var(--text); font-variant-numeric: tabular-nums; }
/* ---------- Termin aus Mail (js/plan/plan-mail-eintrag.js) ---------- */
.mail-ablage {
position: fixed; inset: 0; z-index: 250;
display: flex; align-items: center; justify-content: center;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 3px dashed var(--accent);
pointer-events: none;
}
.mail-ablage[hidden] { display: none; }
.mail-ablage-karte {
display: flex; flex-direction: column; align-items: center; gap: 8px;
max-width: 420px; padding: 24px 28px; text-align: center;
background: var(--bg); color: var(--text);
border: 1px solid var(--line); border-radius: var(--r-lg, 14px);
box-shadow: 0 12px 40px rgba(0,0,0,.18);
}
.mail-ablage-karte svg { width: 32px; height: 32px; color: var(--accent); }
.mail-ablage-karte b { font-size: 1.1em; }
.mail-ablage-karte span { color: var(--text-subtle); font-size: .9em; }
.mail-ablage.ist-beschaeftigt .mail-ablage-karte svg { animation: mail-puls 1.2s ease-in-out infinite; }
@keyframes mail-puls { 50% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) { .mail-ablage.ist-beschaeftigt .mail-ablage-karte svg { animation: none; } }
.pd-notiz {
width: 100%; box-sizing: border-box; resize: vertical; min-height: 80px;
font: inherit; font-size: .9em; line-height: 1.4;
}
.pd-mail-hinweis {
margin: 0 0 6px; padding: 6px 10px; border-radius: 8px;
background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--text);
}
.plan-task-notiz { display: inline-flex; color: var(--text-subtle); }
.plan-task-notiz svg { width: 14px; height: 14px; }
.mail-auswahl { width: min(560px, calc(100vw - 32px)); }
.mail-auswahl-liste { display: flex; flex-direction: column; gap: 4px; max-height: 50vh; overflow-y: auto; margin: 12px 0; }
.mail-auswahl-zeile {
display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center;
padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer;
}
.mail-auswahl-datum { color: var(--text-subtle); font-size: .88em; white-space: nowrap; }
+798 -59
View File
@@ -33,6 +33,7 @@
Kontrast 4,4 : 1 und bliebe damit unter den 4,5 : 1 für normalen Text). */ Kontrast 4,4 : 1 und bliebe damit unter den 4,5 : 1 für normalen Text). */
--band-neu: #0071e3; --band-neu: #0071e3;
--band-fortschritt: #22823a; --band-fortschritt: #22823a;
--meilenstein: #7a3fb8;
/* Gelbe Markierung neu hinzugekommener Protokoll-Punkte — bewusst eigenes /* Gelbe Markierung neu hinzugekommener Protokoll-Punkte — bewusst eigenes
Token statt --band-neu (dort blau, bedeutet "neu kommentiertes Projekt" Token statt --band-neu (dort blau, bedeutet "neu kommentiertes Projekt"
@@ -108,6 +109,11 @@ body {
} }
.container { padding: 20px 24px; } .container { padding: 20px 24px; }
/* Das hidden-Attribut muss immer gewinnen. Regeln wie button.menu-item
{ display:inline-flex } überstimmten sonst die Browser-Vorgabe, und z. B.
"+ Neues Projekt" blieb für Mitglieder sichtbar, obwohl renderUserBar()
es per hidden ausblendet. */
[hidden] { display: none !important; }
/* === App-Shell: globales Menüband oben + linke Navigations-Sidebar + /* === App-Shell: globales Menüband oben + linke Navigations-Sidebar +
Hauptbereich (OpenProject-Anordnung: ein durchgehendes globales Menüband Hauptbereich (OpenProject-Anordnung: ein durchgehendes globales Menüband
@@ -313,6 +319,11 @@ button.menu-item:hover { background: var(--surface-sunk); }
gap: 2px; gap: 2px;
padding: 12px; padding: 12px;
} }
/* Kein Eintrag darf schrumpfen: Reicht die Höhe nicht (z. B. Admin
aufgeklappt), scrollt .sidebar-nav. Ohne das stauchte die Flex-Spalte das
zuletzt aufgeklappte Untermenü (overflow: hidden → Mindesthöhe 0) auf 0 px
— „Werkzeuge“ ließ sich bei offenem Admin scheinbar nicht aufklappen. */
.sidebar-nav > * { flex-shrink: 0; }
.sidebar-collapse-bar { .sidebar-collapse-bar {
flex-shrink: 0; flex-shrink: 0;
@@ -458,7 +469,7 @@ html[data-theme="dark"] body.wandtafel-aktiv {
/* ===== Kopf: Marke "Dashboard" + Sitzung/Uhrzeit + Abwesend-Mini ===== */ /* ===== Kopf: Marke "Dashboard" + Sitzung/Uhrzeit + Abwesend-Mini ===== */
.dash-kopf { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } .dash-kopf { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.dash-marke { font-family: 'Fredoka', sans-serif; font-weight: 600; font-size: 19px; color: var(--dash-ink); } .dash-marke { font-family: 'Outfit', -apple-system, system-ui, sans-serif; font-weight: 600; font-size: 19px; color: var(--dash-ink); }
.dash-kopf-rechts { display: flex; align-items: center; gap: 14px; } .dash-kopf-rechts { display: flex; align-items: center; gap: 14px; }
.dash-datum { font-size: 12.5px; color: var(--dash-text-muted); background: var(--dash-panel-3); padding: 4px 12px; border-radius: var(--r-pill); } .dash-datum { font-size: 12.5px; color: var(--dash-text-muted); background: var(--dash-panel-3); padding: 4px 12px; border-radius: var(--r-pill); }
.dash-abwesend-mini { display: flex; } .dash-abwesend-mini { display: flex; }
@@ -481,7 +492,7 @@ html[data-theme="dark"] body.wandtafel-aktiv {
.dash-kpi.ist-klickbar { cursor: pointer; } .dash-kpi.ist-klickbar { cursor: pointer; }
.dash-kpi.ist-klickbar:hover { filter: brightness(.96); } .dash-kpi.ist-klickbar:hover { filter: brightness(.96); }
.dash-kpi-label { font-size: 11px; font-weight: 700; color: var(--dash-text-muted); margin-bottom: 8px; } .dash-kpi-label { font-size: 11px; font-weight: 700; color: var(--dash-text-muted); margin-bottom: 8px; }
.dash-kpi-wert { font-family: 'Fredoka', sans-serif; font-weight: 600; font-size: 32px; color: var(--dash-ink); font-variant-numeric: tabular-nums; } .dash-kpi-wert { font-family: 'Outfit', -apple-system, system-ui, sans-serif; font-weight: 600; font-size: 32px; color: var(--dash-ink); font-variant-numeric: tabular-nums; }
.dash-kpi-wert.ok { color: var(--dash-ink); } .dash-kpi-wert.ok { color: var(--dash-ink); }
.dash-kpi-wert.info { color: var(--dash-ink-soft); } .dash-kpi-wert.info { color: var(--dash-ink-soft); }
.dash-kpi-wert.warn { color: var(--dash-warn); } .dash-kpi-wert.warn { color: var(--dash-warn); }
@@ -514,7 +525,7 @@ html[data-theme="dark"] body.wandtafel-aktiv {
.wand-meter-leer { font-size: 12px; color: var(--dash-text-subtle); padding-left: 4px; } .wand-meter-leer { font-size: 12px; color: var(--dash-text-subtle); padding-left: 4px; }
.wand-meter-wert { .wand-meter-wert {
flex: 0 0 76px; text-align: right; font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; flex: 0 0 76px; text-align: right; font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums;
font-family: 'Fredoka', sans-serif; color: var(--dash-ink); font-family: 'Outfit', -apple-system, system-ui, sans-serif; color: var(--dash-ink);
} }
.wand-einheit { font-size: 11px; font-weight: 400; color: var(--dash-text-subtle); margin-left: 2px; } .wand-einheit { font-size: 11px; font-weight: 400; color: var(--dash-text-subtle); margin-left: 2px; }
.wand-leer-klein { font-size: 11.5px; color: var(--dash-text-subtle); } .wand-leer-klein { font-size: 11.5px; color: var(--dash-text-subtle); }
@@ -549,7 +560,7 @@ html[data-theme="dark"] body.wandtafel-aktiv {
box-shadow: var(--sh-2); padding: 20px 24px; color: var(--dash-text); box-shadow: var(--sh-2); padding: 20px 24px; color: var(--dash-text);
} }
.wand-uebersicht-kopf { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; flex-shrink: 0; } .wand-uebersicht-kopf { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; flex-shrink: 0; }
.wand-uebersicht-kopf h2 { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: -.01em; font-family: 'Fredoka', sans-serif; } .wand-uebersicht-kopf h2 { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: -.01em; font-family: 'Outfit', -apple-system, system-ui, sans-serif; }
.wand-uebersicht-liste { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; } .wand-uebersicht-liste { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; }
.wand-uebersicht-zeile { .wand-uebersicht-zeile {
display: flex; align-items: center; gap: 18px; padding: 13px 6px; border-top: 1px solid var(--dash-line); display: flex; align-items: center; gap: 18px; padding: 13px 6px; border-top: 1px solid var(--dash-line);
@@ -778,9 +789,10 @@ button.active {
} }
.sidebar-subnav.open { .sidebar-subnav.open {
/* Reicht für die längste Gruppe („Einstellungen", sieben Punkte); die /* Reicht für die längste Gruppe (Admin, zwölf Punkte); die Animation
Animation läuft über max-height, deshalb steht hier eine feste Zahl. */ läuft über max-height, deshalb steht hier eine feste Zahl. 360px
max-height: 360px; schnitt seit „Rollen & Rechte“ die letzten Admin-Punkte ab. */
max-height: 640px;
opacity: 1; opacity: 1;
} }
@@ -1041,6 +1053,11 @@ tr:nth-child(even) td { background: var(--surface-alt); }
width: 22px; height: 20px; border-radius: 4px; font-size: 11px; font-weight: 700; cursor: pointer; line-height: 1; width: 22px; height: 20px; border-radius: 4px; font-size: 11px; font-weight: 700; cursor: pointer; line-height: 1;
} }
.richtext-btn:hover { background: var(--surface-alt); color: var(--text); } .richtext-btn:hover { background: var(--surface-alt); color: var(--text); }
/* "Standard": setzt die Formatierung zurück — Text statt Symbol, ein
Radiergummi-Zeichen wäre missverständlich. Bleibt auch ohne Markierung
voll sichtbar, weil er dann den ganzen Text zurücksetzt. */
.richtext-btn.richtext-btn-loeschen { width: auto; padding: 0 6px; font-weight: 600; }
.richtext-toolbar:not(.hat-auswahl) .richtext-btn.richtext-btn-loeschen { opacity: 1; }
.richtext-divider { width: 1px; height: 14px; background: var(--line); flex-shrink: 0; } .richtext-divider { width: 1px; height: 14px; background: var(--line); flex-shrink: 0; }
.richtext-farben { display: inline-flex; gap: 6px; } .richtext-farben { display: inline-flex; gap: 6px; }
.richtext-farbe-btn { .richtext-farbe-btn {
@@ -2577,6 +2594,8 @@ input[type=text].edit-name:focus {
Logo-Blau selbst (dasselbe #116896 wie am Dashboard). */ Logo-Blau selbst (dasselbe #116896 wie am Dashboard). */
--band-neu: #116896; --band-neu: #116896;
--band-fortschritt: #1f7a4f; --band-fortschritt: #1f7a4f;
--meilenstein: #c19cf0;
--meilenstein: #c19cf0;
--protokoll-neu-bg: rgba(255,214,10,.14); --protokoll-neu-bg: rgba(255,214,10,.14);
--protokoll-neu-border: #ffd60a; --protokoll-neu-border: #ffd60a;
@@ -2908,10 +2927,11 @@ html[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255,2
} }
.gantt-label { .gantt-label {
width: 220px; /* Breite per Griff veränderbar (ganttSpalteZiehen(), js/gantt.js). */
flex: 0 0 220px; width: var(--gantt-label-w, 220px);
min-width: 220px; flex: 0 0 var(--gantt-label-w, 220px);
max-width: 220px; min-width: var(--gantt-label-w, 220px);
max-width: var(--gantt-label-w, 220px);
height: 100%; height: 100%;
align-self: stretch; align-self: stretch;
box-sizing: border-box; box-sizing: border-box;
@@ -3120,10 +3140,6 @@ html[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255,2
.gantt-bar.todo.done { .gantt-bar.todo.done {
background: var(--green); background: var(--green);
} }
.gantt-bar.todo.critical {
background: var(--red);
box-shadow: 0 0 0 2px rgba(215,0,21,.25);
}
.gantt-todo-add-controls { .gantt-todo-add-controls {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -4165,14 +4181,22 @@ tr.is-menu-open { background: var(--surface-alt); }
/* Der Kommentar wird auf sechs Zeilen begrenzt, damit alle Kacheln einer /* Der Kommentar wird auf sechs Zeilen begrenzt, damit alle Kacheln einer
Schiene gleich hoch bleiben; der vollständige Text steht im Tooltip. */ Schiene gleich hoch bleiben; der vollständige Text steht im Tooltip. */
/* Formatierter Kommentar (Absätze als <div>/<br>): statt line-clamp, das
mit Block-Kindern nicht greift, eine Höhe von sechs Zeilen. */
.timeline-text { .timeline-text {
display: -webkit-box; max-height: calc(1.45em * 6);
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
overflow-wrap: anywhere;
line-height: 1.45; line-height: 1.45;
min-height: 3em; min-height: 3em;
} }
.timeline-text p { margin: 0; }
/* Gewerk über dem Kommentar: Systemtext, nicht Teil des Kommentars. */
.kommentar-gewerk {
font-size: 10.5px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase;
color: var(--text-subtle); margin-top: 4px;
}
.kommentar-gewerk:first-child { margin-top: 0; }
.timeline-leer { color: var(--text-muted); font-style: italic; } .timeline-leer { color: var(--text-muted); font-style: italic; }
.timeline-lph-list { display: flex; flex-direction: column; gap: 1px; } .timeline-lph-list { display: flex; flex-direction: column; gap: 1px; }
@@ -4456,11 +4480,11 @@ tr.is-menu-open { background: var(--surface-alt); }
.bericht-rail-dot.is-progress { background: var(--band-fortschritt); } .bericht-rail-dot.is-progress { background: var(--band-fortschritt); }
.bericht-rail-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bericht-rail-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* max-width: die Hauptspalte bleibt eine schmale Lesespalte statt bis zum /* Keine Breitenbegrenzung: Die Hauptspalte nutzt die ganze Bildschirmbreite
rechten Rand zu laufen — Teil der Fokus-Idee, nicht nur die Nebeninfos neben der Leiste (Eriks Wunsch). Früher hielt max-width: 640px sie als
wegzuklappen, sondern auch dem Kommentar selbst keine unnötige Breite zu schmale Lesespalte — auf breiten Monitoren blieb dabei die halbe Fläche
geben. */ leer. */
.bericht-main { padding: 26px 36px 30px; max-width: 640px; min-width: 0; } .bericht-main { padding: 26px 36px 30px; min-width: 0; }
.bericht-head-line { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; } .bericht-head-line { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.bericht-head-nr { .bericht-head-nr {
@@ -4480,36 +4504,64 @@ tr.is-menu-open { background: var(--surface-alt); }
color: var(--amber); font-size: 12px; margin-bottom: 16px; color: var(--amber); font-size: 12px; margin-bottom: 16px;
} }
/* Scrollrad über dem Kommentar-Eingabefeld: "Was hatten wir letzte Woche /* Verlauf der Vorwochen im Wochenbericht (Eriks Wahl "B"): oben die
vereinbart?" ist die häufigste Frage im Meeting - stand bis hierhin als schmale Zeile "Zuletzt KW …" (renderBerichtZuletzt()), unter der
starre Einzeile mit nur der unmittelbaren Vorwoche da. Erik wollte mit dem Bearbeitungsmaske die Zeitleiste über die volle Breite
Mausrad durch MEHRERE vergangene Kommentare blättern können (renderBerichtVerlauf(), js/wochenbericht/wochenbericht.js) — dieselbe
(renderVorwocheRad(), js/wochenbericht/wochenbericht.js), nicht nur die Bildsprache wie der Verlauf im Projekt-Dashboard (.plan-verlauf-*). */
eine Vorwoche sehen - die Quelle ist derselbe, bereits geprüfte Verlauf wie .bericht-zuletzt {
im Verlaufs-Dialog (projectStatusHistory(), js/utils.js). */ background: var(--surface-sunk); border-radius: var(--r-md);
.bericht-vorwoche-rad { padding: 8px 12px; margin-bottom: 14px; font-size: 12.5px; line-height: 1.5; color: var(--text-muted);
display: flex; align-items: center; gap: 8px; background: var(--surface-sunk);
border-radius: var(--r-md); padding: 7px 9px; margin-bottom: 14px; cursor: ns-resize;
} }
.bericht-vorwoche-nav { .bericht-zuletzt-kw { display: block; font-size: 11px; font-weight: 650; color: var(--text); margin-bottom: 2px; }
width: 22px; height: 22px; border: none; background: none; border-radius: 50%; /* Lange Kommentare nicht endlos: höchstens fünf Zeilen, der Rest steht im Verlauf. */
color: var(--text-muted); cursor: pointer; font-size: 13px; flex-shrink: 0; .bericht-zuletzt-text { max-height: calc(1.5em * 5); overflow: hidden; overflow-wrap: anywhere; }
display: flex; align-items: center; justify-content: center; font-family: inherit; .bericht-zuletzt-text p { margin: 0; }
.bericht-verlauf { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--line-soft); }
.bv-kopf { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 6px; }
.bv-titel { font-size: 15px; font-weight: 650; margin: 0; color: var(--text); }
.bv-anzahl { font-size: 12px; color: var(--text-subtle); }
.bv-mehr, .bv-uebernehmen {
border: 0; background: transparent; font: inherit; font-size: 12px; font-weight: 600;
color: var(--accent); cursor: pointer; padding: 4px 8px; border-radius: var(--r-md);
} }
.bericht-vorwoche-nav:hover:not(:disabled) { background: var(--surface); color: var(--text); } .bv-mehr:hover, .bv-uebernehmen:hover { background: var(--surface-alt); }
.bericht-vorwoche-nav:disabled { opacity: .3; cursor: default; } .bv-liste { list-style: none; margin: 0; padding: 0; }
.bericht-vorwoche-inhalt { flex: 1; min-width: 0; } .bv-zeile {
.bericht-vorwoche-label { display: grid; grid-template-columns: 120px 14px minmax(0, 1fr) minmax(0, 220px) auto;
display: block; font-weight: 600; color: var(--text-subtle); column-gap: 14px; align-items: start; padding: 10px 0;
text-transform: uppercase; font-size: 10.5px; letter-spacing: .05em; margin-bottom: 3px;
} }
.bericht-vorwoche-text { .bv-kw { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
color: var(--text-muted); font-size: 12px; display: block; .bv-kw b { font-size: 13px; color: var(--text); }
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; .bv-kw span { font-size: 11.5px; color: var(--text-subtle); }
/* Punkt und Linie: die Linie läuft von Punkt zu Punkt über den Zeilenabstand */
.bv-punkt { position: relative; align-self: stretch; }
.bv-punkt::before {
content: ""; position: absolute; left: 1px; top: 4px; width: 12px; height: 12px;
border-radius: 50%; box-sizing: border-box; border: 2px solid var(--accent); background: var(--surface); z-index: 1;
}
.bv-zeile.ist-juengster .bv-punkt::before { background: var(--accent); }
.bv-punkt::after {
content: ""; position: absolute; left: 6px; top: 16px; bottom: -14px; width: 2px; background: var(--line);
}
.bv-zeile:last-child .bv-punkt::after { display: none; }
.bv-inhalt { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.bv-text { font-size: 13.5px; line-height: 1.5; color: var(--text); overflow-wrap: anywhere; }
.bv-text p { margin: 0; }
.bv-ruhe { font-size: 11.5px; color: var(--text-subtle); }
.bv-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.bv-chip {
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap;
background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent);
}
.bv-uebernehmen { justify-self: end; }
@media (max-width: 1100px) {
.bv-zeile { grid-template-columns: 76px 14px minmax(0, 1fr); row-gap: 6px; }
.bv-chips, .bv-uebernehmen { grid-column: 3; }
.bv-uebernehmen { justify-self: start; padding-left: 0; }
.bv-chips:empty { display: none; }
} }
.bericht-vorwoche-dots { display: flex; flex-direction: column; gap: 3px; flex-shrink: 0; }
.bericht-vorwoche-dots .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--line); }
.bericht-vorwoche-dots .dot.aktiv { background: var(--accent); }
.bericht-detail-fuss { display: flex; align-items: center; gap: 14px; margin-top: 20px; } .bericht-detail-fuss { display: flex; align-items: center; gap: 14px; margin-top: 20px; }
.bericht-fuss-spacer { flex: 1; } .bericht-fuss-spacer { flex: 1; }
@@ -4685,16 +4737,12 @@ tr.is-menu-open { background: var(--surface-alt); }
color: var(--amber); color: var(--amber);
} }
.auftrag-select { /* .auftrag-select steht nur noch in den Projekteinstellungen und sieht dort
font-size: 12px; aus wie jedes andere Auswahlfeld (Gruppe direkt darunter): Größe, Rahmen
padding: 3px 8px; und Pfeil kommen aus der allgemeinen select-Regel. Die frühere kompakte
border: 1px solid var(--line); Fassung setzte `background:` als Kurzform und löschte damit den Pfeil —
border-radius: var(--r-sm); das Feld wirkte wie ein loses Etikett statt wie eine Auswahl. */
background: var(--surface); .auftrag-select { width: 100%; }
color: var(--text);
max-width: 100%;
}
.auftrag-select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* =========================================================== /* ===========================================================
Ein Ort je Angabe — Anzeige statt Eingabe Ein Ort je Angabe — Anzeige statt Eingabe
@@ -5433,3 +5481,694 @@ html[data-theme="dark"] .login-tage li.ist-heute span, html[data-theme="dark"] .
.login-wasserzeichen { width: 260vw; top: 0; transform: translate(-30%, -10%); } .login-wasserzeichen { width: 260vw; top: 0; transform: translate(-30%, -10%); }
} }
@media (prefers-reduced-motion: reduce) { .login-woche { animation: none; } .login-spinner { animation: none; } } @media (prefers-reduced-motion: reduce) { .login-woche { animation: none; } .login-spinner { animation: none; } }
/* ---------- Dashboard: Meine Projekte + Monatskalender (Entwurf B) ---------- */
.dash-zonen { grid-template-columns: minmax(0, 1fr) 340px; }
.dash-proj { margin-bottom: 10px; }
.dash-proj-kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; flex-wrap: wrap; padding: 12px 16px 4px; }
.dash-proj-kopf { align-items: center; padding-bottom: 10px; }
.dash-proj.ist-zu .dash-proj-kopf { padding-top: 10px; }
.dash-proj-toggle { flex: 1 1 260px; min-width: 0; display: flex; align-items: baseline; gap: 0; padding: 0; border: 0; background: none; color: var(--text); font: inherit; text-align: left; cursor: pointer; }
.dash-proj-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--r-sm); }
.dash-proj-titel { font-weight: 650; font-size: 14px; }
/* Die allgemeine Knopfregel (button:hover → Akzentfläche, :active →
Stauchen) ist spezifischer als eine einzelne Klasse und färbte den
Projektkopf beim Zeigen blau ein — der Titel verschwand darin. Diese
Knöpfe sind Textschalter, keine Aktionsknöpfe: Sie bekommen eine eigene,
ruhige Rückmeldung (Kopfzeile hellt auf, Pfeil und Titel in Akzentfarbe). */
button.dash-proj-toggle, button.dash-proj-toggle:hover, button.dash-proj-toggle:active,
button.dash-proj-alle, button.dash-proj-alle:hover, button.dash-proj-alle:active {
background: none; transform: none; box-shadow: none;
}
button.dash-kal-nav:hover { background: var(--surface-sunk); }
button.dash-kal-nav:active, button.dash-kal-tag:active { transform: none; }
.dash-proj-kopf, .dash-proj-sub { transition: background var(--dur, 150ms) var(--ease-out, ease); }
.dash-proj:has(.dash-proj-toggle:hover) .dash-proj-kopf,
.dash-proj:has(.dash-proj-toggle:hover) .dash-proj-sub { background: color-mix(in srgb, var(--accent) 4%, var(--surface)); }
.dash-proj-toggle:hover .dash-proj-titel,
.dash-proj-toggle:hover .dash-proj-chev { color: var(--accent); }
button.dash-proj-alle:hover { text-decoration: underline; }
.dash-proj-chev { display: inline-block; width: 16px; margin-right: 6px; font-size: 17px; line-height: 1; text-align: center; color: var(--text-subtle); transform: rotate(90deg); transition: transform var(--dur, 150ms) var(--ease-out, ease); }
.dash-proj.ist-zu .dash-proj-chev { transform: rotate(0deg); }
.dash-proj.ist-zu .dash-proj-inhalt { display: none; }
.dash-proj-kurz { display: none; gap: 6px; }
.dash-proj.ist-zu .dash-proj-kurz { display: flex; }
.dash-proj-zahl { font-size: 10.5px; padding: 1px 7px; border-radius: var(--r-pill); background: var(--surface-sunk); color: var(--text-muted); white-space: nowrap; }
.dash-proj-zahl.ist-rot { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); font-weight: 600; }
.dash-proj-oeffnen { font-size: 11.5px; color: var(--accent); text-decoration: none; }
.dash-proj-oeffnen:hover { text-decoration: underline; }
.dash-proj-alle { margin-left: auto; padding: 2px 4px; border: 0; background: none; color: var(--accent); font: inherit; font-size: 11.5px; cursor: pointer; }
@media (prefers-reduced-motion: reduce) { .dash-proj-chev { transition: none; } }
.dash-proj-nr { font-family: ui-monospace, 'SF Mono', monospace; font-size: 11px; color: var(--text-subtle); font-weight: 500; margin-right: 8px; }
.dash-rollen { display: flex; gap: 4px; flex-wrap: wrap; }
.dash-rolle { font-size: 10.5px; font-weight: 600; padding: 1px 8px; border-radius: var(--r-pill); background: var(--accent-soft); color: var(--accent); }
.dash-rolle.ist-neben { background: transparent; border: 1px dashed var(--line); color: var(--text-muted); }
.dash-proj-sub { padding: 2px 16px 8px; }
.dash-proj-phase { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--text-muted); max-width: 320px; }
.dash-proj-phase .dash-auf-bar i { background: var(--green); }
.dash-proj-phase.is-leer { color: var(--text-subtle); }
.dash-proj-termine { border-top: 1px solid var(--line-soft); }
.dash-proj-mehr { display: block; padding: 8px 16px; font-size: 11.5px; color: var(--accent); text-decoration: none; border-top: 1px solid var(--line-soft); }
.dash-termin { display: flex; align-items: center; gap: 10px; padding: 8px 16px; border-bottom: 1px solid var(--line-soft); color: inherit; text-decoration: none; }
.dash-termin:last-child { border-bottom: none; }
a.dash-termin:hover { background: var(--surface-sunk); }
.dash-termin-art { flex: 0 0 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; font-size: 11px; font-weight: 700; background: var(--surface-sunk); color: var(--text-muted); }
.dash-termin-art.ist-meins { background: var(--accent-soft); color: var(--accent); }
.dash-termin-art.ist-ms { background: color-mix(in srgb, var(--meilenstein) 14%, transparent); color: var(--meilenstein); }
.dash-termin-text { flex: 1; min-width: 0; }
.dash-termin-titel { display: block; font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-termin-meta { display: block; font-size: 11px; color: var(--text-subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-termin-datum { flex: 0 0 auto; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
.dash-termin-datum em { display: block; font-style: normal; font-size: 10.5px; color: var(--text-subtle); }
.dash-termin-datum.ist-ueberfaellig, .dash-termin-datum.ist-ueberfaellig em { color: var(--red); }
.dash-termin-datum.ist-bald { color: var(--amber); }
.dash-kal { padding: 12px 14px 10px; }
.dash-kal-kopf { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.dash-kal-kopf b { font-size: 13.5px; }
.dash-kal-nav { width: 26px; height: 26px; padding: 0; border: 1px solid var(--line); background: var(--surface); color: var(--text); border-radius: var(--r-sm); cursor: pointer; }
.dash-kal-raster { display: grid; grid-template-columns: 26px repeat(7, 1fr); gap: 2px; font-variant-numeric: tabular-nums; }
.dash-kal-wt { font-size: 10.5px; color: var(--text-subtle); text-align: center; padding-bottom: 4px; }
.dash-kal-kw { font-size: 10px; color: var(--text-subtle); display: grid; place-items: center; }
.dash-kal-tag { position: relative; aspect-ratio: 1; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text); font: inherit; font-size: 12.5px; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; }
.dash-kal-tag:hover { background: var(--surface-sunk); }
.dash-kal-tag.ist-fremd, .dash-kal-tag.ist-frei { color: var(--text-subtle); }
.dash-kal-tag.ist-heute { color: var(--accent); font-weight: 700; box-shadow: inset 0 0 0 1.5px var(--accent); }
.dash-kal-tag.ist-gewaehlt { background: var(--accent); color: var(--surface); }
.dash-kal-tag:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.dash-kal-punkte { display: flex; gap: 2px; height: 5px; }
.dash-kal-punkte i, .dash-kal-legende i { width: 5px; height: 5px; border-radius: 50%; display: inline-block; background: var(--accent); }
.dash-kal-legende i { width: 7px; height: 7px; }
i.ist-fremd { background: color-mix(in srgb, var(--accent) 45%, transparent); }
i.ist-ms { background: var(--meilenstein); }
i.ist-rot { background: var(--red); }
i.ist-ab { background: var(--text-subtle); }
.dash-kal-tag.ist-gewaehlt .dash-kal-punkte i { background: currentColor; }
.dash-kal-legende { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 8px; font-size: 10.5px; color: var(--text-muted); }
.dash-kal-legende span { display: flex; align-items: center; gap: 5px; }
.dash-kal-liste { border-top: 1px solid var(--line-soft); }
.dash-kal-liste-titel { padding: 10px 16px 4px; font-size: 11.5px; font-weight: 600; color: var(--text-muted); }
.dash-kal-hinweis { padding: 2px 16px 6px; font-size: 11.5px; color: var(--amber); }
.dash-kal-liste .dash-termin { padding: 7px 16px; }
/* Griff zum Ändern der Spaltenbreite im GANTT (js/gantt.js). Ein breiter,
unsichtbarer Greifbereich über der Spaltengrenze; sichtbar wird er erst
beim Zeigen und Ziehen, wie die Spaltengrenze in Excel. */
.gantt-spalten-griff {
position: absolute; top: 0; right: -4px; width: 9px; height: 100%;
cursor: col-resize; z-index: 20; touch-action: none;
}
.gantt-spalten-griff::after {
content: ""; position: absolute; top: 3px; bottom: 3px; left: 3px; width: 3px;
border-radius: 2px; background: var(--accent); opacity: 0; transition: opacity .15s;
}
.gantt-spalten-griff:hover::after,
.gantt-spalte-zieht .gantt-spalten-griff::after { opacity: .7; }
.gantt-spalte-zieht, .gantt-spalte-zieht * { cursor: col-resize !important; user-select: none !important; }
/* ---------- Glocke: Benachrichtigungen (js/benachrichtigungen.js) ---------- */
.glocke { position: relative; }
#glockeBtn { position: relative; }
#glockeBtn.hat-neue { color: var(--accent); }
.glocke-zahl {
position: absolute; top: -3px; right: -3px; min-width: 17px; height: 17px; padding: 0 4px;
border-radius: 999px; background: var(--red); color: #fff; font-size: 10px; font-weight: 700;
line-height: 17px; text-align: center; box-shadow: 0 0 0 2px var(--bg);
}
.glocke-menue {
position: absolute; right: 0; top: calc(100% + 8px); z-index: 260;
width: min(360px, calc(100vw - 32px)); max-height: min(520px, 70vh); overflow: auto;
background: var(--surface); border: 1px solid var(--line-soft); border-radius: var(--r-lg);
box-shadow: var(--sh-3);
}
.glocke-kopf { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px 8px; position: sticky; top: 0; background: inherit; }
.glocke-kopf b { font-size: 13px; }
.glocke-alle { padding: 2px 4px; border: 0; background: none !important; color: var(--accent); font: inherit; font-size: 11.5px; cursor: pointer; box-shadow: none !important; }
.glocke-alle:hover { text-decoration: underline; }
.glocke-leer { padding: 10px 14px 16px; font-size: 12.5px; color: var(--text-subtle); line-height: 1.5; }
.glocke-eintrag {
display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 10px 14px;
border: 0; border-top: 1px solid var(--line-soft); border-radius: 0; background: none; color: var(--text);
font: inherit; text-align: left; cursor: pointer; box-shadow: none;
}
.glocke-eintrag:hover { background: var(--surface-sunk); }
.glocke-eintrag:active { transform: none; }
.glocke-punkt { flex: 0 0 8px; height: 8px; margin-top: 5px; border-radius: 50%; background: transparent; }
.glocke-eintrag.ist-neu .glocke-punkt { background: var(--accent); }
.glocke-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.glocke-titel { font-size: 13px; font-weight: 500; }
.glocke-eintrag.ist-neu .glocke-titel { font-weight: 650; }
.glocke-meta { font-size: 11px; color: var(--text-subtle); }
.dash-neu .glocke-eintrag:first-child { border-top: 0; }
#dashNeu:empty { display: none; }
/* Löschen je Benachrichtigung: Zeile = Öffnen-Knopf + ×. */
.glocke-zeile { display: flex; align-items: stretch; border-top: 1px solid var(--line-soft); }
.glocke-zeile .glocke-eintrag { border-top: 0; flex: 1 1 auto; min-width: 0; }
.dash-neu .glocke-zeile:first-child { border-top: 0; }
.glocke-aktionen { display: flex; gap: 6px; }
.glocke-loeschen {
flex: 0 0 34px; padding: 0; border: 0; border-radius: 0; background: none !important; box-shadow: none !important;
color: var(--text-subtle); font-size: 18px; line-height: 1; cursor: pointer; opacity: .55;
}
.glocke-zeile:hover .glocke-loeschen, .glocke-loeschen:focus-visible { opacity: 1; }
.glocke-loeschen:hover { color: var(--red); }
@media (hover: none) { .glocke-loeschen { opacity: 1; } }
/* ===========================================================================
WANDTAFEL IN GLAS (js/wand/wand.js, wt*-Funktionen) — Eriks Wahl aus dem
Entwurf: derselbe Hintergrund wie die Glas-App (Lichtflächen in den
Logo-Blautönen + Logo als Wasserzeichen), Felder halbdeckend OHNE
backdrop-filter (Kiosk-Rechner), Schrift Outfit für Ziffern und
Überschriften. Größen in Container-Einheiten (cqw von #wandContent), damit
die Tafel auf jedem Bildschirm gleich aufgeteilt ist.
=========================================================================== */
body.wandtafel-aktiv {
--wt-tief: #0f5a85; --wt-hell: #94b0cc; --wt-grund: #e8eef3; --wt-mint: #7fc3b6;
--wt-tinte: #14212b; --wt-tinte-2: #4b5d6b; --wt-tinte-3: #6f8291;
--wt-gruen: #1f8a4c; --wt-gelb: #b4780a; --wt-rot: #c8242f; --wt-ms: #6d4bb3;
--wt-feld: rgba(255,255,255,.68); --wt-feld-2: rgba(255,255,255,.46);
--wt-kante: rgba(255,255,255,.85); --wt-kante-2: rgba(255,255,255,.4);
--wt-linie: rgba(20,45,65,.09);
--wt-schatten: 0 calc(1.2 * var(--u)) calc(3.4 * var(--u)) rgba(21,58,86,.12), 0 calc(.2 * var(--u)) calc(.6 * var(--u)) rgba(21,58,86,.06);
--wt-logo: .17;
--wt-zahl: 'Outfit', -apple-system, 'SF Pro Display', system-ui, sans-serif;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) body.wandtafel-aktiv {
--wt-tief: #8cc9e6; --wt-hell: #2c5675; --wt-grund: #0b141b; --wt-mint: #2f6b62;
--wt-tinte: #e8f0f5; --wt-tinte-2: #a9bccb; --wt-tinte-3: #7790a2;
--wt-gruen: #5fd394; --wt-gelb: #f0c05e; --wt-rot: #ff7b72; --wt-ms: #c3a6ff;
--wt-feld: rgba(26,40,52,.72); --wt-feld-2: rgba(30,46,60,.5);
--wt-kante: rgba(255,255,255,.16); --wt-kante-2: rgba(255,255,255,.06); --wt-linie: rgba(255,255,255,.08);
--wt-schatten: 0 calc(1.2 * var(--u)) calc(3.4 * var(--u)) rgba(0,0,0,.4), 0 calc(.2 * var(--u)) calc(.6 * var(--u)) rgba(0,0,0,.3);
--wt-logo: .10;
}
}
html[data-theme="dark"] body.wandtafel-aktiv {
--wt-tief: #8cc9e6; --wt-hell: #2c5675; --wt-grund: #0b141b; --wt-mint: #2f6b62;
--wt-tinte: #e8f0f5; --wt-tinte-2: #a9bccb; --wt-tinte-3: #7790a2;
--wt-gruen: #5fd394; --wt-gelb: #f0c05e; --wt-rot: #ff7b72; --wt-ms: #c3a6ff;
--wt-feld: rgba(26,40,52,.72); --wt-feld-2: rgba(30,46,60,.5);
--wt-kante: rgba(255,255,255,.16); --wt-kante-2: rgba(255,255,255,.06); --wt-linie: rgba(255,255,255,.08);
--wt-schatten: 0 calc(1.2 * var(--u)) calc(3.4 * var(--u)) rgba(0,0,0,.4), 0 calc(.2 * var(--u)) calc(.6 * var(--u)) rgba(0,0,0,.3);
--wt-logo: .10;
}
/* Hintergrund: wie die Glas-App, auch wenn die Glas-Darstellung abgeschaltet ist. */
body.wandtafel-aktiv .container {
position: relative; isolation: isolate;
background:
radial-gradient(52% 48% at 8% 4%, color-mix(in srgb, var(--wt-hell) 72%, transparent), transparent 70%),
radial-gradient(42% 46% at 94% 10%, color-mix(in srgb, var(--wt-tief) 26%, transparent), transparent 70%),
radial-gradient(48% 55% at 72% 98%, color-mix(in srgb, var(--wt-mint) 30%, transparent), transparent 70%),
radial-gradient(38% 40% at 28% 84%, color-mix(in srgb, var(--wt-hell) 44%, transparent), transparent 70%),
var(--wt-grund);
}
body.wandtafel-aktiv .container::before {
content: ""; position: absolute; z-index: -1; left: -3vw; bottom: -11vw; width: 62vw; aspect-ratio: 256 / 216;
background: url("/logo.svg") no-repeat center / contain; opacity: var(--wt-logo); pointer-events: none;
}
/* #wandContent ist Abfrage-Container in Breite UND Höhe. Alle Maße der Tafel
laufen über --u: 1 % der Breite, aber nie mehr, als die Höhe bei 16:9
hergäbe. Auf einem flacheren Fenster (Browserleiste, 16:10-Laptop,
21:9-Monitor) schrumpft so alles gleichmäßig, statt unten abgeschnitten zu
werden; die Spalten füllen die Breite trotzdem über fr-Einheiten. */
body.wandtafel-aktiv .wand-grid-wrap { background: transparent; container-type: size; }
body.wandtafel-aktiv .wand-grid-wrap > * { --u: min(1cqw, 1.7778cqh); }
body.wandtafel-aktiv .wand-kopfzeile { padding-bottom: 6px; }
/* Genau einen Bildschirm hoch (nicht nur mindestens): Die Felder teilen
sich die Höhe, statt den Fuß unter den Rand zu schieben. Die Projektliste
folgt darunter und wird gewischt. */
.wand-ambient.wt { height: 100%; min-height: 0; color: var(--wt-tinte); font-size: calc(1.05 * var(--u)); gap: calc(1.2 * var(--u)); padding-bottom: calc(.4 * var(--u)); }
.wt-gewerke { overflow: hidden; }
.wt-feld { background: var(--wt-feld); border: 1px solid var(--wt-kante-2); border-radius: calc(1.5 * var(--u));
box-shadow: var(--wt-schatten), inset 0 1px 0 var(--wt-kante); }
.wt .ist-klickbar { cursor: pointer; }
.wt-kopf { flex-shrink: 0; display: flex; align-items: center; gap: calc(1.6 * var(--u)); padding: calc(.9 * var(--u)) calc(1.4 * var(--u)); }
.wt-marke { display: flex; align-items: center; gap: calc(.8 * var(--u)); }
.wt-marke img { width: calc(3.4 * var(--u)); height: auto; }
.wt-marke b { font-family: var(--wt-zahl); font-weight: 500; font-size: calc(2 * var(--u)); letter-spacing: -.01em; }
.wt-sitzung { font-size: calc(1 * var(--u)); font-weight: 650; padding: calc(.3 * var(--u)) calc(.9 * var(--u)); border-radius: calc(99 * var(--u)); background: color-mix(in srgb, var(--wt-tief) 12%, transparent); color: var(--wt-tief); }
.wt-abw { display: flex; align-items: center; gap: calc(.5 * var(--u)); margin-left: auto; font-size: calc(1 * var(--u)); color: var(--wt-tinte-2); cursor: pointer; }
.wt-abw.ist-leer { cursor: default; color: var(--wt-tinte-3); }
.wt-ava { width: calc(2.5 * var(--u)); height: calc(2.5 * var(--u)); border-radius: 50%; display: grid; place-items: center; font-size: calc(.85 * var(--u)); font-weight: 700;
background: var(--wt-feld); border: 1px solid var(--wt-kante-2); color: var(--wt-tinte-2); }
.wt-uhr { font-family: var(--wt-zahl); font-weight: 300; font-size: calc(3.4 * var(--u)); letter-spacing: -.02em; font-variant-numeric: tabular-nums; line-height: 1; text-align: right; }
.wt-uhr small { display: block; font-family: inherit; font-weight: 400; font-size: calc(.9 * var(--u)); color: var(--wt-tinte-3); letter-spacing: 0; margin-top: calc(.25 * var(--u)); }
.wt-kpis { flex-shrink: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: calc(1.2 * var(--u)); }
.wt-kpi { padding: calc(1.1 * var(--u)) calc(1.4 * var(--u)); display: flex; align-items: center; gap: calc(1.2 * var(--u)); transition: transform .15s; }
.wt-kpi.ist-klickbar:active { transform: scale(.98); }
.wt-wert { font-family: var(--wt-zahl); font-weight: 500; font-size: calc(4.2 * var(--u)); line-height: 1; font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
.wt-wert small { font-size: calc(2 * var(--u)); margin-left: calc(.15 * var(--u)); }
.wt-lab { font-size: calc(1.05 * var(--u)); color: var(--wt-tinte-2); line-height: 1.35; }
.wt-lab b { display: block; color: var(--wt-tinte); font-size: calc(1.15 * var(--u)); }
.wt-kpi.ist-rot .wt-wert { color: var(--wt-rot); }
.wt-ring { flex: none; width: calc(4.4 * var(--u)); height: calc(4.4 * var(--u)); border-radius: 50%;
background: conic-gradient(var(--c) calc(var(--p) * 1%), color-mix(in srgb, var(--wt-tinte-3) 20%, transparent) 0);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - calc(.55 * var(--u))), #000 calc(100% - calc(.5 * var(--u))));
mask: radial-gradient(farthest-side, transparent calc(100% - calc(.55 * var(--u))), #000 calc(100% - calc(.5 * var(--u)))); }
.wt-felder { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, .92fr) minmax(0, 2.08fr); gap: calc(1.2 * var(--u)); }
.wt-panel { padding: calc(1.1 * var(--u)) calc(1.3 * var(--u)); display: flex; flex-direction: column; min-height: 0; }
.wt-panel h2 { margin: 0 0 calc(.9 * var(--u)); font-family: var(--wt-zahl); font-size: calc(1.45 * var(--u)); font-weight: 500; letter-spacing: -.005em; color: var(--wt-tinte);
display: flex; align-items: baseline; gap: calc(.6 * var(--u)); }
.wt-panel h2 span { margin-left: auto; font-family: inherit; font-size: calc(.95 * var(--u)); font-weight: 400; color: var(--wt-tinte-3); }
.wt-gw { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: calc(5 * var(--u)) minmax(0, 1fr); gap: calc(1 * var(--u)); align-items: center; padding: calc(.9 * var(--u)) 0; border-top: 1px solid var(--wt-linie); }
.wt-gw:first-of-type { border-top: 0; }
.wt-gew { font-family: var(--wt-zahl); font-weight: 500; font-size: calc(1.7 * var(--u)); }
.wt-gew small { display: block; font-family: inherit; font-weight: 400; font-size: calc(.85 * var(--u)); color: var(--wt-tinte-3); }
.wt-gw-werte { display: flex; flex-direction: column; gap: calc(.5 * var(--u)); min-width: 0; }
.wt-gw-zeile { display: grid; grid-template-columns: calc(6.6 * var(--u)) minmax(0, 1fr) calc(4.6 * var(--u)); align-items: center; gap: calc(.8 * var(--u)); padding: calc(.25 * var(--u)) calc(.4 * var(--u)); margin: 0 -calc(.4 * var(--u)); border-radius: calc(.8 * var(--u)); }
.wt-gw-zeile:hover { background: color-mix(in srgb, var(--wt-tief) 7%, transparent); }
.wt-gw-lab { font-size: calc(.92 * var(--u)); color: var(--wt-tinte-3); }
.wt-balken { height: calc(1.1 * var(--u)); border-radius: calc(99 * var(--u)); background: color-mix(in srgb, var(--wt-tinte-3) 16%, transparent); overflow: hidden; display: block; }
.wt-balken i { display: block; height: 100%; border-radius: inherit; }
.wt-zahl { font-family: var(--wt-zahl); font-weight: 500; font-size: calc(1.6 * var(--u)); text-align: right; font-variant-numeric: tabular-nums; }
.wt-zahl.klein { font-size: calc(1.25 * var(--u)); color: var(--wt-tinte-2); }
/* Besprochen-Stand: immer EINE Reihe. Bei wenigen Projekten Kästchen, bei
vielen werden sie schmaler und bilden einen Segmentbalken (ein Segment je
Projekt) — umbrechende Kästchen liefen sonst bei ELT (25 Projekte) über
die Trennlinie zum nächsten Gewerk. */
.wt-punkte { display: flex; flex-wrap: nowrap; gap: calc(.22 * var(--u)); min-width: 0; }
.wt-punkte i { flex: 1 1 0; min-width: 2px; max-width: calc(.95 * var(--u)); height: calc(.95 * var(--u));
border-radius: calc(.25 * var(--u)); background: color-mix(in srgb, var(--wt-tinte-3) 22%, transparent); }
.wt-punkte i.ja { background: var(--wt-gruen); }
.wt-leer { font-size: calc(.95 * var(--u)); color: var(--wt-tinte-3); }
.wt-tage-raster { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: calc(.8 * var(--u)); }
.wt-tag { display: flex; flex-direction: column; gap: calc(.55 * var(--u)); min-height: 0; padding: calc(.8 * var(--u)); border-radius: calc(1.1 * var(--u)); overflow: hidden;
background: color-mix(in srgb, var(--wt-feld) 55%, transparent); border: 1px solid var(--wt-kante-2); }
.wt-tag.ist-heute { background: color-mix(in srgb, var(--wt-tief) 9%, var(--wt-feld)); border-color: color-mix(in srgb, var(--wt-tief) 30%, transparent); }
.wt-tag-kopf { display: flex; align-items: baseline; gap: calc(.55 * var(--u)); padding-bottom: calc(.45 * var(--u)); border-bottom: 1px solid var(--wt-linie); }
.wt-tag-kopf b { font-family: var(--wt-zahl); font-weight: 500; font-size: calc(1.75 * var(--u)); letter-spacing: -.01em; }
.wt-tag-kopf span { font-size: calc(.95 * var(--u)); color: var(--wt-tinte-3); }
.wt-tag.ist-heute .wt-tag-kopf b { color: var(--wt-tief); }
.wt-ein { display: grid; grid-template-columns: calc(.35 * var(--u)) minmax(0, 1fr); gap: calc(.6 * var(--u)); font-size: calc(1 * var(--u)); line-height: 1.3; border-radius: calc(.5 * var(--u)); }
.wt-ein.ist-klickbar:hover { background: color-mix(in srgb, var(--wt-tief) 6%, transparent); }
.wt-ein > i { border-radius: calc(99 * var(--u)); background: var(--c); }
.wt-ein > div { min-width: 0; }
.wt-ein b { font-weight: 600; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.wt-ein span { display: block; color: var(--wt-tinte-3); font-size: calc(.88 * var(--u)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wt-ein.wt-ab b { font-weight: 500; color: var(--wt-tinte-2); }
.wt-ueber { --c: var(--wt-rot); } .wt-aufgabe { --c: var(--wt-tief); } .wt-ms { --c: var(--wt-ms); }
.wt-kommentar { --c: var(--wt-gruen); } .wt-ab { --c: var(--wt-tinte-3); }
.wt-gruppe { font-size: calc(.78 * var(--u)); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--c); margin-top: calc(.15 * var(--u)); }
.wt-tag-liste { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; gap: calc(.55 * var(--u)); }
.wt-mehr { flex-shrink: 0; font-size: calc(.9 * var(--u)); color: var(--wt-tief); font-weight: 600; cursor: pointer; }
.wt-legende { display: flex; flex-wrap: wrap; gap: calc(.4 * var(--u)) calc(1.4 * var(--u)); margin-top: calc(.8 * var(--u)); font-size: calc(.9 * var(--u)); color: var(--wt-tinte-2); }
.wt-legende span { display: flex; align-items: center; gap: calc(.45 * var(--u)); }
.wt-legende i { width: calc(.9 * var(--u)); height: calc(.9 * var(--u)); border-radius: calc(.3 * var(--u)); background: var(--c); }
.wt-fuss { flex-shrink: 0; display: flex; align-items: center; gap: calc(1 * var(--u)); padding: calc(.6 * var(--u)) calc(1.4 * var(--u)); font-size: calc(1.05 * var(--u)); color: var(--wt-tinte-2); }
.wt-pfeil { font-family: var(--wt-zahl); font-weight: 300; font-size: calc(1.7 * var(--u)); color: var(--wt-tief); }
@media (prefers-reduced-motion: reduce) { .wt-kpi { transition: none; } }
/* Hinweis nach dem Zusammenführen gleichzeitiger Änderungen (js/store.js,
zeigeHinweis()) — kein Fehler, deshalb nicht rot wie .sync-problem-bar. */
.sync-problem-bar.sync-hinweis { background: var(--accent); color: #fff; bottom: 70px; }
/* ===========================================================================
DUNKELMODUS NACHGESCHÄRFT (Eriks Wahl „A“ aus dem Kontrast-Vergleich):
Kalender auf „Meine Aufgaben“ und die Farben im Sitzungskommentar.
Alles über Tokens: :root = hell (unverändert bzw. gleichwertig), die
beiden Dunkel-Blöcke (System-Einstellung / ausdrücklich gewählt) setzen
die helleren Fassungen.
=========================================================================== */
:root {
/* „Meine Aufgabe“ und „Projektaufgabe“ waren derselbe Blauton (einmal
halbtransparent) — jetzt zwei klar verschiedene Farben. */
--kal-meine: var(--accent);
--kal-projekt: #138a6e;
--kal-abw: var(--text-subtle);
--kal-wt: var(--text-subtle);
--kal-frei: var(--text-subtle);
--kal-fremd: var(--text-subtle);
--kal-kw: var(--text-subtle);
--kal-legende: var(--text-muted);
--kal-punkt: 5px;
/* Farben der Kommentar-Werkzeugleiste (gespeichert als feste Werte,
siehe richtextToolbarHtml() in js/utils.js). */
--rt-rot: #d70015;
--rt-gruen: #248a3d;
--rt-blau: #0071e3;
--rt-orange: #b25000;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) {
--meilenstein: #c19cf0;
--kal-projekt: #6fd6b4;
--kal-abw: #b4c3cf;
--kal-wt: #93aabb;
--kal-frei: #93aabb;
--kal-fremd: #6d8597;
--kal-kw: #8299aa;
--kal-legende: #b8c8d4;
--kal-punkt: 6px;
--rt-rot: #ff8a7f;
--rt-gruen: #5fd38d;
--rt-blau: #7cc0ff;
--rt-orange: #f2a65a;
--kommentar-ansicht-bg: #1b2730;
}
}
html[data-theme="dark"] {
--meilenstein: #c19cf0; /* fehlte hier bisher: blieb das dunkle Lila */
--kal-projekt: #6fd6b4;
--kal-abw: #b4c3cf;
--kal-wt: #93aabb;
--kal-frei: #93aabb;
--kal-fremd: #6d8597;
--kal-kw: #8299aa;
--kal-legende: #b8c8d4;
--kal-punkt: 6px;
--rt-rot: #ff8a7f;
--rt-gruen: #5fd38d;
--rt-blau: #7cc0ff;
--rt-orange: #f2a65a;
--kommentar-ansicht-bg: #1b2730;
}
/* Kalender */
.dash-kal-wt { color: var(--kal-wt); }
.dash-kal-kw { color: var(--kal-kw); }
.dash-kal-tag.ist-frei { color: var(--kal-frei); }
.dash-kal-tag.ist-fremd { color: var(--kal-fremd); }
.dash-kal-legende { color: var(--kal-legende); }
.dash-kal-punkte { height: var(--kal-punkt); }
.dash-kal-punkte i { width: var(--kal-punkt); height: var(--kal-punkt); background: var(--kal-meine); }
.dash-kal-legende i { background: var(--kal-meine); }
.dash-kal-punkte i.ist-fremd, .dash-kal-legende i.ist-fremd { background: var(--kal-projekt); }
.dash-kal-punkte i.ist-ms, .dash-kal-legende i.ist-ms { background: var(--meilenstein); }
.dash-kal-punkte i.ist-rot, .dash-kal-legende i.ist-rot { background: var(--red); }
.dash-kal-punkte i.ist-ab, .dash-kal-legende i.ist-ab { background: var(--kal-abw); }
.dash-kal-tag.ist-gewaehlt .dash-kal-punkte i { background: currentColor; }
/* Kommentar als Text (Projekt-Dashboard, Projektliste, Wochenbericht
gesperrt): heller Fließtext auf satter Fläche. */
.status-edit.is-ansicht { color: var(--text); background: var(--kommentar-ansicht-bg, var(--surface-sunk)); }
/* Farbige Stellen im Kommentar: Die Farbe steht als Inline-Stil im Text
(mal "#d70015", mal vom Browser als "rgb(215, 0, 21)" geschrieben, alt
auch <font color>). Im Dunkelmodus auf die helle Fassung umsetzen; im
hellen Modus sind die Tokens identisch mit den gespeicherten Werten.
:not([style*="background"]) hält Farbflächen (Werkzeugleiste, Gantt)
heraus. Gespeichert wird weiterhin der Originalwert, das PDF bleibt gleich. */
[style*="color"][style*="d70015" i]:not([style*="background"]),
[style*="color"][style*="215, 0, 21"]:not([style*="background"]),
font[color="#d70015" i] { color: var(--rt-rot) !important; }
[style*="color"][style*="248a3d" i]:not([style*="background"]),
[style*="color"][style*="36, 138, 61"]:not([style*="background"]),
font[color="#248a3d" i] { color: var(--rt-gruen) !important; }
[style*="color"][style*="0071e3" i]:not([style*="background"]),
[style*="color"][style*="0, 113, 227"]:not([style*="background"]),
font[color="#0071e3" i] { color: var(--rt-blau) !important; }
[style*="color"][style*="b25000" i]:not([style*="background"]),
[style*="color"][style*="178, 80, 0"]:not([style*="background"]),
font[color="#b25000" i] { color: var(--rt-orange) !important; }
/* Auslastung als Personenkarten mit Wochenvorschau (Eriks Wahl "B",
renderLoad() in js/load/load.js). Je Person: Projektanzahl, Wert dieser
Woche, zwölf Wochensäulen mit gestrichelter 100-%-Linie, Hinweis auf die
Spitze und die größten Projekte. */
.load-erklaerung { margin: 4px 0 14px; max-width: 900px; line-height: 1.5; }
.load-karten { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 14px; }
.load-karte {
background: var(--surface); border: 1px solid var(--line-soft); border-radius: var(--r-lg);
padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; min-width: 0;
}
.load-karte.ist-ohne { opacity: .75; }
.load-karte-kopf { display: flex; align-items: center; gap: 12px; }
.load-ava {
width: 36px; height: 36px; flex: none; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
font-size: 11.5px; font-weight: 700; background: var(--accent); color: #fff;
}
.load-karte-wer { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.load-karte-wer b { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.load-karte-wer > span { font-size: 12px; color: var(--text-muted); }
.load-karte-wer .load-capacity { align-self: flex-start; margin: 2px 0 0; }
.load-jetzt { display: flex; flex-direction: column; align-items: flex-end; text-align: right; flex: none; }
.load-jetzt b { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; }
.load-jetzt span { font-size: 11px; color: var(--text-muted); }
.load-saeulen {
position: relative; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 4px;
align-items: end; height: 86px; padding-top: 4px;
}
.load-saeule { display: block; border-radius: 4px 4px 2px 2px; opacity: .85; min-height: 0; }
.load-saeule.ist-jetzt { opacity: 1; }
.load-saeule.ist-teil { background-image: repeating-linear-gradient(135deg, transparent 0 4px, rgba(255,255,255,.35) 4px 7px); }
.load-saeule.ist-abwesend { height: 100%; opacity: 1; background: repeating-linear-gradient(135deg, var(--surface-sunk) 0 5px, var(--line) 5px 10px); }
.load-linie { position: absolute; left: 0; right: 0; border-top: 1.5px dashed var(--text-muted); opacity: .55; pointer-events: none; }
.load-kws { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 4px; font-size: 10px; color: var(--text-muted); text-align: center; font-variant-numeric: tabular-nums; }
.load-kws .ist-jetzt { color: var(--accent); font-weight: 700; }
.load-hinweis { font-size: 12.5px; padding: 8px 10px; border-radius: 10px; line-height: 1.4; }
.load-hinweis.ist-rot { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); }
.load-hinweis.ist-gelb { background: color-mix(in srgb, var(--amber) 12%, transparent); color: var(--amber); }
.load-hinweis.ist-ok { background: color-mix(in srgb, var(--green) 10%, transparent); color: var(--green); }
.load-hinweis.ist-leer { background: var(--surface-sunk); color: var(--text-muted); }
.load-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.load-chip {
display: inline-flex; align-items: center; min-height: 24px; max-width: 100%; padding: 0 9px; border-radius: 999px;
background: var(--surface-sunk); font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.load-chip.ist-mehr { color: var(--text-muted); border: 0; font: inherit; font-size: 11.5px; cursor: pointer; }
.load-chip.ist-mehr:hover { color: var(--accent); }
.load-chips:not(.ist-offen) .load-chip.ist-weitere { display: none; }
@media (prefers-color-scheme: dark) { html:not([data-theme="light"]) .load-ava { color: #0b141b; } }
html[data-theme="dark"] .load-ava { color: #0b141b; }
/* =========================================================================
ROLLEN & RECHTE (Admin, js/admin/rollen.js) — Eriks Wahl „B“: links die
Rollen mit „+ Neue Rolle“ darunter, rechts die gewählte Rolle mit Schaltern
je Gruppe.
========================================================================= */
.rr-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: 16px; align-items: start; }
@media (max-width: 820px) { .rr-layout { grid-template-columns: 1fr; } }
.rr-rollen { display: flex; flex-direction: column; gap: 6px; }
.rr-rolle {
display: flex; flex-direction: column; gap: 2px; text-align: left; width: 100%;
border: 1px solid var(--line); background: var(--surface); color: var(--text);
border-radius: 12px; padding: 10px 12px; font: inherit; cursor: pointer;
}
.rr-rolle:hover { border-color: var(--accent); }
.rr-rolle.ist-aktiv { border-color: var(--accent); background: var(--accent-soft); }
.rr-rolle b { font-weight: 600; }
.rr-rolle small { color: var(--text-muted); font-size: 12px; }
.rr-balken { display: block; height: 4px; border-radius: 2px; background: var(--surface-sunk); overflow: hidden; margin-top: 4px; }
.rr-balken i { display: block; height: 100%; background: var(--accent); }
.rr-plus {
border: 1.5px dashed var(--line) !important; background: transparent; color: var(--accent);
border-radius: 12px; padding: 10px 12px; text-align: left; font: inherit; font-weight: 600; cursor: pointer; width: 100%;
}
.rr-plus:hover { border-color: var(--accent) !important; background: var(--accent-soft); }
.rr-neu { border: 1.5px solid var(--accent); border-radius: 12px; padding: 10px; display: flex; flex-direction: column; gap: 8px; background: var(--surface); }
.rr-neu input, .rr-neu select { width: 100%; box-sizing: border-box; }
.rr-neu label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-muted); }
.rr-neu-knoepfe { display: flex; gap: 6px; }
.rr-detail { border: 1px solid var(--line); border-radius: 14px; padding: 16px; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.rr-detail-kopf { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.rr-titel {
font: inherit; font-size: 18px; font-weight: 600; flex: 1 1 180px; min-width: 0;
border: 1px solid transparent !important; background: transparent !important; color: var(--text); border-radius: 8px; padding: 4px 8px;
}
.rr-titel:hover:not(:disabled), .rr-titel:focus { border-color: var(--line) !important; background: var(--surface-sunk) !important; }
.rr-titel:disabled { color: var(--text); opacity: 1; }
.rr-vergleich-wahl { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-muted); }
.rr-meldung { font-size: 12.5px; color: var(--text-muted); min-width: 80px; }
.rr-gruppen { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; align-items: start; }
.rr-gruppe { background: var(--surface-sunk); border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 4px; }
.rr-gruppe-kopf { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.rr-gruppe-kopf small { color: var(--text-muted); }
.rr-luecke { flex: 1; }
.rr-zeile { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
.rr-name { flex: 1; min-width: 0; }
.rr-name small { display: block; color: var(--text-muted); font-size: 11.5px; }
.rr-schalter {
width: 36px; height: 20px; border-radius: 999px; border: 0 !important; padding: 0; flex: none; position: relative; cursor: pointer;
background: color-mix(in srgb, var(--text) 22%, transparent); transition: background var(--dur, 150ms);
}
.rr-schalter::after {
content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%;
background: #fff; transition: transform var(--dur, 150ms) var(--ease-out, ease);
}
.rr-schalter.ist-an { background: var(--accent); }
.rr-schalter.ist-an::after { transform: translateX(16px); }
.rr-schalter:disabled { opacity: .55; cursor: not-allowed; }
.rr-schalter:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.rr-vergleich { font-size: 11px; color: var(--text-muted); min-width: 34px; text-align: right; }
.rr-vergleich.ist-anders { color: var(--accent); font-weight: 700; }
/* =========================================================================
RUNDGANG (js/einfuehrung.js) — abgedunkelte Ebene mit Aussparung um das
Ziel und eine Sprechblase. Liegt über allem (auch über Dialogen, 300),
aber unter der Anmeldeseite (400) bewusst nicht: der Rundgang startet erst
nach der Anmeldung.
========================================================================= */
.tour { position: fixed; inset: 0; z-index: 450; }
.tour-loch {
position: fixed; border-radius: 12px; pointer-events: none;
box-shadow: 0 0 0 9999px rgba(6, 14, 20, .62), 0 0 0 2px var(--accent);
transition: left .25s var(--ease-out, ease), top .25s var(--ease-out, ease), width .25s var(--ease-out, ease), height .25s var(--ease-out, ease);
}
.tour.ohne-ziel { background: rgba(6, 14, 20, .62); }
.tour.ohne-ziel .tour-loch { display: none; }
.tour-blase {
position: fixed; width: min(360px, calc(100vw - 24px)); box-sizing: border-box;
background: var(--surface); color: var(--text); border-radius: 16px; padding: 16px 18px;
box-shadow: var(--sh-3); display: flex; flex-direction: column; gap: 8px; outline: none;
transition: left .25s var(--ease-out, ease), top .25s var(--ease-out, ease);
}
html.glas .tour-blase { background: var(--glas-dicht, var(--surface)); -webkit-backdrop-filter: blur(24px) saturate(160%); backdrop-filter: blur(24px) saturate(160%); }
.tour-blase.ist-unten { left: 12px !important; right: 12px; width: auto; top: auto !important; bottom: calc(12px + env(safe-area-inset-bottom, 0px)); }
.tour-kopf { display: flex; align-items: center; justify-content: space-between; }
.tour-schritt { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); font-weight: 600; }
.tour-x { border: 0 !important; background: transparent; color: var(--text-muted); font-size: 20px; line-height: 1; padding: 0 4px; cursor: pointer; }
.tour-x:hover { color: var(--text); }
.tour-blase h3 { margin: 0; font-size: 17px; font-weight: 650; text-wrap: balance; }
.tour-text { font-size: 13.5px; line-height: 1.55; color: var(--text); }
.tour-hinweise { margin: 8px 0 0; padding-left: 18px; color: var(--text-muted); }
.tour-punkte { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 2px; }
.tour-punkte i { width: 6px; height: 6px; border-radius: 50%; background: var(--line); }
.tour-punkte i.ist-erledigt { background: color-mix(in srgb, var(--accent) 45%, transparent); }
.tour-punkte i.ist-aktiv { background: var(--accent); width: 16px; border-radius: 3px; }
.tour-knoepfe { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
.tour-luecke { flex: 1; }
.tour-skip { border: 0 !important; background: transparent; color: var(--text-muted); font: inherit; font-size: 12.5px; padding: 4px 0; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
.tour-skip:hover { color: var(--text); }
@media (prefers-reduced-motion: reduce) { .tour-loch, .tour-blase { transition: none; } }
/* ============================================================
ABWESENHEITEN (js/abwesenheit/abwesenheit.js) — Eriks Wahl
„Variante 2 im Stil der Projektübersicht“: nutzt .bericht-titelzeile,
.legend/.swatch, .board-list, .board-row(-main), .board-nr, .board-name,
.board-status-line, .phase-pill, .board-row-actions und .board-avatar
der Projektliste; hier nur, was es dort nicht gibt.
Artfarben sind mittlere Töne, in beiden Themen lesbar.
============================================================ */
:root {
--abw-urlaub: #2f7fb8;
--abw-krank: #c4553d;
--abw-fortbildung: #7a5fc0;
--abw-sonstiges: #7d8450;
--abw-verdeckt: #8d9aa6;
}
/* showTab() setzt display:block inline — Abstände deshalb über Geschwister. */
#abwesenheit > * + * { margin-top: 14px; }
#abwesenheit .legend { margin: 6px 0 0; }
.abw-u { --abw-c: var(--abw-urlaub); }
.abw-k { --abw-c: var(--abw-krank); }
.abw-f { --abw-c: var(--abw-fortbildung); }
.abw-s { --abw-c: var(--abw-sonstiges); }
.swatch.abw-u, .swatch.abw-k, .swatch.abw-f, .swatch.abw-s { background: var(--abw-c); }
.swatch.abw-verdeckt, .abw-balken.abw-verdeckt {
background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--abw-verdeckt) 50%, transparent) 0 3px, transparent 3px 6px);
box-shadow: inset 0 0 0 1px var(--abw-verdeckt);
}
/* Zwei Spalten: Neue Abwesenheit | Liste */
/* Beide Kacheln gleich breit und gleich hoch; die Überschrift steht in der
Kachel. Die Liste rechts bestimmt die Höhe nicht mit (height 0 +
min-height 100 %), sie scrollt innen, wenn die Erfassung kürzer ist. */
.abw-zwei { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: stretch; }
.abw-spalte { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.abw-spalte > .board-list { flex: 1; }
.abw-karten-titel { margin: 0 !important; }
.abw-listen-karte { height: 0; min-height: 100%; }
.abw-listen-kopf { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 16px 16px 12px; border-bottom: 1px solid var(--line-soft); }
.abw-listen-zeilen { flex: 1; min-height: 0; overflow-y: auto; }
.abw-karte { padding: 16px; gap: 12px; }
.abw-fuer { display: flex; align-items: center; gap: 10px; font-size: 13px; flex-wrap: wrap; }
.abw-kreis { cursor: default; }
.abw-fuer-wahl { margin-left: auto; border-radius: var(--r-pill); font-size: 12.5px; padding: 4px 10px; max-width: 220px; }
.abw-datumzeile { display: grid; grid-template-columns: 1fr auto 1fr; gap: 10px; align-items: center; }
.abw-datum {
display: grid; text-align: left; gap: 0; padding: 10px 13px; cursor: pointer;
border: 1px solid var(--line-soft); border-radius: 14px; background: var(--surface-alt); color: var(--text);
font: inherit;
}
.abw-datum:hover { background: var(--surface-sunk); }
.abw-datum small { font-size: 10.5px; color: var(--text-subtle); text-transform: uppercase; letter-spacing: .08em; font-weight: 600; }
.abw-datum b { font-size: 21px; font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-weight: 600; letter-spacing: -.01em; }
.abw-datum span { font-size: 12px; color: var(--text-muted); }
.abw-pfeil { color: var(--text-subtle); }
.abw-arten { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.abw-arten button {
display: grid; justify-items: center; gap: 5px; padding: 8px 6px; cursor: pointer; font: inherit; font-size: 12.5px;
border: 1px solid var(--line-soft); border-radius: 12px; background: var(--surface-alt); color: var(--text);
}
.abw-arten button i { width: 18px; height: 6px; border-radius: 3px; background: var(--abw-c); }
.abw-arten button.ist-an { background: color-mix(in srgb, var(--abw-c) 18%, transparent); border-color: var(--abw-c); font-weight: 600; }
.abw-unten { display: flex; gap: 8px; align-items: center; }
.abw-notiz { flex: 1; min-width: 0; border-radius: var(--r-pill); }
.abw-fehler { font-size: 12.5px; color: var(--red); }
.abw-warnung { display: flex; gap: 9px; align-items: flex-start; font-size: 12.5px; border-radius: 12px; padding: 8px 12px; background: color-mix(in srgb, var(--amber) 15%, transparent); }
.abw-warnung::before { content: ""; flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--amber); margin-top: 6px; }
/* Liste: Zeilen der Projektliste, mit Punkt in Artfarbe und Kapsel */
.abw-zeile.ist-aktiv .board-row-main { background: var(--accent-soft); }
.abw-zeile-name { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.abw-punkt { width: 9px; height: 9px; border-radius: 50%; background: var(--abw-c); flex: none; }
.abw-tag { font-size: 11px; font-weight: 500; padding: 1px 8px; border-radius: var(--r-pill); border: 1px solid var(--line); color: var(--text-muted); }
.board-row-actions.abw-fragt { opacity: 1; }
.abw-leer { padding: 14px 16px; font-size: 12.5px; color: var(--text-muted); }
.abw-reiter { display: inline-flex; gap: 2px; padding: 3px; border-radius: var(--r-pill); background: var(--surface-sunk); }
.abw-reiter button { border: 0; background: none; padding: 3px 11px; border-radius: var(--r-pill); font: inherit; font-size: 12px; color: var(--text-muted); cursor: pointer; }
.abw-reiter button.ist-an { background: var(--surface); color: var(--text); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,.12); }
/* Team je Gewerk */
.abw-team-kopf {
margin-top: 26px !important; flex-wrap: wrap; gap: 10px;
/* Hinterlegt wie die Karten darunter (.board-list) */
background: var(--surface); border: 1px solid var(--line-soft);
border-radius: var(--r-xl); box-shadow: var(--sh-2);
padding: 12px 16px; margin-bottom: 14px;
}
.abw-team-steuer { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.abw-monat { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; }
.abw-monat strong { min-width: 120px; text-align: center; font-weight: 600; }
.abw-rund { width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--line); background: var(--surface); color: var(--text); cursor: pointer; display: grid; place-items: center; padding: 0; }
.abw-gewerk-kopf { display: flex; align-items: baseline; gap: 10px; }
.abw-gewerk-kopf small { color: var(--text-muted); font-size: 12.5px; }
#abwesenheit .abw-gewerk-kopf + .abw-tl-karte { margin-top: 8px; }
.abw-tl-karte { overflow-x: auto; }
.abw-tl { display: grid; min-width: 880px; font-size: 11px; }
.abw-tl > div { position: relative; height: 52px; border-bottom: 1px solid var(--line-soft); }
.abw-tl { margin-bottom: -1px; }
.abw-tl-tag, .abw-tl-kopf { height: 36px !important; }
.abw-tl-tag { display: grid; align-content: center; justify-items: center; line-height: 1.1; color: var(--text-subtle); font-variant-numeric: tabular-nums; }
.abw-tl-tag small { font-size: 9px; text-transform: uppercase; letter-spacing: .04em; }
.abw-tl-tag b { font-size: 11.5px; color: var(--text-muted); font-weight: 600; }
.abw-tl-tag.ist-heute b { background: var(--red); color: #fff; border-radius: var(--r-pill); padding: 0 5px; }
.abw-tl-wer { position: sticky; left: 0; z-index: 3; display: flex; align-items: center; gap: 10px; padding: 0 14px; background: var(--surface); }
html.glas .abw-tl-wer { background: var(--glas-deckend, var(--surface)); }
.abw-tl-kopf { color: var(--text-subtle); font-size: 11px; }
.abw-tl-wer.ist-gewaehlt { box-shadow: inset 3px 0 0 var(--accent); }
.abw-tl-wer.ist-klickbar { cursor: pointer; transition: background var(--dur) var(--ease-out); }
.abw-tl-wer.ist-klickbar:hover { background-image: linear-gradient(var(--surface-alt), var(--surface-alt)); } /* Tönung über dem deckenden Grund, die Spalte ist sticky */
.abw-tl-wer.ist-klickbar:hover .abw-tl-wer-name { color: var(--accent); }
.abw-tl-wer.ist-klickbar:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.abw-tl-wer-text { display: flex; flex-direction: column; min-width: 0; }
.abw-tl-wer-name { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.abw-tl-wer-meta { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.abw-tl .ist-frei { background: color-mix(in srgb, var(--text-subtle) 7%, transparent); }
.abw-tl-zelle.ist-heute::after { content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px; background: var(--red); opacity: .5; z-index: 1; }
.abw-tl-zelle.ist-klickbar { cursor: pointer; }
.abw-tl-zelle.ist-klickbar:hover { background: var(--surface-sunk); }
.abw-balken {
position: absolute; top: 14px; bottom: 14px; left: 2px; z-index: 2; cursor: pointer;
display: flex; align-items: center; padding: 0 10px; white-space: nowrap; overflow: hidden;
border-radius: var(--r-pill); font-size: 11px; font-weight: 600; color: var(--text);
background: color-mix(in srgb, var(--abw-c) 20%, transparent); box-shadow: inset 0 0 0 1px var(--abw-c);
}
.abw-balken::before { content: ""; flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--abw-c); margin-right: 6px; }
.abw-balken.abw-verdeckt { cursor: default; }
.abw-balken.abw-verdeckt::before { display: none; }
@media (max-width: 980px) { .abw-zwei { grid-template-columns: 1fr; } .abw-listen-karte { height: auto; min-height: 0; } .abw-listen-zeilen { overflow: visible; } }
@media (max-width: 768px) {
.abw-arten { grid-template-columns: repeat(2, 1fr); }
}
/* Verdeckte Abwesenheit einer anderen Person: nur „abwesend“, neutral, nicht änderbar */
.abw-verdeckt-zeile { --abw-c: var(--text-subtle); }
.abw-verdeckt-zeile .board-row-main { cursor: default; }
+2480 -441
View File
File diff suppressed because it is too large. Load diff