Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51dfbe8cd8 | ||
|
|
e7243f6f26 | ||
|
|
4c2e04e78b | ||
|
|
a2266662ad | ||
|
|
b7e0bd0d92 | ||
|
|
f4fb3c550b | ||
|
|
93505e0e48 | ||
|
|
6ba93b2a92 | ||
|
|
db63a29365 | ||
|
|
bc30f2900b | ||
|
|
18d04e2c4a |
No files matched your search
+9
-5
@@ -7,10 +7,10 @@ WORKDIR /app
|
||||
|
||||
# package.json und package-lock.json zuerst kopieren, damit npm-Installation gecacht wird
|
||||
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
|
||||
COPY server.js ./
|
||||
COPY server.js pdf-arbeit.js ./
|
||||
COPY public ./public
|
||||
|
||||
# Sessions-Ordner fuer die SQLite-Datenbank sicherstellen
|
||||
@@ -20,7 +20,11 @@ USER node
|
||||
|
||||
EXPOSE 3003
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
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))"
|
||||
# wget (busybox) statt eines eigenen Node-Prozesses je Prüfung: auf dem
|
||||
# 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"]
|
||||
Executable
+236
@@ -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"
|
||||
Executable
+44
@@ -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
|
||||
@@ -1,157 +1,129 @@
|
||||
# Projektübersicht — README
|
||||
# Projektübersicht
|
||||
|
||||
## Übersicht
|
||||
Dieses Projekt ist eine lokale Weboberfläche zur Verwaltung von TGA-Projekten (Elektro, HLS, Architektur) über wöchentliche Sitzungen. Die Anwendung besteht aus:
|
||||
Werkzeug für ein Ingenieurbüro der technischen Gebäudeausrüstung (TGA), um
|
||||
Projekte über wöchentliche Sitzungen hinweg zu verfolgen: Leistungsphasen nach
|
||||
HOAI, Termine, Personaleinsatz, die wöchentliche Teambesprechung und die
|
||||
Dokumentenfreigabe. Single-Page-App ohne Build-Schritt: Vanilla JS/HTML/CSS
|
||||
im Frontend, Node.js/Express im Backend, Daten in SQLite.
|
||||
|
||||
- einem Frontend mit `Test.html` als der einen Anwendungsseite — sie enthält alle Bereiche einschließlich des Projektbereichs eines einzelnen Projekts (Terminplan/GANTT, Aufgaben, Dateien, Einstellungen) — dazu `styles.css`, `plan-styles.css` und den JS-Modulen unter `public/js/`
|
||||
- einem einfachen Node.js-/Express-Server in `server.js`
|
||||
- einem Datenspeicher in einer SQLite-Datenbank (`node:sqlite`, benötigt Node ≥ 22.13) unter `sessions/projektuebersicht.db`
|
||||
- einem Docker-Setup (`Docker/Dockerfile` + `docker-compose.yml` im Projektstamm)
|
||||
## Technologie
|
||||
|
||||
## Datei- und Komponentenübersicht
|
||||
- **Frontend**: klassische, unmodulare `<script>`-Dateien unter `public/js/`
|
||||
— alle Funktionen liegen global auf `window`, kein Bundler, kein Framework.
|
||||
**Eine** HTML-Datei, `public/App.html`, bildet die gesamte Oberfläche ab
|
||||
(Seitenleiste + alle Bereiche inklusive des Projektbereichs eines einzelnen
|
||||
Projekts).
|
||||
- **Backend**: Node.js (≥ 22.13, wegen `node:sqlite`) + Express, alles in
|
||||
einer einzigen `server.js`.
|
||||
- **Datenbank**: SQLite über das eingebaute `node:sqlite`-Modul (WAL-Modus,
|
||||
vollständig relational, keine Einzeldatei je Woche), Datei unter
|
||||
`sessions/projektuebersicht.db`.
|
||||
- Kein Linter, kein Formatter, keine Testsuite. Verifikation läuft manuell:
|
||||
Server starten, Bereich im Browser ausprobieren, SQLite-Datei prüfen.
|
||||
|
||||
### Wichtige Laufzeit-Dateien
|
||||
- `server.js`
|
||||
- Node.js/Express-Server
|
||||
- dient als API für das Frontend
|
||||
- stellt statische Dateien (`Test.html`, die Module unter `js/`, `styles.css`, `plan-styles.css`, etc.) bereit; statische Assets werden mit `Cache-Control: no-store` ausgeliefert
|
||||
- implementiert Endpunkte:
|
||||
- `GET /` → liefert `Test.html`
|
||||
- `GET /load-all?department=…` → lädt alle Sitzungen plus globale Personenliste einer Abteilung
|
||||
- `POST /save-week {department, week, baseUpdatedAt}` → speichert/aktualisiert eine Sitzung; mit `baseUpdatedAt` (die vom Client gehaltene Revision = `updatedAt` aus `load-all`) erkennt der Server veraltete Speicherungen und antwortet dann mit HTTP 409 `{ok:false, conflict:true, …}`; auch gelöschte Sitzungen werden mit nicht-leerer Basis nicht wiederbelebt (409). Erfolgreiche Saves antworten `{ok:true, updatedAt}` (neue Revision)
|
||||
- `POST /save-people {department, people}` → ersetzt die globale Personenliste einer Abteilung
|
||||
- `POST /delete-week {department, label}` → löscht eine Sitzung
|
||||
- `GET /health` → Gesundheitscheck (`{ok:true}`)
|
||||
- Deep Links je Abteilung + Tab, z. B. `GET /HLS/Auslastung` oder `GET /ELT/Projektübersicht` → liefert `Test.html`; gültige Tab-Slugs sind `Projektübersicht | Auslastung | Projekte | Projektbeteiligte`, alles andere ergibt 404
|
||||
- `GET /Projektplan(/<ProjName>)?` → liefert ebenfalls `Test.html`; der Projektbereich ist seit dem Umbau kein eigenes Dokument mehr, sondern ein Bereich dieser Seite, den `applyUrlRoute()` (`public/js/ui.js`) aus der Adresse auswählt. Die Identifikation erfolgt über die Query-Parameter `weekLabel` und `projNr`, der Projektname ist nur ein lesbares Pfad-Segment. Alte Links auf `/project-plan.html` werden per 301 auf dieses Schema umgeleitet
|
||||
- validiert Abteilungen gegen `ELT | HLS | ARCH` (unbekannte fallen stillschweigend auf `ELT` zurück)
|
||||
- legt das `sessions`-Verzeichnis beim Start an, falls es nicht existiert
|
||||
- JSON-Body-Limit von 2 MB
|
||||
- beendet sich mit aussagekräftiger Fehlermeldung, wenn der Port belegt ist
|
||||
## Starten
|
||||
|
||||
- `package.json`
|
||||
- definiert den Node.js-Startbefehl `npm start`
|
||||
- enthält Produktionsabhängigkeiten `express` und `cors`
|
||||
|
||||
- `package-lock.json`
|
||||
- wird für reproduzierbare Docker-Builds verwendet (`npm ci`)
|
||||
|
||||
- `public/js/` (Module)
|
||||
- Die Client-Logik ist in kleine klassische Script-Dateien aufgeteilt (alle Funktionen global auf `window`, kein Bundler):
|
||||
- gemeinsame Helfer: `globals.js`, `utils.js`, `ui.js`, `date-picker.js`, `server.js`, `store.js`, `weeks.js`, `gantt.js`, `showmode.js`, `tabs-auth.js`, `pdf.js`, `init.js`
|
||||
- tab-spezifisch in eigenen Ordnern: `board/board.js` (Projektboard), `people/people.js` (Projektbeteiligte), `projects/projects.js` (Projekte), `load/load.js` (Auslastung)
|
||||
- Planungs-Module unter `js/plan/` (`plan-state`, `plan-build-gantt`, `plan-gantt`, `plan-drag`, `plan-modals`, `plan-schedule`)
|
||||
- rendert das Projektboard, die Projekt- und Personenverwaltung sowie die Auslastungsansicht
|
||||
- lädt Daten über den Server und speichert Änderungen als JSON-Objekte in die SQLite-Datenbank zurück
|
||||
- verwendet `currentDepartment` zur Trennung von Abteilungen (`ELT`, `HLS`, `ARCH`)
|
||||
- speichert:
|
||||
- alle Sitzungen und die globale Personenliste pro Abteilung in `sessions/projektuebersicht.db`
|
||||
- sortiert Sitzungen nach Kalenderwoche, nicht alphabetisch
|
||||
- sperrt historische Sitzungen automatisch (bis auf die chronologisch letzte)
|
||||
- Personen sind global pro Abteilung verwaltet; historische Sitzungen behalten ihre lokalen Personenkopien
|
||||
- speichert keine Daten in `localStorage` (dort liegt nur UI-Zustand wie Abteilung, Theme und Header-Collapse)
|
||||
|
||||
- `Test.html`
|
||||
- die Haupt-Benutzeroberfläche der Anwendung (Tabs: Projektboard, Auslastung, Projekte, Projektbeteiligte)
|
||||
- lädt `styles.css` und die JS-Module aus `js/` in fester Reihenfolge (siehe `AGENTS.md`)
|
||||
- enthält die Abteilungsauswahl, Sitzungsnavigation, Tab-Navigation, das Passwort-Modal sowie PDF-Export
|
||||
- lädt CryptoJS (Passwortprüfung) und pdfmake (PDF-Export) von `cdnjs.cloudflare.com` — diese Funktionen benötigen daher Internetzugang
|
||||
|
||||
- Projektbereich (Bereich `#plan` in `Test.html`, Module unter `js/plan/`)
|
||||
- der eigene Bereich EINES Projekts mit Dashboard, Terminplan (GANTT/TODOs/Meilensteine), Aufgaben, Dateien und Einstellungen
|
||||
- wird über `openProjectPlan()` (`js/gantt.js`) geöffnet — ein gewöhnlicher Bereichswechsel über `showTab('plan')`, genau wie vom Dashboard ins Projektboard; Menüband und Seitenleiste bleiben stehen
|
||||
- war bis zum Umbau ein eigenes Dokument `project-plan.html` mit nachgebauter, abgespeckter Seitenleiste; die Datei ist entfallen, ihre Stile liegen jetzt in `plan-styles.css`
|
||||
|
||||
- `plan-styles.css`
|
||||
- die Stile des Projektbereichs (früher ein `<style>`-Block in `project-plan.html`)
|
||||
|
||||
- Bereich „Einstellungen" (`js/settings/settings.js`)
|
||||
- gehört dem angemeldeten Konto selbst: Stammdaten zur Ansicht und der Passwortwechsel (`POST /change-password`). Der Dialog erscheint auch erzwungen direkt nach der Anmeldung, solange das Konto `must_change_password` trägt
|
||||
|
||||
- Bereich „Admin" (`js/admin/admin.js`, nur für Administratoren)
|
||||
- gegliedert in einzelne Abschnitte, zwischen denen die aufklappbare Gruppe „Admin" in der Seitenleiste umschaltet; sichtbar ist immer genau einer, der gewählte steht im URL-Fragment (z. B. `/Admin#auslastung`) und ist damit verlinkbar
|
||||
- Abschnitte: „Benutzerverwaltung" (Konten, Rollen, Wochenstunden, Abwesenheiten — bis zum Umbau die eigene Seite `admin-users.html`, alte Links werden per 301 umgeleitet), „Honorar" (Stundensatz, Anteile je Leistungsphase), „Auslastung" (Wochenstunden einer vollen Stelle, Ampel-Schwellen, Balkenskala, Aufwand je Leistungsphase und Rolle), „Termine & Sitzungen" (Arbeitsstunden je Werktag, automatische Sperre), „Feiertage" (die gesetzlichen Feiertage eines Jahres werden berechnet — feste Termine plus alles, was am Osterdatum hängt — und auf Knopfdruck übernommen; kein Netzdienst, kein Internetzugang nötig), „Aufgaben-Vorlagen", „Betrieb & Sicherheit" (aufbewahrte Backups, Gültigkeit einer Anmeldung, Anmeldebremse) und „Datenbank & Sitzungen" (DB Backup, DB Import, Sitzung löschen)
|
||||
- all diese Werte waren vorher Konstanten im Quelltext; gelesen werden sie über `GET /app-settings` (jedes Konto — das Frontend rechnet damit), geschrieben über `PUT /app-settings` (nur Administratoren, serverseitig über `requireRole('admin')` abgesichert)
|
||||
- die vertraglichen Wochenstunden je Person stehen am Benutzerkonto (Benutzerverwaltung) und sind die Bezugsgröße der Auslastungs-Ampel: Eine halbe Stelle gilt schon bei halber Last als voll ausgelastet
|
||||
|
||||
- `styles.css`
|
||||
- Layout und Styling der Oberfläche
|
||||
|
||||
### Docker-bezogene Dateien
|
||||
- `Docker/Dockerfile`
|
||||
- Basis-Image: `node:22-alpine` (nötig, da `node:sqlite` erst ab Node 22.13 ohne Flag stabil verfügbar ist)
|
||||
- kopiert `package.json` und `package-lock.json`, installiert Produktionsabhängigkeiten mit `npm ci`
|
||||
- kopiert `server.js` und `public/` nach `/app`
|
||||
- legt sicherheitshalber `/app/sessions` an
|
||||
- läuft als nicht-root-User `node`
|
||||
- enthält einen `HEALTHCHECK` auf `/health`
|
||||
- startet `node server.js`
|
||||
|
||||
- `docker-compose.yml` (im Projektstamm)
|
||||
- definiert den Service `projektuebersicht`
|
||||
- baut das Image mit Build-Kontext `.` und `Docker/Dockerfile`
|
||||
- mappt Port `3003:3003`
|
||||
- bindet das Host-Verzeichnis `./sessions` in den Container nach `/app/sessions`
|
||||
- setzt `restart: unless-stopped`
|
||||
- definiert einen `healthcheck`
|
||||
|
||||
- `.dockerignore`
|
||||
- schließt unnötige Dateien aus dem Docker-Build aus: `node_modules/`, `npm-debug.log`, `.git`, `.gitignore`, `Docker/*` (außer `Docker/Dockerfile`)
|
||||
|
||||
### Sonstige Dateien
|
||||
- `dein_bild.ico`
|
||||
- optionales Icon, wird vom Server statisch ausgeliefert, wenn angefragt
|
||||
|
||||
- `AGENTS.md`
|
||||
- Projektnotizen / Dokumentation, aber nicht Teil des Anwendungslaufs
|
||||
|
||||
## Persistenz und Datenfluss
|
||||
|
||||
### Speicherung
|
||||
- Alle persistierten Daten werden in einer SQLite-Datenbank (`node:sqlite`, `DatabaseSync`, WAL-Modus, `PRAGMA foreign_keys = ON`) unter `sessions/projektuebersicht.db` gespeichert.
|
||||
- Das Schema ist vollständig relational (19 Tabellen): `weeks`, `week_people` (+ `week_people_roles`), `attendance`, `projects`, `project_lph`, `project_assign`, `project_aufwand`, `project_aktuelle_lp`, `project_plan`, `plan_people`, `plan_todos` (+ `plan_todo_assignees`, `plan_todo_deps`), `plan_milestones` (+ `plan_milestone_deps`), `people` (+ `person_roles`, `person_absences`). Projekt-/Personen-IDs bleiben dadurch über Neustarts stabil.
|
||||
- Der Datenbankpfad ist über die Umgebungsvariable `DB_PATH` übersteuerbar (für Tests/Backups), Standard ist `sessions/projektuebersicht.db`.
|
||||
|
||||
### Server-API
|
||||
- Der Server wird in `server.js` erstellt und startet auf Port `3003` oder dem Wert von `process.env.PORT`.
|
||||
- Das Frontend ruft den Server über `window.location.origin` an (Fallback `http://localhost:3003` bei `file://`-Öffnung).
|
||||
|
||||
### Wo werden die Daten gespeichert?
|
||||
- Das Frontend schreibt über den Server in die SQLite-Datenbank.
|
||||
- In `js/store.js` speichert die Anwendung nur **geänderte** Sitzungen (Dirty-Tracking über `markWeekDirty()`/`markPeopleDirty()`) via `/save-week` (Sitzungen) bzw. `/save-people` (globale Personen); ein In-Flight-Guard verhindert Doppel-Speicherungen. Jede Sitzung führt einen Revisions-Zeitstempel (`updated_at`, übertragen als `updatedAt`); speichert ein Client mit veralteter Revision (`baseUpdatedAt`), antwortet der Server mit HTTP 409 und der Client lädt nur diese eine Sitzung neu (andere ungespeicherte Änderungen bleiben erhalten).
|
||||
- Der Server zerlegt die JSON-Objekte in die relationalen Tabellen (`serializeWeek()`/`loadWeeks()`) in einer Transaktion pro Sitzung. Beim Start migriert `migrateLegacySchema()` idempotent ein altes Single-JSON-Dokument-Schema in das relationale Schema; anschließend ergänzt `migrateWeeksUpdatedAt()` die `updated_at`-Spalte der `weeks`-Tabelle (idempotent). `PRAGMA busy_timeout = 5000` verhindert Fehler bei parallelen Schreibzugriffen.
|
||||
|
||||
## Betrieb
|
||||
|
||||
### Lokal mit Node.js (Node ≥ 22.13)
|
||||
### Lokal (Node.js ≥ 22.13)
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
Danach ist die Anwendung erreichbar unter `http://localhost:3003`.
|
||||
|
||||
Falls der Port belegt ist:
|
||||
Erreichbar unter `http://localhost:3003`. Anderer Port:
|
||||
```bash
|
||||
PORT=3004 npm start
|
||||
```
|
||||
|
||||
### Mit Docker
|
||||
Im Projektstamm ausführen:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
`docker-compose.yml` liegt im Projektstamm, baut aus `Docker/Dockerfile`
|
||||
(`node:22-alpine`), mappt Port `3003` und bindet `./sessions` in den
|
||||
Container nach `/app/sessions` — die Datenbank und alle Uploads bleiben damit
|
||||
auf dem Host erhalten. `TZ=Europe/Berlin` ist gesetzt, weil mehrere
|
||||
Datumsvergleiche im Server von der lokalen Zeitzone abhängen.
|
||||
|
||||
Danach ist die Anwendung erreichbar unter:
|
||||
### Erste Anmeldung
|
||||
Die gesamte Anwendung verlangt eine Anmeldung. Ist die `users`-Tabelle beim
|
||||
ersten Start leer, legt der Server automatisch ein Admin-Konto
|
||||
(`admin`) mit einem zufälligen Passwort an, gibt es einmalig auf der Konsole
|
||||
aus und schreibt es zusätzlich nach `sessions/INITIAL_ADMIN_PASSWORD.txt`
|
||||
(liegt außerhalb von `public/`, wird also nie ausgeliefert). Das Konto muss
|
||||
sein Passwort beim ersten Login ändern.
|
||||
|
||||
```
|
||||
http://localhost:3003
|
||||
```
|
||||
## Bereiche der Anwendung
|
||||
|
||||
## Wichtige Hinweise
|
||||
- Der Server benötigt Lese-/Schreibzugriff auf `sessions/` (für die SQLite-Datenbank und das Docker-Bind-Mount).
|
||||
- Die Docker-Compose-Konfiguration liegt im Projektstamm und mountet `./sessions` in den Container. Achte darauf, dass der relative Pfad im Kontext korrekt ist, wenn du `docker compose` aus einem anderen Verzeichnis startest.
|
||||
- `js/store.js` und `js/server.js` arbeiten mit Abteilungsparametern und speichern Daten abteilungsweise getrennt.
|
||||
- Die Passwortfunktion (Tabs „Projekte“/„Projektbeteiligte“, Sitzungslöschung, Entsperren) ist nur ein einfacher Client-Mechanismus im Frontend (SHA256-Vergleich) und kein echter Sicherheitsmechanismus.
|
||||
- Globale Personen werden pro Abteilung in der Datenbank verwaltet. Wird eine Person global gelöscht, bleibt sie in bereits existierenden Sitzungen sichtbar (Wochen-Personenkopien).
|
||||
- CryptoJS und pdfmake werden von `cdnjs.cloudflare.com` geladen — PDF-Export und Passwortprüfung funktionieren ohne Internet nicht.
|
||||
Seitenleiste, in dieser Reihenfolge:
|
||||
|
||||
- **Meine Aufgaben** — Dashboard und Startansicht nach dem Login: offene
|
||||
persönliche Aufgaben, Termine der eigenen Projekte, ausstehende
|
||||
Datei-Freigaben, Abwesenheiten von Kollegen.
|
||||
- **Projekte** — die eine Projektliste (Board), gruppiert, per Drag-and-Drop
|
||||
sortierbar.
|
||||
- **Wochenbericht** — die Tagesordnung der wöchentlichen Teambesprechung, je
|
||||
Gewerk (ELT/HLS/ARCH) gefiltert: jedes Projekt dieses Gewerks wird
|
||||
angesehen, mit Vergleich zur Vorwoche, Anwesenheitserfassung und PDF-Export.
|
||||
- **Auslastung** — Personalauslastung in tatsächlichen Wochenstunden,
|
||||
aufgeteilt nach Projekten (Lastband).
|
||||
- **Projektbeteiligte** — schreibgeschützte Übersicht der aktiven
|
||||
Benutzerkonten; Teammitglieder pflegt ausschließlich ein Administrator
|
||||
(Admin → Benutzerverwaltung).
|
||||
- **Planung** — ein von einem konkreten Projekt unabhängiger
|
||||
Terminplan-Sandkasten, z. B. um einen Zeitplan vorab durchzurechnen.
|
||||
- **Einstellungen** — das eigene Konto: Stammdaten, E-Mail, Passwort, eigene
|
||||
KI-Zugangsdaten für die Protokoll-Erkennung (siehe unten).
|
||||
- **Admin** *(nur Administratoren)* — Benutzerverwaltung, Honorar-Vorgaben,
|
||||
Auslastungs-Schwellen, Sitzungsverwaltung, Feiertage, Aufgaben-Vorlagen,
|
||||
Betrieb & Sicherheit, Datenbank-Backup/-Import.
|
||||
|
||||
### Projektbereich
|
||||
|
||||
Aus der Projektliste heraus öffnet sich zu einem Projekt ein eigener Bereich
|
||||
mit sieben Abschnitten: Dashboard, Terminplan (GANTT mit Aufgaben und
|
||||
Meilensteinen), Aufgaben, Leistung (erbrachter Leistungswert je Phase),
|
||||
Dateien, Protokolle (Besprechungsprotokolle) und Einstellungen
|
||||
(Projektstammdaten, Honorar, beauftragte Leistungsphasen).
|
||||
|
||||
## Datenhaltung
|
||||
|
||||
- Vollständig relationales SQLite-Schema statt eines JSON-Dokuments je Woche;
|
||||
Projekt- und Personen-IDs bleiben über Neustarts stabil.
|
||||
- Eine Sitzung ("KW XX / YYYY") entspricht einer Kalenderwoche. Der Wechsel
|
||||
in eine neue Sitzung läuft automatisch im Hintergrund (stündliche Prüfung)
|
||||
— es gibt keine "Neue Sitzung"-Schaltfläche mehr.
|
||||
- Der Server legt bei jedem Start automatisch einen Zeitstempel-Snapshot
|
||||
unter `sessions/backups/` an und behält nur die neuesten (Anzahl einstellbar
|
||||
unter Admin → Betrieb & Sicherheit). Ein Admin kann zusätzlich jederzeit
|
||||
ein Backup herunterladen oder eine Sicherung zurückspielen.
|
||||
- Hochgeladene Dateien (Freigabe-Workflow, importierte Protokolle) liegen
|
||||
unter `sessions/uploads/<uuid>.<endung>`, referenziert über die Datenbank.
|
||||
|
||||
## Authentifizierung & Rollen
|
||||
|
||||
Echte serverseitige Sitzungen (Cookie, `scrypt`-gehashte Passwörter) — kein
|
||||
clientseitiger Passwortschutz. Zwei Rollen: `admin` und `member`; nur
|
||||
wirklich destruktive bzw. administrative Aktionen (Benutzerverwaltung,
|
||||
Sitzung löschen/entsperren, DB-Import/-Backup) sind serverseitig auf Admins
|
||||
beschränkt. Zu viele Fehlversuche sperren ein Konto vorübergehend.
|
||||
|
||||
## Besonderheiten
|
||||
|
||||
- **Keine Abteilungstrennung mehr**: Alle Sitzungen, Projekte und Personen
|
||||
liegen in einer gemeinsamen Liste. `ELT`/`HLS`/`ARCH` bezeichnen heute nur
|
||||
noch die Fachrichtung eines Benutzerkontos bzw. die Gewerke eines Projekts.
|
||||
- **Protokoll-Import** kann ein hochgeladenes PDF/Word-Dokument
|
||||
KI-gestützt (eigener OpenAI- oder Claude-Schlüssel je Konto, auch ein
|
||||
lokal laufendes Modell möglich) in Besprechungspunkte umwandeln und fällt
|
||||
ohne Schlüssel oder bei einem Fehler automatisch auf eine
|
||||
Text-Heuristik zurück.
|
||||
- Der PDF-Export lädt `pdfmake` von `cdnjs.cloudflare.com` — benötigt
|
||||
Internetzugang.
|
||||
- Feiertage werden serverseitig berechnet (keine externe Abhängigkeit), die
|
||||
KI-gestützte Protokoll-Erkennung braucht dagegen Internetzugang bzw. ein
|
||||
erreichbares lokales Modell.
|
||||
|
||||
## Weiterführende Dokumentation
|
||||
|
||||
`CLAUDE.md` im Projektstamm enthält die ausführliche technische
|
||||
Dokumentation: Server-API, vollständiges Datenmodell, Architekturentscheidungen
|
||||
und ihre Begründung. Ausgangspunkt für tiefere Änderungen am Code.
|
||||
+26
-5
@@ -14,10 +14,31 @@ services:
|
||||
# Sommerzeit gälte damit zwischen 00:00 und 02:00 Ortszeit noch der
|
||||
# Vortag, und Termine von heute erschienen als überfällig.
|
||||
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:
|
||||
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))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/health >/dev/null || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
start_period: 60s
|
||||
retries: 3
|
||||
+49
@@ -4,6 +4,55 @@
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.10.tgz",
|
||||
"integrity": "sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "1.0.10",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.10",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.10",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.10.tgz",
|
||||
"integrity": "sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.15",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# `@napi-rs/canvas-darwin-arm64`
|
||||
|
||||
This is the **aarch64-apple-darwin** binary for `@napi-rs/canvas`
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@napi-rs/canvas-darwin-arm64",
|
||||
"version": "1.0.10",
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"main": "skia.darwin-arm64.node",
|
||||
"files": [
|
||||
"skia.darwin-arm64.node"
|
||||
],
|
||||
"description": "Canvas for Node.js with skia backend",
|
||||
"keywords": [
|
||||
"napi-rs",
|
||||
"NAPI",
|
||||
"N-API",
|
||||
"Rust",
|
||||
"node-addon",
|
||||
"node-addon-api",
|
||||
"canvas",
|
||||
"image",
|
||||
"pdf",
|
||||
"svg",
|
||||
"skia"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Brooooooklyn/canvas.git"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 lynweklm@gmail.com
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
# `skr canvas`
|
||||
|
||||
[](https://github.com/Brooooooklyn/canvas/actions/workflows/CI.yaml)
|
||||

|
||||
[](https://packagephobia.com/result?p=@napi-rs/canvas)
|
||||
[](https://npmcharts.com/compare/@napi-rs/canvas?minimal=true)
|
||||
|
||||
> 🚀 Help me to become a full-time open-source developer by [sponsoring me on Github](https://github.com/sponsors/Brooooooklyn)
|
||||
|
||||
Google Skia binding to Node.js via [Node-API](https://napi.rs), **0 System dependencies!**
|
||||
|
||||
[中文文档](./README-zh.md)
|
||||
|
||||
# Install
|
||||
|
||||
```bash
|
||||
yarn add @napi-rs/canvas
|
||||
npm install @napi-rs/canvas
|
||||
```
|
||||
|
||||
# Support matrix
|
||||
|
||||
## System requirement
|
||||
|
||||
### `arm64`
|
||||
|
||||
[**_cortex-a57_**](https://en.wikipedia.org/wiki/ARM_Cortex-A57) or newer CPU architecture on **Linux**.
|
||||
|
||||
All Apple M chips on **macOS**.
|
||||
|
||||
### `armv7`
|
||||
|
||||
[**_cortex-a7_**](https://en.wikipedia.org/wiki/ARM_Cortex-A7) or newer CPU architecture.
|
||||
|
||||
### glibc
|
||||
|
||||
Since Skia relies on the [glibc](https://www.gnu.org/software/libc/) 2.18 API, you need to have at least glibc version >= 2.18 on your system.
|
||||
|
||||
## AWS Lambda usage
|
||||
|
||||
To use this library on Lambda you will need to use a Lambda layer.
|
||||
|
||||
You can simply attach a lambda layer by getting an ARN from [Canvas-Lambda-Layer](https://github.com/ShivamJoker/Canvas-Lambda-Layer)
|
||||
|
||||
> Make sure to exclude `@napi-rs/canvas` while bundling your Lambda.
|
||||
|
||||
# Usage
|
||||
|
||||
```js
|
||||
const { promises } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const { createCanvas, loadImage } = require('@napi-rs/canvas')
|
||||
|
||||
const canvas = createCanvas(300, 320)
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
ctx.lineWidth = 10
|
||||
ctx.strokeStyle = '#03a9f4'
|
||||
ctx.fillStyle = '#03a9f4'
|
||||
|
||||
// Wall
|
||||
ctx.strokeRect(75, 140, 150, 110)
|
||||
|
||||
// Door
|
||||
ctx.fillRect(130, 190, 40, 60)
|
||||
|
||||
// Roof
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(50, 140)
|
||||
ctx.lineTo(150, 60)
|
||||
ctx.lineTo(250, 140)
|
||||
ctx.closePath()
|
||||
ctx.stroke()
|
||||
|
||||
async function main() {
|
||||
// load images from disk or from a URL
|
||||
const catImage = await loadImage('path/to/cat.png')
|
||||
const dogImage = await loadImage('https://example.com/path/to/dog.jpg')
|
||||
|
||||
ctx.drawImage(catImage, 0, 0, catImage.width, catImage.height)
|
||||
|
||||
ctx.drawImage(dogImage, canvas.width / 2, canvas.height / 2, dogImage.width, dogImage.height)
|
||||
|
||||
// export canvas as image
|
||||
const pngData = await canvas.encode('png') // JPEG, AVIF and WebP are also supported
|
||||
// encoding in libuv thread pool, non-blocking
|
||||
await promises.writeFile(join(__dirname, 'simple.png'), pngData)
|
||||
}
|
||||
|
||||
main()
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Emoji text
|
||||
|
||||
```js
|
||||
const { writeFileSync } = require('fs')
|
||||
const { join } = require('path')
|
||||
|
||||
const { createCanvas, GlobalFonts } = require('@napi-rs/canvas')
|
||||
|
||||
GlobalFonts.registerFromPath(join(__dirname, '..', 'fonts', 'AppleColorEmoji@2x.ttf'), 'Apple Emoji')
|
||||
GlobalFonts.registerFromPath(join(__dirname, '..', '__test__', 'fonts', 'COLRv1.ttf'), 'COLRv1')
|
||||
|
||||
console.info(GlobalFonts.families)
|
||||
|
||||
const canvas = createCanvas(760, 360)
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
ctx.font = '50px Apple Emoji'
|
||||
ctx.strokeText('😀😃😄😁😆😅😂🤣☺️😊😊😇', 50, 150)
|
||||
|
||||
ctx.font = '100px COLRv1'
|
||||
ctx.fillText('abc', 50, 300)
|
||||
|
||||
const b = canvas.toBuffer('image/png')
|
||||
|
||||
writeFileSync(join(__dirname, 'draw-emoji.png'), b)
|
||||
```
|
||||
|
||||

|
||||
|
||||
# Performance
|
||||
|
||||
See [benchmark](./benchmark) for benchmark code.
|
||||
|
||||
Hardware info:
|
||||
|
||||
```
|
||||
,MMMM. Host - xxxxxxxxxxxxxxxxxxxxxxx
|
||||
.MMMMMM Machine - Mac15,9
|
||||
MMMMM, Kernel - 24.0.0
|
||||
.;MMMMM:' MMMMMMMMMM;. OS - macOS 15.0.1 Sequoia
|
||||
MMMMMMMMMMMMNWMMMMMMMMMMM: DE - Aqua
|
||||
.MMMMMMMMMMMMMMMMMMMMMMMMWM. WM - Quartz Compositor
|
||||
MMMMMMMMMMMMMMMMMMMMMMMMM. Packages - 194 (Homebrew), 32 (cargo)
|
||||
;MMMMMMMMMMMMMMMMMMMMMMMM: Shell - zsh
|
||||
:MMMMMMMMMMMMMMMMMMMMMMMM: Terminal - warpterminal (Version v0.2024.10.23.14.49.stable_00)
|
||||
.MMMMMMMMMMMMMMMMMMMMMMMMM. Resolution - 5120x2880@160fps (as 2560x1440)
|
||||
MMMMMMMMMMMMMMMMMMMMMMMMMMM. 2992x1934@120fps (as 1496x967)
|
||||
.MMMMMMMMMMMMMMMMMMMMMMMMMM. 2232x1512@60fps (as 1116x756)
|
||||
MMMMMMMMMMMMMMMMMMMMMMMM Uptime - 1d 2h 32m
|
||||
;MMMMMMMMMMMMMMMMMMMM. CPU - Apple M3 Max (16)
|
||||
.MMMM,. .MMMM,. CPU Load - 16%
|
||||
Memory - 50.1 GB / 134.2 GB
|
||||
Battery - 78% & Discharging
|
||||
Disk Space - 624.0 GB / 994.7 GB
|
||||
```
|
||||
|
||||
```
|
||||
❯ yarn bench
|
||||
Draw a House and export to PNG
|
||||
┌─────────┬─────────────────┬───────────────────────┬──────────────────────────┬────────────────────────────┬───────────────────────────┬─────────┐
|
||||
│ (index) │ Task name │ Latency average (ns) │ Latency median (ns) │ Throughput average (ops/s) │ Throughput median (ops/s) │ Samples │
|
||||
├─────────┼─────────────────┼───────────────────────┼──────────────────────────┼────────────────────────────┼───────────────────────────┼─────────┤
|
||||
│ 0 │ '@napi-rs/skia' │ '14676992.14 ± 0.68%' │ '14602333.00' │ '68 ± 0.59%' │ '68' │ 69 │
|
||||
│ 1 │ 'skia-canvas' │ '21167809.17 ± 2.05%' │ '20960021.00 ± 13646.00' │ '47 ± 1.31%' │ '48' │ 64 │
|
||||
│ 2 │ 'node-canvas' │ '16552027.42 ± 0.70%' │ '16451291.50 ± 2208.50' │ '60 ± 0.62%' │ '61' │ 64 │
|
||||
└─────────┴─────────────────┴───────────────────────┴──────────────────────────┴────────────────────────────┴───────────────────────────┴─────────┘
|
||||
Draw Gradient and export to PNG
|
||||
┌─────────┬─────────────────┬───────────────────────┬─────────────────────────┬────────────────────────────┬───────────────────────────┬─────────┐
|
||||
│ (index) │ Task name │ Latency average (ns) │ Latency median (ns) │ Throughput average (ops/s) │ Throughput median (ops/s) │ Samples │
|
||||
├─────────┼─────────────────┼───────────────────────┼─────────────────────────┼────────────────────────────┼───────────────────────────┼─────────┤
|
||||
│ 0 │ '@napi-rs/skia' │ '15228495.58 ± 0.53%' │ '15146312.50 ± 1187.50' │ '66 ± 0.48%' │ '66' │ 66 │
|
||||
│ 1 │ 'skia-canvas' │ '21725564.41 ± 2.20%' │ '21412520.50 ± 2104.50' │ '46 ± 1.39%' │ '47' │ 64 │
|
||||
│ 2 │ 'node-canvas' │ '17976022.14 ± 1.53%' │ '17563479.50 ± 5104.50' │ '56 ± 1.38%' │ '57' │ 64 │
|
||||
└─────────┴─────────────────┴───────────────────────┴─────────────────────────┴────────────────────────────┴───────────────────────────┴─────────┘
|
||||
```
|
||||
|
||||
# Features
|
||||
|
||||
## Path2D
|
||||
|
||||
```typescript
|
||||
new Path2D()
|
||||
new Path2D(path: Path2D)
|
||||
// new Path2D('M108.956,403.826c0,0,0.178,3.344-1.276,3.311 c-1.455-0.033-30.507-84.917-66.752-80.957C40.928,326.18,72.326,313.197,108.956,403.826z')
|
||||
new Path2D(path: string)
|
||||
```
|
||||
|
||||
```typescript
|
||||
export interface DOMMatrix2DInit {
|
||||
a: number
|
||||
b: number
|
||||
c: number
|
||||
d: number
|
||||
e: number
|
||||
f: number
|
||||
}
|
||||
|
||||
export class Path2D {
|
||||
constructor(path?: Path2D | string)
|
||||
|
||||
addPath(path: Path2D, transform?: DOMMatrix2DInit): void
|
||||
arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise?: boolean): void
|
||||
arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void
|
||||
bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void
|
||||
closePath(): void
|
||||
ellipse(
|
||||
x: number,
|
||||
y: number,
|
||||
radiusX: number,
|
||||
radiusY: number,
|
||||
rotation: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
anticlockwise?: boolean,
|
||||
): void
|
||||
lineTo(x: number, y: number): void
|
||||
moveTo(x: number, y: number): void
|
||||
quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void
|
||||
rect(x: number, y: number, w: number, h: number): void
|
||||
|
||||
// PathKit methods
|
||||
op(path: Path2D, operation: PathOp): Path2D
|
||||
toSVGString(): string
|
||||
getFillType(): FillType
|
||||
getFillTypeString(): string
|
||||
setFillType(type: FillType): void
|
||||
simplify(): Path2D
|
||||
asWinding(): Path2D
|
||||
stroke(stroke?: StrokeOptions): Path2D
|
||||
transform(transform: DOMMatrix2DInit): Path2D
|
||||
getBounds(): [left: number, top: number, right: number, bottom: number]
|
||||
computeTightBounds(): [left: number, top: number, right: number, bottom: number]
|
||||
trim(start: number, end: number, isComplement?: boolean): Path2D
|
||||
round(radius: number): Path2D
|
||||
equals(path: Path2D): boolean
|
||||
}
|
||||
```
|
||||
|
||||
## PathKit
|
||||
|
||||
`PathKit` is a toolset for manipulating Path in `Skia`, supporting **_quadratic beziers_**, **_cubic beziers_** and **_conics_**.
|
||||
The main features are.
|
||||
|
||||
### Path Operation
|
||||
|
||||
`.op(path, PathOp)`
|
||||
|
||||
```js
|
||||
const pathOne = new Path2D(
|
||||
'M8 50H92C96.4183 50 100 53.5817 100 58V142C100 146.418 96.4183 150 92 150H8C3.58172 150 0 146.418 0 142V58C0 53.5817 3.58172 50 8 50Z',
|
||||
)
|
||||
const pathTwo = new Path2D(
|
||||
'"M58 0H142C146.418 0 150 3.58172 150 8V92C150 96.4183 146.418 100 142 100H58C53.5817 100 50 96.4183 50 92V8C50 3.58172 53.5817 0 58 0Z',
|
||||
)
|
||||
|
||||
pathOne.op(pathTwo, PathOp.Intersect).toSVGString()
|
||||
// => "M100 100L58 100C53.5817 100 50 96.4183 50 92L50 50L92 50C96.4183 50 100 53.5817 100 58L100 100Z"
|
||||
```
|
||||
|
||||
- **Union**, subtract the op path from the first path
|
||||
- **Difference**, intersect the two paths
|
||||
- **ReverseDifference**, union (inclusive-or) the two paths
|
||||
- **Intersect**, exclusive-or the two paths
|
||||
- **XOR**, subtract the first path from the op path
|
||||
|
||||

|
||||
|
||||
### Covert `FillType` in **_Path_**
|
||||
|
||||
`.asWinding()`
|
||||
|
||||
You can convert `fill-rule="evenodd"` to `fill-rule="nonzero"` in SVG.
|
||||
This is useful for **OpenType** font-related tools, as `fill-rule="nonzero"` is only supported in **OpenType** fonts.
|
||||
|
||||

|
||||
|
||||
```js
|
||||
const pathCircle = new Path2D(
|
||||
'M24.2979 13.6364H129.394V40.9091H24.2979L14.6278 27.2727L24.2979 13.6364ZM21.9592 0C19.0246 0 16.2716 1.42436 14.571 3.82251L1.67756 22.0043C-0.559186 25.1585 -0.559186 29.387 1.67756 32.5411L14.571 50.7227C16.2716 53.1209 19.0246 54.5455 21.9592 54.5455H70.4673V68.1818H16.073C11.0661 68.1818 7.00728 72.2518 7.00728 77.2727V113.636C7.00728 118.657 11.0661 122.727 16.073 122.727H70.4673V150H84.0658V122.727H128.041C130.975 122.727 133.729 121.303 135.429 118.905L148.323 100.723C150.559 97.5686 150.559 93.3405 148.323 90.1864L135.429 72.0045C133.729 69.6064 130.975 68.1818 128.041 68.1818H84.0658V54.5455H133.927C138.934 54.5455 142.993 50.4755 142.993 45.4545V9.09091C142.993 4.07014 138.934 0 133.927 0H21.9592ZM125.702 109.091H20.6058V81.8182H125.702L135.372 95.4545L125.702 109.091Z',
|
||||
)
|
||||
pathCircle.setFillType(FillType.EvenOdd)
|
||||
pathCircle.asWinding().toSVGString()
|
||||
// => "M24.2979 13.6364L129.394 13.6364L129.394 40.9091L24.2979 40.9091L14.6278 27.2727L24.2979 13.6364ZM21.9592 0C19.0246 0 16.2716 1.42436 14.571 3.82251L1.67756 22.0043C-0.559186 25.1585 -0.559186 29.387 1.67756 32.5411L14.571 50.7227C16.2716 53.1209 19.0246 54.5455 21.9592 54.5455L70.4673 54.5455L70.4673 68.1818L16.073 68.1818C11.0661 68.1818 7.00728 72.2518 7.00728 77.2727L7.00728 113.636C7.00728 118.657 11.0661 122.727 16.073 122.727L70.4673 122.727L70.4673 150L84.0658 150L84.0658 122.727L128.041 122.727C130.975 122.727 133.729 121.303 135.429 118.905L148.323 100.723C150.559 97.5686 150.559 93.3405 148.323 90.1864L135.429 72.0045C133.729 69.6064 130.975 68.1818 128.041 68.1818L84.0658 68.1818L84.0658 54.5455L133.927 54.5455C138.934 54.5455 142.993 50.4755 142.993 45.4545L142.993 9.09091C142.993 4.07014 138.934 0 133.927 0L21.9592 0ZM125.702 109.091L20.6058 109.091L20.6058 81.8182L125.702 81.8182L135.372 95.4545L125.702 109.091Z"
|
||||
```
|
||||
|
||||
### Simplify **_Path_**
|
||||
|
||||
`.simplify()`
|
||||
|
||||
Set the path to the same non-overlapping contour as the original path area, which means that it can also remove overlapping paths.
|
||||
|
||||
<img width="800" src="./docs/imgs/simplify.png" >
|
||||
|
||||
[SVG with overlapping paths](./docs/imgs/overlapping-path.svg) (Left)
|
||||
|
||||
```js
|
||||
const path =
|
||||
'M2.933,89.89 L89.005,3.818 Q90.412,2.411 92.249,1.65 Q94.087,0.889 96.076,0.889 Q98.065,0.889 99.903,1.65 Q101.741,2.411 103.147,3.818 L189.22,89.89 Q190.626,91.296 191.387,93.134 Q192.148,94.972 192.148,96.961 Q192.148,98.95 191.387,100.788 Q190.626,102.625 189.219,104.032 Q187.813,105.439 185.975,106.2 Q184.138,106.961 182.148,106.961 Q180.159,106.961 178.322,106.2 Q176.484,105.439 175.077,104.032 L89.005,17.96 L96.076,10.889 L103.147,17.96 L17.075,104.032 Q15.668,105.439 13.831,106.2 Q11.993,106.961 10.004,106.961 Q8.015,106.961 6.177,106.2 Q4.339,105.439 2.933,104.032 Q1.526,102.625 0.765,100.788 Q0.004,98.95 0.004,96.961 Q0.004,94.972 0.765,93.134 Q1.526,91.296 2.933,89.89 Z'
|
||||
|
||||
path.simplify().toSVGString()
|
||||
// => "M89.005 3.818L2.933 89.89Q1.526 91.296 0.765 93.134Q0.004 94.972 0.004 96.961Q0.004 98.95 0.765 100.788Q1.526 102.625 2.933 104.032Q4.339 105.439 6.177 106.2Q8.015 106.961 10.004 106.961Q11.993 106.961 13.831 106.2Q15.668 105.439 17.075 104.032L96.076 25.031L175.077 104.032Q176.484 105.439 178.322 106.2Q180.159 106.961 182.148 106.961Q184.138 106.961 185.975 106.2Q187.813 105.439 189.219 104.032Q190.626 102.625 191.387 100.788Q192.148 98.95 192.148 96.961Q192.148 94.972 191.387 93.134Q190.626 91.296 189.22 89.89L103.147 3.818Q101.741 2.411 99.903 1.65Q98.065 0.889 96.076 0.889Q94.087 0.889 92.249 1.65Q90.412 2.411 89.005 3.818Z"
|
||||
```
|
||||
|
||||
## Lottie Animation
|
||||
|
||||
Render [Lottie](https://airbnb.io/lottie/) animations using Skia's [Skottie](https://skia.org/docs/user/modules/skottie/) module.
|
||||
|
||||
### Load Animation
|
||||
|
||||
```js
|
||||
const { LottieAnimation } = require('@napi-rs/canvas')
|
||||
|
||||
// Load from file
|
||||
const animation = LottieAnimation.loadFromFile('animation.json')
|
||||
|
||||
// Load from JSON string with resource path for external assets
|
||||
const animation = LottieAnimation.loadFromData(jsonString, {
|
||||
resourcePath: '/path/to/assets',
|
||||
})
|
||||
```
|
||||
|
||||
### Animation Properties
|
||||
|
||||
```js
|
||||
animation.duration // Total duration in seconds
|
||||
animation.fps // Frames per second
|
||||
animation.frames // Total frame count
|
||||
animation.width // Animation width
|
||||
animation.height // Animation height
|
||||
animation.version // Lottie format version
|
||||
```
|
||||
|
||||
### Playback Control
|
||||
|
||||
```js
|
||||
animation.seekFrame(30) // Seek to frame 30
|
||||
animation.seek(1.5) // Seek to 1.5 seconds
|
||||
```
|
||||
|
||||
### Render to Canvas
|
||||
|
||||
```js
|
||||
const { createCanvas, LottieAnimation } = require('@napi-rs/canvas')
|
||||
|
||||
const animation = LottieAnimation.loadFromFile('animation.json')
|
||||
const canvas = createCanvas(animation.width, animation.height)
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// Render at original size
|
||||
animation.render(ctx)
|
||||
|
||||
// Render with custom destination rect
|
||||
animation.render(ctx, { x: 0, y: 0, width: 800, height: 600 })
|
||||
```
|
||||
|
||||
### Supported Features
|
||||
|
||||
- **Embedded images** - Base64-encoded images (`data:image/png;base64,...`)
|
||||
- **Embedded fonts** - Vector glyph paths for text rendering without system fonts
|
||||
- **External assets** - Load images from `resourcePath` directory
|
||||
- **dotLottie format** - Extract `.lottie` ZIP files at runtime (see example)
|
||||
|
||||
### Example: Encode Lottie to Video
|
||||
|
||||
See [`example/lottie-to-video.ts`](./example/lottie-to-video.ts) for encoding Lottie animations to MP4 using [`@napi-rs/webcodecs`](https://github.com/Brooooooklyn/webcodecs-node).
|
||||
|
||||
```js
|
||||
import { createCanvas, LottieAnimation } from '@napi-rs/canvas'
|
||||
import {
|
||||
VideoEncoder,
|
||||
VideoFrame,
|
||||
Mp4Muxer,
|
||||
type EncodedVideoChunk,
|
||||
type EncodedVideoChunkMetadata,
|
||||
} from '@napi-rs/webcodecs'
|
||||
|
||||
const animation = LottieAnimation.loadFromFile('animation.json')
|
||||
const canvas = createCanvas(animation.width, animation.height)
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
for (let frame = 0; frame < animation.frames; frame++) {
|
||||
animation.seekFrame(frame)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
animation.render(ctx)
|
||||
// Encode frame to video...
|
||||
}
|
||||
```
|
||||
|
||||
# [Example](./example/tiger.js)
|
||||
|
||||
> The tiger.json was serialized from [gojs/samples/tiger](https://github.com/NorthwoodsSoftware/GoJS/blob/master/samples/tiger.html)
|
||||
|
||||
<img width="500" src="example/tiger.png">
|
||||
|
||||
```shell
|
||||
node example/anime-girl.js
|
||||
```
|
||||
|
||||
| SVG | PNG |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <img width="500" src="example/anime-girl.svg"><br/>[CC-BY-SA 3.0](https://creativecommons.org/licenses/by/3.0) by [Niabot](https://commons.wikimedia.org/wiki/User:Niabot) | <img width="500" src="example/anime-girl.png"><br/>[CC-BY-SA 3.0](https://creativecommons.org/licenses/by/3.0) by [Niabot](https://commons.wikimedia.org/wiki/User:Niabot) |
|
||||
|
||||
# Building
|
||||
|
||||
## Build skia from source
|
||||
|
||||
You can build this project from source, the system requirements are here: https://skia.org/docs/user/build
|
||||
|
||||
```sh
|
||||
# Clone the code:
|
||||
$ git clone --recurse-submodules https://github.com/Brooooooklyn/canvas.git
|
||||
$ cd canvas
|
||||
|
||||
# Build Skia:
|
||||
$ node scripts/build-skia.js
|
||||
|
||||
# Install NPM packages, build the Node.js addon:
|
||||
$ npm install -g yarn
|
||||
$ yarn install --mode=skip-build # Here are modules that are used for benchmarking and are hard to install, you can skip it by specifying `--mode=skip-build`
|
||||
$ sudo dnf install clang # https://fedora.pkgs.org/34/fedora-x86_64/clang-12.0.0-0.3.rc1.fc34.x86_64.rpm.html
|
||||
$ yarn build
|
||||
|
||||
# All done! Run test cases or examples now:
|
||||
$ yarn test
|
||||
$ node example/tiger.js
|
||||
```
|
||||
|
||||
## Pull pre-build skia binary from GitHub
|
||||
|
||||
You can pull skia pre-build binaries if you just care the `Rust` part:
|
||||
|
||||
```sh
|
||||
# Clone the code:
|
||||
$ git clone --recurse-submodules https://github.com/Brooooooklyn/canvas.git
|
||||
$ cd canvas
|
||||
|
||||
# Download Skia binaries:
|
||||
# It will pull the binaries match the git hash in `./skia` submodule
|
||||
$ node scripts/release-skia-binary.mjs --download
|
||||
|
||||
# Install NPM packages, build the Node.js addon:
|
||||
$ npm install -g yarn
|
||||
$ yarn install --mode=skip-build
|
||||
$ sudo dnf install clang # https://fedora.pkgs.org/34/fedora-x86_64/clang-12.0.0-0.3.rc1.fc34.x86_64.rpm.html
|
||||
$ yarn build
|
||||
|
||||
# All done! Run test cases or examples now:
|
||||
$ yarn test
|
||||
$ node example/tiger.js
|
||||
```
|
||||
+873
@@ -0,0 +1,873 @@
|
||||
const { inspect } = require('util')
|
||||
/*
|
||||
* vendored in order to fix its dependence on the window global [cds 2020/08/04]
|
||||
* otherwise unchanged from https://github.com/jarek-foksa/geometry-polyfill/tree/f36bbc8f4bc43539d980687904ce46c8e915543d
|
||||
*/
|
||||
|
||||
// @info
|
||||
// DOMPoint polyfill
|
||||
// @src
|
||||
// https://drafts.fxtf.org/geometry/#DOMPoint
|
||||
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_point_read_only.cc
|
||||
class DOMPoint {
|
||||
constructor(x = 0, y = 0, z = 0, w = 1) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.z = z
|
||||
this.w = w
|
||||
}
|
||||
|
||||
static fromPoint(otherPoint) {
|
||||
return new DOMPoint(
|
||||
otherPoint.x,
|
||||
otherPoint.y,
|
||||
otherPoint.z !== undefined ? otherPoint.z : 0,
|
||||
otherPoint.w !== undefined ? otherPoint.w : 1,
|
||||
)
|
||||
}
|
||||
|
||||
matrixTransform(matrix) {
|
||||
if ((matrix.is2D || matrix instanceof SVGMatrix) && this.z === 0 && this.w === 1) {
|
||||
return new DOMPoint(
|
||||
this.x * matrix.a + this.y * matrix.c + matrix.e,
|
||||
this.x * matrix.b + this.y * matrix.d + matrix.f,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
} else {
|
||||
return new DOMPoint(
|
||||
this.x * matrix.m11 + this.y * matrix.m21 + this.z * matrix.m31 + this.w * matrix.m41,
|
||||
this.x * matrix.m12 + this.y * matrix.m22 + this.z * matrix.m32 + this.w * matrix.m42,
|
||||
this.x * matrix.m13 + this.y * matrix.m23 + this.z * matrix.m33 + this.w * matrix.m43,
|
||||
this.x * matrix.m14 + this.y * matrix.m24 + this.z * matrix.m34 + this.w * matrix.m44,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
z: this.z,
|
||||
w: this.w,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @info
|
||||
// DOMRect polyfill
|
||||
// @src
|
||||
// https://drafts.fxtf.org/geometry/#DOMRect
|
||||
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_rect_read_only.cc
|
||||
|
||||
class DOMRect {
|
||||
constructor(x = 0, y = 0, width = 0, height = 0) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
|
||||
static fromRect(otherRect) {
|
||||
return new DOMRect(otherRect.x, otherRect.y, otherRect.width, otherRect.height)
|
||||
}
|
||||
|
||||
get top() {
|
||||
return this.y
|
||||
}
|
||||
|
||||
get left() {
|
||||
return this.x
|
||||
}
|
||||
|
||||
get right() {
|
||||
return this.x + this.width
|
||||
}
|
||||
|
||||
get bottom() {
|
||||
return this.y + this.height
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
top: this.top,
|
||||
left: this.left,
|
||||
right: this.right,
|
||||
bottom: this.bottom,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const propertyName of ['top', 'right', 'bottom', 'left']) {
|
||||
const propertyDescriptor = Object.getOwnPropertyDescriptor(DOMRect.prototype, propertyName)
|
||||
propertyDescriptor.enumerable = true
|
||||
Object.defineProperty(DOMRect.prototype, propertyName, propertyDescriptor)
|
||||
}
|
||||
|
||||
// @info
|
||||
// DOMMatrix polyfill (SVG 2)
|
||||
// @src
|
||||
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_matrix_read_only.cc
|
||||
// https://github.com/tocharomera/generativecanvas/blob/master/node-canvas/lib/DOMMatrix.js
|
||||
|
||||
const M11 = 0,
|
||||
M12 = 1,
|
||||
M13 = 2,
|
||||
M14 = 3
|
||||
const M21 = 4,
|
||||
M22 = 5,
|
||||
M23 = 6,
|
||||
M24 = 7
|
||||
const M31 = 8,
|
||||
M32 = 9,
|
||||
M33 = 10,
|
||||
M34 = 11
|
||||
const M41 = 12,
|
||||
M42 = 13,
|
||||
M43 = 14,
|
||||
M44 = 15
|
||||
|
||||
const A = M11,
|
||||
B = M12
|
||||
const C = M21,
|
||||
D = M22
|
||||
const E = M41,
|
||||
F = M42
|
||||
|
||||
const DEGREE_PER_RAD = 180 / Math.PI
|
||||
const RAD_PER_DEGREE = Math.PI / 180
|
||||
|
||||
const VALUES = Symbol('values')
|
||||
const IS_2D = Symbol('is2D')
|
||||
|
||||
function parseMatrix(init) {
|
||||
let parsed = init.replace(/matrix\(/, '').split(/,/, 7)
|
||||
|
||||
if (parsed.length !== 6) {
|
||||
throw new Error(`Failed to parse ${init}`)
|
||||
}
|
||||
|
||||
parsed = parsed.map(parseFloat)
|
||||
|
||||
return [parsed[0], parsed[1], 0, 0, parsed[2], parsed[3], 0, 0, 0, 0, 1, 0, parsed[4], parsed[5], 0, 1]
|
||||
}
|
||||
|
||||
function parseMatrix3d(init) {
|
||||
const parsed = init.replace(/matrix3d\(/, '').split(/,/, 17)
|
||||
|
||||
if (parsed.length !== 16) {
|
||||
throw new Error(`Failed to parse ${init}`)
|
||||
}
|
||||
|
||||
return parsed.map(parseFloat)
|
||||
}
|
||||
|
||||
function parseTransform(tform) {
|
||||
const type = tform.split(/\(/, 1)[0]
|
||||
|
||||
if (type === 'matrix') {
|
||||
return parseMatrix(tform)
|
||||
} else if (type === 'matrix3d') {
|
||||
return parseMatrix3d(tform)
|
||||
} else {
|
||||
throw new Error(`${type} parsing not implemented`)
|
||||
}
|
||||
}
|
||||
|
||||
const setNumber2D = (receiver, index, value) => {
|
||||
if (typeof value !== 'number') {
|
||||
throw new TypeError('Expected number')
|
||||
}
|
||||
|
||||
receiver[VALUES][index] = value
|
||||
}
|
||||
|
||||
const setNumber3D = (receiver, index, value) => {
|
||||
if (typeof value !== 'number') {
|
||||
throw new TypeError('Expected number')
|
||||
}
|
||||
|
||||
if (index === M33 || index === M44) {
|
||||
if (value !== 1) {
|
||||
receiver[IS_2D] = false
|
||||
}
|
||||
} else if (value !== 0) {
|
||||
receiver[IS_2D] = false
|
||||
}
|
||||
|
||||
receiver[VALUES][index] = value
|
||||
}
|
||||
|
||||
const newInstance = (values) => {
|
||||
const instance = Object.create(DOMMatrix.prototype)
|
||||
instance.constructor = DOMMatrix
|
||||
instance[IS_2D] = true
|
||||
instance[VALUES] = values
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
const multiply = (first, second) => {
|
||||
const dest = new Float64Array(16)
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
let sum = 0
|
||||
|
||||
for (let k = 0; k < 4; k++) {
|
||||
sum += first[i * 4 + k] * second[k * 4 + j]
|
||||
}
|
||||
|
||||
dest[i * 4 + j] = sum
|
||||
}
|
||||
}
|
||||
|
||||
return dest
|
||||
}
|
||||
|
||||
class DOMMatrix {
|
||||
get m11() {
|
||||
return this[VALUES][M11]
|
||||
}
|
||||
set m11(value) {
|
||||
setNumber2D(this, M11, value)
|
||||
}
|
||||
get m12() {
|
||||
return this[VALUES][M12]
|
||||
}
|
||||
set m12(value) {
|
||||
setNumber2D(this, M12, value)
|
||||
}
|
||||
get m13() {
|
||||
return this[VALUES][M13]
|
||||
}
|
||||
set m13(value) {
|
||||
setNumber3D(this, M13, value)
|
||||
}
|
||||
get m14() {
|
||||
return this[VALUES][M14]
|
||||
}
|
||||
set m14(value) {
|
||||
setNumber3D(this, M14, value)
|
||||
}
|
||||
get m21() {
|
||||
return this[VALUES][M21]
|
||||
}
|
||||
set m21(value) {
|
||||
setNumber2D(this, M21, value)
|
||||
}
|
||||
get m22() {
|
||||
return this[VALUES][M22]
|
||||
}
|
||||
set m22(value) {
|
||||
setNumber2D(this, M22, value)
|
||||
}
|
||||
get m23() {
|
||||
return this[VALUES][M23]
|
||||
}
|
||||
set m23(value) {
|
||||
setNumber3D(this, M23, value)
|
||||
}
|
||||
get m24() {
|
||||
return this[VALUES][M24]
|
||||
}
|
||||
set m24(value) {
|
||||
setNumber3D(this, M24, value)
|
||||
}
|
||||
get m31() {
|
||||
return this[VALUES][M31]
|
||||
}
|
||||
set m31(value) {
|
||||
setNumber3D(this, M31, value)
|
||||
}
|
||||
get m32() {
|
||||
return this[VALUES][M32]
|
||||
}
|
||||
set m32(value) {
|
||||
setNumber3D(this, M32, value)
|
||||
}
|
||||
get m33() {
|
||||
return this[VALUES][M33]
|
||||
}
|
||||
set m33(value) {
|
||||
setNumber3D(this, M33, value)
|
||||
}
|
||||
get m34() {
|
||||
return this[VALUES][M34]
|
||||
}
|
||||
set m34(value) {
|
||||
setNumber3D(this, M34, value)
|
||||
}
|
||||
get m41() {
|
||||
return this[VALUES][M41]
|
||||
}
|
||||
set m41(value) {
|
||||
setNumber2D(this, M41, value)
|
||||
}
|
||||
get m42() {
|
||||
return this[VALUES][M42]
|
||||
}
|
||||
set m42(value) {
|
||||
setNumber2D(this, M42, value)
|
||||
}
|
||||
get m43() {
|
||||
return this[VALUES][M43]
|
||||
}
|
||||
set m43(value) {
|
||||
setNumber3D(this, M43, value)
|
||||
}
|
||||
get m44() {
|
||||
return this[VALUES][M44]
|
||||
}
|
||||
set m44(value) {
|
||||
setNumber3D(this, M44, value)
|
||||
}
|
||||
|
||||
get a() {
|
||||
return this[VALUES][A]
|
||||
}
|
||||
set a(value) {
|
||||
setNumber2D(this, A, value)
|
||||
}
|
||||
get b() {
|
||||
return this[VALUES][B]
|
||||
}
|
||||
set b(value) {
|
||||
setNumber2D(this, B, value)
|
||||
}
|
||||
get c() {
|
||||
return this[VALUES][C]
|
||||
}
|
||||
set c(value) {
|
||||
setNumber2D(this, C, value)
|
||||
}
|
||||
get d() {
|
||||
return this[VALUES][D]
|
||||
}
|
||||
set d(value) {
|
||||
setNumber2D(this, D, value)
|
||||
}
|
||||
get e() {
|
||||
return this[VALUES][E]
|
||||
}
|
||||
set e(value) {
|
||||
setNumber2D(this, E, value)
|
||||
}
|
||||
get f() {
|
||||
return this[VALUES][F]
|
||||
}
|
||||
set f(value) {
|
||||
setNumber2D(this, F, value)
|
||||
}
|
||||
|
||||
get is2D() {
|
||||
return this[IS_2D]
|
||||
}
|
||||
|
||||
get isIdentity() {
|
||||
const values = this[VALUES]
|
||||
|
||||
return (
|
||||
values[M11] === 1 &&
|
||||
values[M12] === 0 &&
|
||||
values[M13] === 0 &&
|
||||
values[M14] === 0 &&
|
||||
values[M21] === 0 &&
|
||||
values[M22] === 1 &&
|
||||
values[M23] === 0 &&
|
||||
values[M24] === 0 &&
|
||||
values[M31] === 0 &&
|
||||
values[M32] === 0 &&
|
||||
values[M33] === 1 &&
|
||||
values[M34] === 0 &&
|
||||
values[M41] === 0 &&
|
||||
values[M42] === 0 &&
|
||||
values[M43] === 0 &&
|
||||
values[M44] === 1
|
||||
)
|
||||
}
|
||||
|
||||
static fromMatrix(init) {
|
||||
if (init instanceof DOMMatrix) {
|
||||
return new DOMMatrix(init[VALUES])
|
||||
} else if (init instanceof SVGMatrix) {
|
||||
return new DOMMatrix([init.a, init.b, init.c, init.d, init.e, init.f])
|
||||
} else {
|
||||
throw new TypeError('Expected DOMMatrix')
|
||||
}
|
||||
}
|
||||
|
||||
static fromFloat32Array(init) {
|
||||
if (!(init instanceof Float32Array)) throw new TypeError('Expected Float32Array')
|
||||
return new DOMMatrix(init)
|
||||
}
|
||||
|
||||
static fromFloat64Array(init) {
|
||||
if (!(init instanceof Float64Array)) throw new TypeError('Expected Float64Array')
|
||||
return new DOMMatrix(init)
|
||||
}
|
||||
|
||||
// @type
|
||||
// (Float64Array) => void
|
||||
constructor(init) {
|
||||
this[IS_2D] = true
|
||||
|
||||
this[VALUES] = new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])
|
||||
|
||||
// Parse CSS transformList
|
||||
if (typeof init === 'string') {
|
||||
if (init === '') {
|
||||
return
|
||||
} else {
|
||||
const tforms = init.split(/\)\s+/, 20).map(parseTransform)
|
||||
|
||||
if (tforms.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
init = tforms[0]
|
||||
|
||||
for (let i = 1; i < tforms.length; i++) {
|
||||
init = multiply(tforms[i], init)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let i = 0
|
||||
|
||||
if (init && init.length === 6) {
|
||||
setNumber2D(this, A, init[i++])
|
||||
setNumber2D(this, B, init[i++])
|
||||
setNumber2D(this, C, init[i++])
|
||||
setNumber2D(this, D, init[i++])
|
||||
setNumber2D(this, E, init[i++])
|
||||
setNumber2D(this, F, init[i++])
|
||||
} else if (init && init.length === 16) {
|
||||
setNumber2D(this, M11, init[i++])
|
||||
setNumber2D(this, M12, init[i++])
|
||||
setNumber3D(this, M13, init[i++])
|
||||
setNumber3D(this, M14, init[i++])
|
||||
setNumber2D(this, M21, init[i++])
|
||||
setNumber2D(this, M22, init[i++])
|
||||
setNumber3D(this, M23, init[i++])
|
||||
setNumber3D(this, M24, init[i++])
|
||||
setNumber3D(this, M31, init[i++])
|
||||
setNumber3D(this, M32, init[i++])
|
||||
setNumber3D(this, M33, init[i++])
|
||||
setNumber3D(this, M34, init[i++])
|
||||
setNumber2D(this, M41, init[i++])
|
||||
setNumber2D(this, M42, init[i++])
|
||||
setNumber3D(this, M43, init[i++])
|
||||
setNumber3D(this, M44, init[i])
|
||||
} else if (init !== undefined) {
|
||||
throw new TypeError('Expected string or array.')
|
||||
}
|
||||
}
|
||||
|
||||
dump() {
|
||||
const mat = this[VALUES]
|
||||
console.info([mat.slice(0, 4), mat.slice(4, 8), mat.slice(8, 12), mat.slice(12, 16)])
|
||||
}
|
||||
|
||||
[inspect.custom](depth) {
|
||||
if (depth < 0) return '[DOMMatrix]'
|
||||
|
||||
const { a, b, c, d, e, f, is2D, isIdentity } = this
|
||||
if (this.is2D) {
|
||||
return `DOMMatrix ${inspect({ a, b, c, d, e, f, is2D, isIdentity }, { colors: true })}`
|
||||
} else {
|
||||
const { m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34, m41, m42, m43, m44, is2D, isIdentity } = this
|
||||
return `DOMMatrix ${inspect(
|
||||
{
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
d,
|
||||
e,
|
||||
f,
|
||||
m11,
|
||||
m12,
|
||||
m13,
|
||||
m14,
|
||||
m21,
|
||||
m22,
|
||||
m23,
|
||||
m24,
|
||||
m31,
|
||||
m32,
|
||||
m33,
|
||||
m34,
|
||||
m41,
|
||||
m42,
|
||||
m43,
|
||||
m44,
|
||||
is2D,
|
||||
isIdentity,
|
||||
},
|
||||
{ colors: true },
|
||||
)}`
|
||||
}
|
||||
}
|
||||
|
||||
multiply(other) {
|
||||
return newInstance(this[VALUES]).multiplySelf(other)
|
||||
}
|
||||
|
||||
multiplySelf(other) {
|
||||
this[VALUES] = multiply(other[VALUES], this[VALUES])
|
||||
|
||||
if (!other.is2D) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
preMultiplySelf(other) {
|
||||
this[VALUES] = multiply(this[VALUES], other[VALUES])
|
||||
|
||||
if (!other.is2D) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
translate(tx, ty, tz) {
|
||||
return newInstance(this[VALUES]).translateSelf(tx, ty, tz)
|
||||
}
|
||||
|
||||
translateSelf(tx = 0, ty = 0, tz = 0) {
|
||||
this[VALUES] = multiply([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, tx, ty, tz, 1], this[VALUES])
|
||||
|
||||
if (tz !== 0) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
scale(scaleX, scaleY, scaleZ, originX, originY, originZ) {
|
||||
return newInstance(this[VALUES]).scaleSelf(scaleX, scaleY, scaleZ, originX, originY, originZ)
|
||||
}
|
||||
|
||||
scale3d(scale, originX, originY, originZ) {
|
||||
return newInstance(this[VALUES]).scale3dSelf(scale, originX, originY, originZ)
|
||||
}
|
||||
|
||||
scale3dSelf(scale, originX, originY, originZ) {
|
||||
return this.scaleSelf(scale, scale, scale, originX, originY, originZ)
|
||||
}
|
||||
|
||||
scaleSelf(scaleX, scaleY, scaleZ, originX, originY, originZ) {
|
||||
// Not redundant with translate's checks because we need to negate the values later.
|
||||
if (typeof originX !== 'number') originX = 0
|
||||
if (typeof originY !== 'number') originY = 0
|
||||
if (typeof originZ !== 'number') originZ = 0
|
||||
|
||||
this.translateSelf(originX, originY, originZ)
|
||||
|
||||
if (typeof scaleX !== 'number') scaleX = 1
|
||||
if (typeof scaleY !== 'number') scaleY = scaleX
|
||||
if (typeof scaleZ !== 'number') scaleZ = 1
|
||||
|
||||
this[VALUES] = multiply([scaleX, 0, 0, 0, 0, scaleY, 0, 0, 0, 0, scaleZ, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
this.translateSelf(-originX, -originY, -originZ)
|
||||
|
||||
if (scaleZ !== 1 || originZ !== 0) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
rotateFromVector(x, y) {
|
||||
return newInstance(this[VALUES]).rotateFromVectorSelf(x, y)
|
||||
}
|
||||
|
||||
rotateFromVectorSelf(x = 0, y = 0) {
|
||||
const theta = x === 0 && y === 0 ? 0 : Math.atan2(y, x) * DEGREE_PER_RAD
|
||||
return this.rotateSelf(theta)
|
||||
}
|
||||
|
||||
rotate(rotX, rotY, rotZ) {
|
||||
return newInstance(this[VALUES]).rotateSelf(rotX, rotY, rotZ)
|
||||
}
|
||||
|
||||
rotateSelf(rotX, rotY, rotZ) {
|
||||
if (rotY === undefined && rotZ === undefined) {
|
||||
rotZ = rotX
|
||||
rotX = rotY = 0
|
||||
}
|
||||
|
||||
if (typeof rotY !== 'number') rotY = 0
|
||||
if (typeof rotZ !== 'number') rotZ = 0
|
||||
|
||||
if (rotX !== 0 || rotY !== 0) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
rotX *= RAD_PER_DEGREE
|
||||
rotY *= RAD_PER_DEGREE
|
||||
rotZ *= RAD_PER_DEGREE
|
||||
|
||||
let c = Math.cos(rotZ)
|
||||
let s = Math.sin(rotZ)
|
||||
|
||||
this[VALUES] = multiply([c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
c = Math.cos(rotY)
|
||||
s = Math.sin(rotY)
|
||||
|
||||
this[VALUES] = multiply([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
c = Math.cos(rotX)
|
||||
s = Math.sin(rotX)
|
||||
|
||||
this[VALUES] = multiply([1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
rotateAxisAngle(x, y, z, angle) {
|
||||
return newInstance(this[VALUES]).rotateAxisAngleSelf(x, y, z, angle)
|
||||
}
|
||||
|
||||
rotateAxisAngleSelf(x = 0, y = 0, z = 0, angle = 0) {
|
||||
const length = Math.sqrt(x * x + y * y + z * z)
|
||||
|
||||
if (length === 0) {
|
||||
return this
|
||||
}
|
||||
|
||||
if (length !== 1) {
|
||||
x /= length
|
||||
y /= length
|
||||
z /= length
|
||||
}
|
||||
|
||||
angle *= RAD_PER_DEGREE
|
||||
|
||||
const c = Math.cos(angle)
|
||||
const s = Math.sin(angle)
|
||||
const t = 1 - c
|
||||
const tx = t * x
|
||||
const ty = t * y
|
||||
|
||||
this[VALUES] = multiply(
|
||||
[
|
||||
tx * x + c,
|
||||
tx * y + s * z,
|
||||
tx * z - s * y,
|
||||
0,
|
||||
tx * y - s * z,
|
||||
ty * y + c,
|
||||
ty * z + s * x,
|
||||
0,
|
||||
tx * z + s * y,
|
||||
ty * z - s * x,
|
||||
t * z * z + c,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
],
|
||||
this[VALUES],
|
||||
)
|
||||
|
||||
if (x !== 0 || y !== 0) {
|
||||
this[IS_2D] = false
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
skewX(sx) {
|
||||
return newInstance(this[VALUES]).skewXSelf(sx)
|
||||
}
|
||||
|
||||
skewXSelf(sx) {
|
||||
if (typeof sx !== 'number') {
|
||||
return this
|
||||
}
|
||||
|
||||
const t = Math.tan(sx * RAD_PER_DEGREE)
|
||||
|
||||
this[VALUES] = multiply([1, 0, 0, 0, t, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
skewY(sy) {
|
||||
return newInstance(this[VALUES]).skewYSelf(sy)
|
||||
}
|
||||
|
||||
skewYSelf(sy) {
|
||||
if (typeof sy !== 'number') {
|
||||
return this
|
||||
}
|
||||
|
||||
const t = Math.tan(sy * RAD_PER_DEGREE)
|
||||
|
||||
this[VALUES] = multiply([1, t, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
flipX() {
|
||||
return newInstance(multiply([-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES]))
|
||||
}
|
||||
|
||||
flipY() {
|
||||
return newInstance(multiply([1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES]))
|
||||
}
|
||||
|
||||
inverse() {
|
||||
return newInstance(this[VALUES].slice()).invertSelf()
|
||||
}
|
||||
|
||||
invertSelf() {
|
||||
if (this[IS_2D]) {
|
||||
const det = this[VALUES][A] * this[VALUES][D] - this[VALUES][B] * this[VALUES][C]
|
||||
|
||||
// Invertable
|
||||
if (det !== 0) {
|
||||
const newA = this[VALUES][D] / det
|
||||
const newB = -this[VALUES][B] / det
|
||||
const newC = -this[VALUES][C] / det
|
||||
const newD = this[VALUES][A] / det
|
||||
const newE = (this[VALUES][C] * this[VALUES][F] - this[VALUES][D] * this[VALUES][E]) / det
|
||||
const newF = (this[VALUES][B] * this[VALUES][E] - this[VALUES][A] * this[VALUES][F]) / det
|
||||
|
||||
this.a = newA
|
||||
this.b = newB
|
||||
this.c = newC
|
||||
this.d = newD
|
||||
this.e = newE
|
||||
this.f = newF
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
// Not invertable
|
||||
else {
|
||||
this[IS_2D] = false
|
||||
|
||||
this[VALUES] = [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]
|
||||
|
||||
return this
|
||||
}
|
||||
} else {
|
||||
throw new Error('3D matrix inversion is not implemented.')
|
||||
}
|
||||
}
|
||||
|
||||
setMatrixValue(transformList) {
|
||||
const temp = new DOMMatrix(transformList)
|
||||
|
||||
this[VALUES] = temp[VALUES]
|
||||
this[IS_2D] = temp[IS_2D]
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
transformPoint(point) {
|
||||
const x = point.x || 0
|
||||
const y = point.y || 0
|
||||
const z = point.z || 0
|
||||
const w = point.w || 1
|
||||
|
||||
const values = this[VALUES]
|
||||
|
||||
const nx = values[M11] * x + values[M21] * y + values[M31] * z + values[M41] * w
|
||||
const ny = values[M12] * x + values[M22] * y + values[M32] * z + values[M42] * w
|
||||
const nz = values[M13] * x + values[M23] * y + values[M33] * z + values[M43] * w
|
||||
const nw = values[M14] * x + values[M24] * y + values[M34] * z + values[M44] * w
|
||||
|
||||
return new DOMPoint(nx, ny, nz, nw)
|
||||
}
|
||||
|
||||
toFloat32Array() {
|
||||
return Float32Array.from(this[VALUES])
|
||||
}
|
||||
|
||||
toFloat64Array() {
|
||||
return this[VALUES].slice(0)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
a: this.a,
|
||||
b: this.b,
|
||||
c: this.c,
|
||||
d: this.d,
|
||||
e: this.e,
|
||||
f: this.f,
|
||||
m11: this.m11,
|
||||
m12: this.m12,
|
||||
m13: this.m13,
|
||||
m14: this.m14,
|
||||
m21: this.m21,
|
||||
m22: this.m22,
|
||||
m23: this.m23,
|
||||
m24: this.m24,
|
||||
m31: this.m31,
|
||||
m32: this.m32,
|
||||
m33: this.m33,
|
||||
m34: this.m34,
|
||||
m41: this.m41,
|
||||
m42: this.m42,
|
||||
m43: this.m43,
|
||||
m44: this.m44,
|
||||
is2D: this.is2D,
|
||||
isIdentity: this.isIdentity,
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
if (this.is2D) {
|
||||
return `matrix(${this.a}, ${this.b}, ${this.c}, ${this.d}, ${this.e}, ${this.f})`
|
||||
} else {
|
||||
return `matrix3d(${this[VALUES].join(', ')})`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const propertyName of [
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'm11',
|
||||
'm12',
|
||||
'm13',
|
||||
'm14',
|
||||
'm21',
|
||||
'm22',
|
||||
'm23',
|
||||
'm24',
|
||||
'm31',
|
||||
'm32',
|
||||
'm33',
|
||||
'm34',
|
||||
'm41',
|
||||
'm42',
|
||||
'm43',
|
||||
'm44',
|
||||
'is2D',
|
||||
'isIdentity',
|
||||
]) {
|
||||
const propertyDescriptor = Object.getOwnPropertyDescriptor(DOMMatrix.prototype, propertyName)
|
||||
propertyDescriptor.enumerable = true
|
||||
Object.defineProperty(DOMMatrix.prototype, propertyName, propertyDescriptor)
|
||||
}
|
||||
|
||||
module.exports = { DOMPoint, DOMMatrix, DOMRect }
|
||||
+1097
File diff suppressed because it is too large.
Load diff
+196
@@ -0,0 +1,196 @@
|
||||
const { platform, homedir } = require('os')
|
||||
const { join } = require('path')
|
||||
|
||||
const {
|
||||
clearAllCache,
|
||||
CanvasRenderingContext2D,
|
||||
CanvasElement,
|
||||
SVGCanvas,
|
||||
Path: Path2D,
|
||||
ImageData,
|
||||
Image,
|
||||
FontKey,
|
||||
GlobalFonts,
|
||||
PathOp,
|
||||
FillType,
|
||||
StrokeJoin,
|
||||
StrokeCap,
|
||||
convertSVGTextToPath,
|
||||
PdfDocument,
|
||||
GifEncoder,
|
||||
GifDisposal,
|
||||
LottieAnimation,
|
||||
} = require('./js-binding')
|
||||
|
||||
const { DOMPoint, DOMMatrix, DOMRect } = require('./geometry')
|
||||
|
||||
const loadImage = require('./load-image')
|
||||
|
||||
// Add Symbol.dispose support for GifEncoder (ECMAScript 2024 Explicit Resource Management)
|
||||
if (GifEncoder && typeof Symbol.dispose !== 'undefined') {
|
||||
GifEncoder.prototype[Symbol.dispose] = function () {
|
||||
this.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const SvgExportFlag = {
|
||||
ConvertTextToPaths: 0x01,
|
||||
NoPrettyXML: 0x02,
|
||||
RelativePathEncoding: 0x04,
|
||||
}
|
||||
|
||||
if (!('families' in GlobalFonts)) {
|
||||
Object.defineProperty(GlobalFonts, 'families', {
|
||||
get: function () {
|
||||
return JSON.parse(GlobalFonts.getFamilies().toString())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!('has' in GlobalFonts)) {
|
||||
Object.defineProperty(GlobalFonts, 'has', {
|
||||
value: function has(name) {
|
||||
return !!JSON.parse(GlobalFonts.getFamilies().toString()).find(({ family }) => family === name)
|
||||
},
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
})
|
||||
}
|
||||
|
||||
const _toBlob = CanvasElement.prototype.toBlob
|
||||
const _convertToBlob = CanvasElement.prototype.convertToBlob
|
||||
if ('Blob' in globalThis) {
|
||||
CanvasElement.prototype.toBlob = function toBlob(callback, mimeType, quality) {
|
||||
_toBlob.call(
|
||||
this,
|
||||
function (/** @type {Uint8Array} */ imageBuffer) {
|
||||
const blob = new Blob([imageBuffer.buffer], { type: mimeType })
|
||||
callback(blob)
|
||||
},
|
||||
mimeType,
|
||||
quality,
|
||||
)
|
||||
}
|
||||
CanvasElement.prototype.convertToBlob = function convertToBlob(options) {
|
||||
return _convertToBlob.call(this, options).then((/** @type {Uint8Array} */ imageBuffer) => {
|
||||
const blob = new Blob([imageBuffer.buffer], { type: options?.mime || 'image/png' })
|
||||
return blob
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
CanvasElement.prototype.toBlob = function toBlob(callback, mimeType, quality) {
|
||||
callback(null)
|
||||
}
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
CanvasElement.prototype.convertToBlob = function convertToBlob(options) {
|
||||
return Promise.reject(new Error('Blob is not supported in this environment'))
|
||||
}
|
||||
}
|
||||
|
||||
const _getTransform = CanvasRenderingContext2D.prototype.getTransform
|
||||
|
||||
CanvasRenderingContext2D.prototype.getTransform = function getTransform() {
|
||||
const transform = _getTransform.apply(this, arguments)
|
||||
// monkey patched, skip
|
||||
if (transform instanceof DOMMatrix) {
|
||||
return transform
|
||||
}
|
||||
const { a, b, c, d, e, f } = transform
|
||||
return new DOMMatrix([a, b, c, d, e, f])
|
||||
}
|
||||
|
||||
// Workaround for webpack bundling issue with drawImage
|
||||
// Store the original drawImage method
|
||||
const _drawImage = CanvasRenderingContext2D.prototype.drawImage
|
||||
|
||||
// Override drawImage to ensure proper type recognition in bundled environments
|
||||
CanvasRenderingContext2D.prototype.drawImage = function drawImage(image, ...args) {
|
||||
// If the image is a Canvas-like object but not recognized due to bundling,
|
||||
// we need to ensure it's properly identified
|
||||
if (image && typeof image === 'object') {
|
||||
// First check if it's a wrapped canvas object
|
||||
if (image.canvas instanceof CanvasElement || image.canvas instanceof SVGCanvas) {
|
||||
image = image.canvas
|
||||
} else if (image._canvas instanceof CanvasElement || image._canvas instanceof SVGCanvas) {
|
||||
image = image._canvas
|
||||
}
|
||||
// Then check if it's a Canvas-like object by checking for getContext method
|
||||
else if (typeof image.getContext === 'function' && image.width && image.height) {
|
||||
// If it has canvas properties but isn't recognized as CanvasElement or SVGCanvas,
|
||||
// try to correct the prototype chain
|
||||
if (!(image instanceof CanvasElement) && !(image instanceof SVGCanvas)) {
|
||||
// Try to create a proper CanvasElement from the canvas-like object
|
||||
// This helps when webpack has transformed the prototype chain
|
||||
Object.setPrototypeOf(image, CanvasElement.prototype)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call the original drawImage with the potentially corrected image
|
||||
return _drawImage.apply(this, [image, ...args])
|
||||
}
|
||||
|
||||
function createCanvas(width, height, flag) {
|
||||
const isSvgBackend = typeof flag !== 'undefined'
|
||||
return isSvgBackend ? new SVGCanvas(width, height, flag) : new CanvasElement(width, height)
|
||||
}
|
||||
|
||||
class Canvas {
|
||||
constructor(width, height, flag) {
|
||||
return createCanvas(width, height, flag)
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance](instance) {
|
||||
return instance instanceof CanvasElement || instance instanceof SVGCanvas
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.DISABLE_SYSTEM_FONTS_LOAD) {
|
||||
GlobalFonts.loadSystemFonts()
|
||||
const platformName = platform()
|
||||
const homedirPath = homedir()
|
||||
switch (platformName) {
|
||||
case 'win32':
|
||||
GlobalFonts.loadFontsFromDir(join(homedirPath, 'AppData', 'Local', 'Microsoft', 'Windows', 'Fonts'))
|
||||
break
|
||||
case 'darwin':
|
||||
GlobalFonts.loadFontsFromDir(join(homedirPath, 'Library', 'Fonts'))
|
||||
break
|
||||
case 'linux':
|
||||
GlobalFonts.loadFontsFromDir(join('usr', 'local', 'share', 'fonts'))
|
||||
GlobalFonts.loadFontsFromDir(join(homedirPath, '.fonts'))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clearAllCache,
|
||||
Canvas,
|
||||
createCanvas,
|
||||
Path2D,
|
||||
ImageData,
|
||||
Image,
|
||||
PathOp,
|
||||
FillType,
|
||||
StrokeCap,
|
||||
StrokeJoin,
|
||||
SvgExportFlag,
|
||||
GlobalFonts: GlobalFonts,
|
||||
convertSVGTextToPath,
|
||||
DOMPoint,
|
||||
DOMMatrix,
|
||||
DOMRect,
|
||||
loadImage,
|
||||
FontKey,
|
||||
// Export these for better webpack compatibility
|
||||
CanvasElement,
|
||||
SVGCanvas,
|
||||
PDFDocument: PdfDocument,
|
||||
// GIF encoding
|
||||
GifEncoder,
|
||||
GifDisposal,
|
||||
// Lottie animation
|
||||
LottieAnimation,
|
||||
}
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
// prettier-ignore
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
/* auto-generated by NAPI-RS */
|
||||
|
||||
const { readFileSync } = require('fs')
|
||||
let nativeBinding = null
|
||||
const loadErrors = []
|
||||
|
||||
const isMusl = () => {
|
||||
let musl = false
|
||||
if (process.platform === 'linux') {
|
||||
musl = isMuslFromFilesystem()
|
||||
if (musl === null) {
|
||||
musl = isMuslFromReport()
|
||||
}
|
||||
if (musl === null) {
|
||||
musl = isMuslFromChildProcess()
|
||||
}
|
||||
}
|
||||
return musl
|
||||
}
|
||||
|
||||
const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-')
|
||||
|
||||
const isMuslFromFilesystem = () => {
|
||||
try {
|
||||
return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const isMuslFromReport = () => {
|
||||
let report = null
|
||||
if (typeof process.report?.getReport === 'function') {
|
||||
process.report.excludeNetwork = true
|
||||
report = process.report.getReport()
|
||||
}
|
||||
if (!report) {
|
||||
return null
|
||||
}
|
||||
if (report.header && report.header.glibcVersionRuntime) {
|
||||
return false
|
||||
}
|
||||
if (Array.isArray(report.sharedObjects)) {
|
||||
if (report.sharedObjects.some(isFileMusl)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isMuslFromChildProcess = () => {
|
||||
try {
|
||||
return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl')
|
||||
} catch (e) {
|
||||
// If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function requireNative() {
|
||||
if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) {
|
||||
try {
|
||||
return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH)
|
||||
} catch (err) {
|
||||
loadErrors.push(err)
|
||||
}
|
||||
} else if (process.platform === 'android') {
|
||||
if (process.arch === 'arm64') {
|
||||
try {
|
||||
return require('./skia.android-arm64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-android-arm64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 'arm') {
|
||||
try {
|
||||
return require('./skia.android-arm-eabi.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-android-arm-eabi')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported architecture on Android ${process.arch}`))
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
if (process.arch === 'x64') {
|
||||
if (
|
||||
process.config?.variables?.shlib_suffix === 'dll.a' ||
|
||||
process.config?.variables?.node_target_type === 'shared_library'
|
||||
) {
|
||||
try {
|
||||
return require('./skia.win32-x64-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-win32-x64-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return require('./skia.win32-x64-msvc.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-win32-x64-msvc')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
}
|
||||
} else if (process.arch === 'ia32') {
|
||||
try {
|
||||
return require('./skia.win32-ia32-msvc.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-win32-ia32-msvc')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 'arm64') {
|
||||
try {
|
||||
return require('./skia.win32-arm64-msvc.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-win32-arm64-msvc')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported architecture on Windows: ${process.arch}`))
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
try {
|
||||
return require('./skia.darwin-universal.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-darwin-universal')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
if (process.arch === 'x64') {
|
||||
try {
|
||||
return require('./skia.darwin-x64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-darwin-x64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 'arm64') {
|
||||
try {
|
||||
return require('./skia.darwin-arm64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-darwin-arm64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported architecture on macOS: ${process.arch}`))
|
||||
}
|
||||
} else if (process.platform === 'freebsd') {
|
||||
if (process.arch === 'x64') {
|
||||
try {
|
||||
return require('./skia.freebsd-x64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-freebsd-x64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 'arm64') {
|
||||
try {
|
||||
return require('./skia.freebsd-arm64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-freebsd-arm64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`))
|
||||
}
|
||||
} else if (process.platform === 'linux') {
|
||||
if (process.arch === 'x64') {
|
||||
if (isMusl()) {
|
||||
try {
|
||||
return require('./skia.linux-x64-musl.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-x64-musl')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return require('./skia.linux-x64-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-x64-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
}
|
||||
} else if (process.arch === 'arm64') {
|
||||
if (isMusl()) {
|
||||
try {
|
||||
return require('./skia.linux-arm64-musl.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-arm64-musl')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return require('./skia.linux-arm64-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-arm64-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
}
|
||||
} else if (process.arch === 'arm') {
|
||||
if (isMusl()) {
|
||||
try {
|
||||
return require('./skia.linux-arm-musleabihf.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-arm-musleabihf')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return require('./skia.linux-arm-gnueabihf.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-arm-gnueabihf')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
}
|
||||
} else if (process.arch === 'loong64') {
|
||||
if (isMusl()) {
|
||||
try {
|
||||
return require('./skia.linux-loong64-musl.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-loong64-musl')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return require('./skia.linux-loong64-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-loong64-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
}
|
||||
} else if (process.arch === 'riscv64') {
|
||||
if (isMusl()) {
|
||||
try {
|
||||
return require('./skia.linux-riscv64-musl.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-riscv64-musl')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
return require('./skia.linux-riscv64-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-riscv64-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
}
|
||||
} else if (process.arch === 'ppc64') {
|
||||
try {
|
||||
return require('./skia.linux-ppc64-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-ppc64-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 's390x') {
|
||||
try {
|
||||
return require('./skia.linux-s390x-gnu.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-linux-s390x-gnu')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported architecture on Linux: ${process.arch}`))
|
||||
}
|
||||
} else if (process.platform === 'openharmony') {
|
||||
if (process.arch === 'arm64') {
|
||||
try {
|
||||
return require('./skia.openharmony-arm64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-openharmony-arm64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 'x64') {
|
||||
try {
|
||||
return require('./skia.openharmony-x64.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
return require('@napi-rs/canvas-openharmony-x64')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else if (process.arch === 'arm') {
|
||||
try {
|
||||
return require('./skia.openharmony-arm.node')
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
try {
|
||||
const binding = require('@napi-rs/canvas-openharmony-arm')
|
||||
return binding
|
||||
} catch (e) {
|
||||
loadErrors.push(e)
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported architecture on OpenHarmony: ${process.arch}`))
|
||||
}
|
||||
} else {
|
||||
loadErrors.push(new Error(`Unsupported OS: ${process.platform}, architecture: ${process.arch}`))
|
||||
}
|
||||
}
|
||||
|
||||
nativeBinding = requireNative()
|
||||
|
||||
if (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) {
|
||||
let wasiBinding = null
|
||||
let wasiBindingError = null
|
||||
try {
|
||||
wasiBinding = require('./skia.wasi.cjs')
|
||||
nativeBinding = wasiBinding
|
||||
} catch (err) {
|
||||
if (process.env.NAPI_RS_FORCE_WASI) {
|
||||
wasiBindingError = err
|
||||
}
|
||||
}
|
||||
if (!nativeBinding) {
|
||||
try {
|
||||
wasiBinding = require('@napi-rs/canvas-wasm32-wasi')
|
||||
nativeBinding = wasiBinding
|
||||
} catch (err) {
|
||||
if (process.env.NAPI_RS_FORCE_WASI) {
|
||||
wasiBindingError.cause = err
|
||||
loadErrors.push(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!nativeBinding) {
|
||||
if (loadErrors.length > 0) {
|
||||
throw new Error(
|
||||
`Cannot find native binding. ` +
|
||||
`npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` +
|
||||
'Please try `npm i` again after removing both package-lock.json and node_modules directory.',
|
||||
{
|
||||
cause: loadErrors.reduce((err, cur) => {
|
||||
cur.cause = err
|
||||
return cur
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
module.exports = nativeBinding
|
||||
module.exports.GlobalFonts = nativeBinding.GlobalFonts
|
||||
module.exports.CanvasElement = nativeBinding.CanvasElement
|
||||
module.exports.CanvasGradient = nativeBinding.CanvasGradient
|
||||
module.exports.CanvasPattern = nativeBinding.CanvasPattern
|
||||
module.exports.CanvasRenderingContext2D = nativeBinding.CanvasRenderingContext2D
|
||||
module.exports.FontKey = nativeBinding.FontKey
|
||||
module.exports.Image = nativeBinding.Image
|
||||
module.exports.ImageData = nativeBinding.ImageData
|
||||
module.exports.Path = nativeBinding.Path
|
||||
module.exports.PdfDocument = nativeBinding.PdfDocument
|
||||
module.exports.SVGCanvas = nativeBinding.SVGCanvas
|
||||
module.exports.ChromaSubsampling = nativeBinding.ChromaSubsampling
|
||||
module.exports.clearAllCache = nativeBinding.clearAllCache
|
||||
module.exports.convertSVGTextToPath = nativeBinding.convertSVGTextToPath
|
||||
module.exports.FillType = nativeBinding.FillType
|
||||
module.exports.PathOp = nativeBinding.PathOp
|
||||
module.exports.StrokeCap = nativeBinding.StrokeCap
|
||||
module.exports.StrokeJoin = nativeBinding.StrokeJoin
|
||||
module.exports.SvgExportFlag = nativeBinding.SvgExportFlag
|
||||
module.exports.GifEncoder = nativeBinding.GifEncoder
|
||||
module.exports.GifDisposal = nativeBinding.GifDisposal
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
const fs = require('fs')
|
||||
const { Readable } = require('stream')
|
||||
const { URL } = require('url')
|
||||
|
||||
const { Image } = require('./js-binding')
|
||||
|
||||
let http, https
|
||||
|
||||
const MAX_REDIRECTS = 20
|
||||
const REDIRECT_STATUSES = new Set([301, 302])
|
||||
|
||||
/**
|
||||
* Loads the given source into canvas Image
|
||||
* @param {string|URL|Image|Buffer} source The image source to be loaded
|
||||
* @param {object} options Options passed to the loader
|
||||
*/
|
||||
module.exports = async function loadImage(source, options = {}) {
|
||||
// use the same buffer without copying if the source is a buffer
|
||||
if (Buffer.isBuffer(source) || source instanceof Uint8Array) return createImage(source, options.alt)
|
||||
// load readable stream as image
|
||||
if (source instanceof Readable) return createImage(await consumeStream(source), options.alt)
|
||||
// construct a Uint8Array if the source is ArrayBuffer or SharedArrayBuffer
|
||||
if (source instanceof ArrayBuffer || source instanceof SharedArrayBuffer)
|
||||
return createImage(new Uint8Array(source), options.alt)
|
||||
// construct a buffer if the source is buffer-like
|
||||
if (isBufferLike(source)) return createImage(Buffer.from(source), options.alt)
|
||||
// if the source is Image instance, copy the image src to new image
|
||||
if (source instanceof Image) return createImage(source.src, options.alt)
|
||||
// if source is string and in data uri format, construct image using data uri
|
||||
if (typeof source === 'string' && source.trimStart().startsWith('data:')) {
|
||||
const commaIdx = source.indexOf(',')
|
||||
const encoding = source.lastIndexOf('base64', commaIdx) < 0 ? 'utf-8' : 'base64'
|
||||
const data = Buffer.from(source.slice(commaIdx + 1), encoding)
|
||||
// Empty payload would silently complete on Image (HTML spec) and the loadImage
|
||||
// promise would never settle; reject upfront instead.
|
||||
// See https://github.com/Brooooooklyn/canvas/issues/1255
|
||||
if (data.length === 0) throw new Error(`Invalid data URI: empty payload in ${source.slice(0, 64)}`)
|
||||
return createImage(data, options.alt)
|
||||
}
|
||||
// if source is a string or URL instance
|
||||
if (typeof source === 'string') {
|
||||
// if the source exists as a file, construct image from that file
|
||||
if (!source.startsWith('http') && !source.startsWith('https') && (await exists(source))) {
|
||||
return createImage(source, options.alt)
|
||||
} else {
|
||||
// the source is a remote url here
|
||||
source = new URL(source)
|
||||
// attempt to download the remote source and construct image
|
||||
const data = await new Promise((resolve, reject) =>
|
||||
makeRequest(
|
||||
source,
|
||||
resolve,
|
||||
reject,
|
||||
typeof options.maxRedirects === 'number' && options.maxRedirects >= 0 ? options.maxRedirects : MAX_REDIRECTS,
|
||||
options.requestOptions,
|
||||
),
|
||||
)
|
||||
return createImage(data, options.alt)
|
||||
}
|
||||
}
|
||||
|
||||
if (source instanceof URL) {
|
||||
if (source.protocol === 'file:') {
|
||||
// remove the leading slash on windows
|
||||
return createImage(process.platform === 'win32' ? source.pathname.substring(1) : source.pathname, options.alt)
|
||||
} else {
|
||||
const data = await new Promise((resolve, reject) =>
|
||||
makeRequest(
|
||||
source,
|
||||
resolve,
|
||||
reject,
|
||||
typeof options.maxRedirects === 'number' && options.maxRedirects >= 0 ? options.maxRedirects : MAX_REDIRECTS,
|
||||
options.requestOptions,
|
||||
),
|
||||
)
|
||||
return createImage(data, options.alt)
|
||||
}
|
||||
}
|
||||
|
||||
// throw error as don't support that source
|
||||
throw new TypeError('unsupported image source')
|
||||
}
|
||||
|
||||
function makeRequest(url, resolve, reject, redirectCount, requestOptions) {
|
||||
const isHttps = url.protocol === 'https:'
|
||||
// lazy load the lib
|
||||
const lib = isHttps ? (!https ? (https = require('https')) : https) : !http ? (http = require('http')) : http
|
||||
|
||||
lib
|
||||
.get(url.toString(), requestOptions || {}, (res) => {
|
||||
try {
|
||||
const shouldRedirect = REDIRECT_STATUSES.has(res.statusCode) && typeof res.headers.location === 'string'
|
||||
if (shouldRedirect && redirectCount > 0)
|
||||
return makeRequest(
|
||||
new URL(res.headers.location, url.origin),
|
||||
resolve,
|
||||
reject,
|
||||
redirectCount - 1,
|
||||
requestOptions,
|
||||
)
|
||||
if (typeof res.statusCode === 'number' && (res.statusCode < 200 || res.statusCode >= 300)) {
|
||||
return reject(new Error(`remote source rejected with status code ${res.statusCode}`))
|
||||
}
|
||||
|
||||
consumeStream(res).then(resolve, reject)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
.on('error', reject)
|
||||
}
|
||||
|
||||
// use stream/consumers in the future?
|
||||
function consumeStream(res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = []
|
||||
|
||||
res.on('data', (chunk) => chunks.push(chunk))
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
res.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function createImage(src, alt) {
|
||||
// Empty Buffer / Uint8Array: Image follows the HTML spec (silent completion,
|
||||
// no events) so the promise below would hang. Reject upfront.
|
||||
// See https://github.com/Brooooooklyn/canvas/issues/1255
|
||||
if ((Buffer.isBuffer(src) || src instanceof Uint8Array) && src.length === 0) {
|
||||
throw new Error('loadImage: empty image data')
|
||||
}
|
||||
|
||||
const image = new Image()
|
||||
if (typeof alt === 'string') image.alt = alt
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
image.onload = () => {
|
||||
// Wait for bitmap decode before resolving. SVG sources fire onload
|
||||
// synchronously inside the src setter, where napi >= 3.12 rejects
|
||||
// reentrant &mut borrows of the Image, so decode on a microtask.
|
||||
Promise.resolve()
|
||||
.then(() => image.decode())
|
||||
.then(() => resolve(image), reject)
|
||||
}
|
||||
image.onerror = (e) => reject(e)
|
||||
image.src = src
|
||||
})
|
||||
}
|
||||
|
||||
function isBufferLike(src) {
|
||||
return (src && src.type === 'Buffer') || Array.isArray(src)
|
||||
}
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await fs.promises.access(path, fs.constants.F_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* node-canvas compatibility layer for @napi-rs/canvas.
|
||||
*
|
||||
* Provides a drop-in replacement API matching the `canvas` (node-canvas) npm package.
|
||||
* Quality values for JPEG/WebP use the node-canvas 0-1 scale and are automatically
|
||||
* converted to @napi-rs/canvas's 0-100 scale internally.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Drop-in replacement: change this import
|
||||
* // import { createCanvas, registerFont, loadImage } from 'canvas'
|
||||
* // to this:
|
||||
* import { createCanvas, registerFont, loadImage } from '@napi-rs/canvas/node-canvas'
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
// Re-export types from @napi-rs/canvas that are API-compatible
|
||||
import {
|
||||
Image as NapiImage,
|
||||
ImageData as NapiImageData,
|
||||
Path2D as NapiPath2D,
|
||||
DOMPoint as NapiDOMPoint,
|
||||
DOMMatrix as NapiDOMMatrix,
|
||||
DOMRect as NapiDOMRect,
|
||||
SKRSContext2D,
|
||||
Canvas as NapiCanvas,
|
||||
SvgCanvas,
|
||||
SvgExportFlag,
|
||||
IGlobalFonts,
|
||||
PDFDocument,
|
||||
} from './index'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config interfaces (node-canvas conventions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PngConfig {
|
||||
/** ZLIB compression level (0-9). Accepted for compatibility; Skia uses its own encoder. */
|
||||
compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
/** PNG filter flags. Accepted for compatibility; Skia uses its own encoder. */
|
||||
filters?: number
|
||||
/** Palette for indexed PNGs. Not supported by Skia backend. */
|
||||
palette?: Uint8ClampedArray
|
||||
/** Background color index for indexed PNGs. Not supported by Skia backend. */
|
||||
backgroundIndex?: number
|
||||
/** Pixels per inch. Accepted for compatibility. */
|
||||
resolution?: number
|
||||
}
|
||||
|
||||
export interface JpegConfig {
|
||||
/** Quality between 0 and 1. Defaults to 0.75. Converted to 0-100 for Skia. */
|
||||
quality?: number
|
||||
/** Progressive encoding. Accepted for compatibility; not supported by Skia backend. */
|
||||
progressive?: boolean
|
||||
/** 2x2 chroma subsampling. Accepted for compatibility; not supported by Skia backend. */
|
||||
chromaSubsampling?: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canvas class (with node-canvas compatible methods)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Canvas extends Omit<NapiCanvas, 'toBuffer' | 'toDataURL'> {
|
||||
/**
|
||||
* Encode the canvas as a PNG and return a Node.js Readable stream.
|
||||
* @param config PNG encoding options (accepted for compatibility)
|
||||
*/
|
||||
createPNGStream(config?: PngConfig): PNGStream
|
||||
|
||||
/**
|
||||
* Encode the canvas as a JPEG and return a Node.js Readable stream.
|
||||
* @param config JPEG encoding options. Quality is 0-1 (default 0.75).
|
||||
*/
|
||||
createJPEGStream(config?: JpegConfig): JPEGStream
|
||||
|
||||
// --- toBuffer overloads (node-canvas conventions) ---
|
||||
|
||||
/** Encode as PNG (default). */
|
||||
toBuffer(): Buffer
|
||||
toBuffer(mimeType: 'image/png', config?: PngConfig): Buffer
|
||||
/** Encode as JPEG. Quality in config is 0-1 (default 0.75). */
|
||||
toBuffer(mimeType: 'image/jpeg', config?: JpegConfig): Buffer
|
||||
/** Get raw unencoded pixel data. */
|
||||
toBuffer(mimeType: 'raw'): Buffer
|
||||
|
||||
/** Async: encode as PNG (default) via callback. */
|
||||
toBuffer(cb: (err: Error | null, result: Buffer) => void): void
|
||||
toBuffer(cb: (err: Error | null, result: Buffer) => void, mimeType: 'image/png', config?: PngConfig): void
|
||||
/** Async: encode as JPEG via callback. Quality in config is 0-1 (default 0.75). */
|
||||
toBuffer(cb: (err: Error | null, result: Buffer) => void, mimeType: 'image/jpeg', config?: JpegConfig): void
|
||||
|
||||
// --- toDataURL overloads (node-canvas conventions) ---
|
||||
|
||||
/** Encode as PNG data URL (default). */
|
||||
toDataURL(): string
|
||||
toDataURL(mimeType: 'image/png'): string
|
||||
/** Encode as JPEG data URL. Quality is 0-1. */
|
||||
toDataURL(mimeType: 'image/jpeg', quality?: number): string
|
||||
/** Async: encode as data URL via callback. */
|
||||
toDataURL(cb: (err: Error | null, result: string) => void): void
|
||||
toDataURL(mimeType: 'image/png', cb: (err: Error | null, result: string) => void): void
|
||||
toDataURL(mimeType: 'image/jpeg', cb: (err: Error | null, result: string) => void): void
|
||||
toDataURL(mimeType: 'image/jpeg', quality: number, cb: (err: Error | null, result: string) => void): void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream classes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Readable stream that emits PNG-encoded data. */
|
||||
export class PNGStream extends Readable {}
|
||||
|
||||
/** Readable stream that emits JPEG-encoded data. */
|
||||
export class JPEGStream extends Readable {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new canvas with node-canvas compatible API.
|
||||
* Canvas instances created through this function have `createPNGStream()`,
|
||||
* `createJPEGStream()`, and node-canvas compatible `toBuffer()` overloads.
|
||||
*
|
||||
* When `type` is `'svg'`, returns a `SvgCanvas` which produces vector output
|
||||
* via `getContent()` instead of raster encoding methods.
|
||||
*
|
||||
* @param width Canvas width in pixels
|
||||
* @param height Canvas height in pixels
|
||||
* @param type Canvas type: 'image' (default) or 'svg'
|
||||
*/
|
||||
export function createCanvas(width: number, height: number, type: 'svg'): SvgCanvas
|
||||
export function createCanvas(width: number, height: number, type?: 'image'): Canvas
|
||||
|
||||
/**
|
||||
* Create an ImageData instance.
|
||||
* @param data Pixel data array
|
||||
* @param width Width in pixels
|
||||
* @param height Height in pixels (calculated from data length if omitted)
|
||||
*/
|
||||
export function createImageData(data: Uint8ClampedArray, width: number, height?: number): NapiImageData
|
||||
export function createImageData(width: number, height: number): NapiImageData
|
||||
|
||||
/**
|
||||
* Load an image from a file path, URL, Buffer, or other source.
|
||||
* Returns a Promise that resolves to an Image instance.
|
||||
*/
|
||||
export function loadImage(
|
||||
source: string | URL | Buffer | ArrayBufferLike | Uint8Array | NapiImage | import('stream').Readable,
|
||||
options?: { alt?: string; maxRedirects?: number; requestOptions?: import('http').RequestOptions },
|
||||
): Promise<NapiImage>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font registration (node-canvas convention)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register a font file for use in canvas text rendering.
|
||||
* Compatible with node-canvas's `registerFont()`.
|
||||
*
|
||||
* Note: @napi-rs/canvas auto-detects weight and style from font file metadata
|
||||
* (matching browser behavior). The `weight` and `style` properties in fontFace
|
||||
* are accepted for API compatibility.
|
||||
*
|
||||
* @param path Absolute path to the font file (.ttf, .otf, etc.)
|
||||
* @param fontFace Font face properties matching CSS @font-face descriptors
|
||||
*/
|
||||
export function registerFont(path: string, fontFace: { family: string; weight?: string; style?: string }): void
|
||||
|
||||
/**
|
||||
* Deregister all previously registered fonts.
|
||||
* Compatible with node-canvas's `deregisterAllFonts()`.
|
||||
*/
|
||||
export function deregisterAllFonts(): void
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exported classes & values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Canvas is exported both as a type (the compat interface above) and as a
|
||||
// value (constructor that returns compat Canvas instances at runtime).
|
||||
export declare const Canvas: {
|
||||
new (width: number, height: number): Canvas
|
||||
new (width: number, height: number, flag: SvgExportFlag): SvgCanvas
|
||||
prototype: NapiCanvas
|
||||
}
|
||||
|
||||
export { NapiImage as Image }
|
||||
export { NapiImageData as ImageData }
|
||||
export { NapiPath2D as Path2D }
|
||||
export { NapiDOMPoint as DOMPoint }
|
||||
export { NapiDOMMatrix as DOMMatrix }
|
||||
export { NapiDOMRect as DOMRect }
|
||||
|
||||
// SKRSContext2D is an interface in index.d.ts but a class value at runtime.
|
||||
// Declare as const to export the value for instanceof checks and re-export the type.
|
||||
export type CanvasRenderingContext2D = SKRSContext2D
|
||||
export declare const CanvasRenderingContext2D: { prototype: SKRSContext2D }
|
||||
/** Legacy alias for CanvasRenderingContext2D. */
|
||||
export type Context2d = SKRSContext2D
|
||||
export declare const Context2d: { prototype: SKRSContext2D }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// @napi-rs/canvas extras (not part of node-canvas, but useful)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { IGlobalFonts }
|
||||
export declare const GlobalFonts: IGlobalFonts & {
|
||||
/** Reload system fonts. Available at runtime but not declared in IGlobalFonts. */
|
||||
loadSystemFonts(): number
|
||||
}
|
||||
export { PDFDocument }
|
||||
export { NapiCanvas as CanvasElement }
|
||||
export { SvgCanvas as SVGCanvas }
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
'use strict'
|
||||
|
||||
const { Readable } = require('node:stream')
|
||||
|
||||
const {
|
||||
createCanvas: _createCanvas,
|
||||
Canvas,
|
||||
CanvasElement,
|
||||
SVGCanvas,
|
||||
GlobalFonts,
|
||||
Image,
|
||||
ImageData,
|
||||
Path2D,
|
||||
DOMPoint,
|
||||
DOMMatrix,
|
||||
DOMRect,
|
||||
loadImage,
|
||||
PDFDocument,
|
||||
SvgExportFlag,
|
||||
} = require('./index.js')
|
||||
|
||||
// CanvasRenderingContext2D is not re-exported by index.js, grab from native bindings
|
||||
const { CanvasRenderingContext2D } = require('./js-binding')
|
||||
|
||||
// node-canvas defaults JPEG quality to 0.75 across all paths.
|
||||
// @napi-rs/canvas native default is 0.92 (matching Blink/Chrome).
|
||||
const NODE_CANVAS_DEFAULT_QUALITY = 0.75
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream classes (node-canvas returns Node.js Readable streams)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class PNGStream extends Readable {
|
||||
constructor(canvas, options) {
|
||||
super()
|
||||
this._canvas = canvas
|
||||
this._options = options || {}
|
||||
this._done = false
|
||||
}
|
||||
|
||||
_read() {
|
||||
if (this._done) return
|
||||
this._done = true
|
||||
this._canvas
|
||||
.encode('png')
|
||||
.then((buf) => {
|
||||
this.push(buf)
|
||||
this.push(null)
|
||||
})
|
||||
.catch((err) => {
|
||||
this.destroy(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class JPEGStream extends Readable {
|
||||
constructor(canvas, options) {
|
||||
super()
|
||||
this._canvas = canvas
|
||||
const opts = options || {}
|
||||
// node-canvas quality: 0-1 (default 0.75), encode() expects 0-100
|
||||
this._quality = Math.round((opts.quality != null ? opts.quality : NODE_CANVAS_DEFAULT_QUALITY) * 100)
|
||||
this._done = false
|
||||
}
|
||||
|
||||
_read() {
|
||||
if (this._done) return
|
||||
this._done = true
|
||||
this._canvas
|
||||
.encode('jpeg', this._quality)
|
||||
.then((buf) => {
|
||||
this.push(buf)
|
||||
this.push(null)
|
||||
})
|
||||
.catch((err) => {
|
||||
this.destroy(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quality normalization helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MIME_FORMAT_MAP = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpeg',
|
||||
'image/webp': 'webp',
|
||||
'image/avif': 'avif',
|
||||
'image/gif': 'gif',
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract quality from a node-canvas config object or number.
|
||||
* Returns the raw 0-1 quality value, defaulting to NODE_CANVAS_DEFAULT_QUALITY
|
||||
* for JPEG/WebP when not specified.
|
||||
*
|
||||
* @param {string} mime
|
||||
* @param {number|object|undefined} configOrQuality
|
||||
* @returns {number|undefined} 0-1 scale for JPEG/WebP, undefined for other mimes
|
||||
*/
|
||||
function _extractQuality(mime, configOrQuality) {
|
||||
if (mime !== 'image/jpeg' && mime !== 'image/webp') return undefined
|
||||
if (configOrQuality == null) return NODE_CANVAS_DEFAULT_QUALITY
|
||||
if (typeof configOrQuality === 'number') return configOrQuality
|
||||
if (typeof configOrQuality === 'object' && configOrQuality.quality != null) {
|
||||
return configOrQuality.quality
|
||||
}
|
||||
return NODE_CANVAS_DEFAULT_QUALITY
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compat methods added to each canvas instance created via this module
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Keep references to the original prototype methods
|
||||
const _origToBuffer = CanvasElement.prototype.toBuffer
|
||||
const _origToDataURL = CanvasElement.prototype.toDataURL
|
||||
|
||||
function _compatCreatePNGStream(options) {
|
||||
return new PNGStream(this, options)
|
||||
}
|
||||
|
||||
function _compatCreateJPEGStream(options) {
|
||||
return new JPEGStream(this, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* node-canvas compatible toBuffer:
|
||||
* - toBuffer() → PNG (sync)
|
||||
* - toBuffer('image/png', config?) → PNG (sync)
|
||||
* - toBuffer('image/jpeg', config?) → JPEG (sync, quality 0-1)
|
||||
* - toBuffer('raw') → raw pixel data (sync)
|
||||
* - toBuffer(callback) → PNG (async)
|
||||
* - toBuffer(callback, mime, config?) → specified format (async)
|
||||
*/
|
||||
function _compatToBuffer(mimeOrCallback, configOrQuality) {
|
||||
// Callback form: toBuffer(callback) or toBuffer(callback, mime, config)
|
||||
if (typeof mimeOrCallback === 'function') {
|
||||
const callback = mimeOrCallback
|
||||
const mime = typeof configOrQuality === 'string' ? configOrQuality : 'image/png'
|
||||
const config = arguments[2]
|
||||
|
||||
if (mime === 'raw') {
|
||||
let buf
|
||||
try {
|
||||
buf = this.data()
|
||||
} catch (err) {
|
||||
callback(err)
|
||||
return
|
||||
}
|
||||
callback(null, buf)
|
||||
return
|
||||
}
|
||||
|
||||
const format = MIME_FORMAT_MAP[mime]
|
||||
if (!format) {
|
||||
callback(new TypeError(`Unsupported MIME type "${mime}". Supported: ${Object.keys(MIME_FORMAT_MAP).join(', ')}`))
|
||||
return
|
||||
}
|
||||
const q = _extractQuality(mime, config)
|
||||
this.encode(format, q != null ? Math.round(q * 100) : undefined).then(
|
||||
(buf) => callback(null, buf),
|
||||
(err) => callback(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Sync: no args → PNG
|
||||
if (mimeOrCallback === undefined) {
|
||||
return _origToBuffer.call(this, 'image/png')
|
||||
}
|
||||
|
||||
// Sync: raw pixel data
|
||||
if (mimeOrCallback === 'raw') {
|
||||
return this.data()
|
||||
}
|
||||
|
||||
// Sync: extract quality (0-1) and scale to 0-100 for native toBuffer
|
||||
const q = _extractQuality(mimeOrCallback, configOrQuality)
|
||||
return _origToBuffer.call(this, mimeOrCallback, q != null ? Math.round(q * 100) : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* node-canvas compatible toDataURL:
|
||||
* - toDataURL() → PNG data URL (sync)
|
||||
* - toDataURL('image/jpeg', quality) → JPEG data URL, quality 0-1 (sync)
|
||||
* - toDataURL(callback) → PNG data URL (async)
|
||||
* - toDataURL(mime, callback) → data URL (async)
|
||||
* - toDataURL(mime, quality, callback) → data URL with quality (async)
|
||||
*/
|
||||
function _compatToDataURL(mimeOrCallback, qualityOrCallback) {
|
||||
// toDataURL(callback)
|
||||
if (typeof mimeOrCallback === 'function') {
|
||||
this.toDataURLAsync('image/png').then(
|
||||
(url) => mimeOrCallback(null, url),
|
||||
(err) => mimeOrCallback(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
// toDataURL(mime, callback)
|
||||
if (typeof qualityOrCallback === 'function') {
|
||||
this.toDataURLAsync(mimeOrCallback, _extractQuality(mimeOrCallback, undefined)).then(
|
||||
(url) => qualityOrCallback(null, url),
|
||||
(err) => qualityOrCallback(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
// toDataURL(mime, quality, callback)
|
||||
const cb = arguments[2]
|
||||
if (typeof cb === 'function') {
|
||||
// Native toDataURLAsync expects 0-1 (f64) — pass quality through as-is
|
||||
const quality = _extractQuality(mimeOrCallback, qualityOrCallback)
|
||||
this.toDataURLAsync(mimeOrCallback, quality).then(
|
||||
(url) => cb(null, url),
|
||||
(err) => cb(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Sync form: native toDataURL expects 0-1 (f64) — pass quality through as-is
|
||||
const quality = _extractQuality(mimeOrCallback, qualityOrCallback)
|
||||
return _origToDataURL.call(this, mimeOrCallback, quality)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach node-canvas compatible methods to a CanvasElement instance.
|
||||
*/
|
||||
function _addCompatMethods(canvas) {
|
||||
canvas.createPNGStream = _compatCreatePNGStream
|
||||
canvas.createJPEGStream = _compatCreateJPEGStream
|
||||
canvas.toBuffer = _compatToBuffer
|
||||
canvas.toDataURL = _compatToDataURL
|
||||
return canvas
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: registerFont / deregisterAllFonts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Track FontKeys from registerFont() so deregisterAllFonts() can remove
|
||||
// only user-registered fonts (matching node-canvas behavior which leaves
|
||||
// system fonts untouched).
|
||||
const _registeredFontKeys = []
|
||||
|
||||
/**
|
||||
* Register a font file with the specified font face properties.
|
||||
* Compatible with node-canvas's registerFont(path, { family, weight?, style? }).
|
||||
*
|
||||
* Note: @napi-rs/canvas auto-detects weight and style from font file metadata
|
||||
* (matching browser behavior). The weight and style properties in fontFace are
|
||||
* accepted for API compatibility but the actual values are read from the font.
|
||||
*
|
||||
* @param {string} path Absolute path to the font file (.ttf, .otf, etc.)
|
||||
* @param {{ family: string, weight?: string, style?: string }} fontFace
|
||||
*/
|
||||
function registerFont(path, fontFace) {
|
||||
if (!fontFace || typeof fontFace.family !== 'string') {
|
||||
throw new TypeError('registerFont requires a fontFace with a "family" property')
|
||||
}
|
||||
const key = GlobalFonts.registerFromPath(path, fontFace.family)
|
||||
if (!key) {
|
||||
throw new Error(`Failed to register font from "${path}" with family "${fontFace.family}"`)
|
||||
}
|
||||
_registeredFontKeys.push(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deregister all fonts previously registered via registerFont().
|
||||
* Compatible with node-canvas's deregisterAllFonts() which only removes
|
||||
* user-registered fonts and leaves system fonts untouched.
|
||||
*/
|
||||
function deregisterAllFonts() {
|
||||
if (_registeredFontKeys.length > 0) {
|
||||
GlobalFonts.removeBatch(_registeredFontKeys)
|
||||
_registeredFontKeys.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: createCanvas / createImageData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new canvas instance with node-canvas compatible API.
|
||||
*
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {'image'|'svg'} [type='image']
|
||||
* @returns {Canvas}
|
||||
*/
|
||||
function createCanvas(width, height, type) {
|
||||
if (type === 'svg') {
|
||||
// SvgExportFlag enum requires a valid variant; NoPrettyXML (0x02) is the
|
||||
// least impactful on rendering behavior (only affects XML whitespace).
|
||||
return _createCanvas(width, height, SvgExportFlag.NoPrettyXML)
|
||||
}
|
||||
if (type === 'pdf') {
|
||||
throw new Error(
|
||||
'createCanvas with type "pdf" is not supported. Use the PDFDocument class from @napi-rs/canvas directly.',
|
||||
)
|
||||
}
|
||||
if (type != null && type !== 'image') {
|
||||
throw new TypeError(`createCanvas: unknown type "${type}". Supported types: "image", "svg".`)
|
||||
}
|
||||
return _addCompatMethods(_createCanvas(width, height))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ImageData instance.
|
||||
* Compatible with node-canvas's createImageData().
|
||||
*
|
||||
* @param {Uint8ClampedArray|number} dataOrWidth
|
||||
* @param {number} widthOrHeight
|
||||
* @param {number} [height]
|
||||
* @returns {ImageData}
|
||||
*/
|
||||
function createImageData(dataOrWidth, widthOrHeight, height) {
|
||||
if (typeof dataOrWidth === 'number') {
|
||||
return new ImageData(dataOrWidth, widthOrHeight)
|
||||
}
|
||||
if (height != null) {
|
||||
return new ImageData(dataOrWidth, widthOrHeight, height)
|
||||
}
|
||||
return new ImageData(dataOrWidth, widthOrHeight)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canvas constructor wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// In node-canvas, `new Canvas(w, h)` and `createCanvas(w, h)` produce
|
||||
// equivalent canvases. Wrap the native Canvas constructor so that
|
||||
// `new Canvas(w, h)` also gets compat methods, without polluting the
|
||||
// prototype (which would affect @napi-rs/canvas users who don't use
|
||||
// this compat layer).
|
||||
const CompatCanvas = new Proxy(Canvas, {
|
||||
construct(target, args) {
|
||||
const canvas = new target(...args)
|
||||
if (canvas instanceof CanvasElement) {
|
||||
return _addCompatMethods(canvas)
|
||||
}
|
||||
return canvas
|
||||
},
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports (matches node-canvas export shape)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
module.exports = {
|
||||
// Factory functions
|
||||
Canvas: CompatCanvas,
|
||||
createCanvas,
|
||||
createImageData,
|
||||
loadImage,
|
||||
registerFont,
|
||||
deregisterAllFonts,
|
||||
|
||||
// Classes
|
||||
Image,
|
||||
ImageData,
|
||||
CanvasRenderingContext2D,
|
||||
Context2d: CanvasRenderingContext2D,
|
||||
PNGStream,
|
||||
JPEGStream,
|
||||
Path2D,
|
||||
|
||||
// Geometry
|
||||
DOMPoint,
|
||||
DOMMatrix,
|
||||
DOMRect,
|
||||
|
||||
// @napi-rs/canvas extras (available but not part of node-canvas)
|
||||
GlobalFonts,
|
||||
PDFDocument,
|
||||
CanvasElement,
|
||||
SVGCanvas,
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"name": "@napi-rs/canvas",
|
||||
"version": "1.0.10",
|
||||
"description": "Canvas for Node.js with skia backend",
|
||||
"main": "index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Brooooooklyn/canvas.git"
|
||||
},
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"napi-rs",
|
||||
"NAPI",
|
||||
"N-API",
|
||||
"Rust",
|
||||
"node-addon",
|
||||
"node-addon-api",
|
||||
"canvas",
|
||||
"image",
|
||||
"pdf",
|
||||
"svg",
|
||||
"skia"
|
||||
],
|
||||
"files": [
|
||||
"index.d.ts",
|
||||
"index.js",
|
||||
"geometry.js",
|
||||
"js-binding.js",
|
||||
"load-image.js",
|
||||
"node-canvas.js",
|
||||
"node-canvas.d.ts"
|
||||
],
|
||||
"napi": {
|
||||
"binaryName": "skia",
|
||||
"targets": [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc",
|
||||
"aarch64-pc-windows-msvc",
|
||||
"armv7-unknown-linux-gnueabihf",
|
||||
"x86_64-unknown-linux-musl",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-musl",
|
||||
"aarch64-apple-darwin",
|
||||
"aarch64-linux-android",
|
||||
"riscv64-unknown-linux-gnu"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"artifacts": "napi artifacts",
|
||||
"bench": "node --import @oxc-node/core/register benchmark/bench.ts",
|
||||
"build": "napi build --platform --release --js js-binding.js",
|
||||
"build:debug": "napi build --platform --js js-binding.js",
|
||||
"example-lottie": "yarn oxnode ./example/lottie-to-video.ts",
|
||||
"format": "run-p format:source format:rs format:toml",
|
||||
"format:rs": "cargo fmt",
|
||||
"format:source": "prettier . -w",
|
||||
"format:toml": "taplo format",
|
||||
"lint": "oxlint",
|
||||
"prepublishOnly": "pinst --disable && napi prepublish -t npm",
|
||||
"postpublish": "pinst --enable",
|
||||
"test:ci": "NODE_OPTIONS='--import=core-js/proposals/iterator-helpers.js' ava -c 1",
|
||||
"test": "NODE_OPTIONS='--import=core-js/proposals/iterator-helpers.js' ava",
|
||||
"e2e": "yarn workspace @napi-rs/canvas-e2e-webpack test",
|
||||
"version": "napi version && conventional-changelog -p angular -i CHANGELOG.md -s && git add ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jimp/core": "^1.6.0",
|
||||
"@jimp/custom": "^0.22.12",
|
||||
"@jimp/jpeg": "^0.22.12",
|
||||
"@jimp/png": "^0.22.12",
|
||||
"@napi-rs/cli": "^3.8.3",
|
||||
"@napi-rs/webcodecs": "^1.1.1",
|
||||
"@node-rs/crc32": "^1.10.6",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@oxc-node/cli": "^0.1.0",
|
||||
"@oxc-node/core": "^0.1.0",
|
||||
"@taplo/cli": "^0.7.0",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/semver": "^7",
|
||||
"ava": "^8.0.0",
|
||||
"canvas": "^3.2.0",
|
||||
"canvaskit-wasm": "^0.42.0",
|
||||
"colorette": "^2.0.20",
|
||||
"conventional-changelog-cli": "^5.0.0",
|
||||
"core-js": "^3.47.0",
|
||||
"echarts": "^6.0.0",
|
||||
"electron": "^44.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"npm-run-all2": "^9.0.0",
|
||||
"oxlint": "^1.34.0",
|
||||
"oxlint-tsgolint": "^7.0.0",
|
||||
"pinst": "^3.0.0",
|
||||
"png.js": "^0.2.1",
|
||||
"prettier": "^3.7.4",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"semver": "^7.7.3",
|
||||
"skia-canvas": "^3.0.8",
|
||||
"table": "^6.9.0",
|
||||
"tinybench": "^6.0.0",
|
||||
"typescript": "^7.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.@(js|ts|tsx|yml|yaml|md|json|html)": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.@(js|ts|tsx)": [
|
||||
"oxlint --fix"
|
||||
]
|
||||
},
|
||||
"ava": {
|
||||
"nodeArguments": [
|
||||
"--import=@oxc-node/core/register",
|
||||
"--import=core-js/proposals/promise-with-resolvers.js"
|
||||
],
|
||||
"extensions": [
|
||||
"ts"
|
||||
],
|
||||
"files": [
|
||||
"__test__/**/*.spec.ts",
|
||||
"scripts/__test__/**/*.spec.ts"
|
||||
],
|
||||
"workerThreads": false,
|
||||
"cache": false,
|
||||
"timeout": "3m",
|
||||
"environmentVariables": {
|
||||
"SWC_NODE_PROJECT": "./tsconfig.json",
|
||||
"NODE_ENV": "ava"
|
||||
}
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 120,
|
||||
"semi": false,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"arrowParens": "always"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"packageManager": "yarn@4.18.1",
|
||||
"dependenciesMeta": {
|
||||
"canvas": {
|
||||
"built": true
|
||||
},
|
||||
"skia-canvas": {
|
||||
"built": true
|
||||
}
|
||||
},
|
||||
"resolutions": {
|
||||
"@napi-rs/canvas": "workspace:"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.10",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.10",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.10",
|
||||
"@napi-rs/canvas-android-arm64": "1.0.10",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.10"
|
||||
}
|
||||
}
|
||||
Generated
+48
-48
@@ -8,6 +8,7 @@
|
||||
"name": "projektuebersicht",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^1.0.10",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"mammoth": "^1.13.0",
|
||||
@@ -16,11 +17,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.9.tgz",
|
||||
"integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.10.tgz",
|
||||
"integrity": "sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
@@ -32,23 +32,23 @@
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "1.0.9",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.9",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.9",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.9",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.9",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.9",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.9",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.9",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.9",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.9"
|
||||
"@napi-rs/canvas-android-arm64": "1.0.10",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.10",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.10",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz",
|
||||
"integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.10.tgz",
|
||||
"integrity": "sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -66,9 +66,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz",
|
||||
"integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.10.tgz",
|
||||
"integrity": "sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -86,9 +86,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz",
|
||||
"integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.10.tgz",
|
||||
"integrity": "sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -106,9 +106,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz",
|
||||
"integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.10.tgz",
|
||||
"integrity": "sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -126,9 +126,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz",
|
||||
"integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.10.tgz",
|
||||
"integrity": "sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -149,9 +149,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz",
|
||||
"integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.10.tgz",
|
||||
"integrity": "sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -172,9 +172,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz",
|
||||
"integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.10.tgz",
|
||||
"integrity": "sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -195,9 +195,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz",
|
||||
"integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.10.tgz",
|
||||
"integrity": "sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -218,9 +218,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz",
|
||||
"integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.10.tgz",
|
||||
"integrity": "sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -241,9 +241,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz",
|
||||
"integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.10.tgz",
|
||||
"integrity": "sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -261,9 +261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz",
|
||||
"integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==",
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.10.tgz",
|
||||
"integrity": "sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^1.0.10",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"mammoth": "^1.13.0",
|
||||
|
||||
+239
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
+844
-446
File diff suppressed because it is too large.
Load diff
+400
@@ -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); }
|
||||
@@ -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" title="Voriger Monat">${icon('chevronLeft')}</button>
|
||||
<strong>${esc(monatName)}</strong>
|
||||
<button type="button" class="abw-rund" onclick="abwMonatWechseln(1)" aria-label="Nächster Monat" title="Nächster Monat">${icon('chevronRight')}</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(); });
|
||||
}
|
||||
+323
-36
@@ -37,6 +37,10 @@
|
||||
|
||||
// initialsManuallyEdited: gleiches Muster für das Kürzel-Feld.
|
||||
let initialsManuallyEdited = false;
|
||||
// emailManuallyEdited: wie oben, für den E-Mail-Vorschlag (emailVorschlag()).
|
||||
let emailManuallyEdited = false;
|
||||
// usernameManuallyEdited: wie oben, für den Benutzernamen ("Vorname Nachname").
|
||||
let usernameManuallyEdited = false;
|
||||
// editUserAbsencesCache: zuletzt geladene Abwesenheiten des im
|
||||
// Bearbeiten-Modal geöffneten Benutzers (für Rendern/Löschen ohne erneuten
|
||||
// Request pro Aktion).
|
||||
@@ -69,6 +73,52 @@
|
||||
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
|
||||
// Formulare aufgerufen (which = 'new'|'edit'). Ein Kiosk-Konto kann keine
|
||||
// Fachrolle haben (server.js weist das sonst mit 400 zurück) — die
|
||||
// Checkboxen werden deshalb schon hier abgehakt entfernt und gesperrt,
|
||||
// statt den Fehler erst nach dem Absenden zu zeigen.
|
||||
window.onUserRoleChange = function(which){
|
||||
const istKiosk = nurWandtafelRolle(document.getElementById(which + 'UserRole').value);
|
||||
const hint = document.getElementById(which + 'UserRoleHint');
|
||||
if(hint) hint.style.display = istKiosk ? '' : 'none';
|
||||
document.querySelectorAll('#' + which + 'UserRoleCheckboxes input[type="checkbox"]').forEach(cb => {
|
||||
cb.disabled = istKiosk;
|
||||
if(istKiosk) cb.checked = false;
|
||||
});
|
||||
};
|
||||
|
||||
// formulareVorbereitet: Abteilungs-Auswahl und Rollen-Ankreuzfelder muessen
|
||||
// nur einmal aufgebaut werden; renderAdminUsers() laeuft aber bei jedem
|
||||
// Wechsel in den Abschnitt.
|
||||
@@ -81,6 +131,7 @@
|
||||
// erreichbar (showTab() leitet sie um) und die Endpunkte sind serverseitig
|
||||
// abgesichert. Keine Parameter, keine Rueckgabe.
|
||||
window.renderAdminUsers = function(){
|
||||
ladeSystemRollen();
|
||||
if(!formulareVorbereitet){
|
||||
renderDepartmentOptions('newUserDepartment');
|
||||
renderDepartmentOptions('editUserDepartment');
|
||||
@@ -127,27 +178,24 @@
|
||||
const statusParts = [];
|
||||
statusParts.push(u.active ? '<span class="badge-active">Aktiv</span>' : '<span class="badge-inactive">Deaktiviert</span>');
|
||||
if(u.mustChangePassword) statusParts.push('<span class="badge-warn">Passwortwechsel erforderlich</span>');
|
||||
const toggleLabel = u.active ? 'Deaktivieren' : 'Aktivieren';
|
||||
const isSelf = currentUser && currentUser.id === u.id;
|
||||
const deleteBtn = (!u.active)
|
||||
? `<button class="danger small" ${isSelf ? 'disabled' : ''} onclick="hardDeleteUser(${u.id}, '${escJs(u.username)}')">Endgültig löschen</button>`
|
||||
: '';
|
||||
const deptLabel = (DEPARTMENTS[u.department] && DEPARTMENTS[u.department].label) || esc(u.department || '');
|
||||
return `<tr>
|
||||
<td>${esc(u.username)}</td>
|
||||
<td>${esc(u.displayName)}</td>
|
||||
<td>${esc(u.email || '')}</td>
|
||||
<td>${u.role === 'admin' ? 'Administrator' : 'Mitglied'}</td>
|
||||
<td>${esc(rollenName(u.role))}</td>
|
||||
<td>${deptLabel}</td>
|
||||
<td>${esc(u.initials || '')}</td>
|
||||
<td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td>
|
||||
<td>${esc(u.personId || '')}</td>
|
||||
<td>${statusParts.join('<br>')}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="secondary small" onclick="openEditUserModal(${u.id})">Bearbeiten</button>
|
||||
<button class="secondary small" onclick="resetUserPassword(${u.id}, '${escJs(u.username)}')">Passwort zurücksetzen</button>
|
||||
<button class="secondary small" ${isSelf ? 'disabled title="Sie können sich nicht selbst deaktivieren."' : ''} onclick="toggleUserActive(${u.id}, ${u.active})">${toggleLabel}</button>
|
||||
${deleteBtn}
|
||||
<span class="user-aktionen">
|
||||
<button class="secondary small" onclick="openEditUserModal(${u.id})">Bearbeiten</button>
|
||||
<button type="button" class="user-kebab" aria-haspopup="menu" aria-expanded="false"
|
||||
aria-label="Weitere Aktionen für ${esc(u.displayName || u.username)}"
|
||||
onclick="openUserActionMenu(event, ${u.id})">${PUNKTE_SVG}</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -183,12 +231,17 @@
|
||||
|
||||
window.openNewUserModal = function(){
|
||||
document.getElementById('newUserUsername').value = '';
|
||||
document.getElementById('newUserDisplayName').value = '';
|
||||
document.getElementById('newUserFirstName').value = '';
|
||||
document.getElementById('newUserLastName').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('newUserInitials').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.getElementById('newUserPassword').value = '';
|
||||
document.getElementById('newUserPassword').style.display = 'none';
|
||||
@@ -196,27 +249,66 @@
|
||||
document.getElementById('newUserError').textContent = '';
|
||||
personIdManuallyEdited = false;
|
||||
initialsManuallyEdited = false;
|
||||
emailManuallyEdited = false;
|
||||
usernameManuallyEdited = false;
|
||||
document.getElementById('newUserModal').style.display = 'flex';
|
||||
document.getElementById('newUserUsername').focus();
|
||||
document.getElementById('newUserFirstName').focus();
|
||||
};
|
||||
|
||||
window.closeNewUserModal = function(){
|
||||
document.getElementById('newUserModal').style.display = 'none';
|
||||
};
|
||||
|
||||
// onNewUserDisplayNameInput: schlägt beim Tippen des Anzeigenamens eine
|
||||
// Person-ID über personIdForName() vor (js/utils.js) — nur solange das Feld
|
||||
// nicht bereits manuell bearbeitet wurde (Kollisionsvermeidung laut Plan).
|
||||
window.onNewUserDisplayNameInput = function(){
|
||||
const name = document.getElementById('newUserDisplayName').value;
|
||||
if(!personIdManuallyEdited){
|
||||
document.getElementById('newUserPersonId').value = name.trim() ? personIdForName(name) : '';
|
||||
}
|
||||
// onNewUserNameInput: schlägt beim Tippen von Vor-/Nachname E-Mail,
|
||||
// Benutzername (= Anfang der E-Mail, z. B. m.koerner), Person-ID
|
||||
// (personIdForName()) und Kürzel vor (js/utils.js) — jedes Feld nur,
|
||||
// solange es nicht bereits manuell bearbeitet wurde.
|
||||
window.onNewUserNameInput = function(){
|
||||
const vorname = document.getElementById('newUserFirstName').value;
|
||||
const nachname = document.getElementById('newUserLastName').value;
|
||||
const name = (vorname.trim() + ' ' + nachname.trim()).trim();
|
||||
if(!initialsManuallyEdited){
|
||||
document.getElementById('newUserInitials').value = name.trim() ? initialsForName(name) : '';
|
||||
}
|
||||
personIdAusKuerzel();
|
||||
// Firmen-Adresse nach dem Schema v.nachname@rauschenberg-ing.de
|
||||
// (emailVorschlag(), js/utils.js) — wie Person-ID und Kürzel nur,
|
||||
// solange das Feld nicht von Hand geändert wurde.
|
||||
if(!emailManuallyEdited){
|
||||
document.getElementById('newUserEmail').value = emailVorschlag(vorname, nachname);
|
||||
}
|
||||
benutzernameAusEmail();
|
||||
};
|
||||
|
||||
// personIdAusKuerzel: Person-ID aus dem Kürzel ("MK" → "p_mk") — dasselbe
|
||||
// Schema, das alle Bestandskonten tragen (ihre IDs stammen aus der Zeit,
|
||||
// als die Personenliste über das Kürzel geführt wurde) und an dem ihre
|
||||
// Projektzuordnungen hängen. Vorher kam die ID aus dem vollen Namen
|
||||
// ("p_miriam_k_rner"), sodass neue und alte Konten verschieden aussahen.
|
||||
// Ist die ID schon vergeben, wird eine Ziffer angehängt (p_mk2, p_mk3 …),
|
||||
// denn person_id ist eindeutig. Folgt dem Kürzel, solange die ID nicht
|
||||
// von Hand geändert wurde.
|
||||
function personIdAusKuerzel(){
|
||||
if(personIdManuallyEdited) return;
|
||||
const kuerzel = document.getElementById('newUserInitials').value.trim();
|
||||
const feld = document.getElementById('newUserPersonId');
|
||||
if(!kuerzel){ feld.value = ''; return; }
|
||||
const basis = personIdForName(kuerzel);
|
||||
const vergeben = new Set(usersCache.map(u => u.personId).filter(Boolean));
|
||||
let id = basis, n = 2;
|
||||
while(vergeben.has(id)) id = basis + (n++);
|
||||
feld.value = id;
|
||||
}
|
||||
|
||||
// benutzernameAusEmail: Benutzername = Anfang der E-Mail-Adresse. Folgt
|
||||
// der Adresse auch dann, wenn sie von Hand geändert wird (z. B. bei einem
|
||||
// Doppelnamen) — solange der Benutzername selbst nicht von Hand geändert
|
||||
// wurde.
|
||||
function benutzernameAusEmail(){
|
||||
if(usernameManuallyEdited) return;
|
||||
const adresse = document.getElementById('newUserEmail').value.trim();
|
||||
document.getElementById('newUserUsername').value = adresse.includes('@') ? adresse.slice(0, adresse.indexOf('@')) : adresse;
|
||||
}
|
||||
window.onNewUserPwModeChange = function(){
|
||||
const manual = document.querySelector('input[name="newUserPwMode"]:checked').value === 'manual';
|
||||
document.getElementById('newUserPassword').style.display = manual ? '' : 'none';
|
||||
@@ -224,7 +316,9 @@
|
||||
|
||||
window.saveNewUser = function(){
|
||||
const username = document.getElementById('newUserUsername').value.trim();
|
||||
const displayName = document.getElementById('newUserDisplayName').value.trim();
|
||||
// Mit großem Anfangsbuchstaben gespeichert.
|
||||
const firstName = grossAnfang(document.getElementById('newUserFirstName').value);
|
||||
const lastName = grossAnfang(document.getElementById('newUserLastName').value);
|
||||
const email = document.getElementById('newUserEmail').value.trim();
|
||||
const role = document.getElementById('newUserRole').value;
|
||||
const department = document.getElementById('newUserDepartment').value;
|
||||
@@ -236,8 +330,12 @@
|
||||
const errEl = document.getElementById('newUserError');
|
||||
errEl.textContent = '';
|
||||
|
||||
if(!username || !displayName){
|
||||
errEl.textContent = 'Benutzername und Anzeigename sind erforderlich.';
|
||||
if(!firstName || !lastName){
|
||||
errEl.textContent = 'Vor- und Nachname sind erforderlich.';
|
||||
return;
|
||||
}
|
||||
if(!username){
|
||||
errEl.textContent = 'Benutzername ist erforderlich.';
|
||||
return;
|
||||
}
|
||||
if(!department){
|
||||
@@ -247,8 +345,10 @@
|
||||
// Ohne Fachrolle ist der Benutzer in der ganzen Anwendung unsichtbar:
|
||||
// peopleByRole() (js/utils.js) gruppiert ausschließlich über die Rollen —
|
||||
// das Konto erschiene weder als Board-Spalte noch in der Auslastung noch
|
||||
// im PDF-Export, ohne dass es dafür irgendeinen Hinweis gäbe.
|
||||
if(!roles || roles.length === 0){
|
||||
// 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
|
||||
// auftauchen, server.js weist eine gesetzte Fachrolle dort sogar zurück.
|
||||
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.';
|
||||
return;
|
||||
}
|
||||
@@ -258,7 +358,7 @@
|
||||
errEl.textContent = 'Bitte eine Personen-Kennung vergeben — ohne sie kann der Benutzer keiner Sitzung und keinen Aufgaben zugeordnet werden.';
|
||||
return;
|
||||
}
|
||||
const body = { username, displayName, email, role, personId, department, initials, capacityHours, roles };
|
||||
const body = { username, firstName, lastName, email, role, personId, department, initials, capacityHours, roles };
|
||||
if(pwMode === 'manual'){
|
||||
const password = document.getElementById('newUserPassword').value;
|
||||
if(password.length < 8){
|
||||
@@ -277,6 +377,10 @@
|
||||
}
|
||||
closeNewUserModal();
|
||||
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){
|
||||
showGeneratedPassword(username, res.data.generatedPassword);
|
||||
}
|
||||
@@ -290,8 +394,13 @@
|
||||
if(!u) return;
|
||||
editingUserId = id;
|
||||
document.getElementById('editUserUsernameLabel').textContent = 'Benutzername: ' + u.username + ' (nicht änderbar)';
|
||||
document.getElementById('editUserDisplayName').value = u.displayName || '';
|
||||
// Konten von vor der Umstellung tragen evtl. noch keinen Vor-/Nachnamen:
|
||||
// dann aus dem Anzeigenamen geteilt (erstes Wort / Rest).
|
||||
const teile = String(u.displayName || '').trim().split(/\s+/).filter(Boolean);
|
||||
document.getElementById('editUserFirstName').value = u.firstName || u.lastName ? (u.firstName || '') : (teile[0] || '');
|
||||
document.getElementById('editUserLastName').value = u.firstName || u.lastName ? (u.lastName || '') : teile.slice(1).join(' ');
|
||||
document.getElementById('editUserEmail').value = u.email || '';
|
||||
onEditUserNameInput();
|
||||
document.getElementById('editUserRole').value = u.role;
|
||||
document.getElementById('editUserDepartment').value = u.department || DEFAULT_DEPARTMENT;
|
||||
document.getElementById('editUserInitials').value = u.initials || '';
|
||||
@@ -301,6 +410,7 @@
|
||||
document.querySelectorAll('#editUserRoleCheckboxes input[type="checkbox"]').forEach(cb => {
|
||||
cb.checked = userRoles.includes(cb.value);
|
||||
});
|
||||
onUserRoleChange('edit');
|
||||
document.getElementById('editUserError').textContent = '';
|
||||
document.getElementById('editUserAbsenceError').textContent = '';
|
||||
document.getElementById('editUserAbsenceStart').value = '';
|
||||
@@ -328,7 +438,8 @@
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
displayName: document.getElementById('editUserDisplayName').value.trim(),
|
||||
firstName: grossAnfang(document.getElementById('editUserFirstName').value),
|
||||
lastName: grossAnfang(document.getElementById('editUserLastName').value),
|
||||
email: document.getElementById('editUserEmail').value.trim(),
|
||||
role: document.getElementById('editUserRole').value,
|
||||
department,
|
||||
@@ -344,6 +455,10 @@
|
||||
}
|
||||
closeEditUserModal();
|
||||
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; });
|
||||
};
|
||||
|
||||
@@ -429,8 +544,8 @@
|
||||
|
||||
// ---------- Zeilen-Aktionen (Passwort-Reset, Aktivieren/Deaktivieren, Löschen) ----------
|
||||
|
||||
window.resetUserPassword = function(id, username){
|
||||
if(!confirm('Passwort für "' + username + '" wirklich zurücksetzen? Bisherige Sitzungen dieses Benutzers werden beendet.')) return;
|
||||
window.resetUserPassword = function(id, username, ohneRueckfrage){
|
||||
if(!ohneRueckfrage && !confirm('Passwort für "' + username + '" wirklich zurücksetzen? Bisherige Sitzungen dieses Benutzers werden beendet.')) return;
|
||||
apiFetch('/admin/users/' + id + '/reset-password', { method: 'POST' }).then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
||||
@@ -441,29 +556,193 @@
|
||||
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
||||
};
|
||||
|
||||
window.toggleUserActive = function(id, currentlyActive){
|
||||
window.toggleUserActive = function(id, currentlyActive, ohneRueckfrage){
|
||||
const goingActive = !currentlyActive;
|
||||
if(!goingActive && !confirm('Benutzer wirklich deaktivieren? Bestehende Sitzungen werden sofort beendet.')) return;
|
||||
if(!goingActive && !ohneRueckfrage && !confirm('Benutzer wirklich deaktivieren? Bestehende Sitzungen werden sofort beendet.')) return;
|
||||
apiFetch('/admin/users/' + id, { method: 'PATCH', body: JSON.stringify({ active: goingActive }) }).then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
||||
return;
|
||||
}
|
||||
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));
|
||||
};
|
||||
|
||||
window.hardDeleteUser = function(id, username){
|
||||
if(!confirm('Benutzer "' + username + '" wirklich ENDGÜLTIG löschen? Das kann nicht rückgängig gemacht werden.')) return;
|
||||
window.hardDeleteUser = function(id, username, ohneRueckfrage){
|
||||
if(!ohneRueckfrage && !confirm('Benutzer "' + username + '" wirklich ENDGÜLTIG löschen? Die Person wird auch aus allen Projekten und Aufgaben der aktuellen Sitzung entfernt. Das kann nicht rückgängig gemacht werden.')) return;
|
||||
apiFetch('/admin/users/' + id + '?hard=1', { method: 'DELETE' }).then(res => {
|
||||
if(!res.ok || !res.data || !res.data.ok){
|
||||
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
|
||||
return;
|
||||
}
|
||||
loadAndRenderUsers();
|
||||
// Der Server hat die Person aus der aktuellen Sitzung entfernt (Projekt-
|
||||
// zuordnungen, Aufgaben, Teilnehmerkreis). Neu laden, damit Projekte
|
||||
// und Auslastung in diesem Browser sofort ohne sie dastehen und das
|
||||
// nächste Speichern nicht auf einem veralteten Stand aufsetzt.
|
||||
if(typeof load === 'function') load(true);
|
||||
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
|
||||
};
|
||||
|
||||
// ---------- Drei-Punkte-Menü der Zeilen ----------
|
||||
// „Bearbeiten" bleibt als Knopf in der Zeile, die selteneren Aktionen
|
||||
// liegen hinter den drei Punkten (Eriks Wahl „C" aus dem Vergleich der
|
||||
// Menü-Varianten). Was nicht rückgängig zu machen ist — Passwort
|
||||
// zurücksetzen, Deaktivieren, Endgültig löschen — fragt direkt im Menü
|
||||
// nach, statt ein Browser-Dialogfenster zu öffnen.
|
||||
//
|
||||
// Das Menü hängt an <body> und steht `position: fixed`: Die Tabelle liegt
|
||||
// in einem quer scrollbaren Rahmen, der ein darin verankertes Menü
|
||||
// abschneiden würde. Reicht der Platz unter dem Knopf nicht, klappt es nach
|
||||
// oben auf. Scrollen oder Fenstergröße ändern schließt es.
|
||||
|
||||
const PUNKTE_SVG = '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.2" cy="8" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12.8" cy="8" r="1.4"/></svg>';
|
||||
const MENU_IC = {
|
||||
schluessel: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5.5" cy="10.5" r="3"/><path d="M7.6 8.4L13.5 2.5M11 5l1.8 1.8M12.5 3.5l1.5 1.5"/></svg>',
|
||||
aus: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M4 12L12 4"/></svg>',
|
||||
an: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M5.5 8.2l1.8 1.8 3.4-3.6"/></svg>',
|
||||
muell: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4.5h11M6 4.5V3h4v1.5M4 4.5l.7 8.5h6.6l.7-8.5"/></svg>',
|
||||
};
|
||||
|
||||
// offenesMenu: { el, knopf, zeile, u } solange ein Menü offen ist.
|
||||
let offenesMenu = null;
|
||||
|
||||
function menuEintrag(ic, text, aktion, opts){
|
||||
opts = opts || {};
|
||||
return `<button type="button" role="menuitem" class="user-menu-item${opts.gefahr ? ' is-danger' : ''}"
|
||||
data-aktion="${aktion}" ${opts.gesperrt ? 'aria-disabled="true"' : ''}>
|
||||
${MENU_IC[ic]}<span class="user-menu-text"><span>${esc(text)}</span>${opts.unter ? `<span class="user-menu-sub">${esc(opts.unter)}</span>` : ''}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function menuInhalt(u){
|
||||
const isSelf = currentUser && currentUser.id === u.id;
|
||||
let h = menuEintrag('schluessel', 'Passwort zurücksetzen', 'passwort');
|
||||
h += u.active
|
||||
? menuEintrag('aus', 'Deaktivieren', 'deaktivieren', { gesperrt: isSelf, unter: isSelf ? 'Eigenes Konto' : '' })
|
||||
: menuEintrag('an', 'Aktivieren', 'aktivieren');
|
||||
if(!u.active){
|
||||
h += '<hr class="user-menu-trenner">'
|
||||
+ menuEintrag('muell', 'Endgültig löschen', 'loeschen', { gefahr: true, gesperrt: isSelf });
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
const RUECKFRAGEN = {
|
||||
passwort: u => ({ frage: 'Passwort von <b>' + esc(u.displayName || u.username) + '</b> zurücksetzen? Alle Sitzungen werden abgemeldet.', knopf: 'Zurücksetzen' }),
|
||||
deaktivieren: u => ({ frage: '<b>' + esc(u.displayName || u.username) + '</b> deaktivieren? Alle Sitzungen werden sofort abgemeldet.', knopf: 'Deaktivieren' }),
|
||||
loeschen: u => ({ frage: '<b>' + esc(u.displayName || u.username) + '</b> endgültig löschen? Die Person wird auch aus allen Projekten und Aufgaben der aktuellen Sitzung entfernt. Das lässt sich nicht rückgängig machen.', knopf: 'Löschen' }),
|
||||
};
|
||||
|
||||
function menuFokusziele(){
|
||||
return offenesMenu ? Array.from(offenesMenu.el.querySelectorAll('button:not([aria-disabled="true"])')) : [];
|
||||
}
|
||||
|
||||
function menuPositionieren(){
|
||||
if(!offenesMenu) return;
|
||||
const { el, knopf } = offenesMenu;
|
||||
const r = knopf.getBoundingClientRect();
|
||||
const m = el.getBoundingClientRect();
|
||||
const nachOben = (window.innerHeight - r.bottom) < m.height + 12 && r.top > m.height + 12;
|
||||
el.classList.toggle('nach-oben', nachOben);
|
||||
el.style.top = (nachOben ? r.top - m.height - 6 : r.bottom + 6) + 'px';
|
||||
el.style.left = Math.max(8, Math.min(r.right - m.width, window.innerWidth - m.width - 8)) + 'px';
|
||||
}
|
||||
|
||||
function schliesseUserMenu(fokusZurueck){
|
||||
if(!offenesMenu) return;
|
||||
const { el, knopf, zeile } = offenesMenu;
|
||||
offenesMenu = null;
|
||||
el.remove();
|
||||
knopf.setAttribute('aria-expanded', 'false');
|
||||
if(zeile) zeile.classList.remove('is-menu-open');
|
||||
if(fokusZurueck && document.contains(knopf)) knopf.focus();
|
||||
}
|
||||
|
||||
function zeigeMenuEintraege(){
|
||||
offenesMenu.el.innerHTML = menuInhalt(offenesMenu.u);
|
||||
menuPositionieren();
|
||||
}
|
||||
|
||||
function fuehreAus(aktion, u){
|
||||
schliesseUserMenu(true);
|
||||
if(aktion === 'passwort') resetUserPassword(u.id, u.username, true);
|
||||
else if(aktion === 'deaktivieren') toggleUserActive(u.id, true, true);
|
||||
else if(aktion === 'aktivieren') toggleUserActive(u.id, false, true);
|
||||
else if(aktion === 'loeschen') hardDeleteUser(u.id, u.username, true);
|
||||
}
|
||||
|
||||
// openUserActionMenu: öffnet (bzw. schließt bei erneutem Klick) das Menü
|
||||
// einer Zeile. Per Tastatur geöffnet (event.detail === 0) landet der Fokus
|
||||
// gleich auf dem ersten Eintrag.
|
||||
window.openUserActionMenu = function(event, id){
|
||||
event.stopPropagation();
|
||||
const knopf = event.currentTarget;
|
||||
if(offenesMenu && offenesMenu.knopf === knopf){ schliesseUserMenu(true); return; }
|
||||
schliesseUserMenu(false);
|
||||
const u = usersCache.find(x => x.id === id);
|
||||
if(!u) return;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'user-menu';
|
||||
el.setAttribute('role', 'menu');
|
||||
document.body.appendChild(el);
|
||||
const zeile = knopf.closest('tr');
|
||||
offenesMenu = { el, knopf, zeile, u };
|
||||
knopf.setAttribute('aria-expanded', 'true');
|
||||
if(zeile) zeile.classList.add('is-menu-open');
|
||||
zeigeMenuEintraege();
|
||||
|
||||
el.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const b = e.target.closest('button');
|
||||
if(!b || b.getAttribute('aria-disabled') === 'true') return;
|
||||
if(b.dataset.zurueck){ zeigeMenuEintraege(); const z = menuFokusziele(); if(z[0]) z[0].focus(); return; }
|
||||
if(b.dataset.ausfuehren){ fuehreAus(b.dataset.ausfuehren, u); return; }
|
||||
const aktion = b.dataset.aktion;
|
||||
if(RUECKFRAGEN[aktion]){
|
||||
const r = RUECKFRAGEN[aktion](u);
|
||||
el.innerHTML = `<div class="user-menu-confirm"><p>${r.frage}</p><div class="user-menu-confirm-row">
|
||||
<button type="button" class="secondary small" data-zurueck="1">Abbrechen</button>
|
||||
<button type="button" class="danger small" data-ausfuehren="${aktion}">${esc(r.knopf)}</button>
|
||||
</div></div>`;
|
||||
menuPositionieren();
|
||||
el.querySelector('[data-ausfuehren]').focus();
|
||||
return;
|
||||
}
|
||||
fuehreAus(aktion, u);
|
||||
});
|
||||
|
||||
if(event.detail === 0){ const z = menuFokusziele(); if(z[0]) z[0].focus(); }
|
||||
};
|
||||
|
||||
document.addEventListener('click', () => schliesseUserMenu(false));
|
||||
document.addEventListener('keydown', e => {
|
||||
if(!offenesMenu) return;
|
||||
const ziele = menuFokusziele();
|
||||
const i = ziele.indexOf(document.activeElement);
|
||||
if(e.key === 'Escape'){ e.preventDefault(); schliesseUserMenu(true); }
|
||||
else if(e.key === 'ArrowDown'){ e.preventDefault(); if(ziele.length) ziele[(i + 1) % ziele.length].focus(); }
|
||||
else if(e.key === 'ArrowUp'){ e.preventDefault(); if(ziele.length) ziele[(i - 1 + ziele.length) % ziele.length].focus(); }
|
||||
else if(e.key === 'Tab'){ schliesseUserMenu(false); }
|
||||
});
|
||||
window.addEventListener('resize', () => schliesseUserMenu(false));
|
||||
window.addEventListener('scroll', () => schliesseUserMenu(false), true);
|
||||
|
||||
// onEditUserNameInput: Im Bearbeiten-Dialog wird eine vorhandene Adresse
|
||||
// nie überschrieben; ist das E-Mail-Feld leer, steht der Vorschlag als
|
||||
// Platzhalter darin und folgt Vor-/Nachname.
|
||||
window.onEditUserNameInput = function(){
|
||||
const feld = document.getElementById('editUserEmail');
|
||||
if(!feld) return;
|
||||
feld.placeholder = emailVorschlag(document.getElementById('editUserFirstName').value,
|
||||
document.getElementById('editUserLastName').value) || 'v.nachname@' + EMAIL_DOMAIN;
|
||||
};
|
||||
|
||||
// personIdManuallyEdited wird gesetzt, sobald der Nutzer das Person-ID-Feld
|
||||
// selbst bearbeitet (nach dem Laden des DOM gebunden).
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
@@ -473,7 +752,15 @@
|
||||
}
|
||||
const initialsInput = document.getElementById('newUserInitials');
|
||||
if(initialsInput){
|
||||
initialsInput.addEventListener('input', function(){ initialsManuallyEdited = true; });
|
||||
initialsInput.addEventListener('input', function(){ initialsManuallyEdited = true; personIdAusKuerzel(); });
|
||||
}
|
||||
const emailInput = document.getElementById('newUserEmail');
|
||||
if(emailInput){
|
||||
emailInput.addEventListener('input', function(){ emailManuallyEdited = true; benutzernameAusEmail(); });
|
||||
}
|
||||
const usernameInput = document.getElementById('newUserUsername');
|
||||
if(usernameInput){
|
||||
usernameInput.addEventListener('input', function(){ usernameManuallyEdited = true; });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+467
-100
@@ -42,30 +42,55 @@
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
// ADMIN_SECTIONS: die Abschnitte in der Reihenfolge der Seitenleiste. Die
|
||||
// EINE Stelle, an der ein Abschnitt definiert wird — Schlüssel (zugleich
|
||||
// URL-Fragment) und Überschrift. Die Zuordnung zum Markup läuft über das
|
||||
// Attribut data-admin-section, sowohl an der Karte in Test.html als auch am
|
||||
// Attribut data-admin-section, sowohl an der Karte in App.html als auch am
|
||||
// Unterpunkt in der Seitenleiste; ein neuer Abschnitt braucht damit nur einen
|
||||
// Eintrag hier plus die Karte. `wide` hebt die Breitenbegrenzung des Rasters
|
||||
// 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 = [
|
||||
{ key: 'benutzer', title: 'Benutzerverwaltung', wide: true },
|
||||
{ key: 'honorar', title: 'Honorar' },
|
||||
{ key: 'auslastung', title: 'Auslastung' },
|
||||
{ key: 'termine', title: 'Termine & Sitzungen' },
|
||||
{ key: 'feiertage', title: 'Feiertage' },
|
||||
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen' },
|
||||
{ key: 'betrieb', title: 'Betrieb & Sicherheit' },
|
||||
{ key: 'datenbank', title: 'Datenbank & Sitzungen' },
|
||||
{ key: 'benutzer', title: 'Benutzerverwaltung', wide: true, recht: 'a.benutzer' },
|
||||
{ key: 'rollen', title: 'Rollen & Rechte', wide: true, recht: 'a.rollen' },
|
||||
{ key: 'honorar', title: 'Honorar', recht: 'a.honorar' },
|
||||
{ key: 'auslastung', title: 'Auslastung', recht: 'a.auslastung' },
|
||||
{ key: 'termine', title: 'Termine & Sitzungen', recht: 'a.termine' },
|
||||
{ key: 'feiertage', title: 'Feiertage', recht: 'a.feiertage' },
|
||||
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen', recht: 'a.vorlagen' },
|
||||
{ key: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' },
|
||||
{ key: 'betrieb', title: 'Betrieb & Sicherheit', recht: 'a.betrieb' },
|
||||
{ key: 'teams', title: 'Teams', recht: 'a.teams' },
|
||||
{ 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.
|
||||
let adminSection = 'benutzer';
|
||||
|
||||
// adminPlanCheckerMerkmale: zuletzt geladene Plan-Checker-Merkmale-Liste
|
||||
// (siehe Abschnitt "Plan-Checker-Merkmale" weiter unten).
|
||||
let adminPlanCheckerMerkmale = [];
|
||||
|
||||
// renderAdminArea: Einstiegspunkt des Bereichs, von showTab('admin')
|
||||
// gerufen. Befüllt alle Masken und wählt danach den sichtbaren Abschnitt.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
@@ -74,6 +99,7 @@ function renderAdminArea(){
|
||||
|
||||
loadAdminHonorar();
|
||||
loadAdminTemplates();
|
||||
loadAdminPlanCheckerMerkmale();
|
||||
// Die firmenweiten Zahlen und Feiertage stehen bereits in APP_SETTINGS bzw.
|
||||
// HOLIDAYS (beim Seitenstart geladen, js/store.js) — die Masken füllen sich
|
||||
// also ohne einen weiteren Serveraufruf.
|
||||
@@ -88,13 +114,13 @@ function renderAdminArea(){
|
||||
// isAdminUser: Kurzform für die Sichtbarkeit des Bereichs.
|
||||
// Rückgabe: Boolean.
|
||||
function isAdminUser(){
|
||||
return !!(currentUser && currentUser.role === 'admin');
|
||||
return hatAdminRecht();
|
||||
}
|
||||
|
||||
// adminSectionExists: prüft, ob ein Schlüssel ein bekannter Abschnitt ist.
|
||||
// Parameter: key. Rückgabe: Boolean.
|
||||
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
|
||||
@@ -104,8 +130,9 @@ function adminSectionExists(key){
|
||||
// (Klick aus einem anderen Bereich heraus), wird zuerst dorthin gewechselt.
|
||||
// Parameter: key. Keine Rückgabe.
|
||||
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;
|
||||
adminNavAbgleichen();
|
||||
|
||||
if(currentTab !== 'admin'){ showTab('admin'); return; }
|
||||
|
||||
@@ -130,6 +157,11 @@ function showAdminSection(key){
|
||||
// aendert sich auch ausserhalb dieses Browsers, und die uebrigen Masken
|
||||
// rechnen mit ihr (Wochenstunden -> Auslastung).
|
||||
if(key === 'benutzer' && typeof renderAdminUsers === 'function') renderAdminUsers();
|
||||
// Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso
|
||||
// frisch holen.
|
||||
if(key === 'papierkorb') loadAdminPapierkorb();
|
||||
if(key === 'teams') loadAdminTeams();
|
||||
if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte();
|
||||
|
||||
try {
|
||||
history.replaceState(null, '', location.pathname + location.search + '#' + key);
|
||||
@@ -176,8 +208,8 @@ const APP_SETTING_FIELDS = {
|
||||
// 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
|
||||
// Achtel gefüllt war; Balken und Ampel widersprachen einander.
|
||||
setReferenzHonorar: 'referenzHonorar',
|
||||
setVerteilWochen: 'verteilzeitraumWochen',
|
||||
// Referenzhonorar und Verteilzeitraum sind entfallen: Die Auslastung
|
||||
// rechnet nur noch mit Aufgaben-Stunden (js/load/load.js).
|
||||
},
|
||||
termine: {
|
||||
setStundenProTag: 'arbeitsstundenProTag',
|
||||
@@ -203,49 +235,6 @@ function renderAppSettingFields(){
|
||||
});
|
||||
const autoLock = document.getElementById('setAutoLock');
|
||||
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
|
||||
@@ -284,11 +273,9 @@ function fieldValues(gruppe){
|
||||
return patch;
|
||||
}
|
||||
|
||||
// saveAuslastungSettings: Schwellen, Skala, Vollzeit-Stunden und die
|
||||
// Aufwandsvorlage in einem Zug. Keine Parameter, keine Rückgabe.
|
||||
// saveAuslastungSettings: Schwellen und Vollzeit-Stunden in einem Zug. Keine Parameter, keine Rückgabe.
|
||||
function saveAuslastungSettings(){
|
||||
const patch = fieldValues('auslastung');
|
||||
patch.aufwandVorlage = collectAufwandMatrix();
|
||||
putAppSettings(patch, 'Auslastungs-Einstellungen gespeichert.');
|
||||
}
|
||||
|
||||
@@ -302,6 +289,14 @@ function juengsteWoche(){
|
||||
return [...db.weeks].sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))).pop();
|
||||
}
|
||||
|
||||
// adminSitzungen: ALLE Sitzungen für die Liste — geladene als volles Objekt
|
||||
// (aktueller Sperrstand), nicht geladene aus db.wochenListe (schlank). Seit
|
||||
// beim Start nur die jüngsten Sitzungen geladen werden (js/store.js).
|
||||
function adminSitzungen(){
|
||||
const liste = (db.wochenListe && db.wochenListe.length) ? db.wochenListe : db.weeks;
|
||||
return liste.map(k => db.weeks.find(w => w.label === k.label) || k);
|
||||
}
|
||||
|
||||
// renderAdminSitzungen: listet alle Sitzungen (js/weeks.js: db.weeks),
|
||||
// neueste zuerst, mit Datum, Zustand und den passenden Aktionen.
|
||||
//
|
||||
@@ -336,7 +331,7 @@ function renderAdminSitzungen(){
|
||||
if(!el || !Array.isArray(db.weeks)) return;
|
||||
|
||||
const juengste = juengsteWoche();
|
||||
const sortiert = [...db.weeks].sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label)));
|
||||
const sortiert = adminSitzungen().sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label)));
|
||||
el.innerHTML = sortiert.map(w => {
|
||||
const aktuell = w.id === db.currentWeek;
|
||||
const istJuengste = juengste && w.id === juengste.id;
|
||||
@@ -352,20 +347,20 @@ function renderAdminSitzungen(){
|
||||
// jede Aktion und hätte keinen Weg zurück außer über eine andere,
|
||||
// zufällige applyAutoLock()-auslösende Handlung.
|
||||
chip = '<span class="sitzung-chip ist-geoeffnet">Geöffnet (Korrektur)</span>';
|
||||
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.id)}')">Fertig – wieder sperren</button>`;
|
||||
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.label)}')">Fertig – wieder sperren</button>`;
|
||||
} else if(w.locked){
|
||||
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.label)}')">Entsperren</button>` : '';
|
||||
} else {
|
||||
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.label)}')">Öffnen</button>`;
|
||||
}
|
||||
|
||||
return `<div class="template-admin-row">
|
||||
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
|
||||
${chip}
|
||||
${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.label)}')" title="Sitzung löschen">×</button>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
@@ -378,15 +373,22 @@ function renderAdminSitzungen(){
|
||||
// nicht. "Öffnen" macht dagegen ausdrücklich sichtbar, was sich geändert
|
||||
// hat: Es wechselt zur Sitzung UND zum Board, statt sie nur im Hintergrund
|
||||
// zur aktuellen zu machen.
|
||||
function adminUnlockWeek(weekId){
|
||||
unlockWeek(db.weeks.find(w => w.id === weekId), renderAdminSitzungen);
|
||||
// Alle vier nehmen das Label: Die Sitzung ist evtl. nicht geladen und wird
|
||||
// dafür erst nachgeholt (sitzungSicherLaden(), js/store.js).
|
||||
function adminMitSitzung(label, fn){
|
||||
sitzungSicherLaden(label).then(w => {
|
||||
if(!w){ alert('Die Sitzung ' + label + ' wurde am Server nicht gefunden.'); renderAdminSitzungen(); return; }
|
||||
fn(w);
|
||||
}).catch(() => alert('Die Sitzung ' + label + ' konnte nicht geladen werden.'));
|
||||
}
|
||||
function adminDeleteWeek(weekId){
|
||||
deleteWeekById(weekId, renderAdminSitzungen);
|
||||
function adminUnlockWeek(label){
|
||||
adminMitSitzung(label, w => unlockWeek(w, () => { sitzungKurzAbgleichen(w); renderAdminSitzungen(); }));
|
||||
}
|
||||
function adminOpenWeek(weekId){
|
||||
switchWeek(weekId);
|
||||
showTab('board');
|
||||
function adminDeleteWeek(label){
|
||||
adminMitSitzung(label, w => deleteWeekById(w.id, renderAdminSitzungen));
|
||||
}
|
||||
function adminOpenWeek(label){
|
||||
adminMitSitzung(label, w => { switchWeek(w.id); showTab('board'); });
|
||||
}
|
||||
|
||||
// adminRelockWeek: schließt eine Korrektur ab — sperrt die übergebene
|
||||
@@ -398,10 +400,11 @@ function adminOpenWeek(weekId){
|
||||
// markieren würde — und das ist zu diesem Zeitpunkt schon die jüngste, NICHT
|
||||
// die gerade gesperrte Sitzung (gleiches Muster wie savePlan() in
|
||||
// js/plan/plan-state.js). Parameter: weekId. Keine Rückgabe.
|
||||
function adminRelockWeek(weekId){
|
||||
const w = db.weeks.find(x => x.id === weekId);
|
||||
function adminRelockWeek(label){
|
||||
const w = db.weeks.find(x => x.label === label);
|
||||
if(!w || w.locked) return;
|
||||
w.locked = true;
|
||||
sitzungKurzAbgleichen(w);
|
||||
markWeekDirty(w.label);
|
||||
save({ week: false });
|
||||
const juengste = juengsteWoche();
|
||||
@@ -599,17 +602,31 @@ function deleteHoliday(date){
|
||||
}
|
||||
|
||||
// ---------- 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
|
||||
// rendert die Liste der gerade gewählten Leistungsphase.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
// rendert die Liste. Keine Parameter, keine Rückgabe.
|
||||
function loadAdminTemplates(){
|
||||
const select = document.getElementById('adminTemplateLph');
|
||||
if(select && !select.options.length){
|
||||
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');
|
||||
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')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
@@ -623,33 +640,74 @@ function loadAdminTemplates(){
|
||||
});
|
||||
}
|
||||
|
||||
// renderAdminTemplates: zeigt die Schritte der gewählten Leistungsphase in
|
||||
// ihrer gepflegten Reihenfolge, je Zeile mit Versatz/Dauer und Lösch-Knopf.
|
||||
// Wird auch vom onchange der Leistungsphasen-Auswahl gerufen.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function renderAdminTemplates(){
|
||||
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('');
|
||||
// vorlagenAnteilText: Anzeige der Spalte „Anteil %“ — Zahl mit Komma oder
|
||||
// „in a)“ / „in a) + b)“ (Siemon: in anderen Schritten enthalten).
|
||||
function vorlagenAnteilText(t){
|
||||
if(t.enthaltenIn) return 'in ' + t.enthaltenIn.split(',').map(x => x + ')').join(' + ');
|
||||
return t.anteil ? String(t.anteil).replace('.', ',') : '';
|
||||
}
|
||||
|
||||
// addTemplateEntry: legt einen Schritt für die gewählte Leistungsphase an
|
||||
// (POST /lph-templates, Admin-only). Keine Parameter, keine Rückgabe.
|
||||
// waehleAdminTemplateGewerk: Umschalter Gewerk. Parameter: g. 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(){
|
||||
const select = document.getElementById('adminTemplateLph');
|
||||
const titleEl = document.getElementById('templateNewTitle');
|
||||
const offsetEl = document.getElementById('templateNewOffset');
|
||||
const durationEl = document.getElementById('templateNewDuration');
|
||||
const anteilEl = document.getElementById('templateNewAnteil');
|
||||
const title = titleEl ? titleEl.value.trim() : '';
|
||||
if(!title){ alert('Bitte eine Bezeichnung für den Schritt eingeben.'); return; }
|
||||
|
||||
@@ -658,15 +716,19 @@ function addTemplateEntry(){
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({
|
||||
lph: select ? select.value : '',
|
||||
gewerk: adminTemplateGewerk,
|
||||
title,
|
||||
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(({ ok, json }) => {
|
||||
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();
|
||||
showAdminNote('Schritt angelegt.');
|
||||
})
|
||||
@@ -676,6 +738,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).
|
||||
// Bereits in Projekte eingefügte Aufgaben bleiben unberührt — sie sind nach
|
||||
// dem Einfügen ganz normale plan_todos ohne Verbindung zur Vorlage.
|
||||
@@ -694,6 +800,91 @@ function deleteTemplateEntry(id){
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Abschnitt Plan-Checker-Merkmale ----------
|
||||
//
|
||||
// Eine add/delete-only Liste wie früher die Aufgaben-Vorlagen oben, ohne
|
||||
// Gruppierung (die Merkmale gelten unterschiedslos für jede Prüfung) — siehe
|
||||
// plan_checker_merkmale/GET+POST+DELETE /plan-checker-merkmale, server.js.
|
||||
// Die Liste selbst nutzt auch der Plan Checker (js/planchecker/planchecker.js),
|
||||
// um zu zeigen, was geprüft wird — beide Stellen lesen denselben Endpunkt.
|
||||
|
||||
// loadAdminPlanCheckerMerkmale: holt alle Merkmale und rendert die Liste.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function loadAdminPlanCheckerMerkmale(){
|
||||
const listEl = document.getElementById('planCheckerMerkmaleAdminListe');
|
||||
if(listEl) listEl.innerHTML = '<span class="hint">Lade Merkmale…</span>';
|
||||
|
||||
fetch(baseUrl() + '/plan-checker-merkmale')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(data => {
|
||||
adminPlanCheckerMerkmale = Array.isArray(data.merkmale) ? data.merkmale : [];
|
||||
renderAdminPlanCheckerMerkmale();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Plan-Checker-Merkmale laden fehlgeschlagen:', err);
|
||||
if(listEl) listEl.innerHTML = '<span class="hint">Merkmale konnten nicht geladen werden.</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// renderAdminPlanCheckerMerkmale: zeigt die Merkmale in gepflegter Reihenfolge,
|
||||
// je Zeile mit Prüfanweisung und Lösch-Knopf. Keine Parameter, keine Rückgabe.
|
||||
function renderAdminPlanCheckerMerkmale(){
|
||||
const listEl = document.getElementById('planCheckerMerkmaleAdminListe');
|
||||
if(!listEl) return;
|
||||
listEl.innerHTML = adminPlanCheckerMerkmale.length === 0
|
||||
? '<span class="hint">Noch kein Merkmal hinterlegt.</span>'
|
||||
: adminPlanCheckerMerkmale.map(m => `<div class="template-admin-row">
|
||||
<span class="template-admin-title">${esc(m.titel)}</span>
|
||||
<span class="hint" style="margin:0">${esc(m.prompt)}</span>
|
||||
<button class="danger small" onclick="deletePlanCheckerMerkmal('${esc(m.id)}')" title="Merkmal löschen">×</button>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// addPlanCheckerMerkmal: legt ein neues Merkmal an (POST, Admin-only).
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function addPlanCheckerMerkmal(){
|
||||
const titelEl = document.getElementById('merkmalNeuTitel');
|
||||
const promptEl = document.getElementById('merkmalNeuPrompt');
|
||||
const titel = titelEl ? titelEl.value.trim() : '';
|
||||
const prompt = promptEl ? promptEl.value.trim() : '';
|
||||
if(!titel){ alert('Bitte einen Titel eingeben.'); return; }
|
||||
if(!prompt){ alert('Bitte eine Prüfanweisung eingeben.'); return; }
|
||||
|
||||
fetch(baseUrl() + '/plan-checker-merkmale', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ titel, prompt })
|
||||
})
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.');
|
||||
if(titelEl) titelEl.value = '';
|
||||
if(promptEl) promptEl.value = '';
|
||||
loadAdminPlanCheckerMerkmale();
|
||||
showAdminNote('Merkmal angelegt.');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Merkmal anlegen fehlgeschlagen:', err);
|
||||
alert('Merkmal konnte nicht angelegt werden: ' + (err.message || err));
|
||||
});
|
||||
}
|
||||
|
||||
// deletePlanCheckerMerkmal: löscht ein Merkmal (DELETE, Admin-only).
|
||||
// Parameter: id (String). Keine Rückgabe.
|
||||
function deletePlanCheckerMerkmal(id){
|
||||
if(!confirm('Dieses Merkmal löschen?')) return;
|
||||
fetch(baseUrl() + '/plan-checker-merkmale/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||
.then(r => r.json().then(json => ({ ok: r.ok, json })))
|
||||
.then(({ ok, json }) => {
|
||||
if(!ok || !json.ok) throw new Error((json && json.error) || 'Löschen fehlgeschlagen.');
|
||||
loadAdminPlanCheckerMerkmale();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Merkmal löschen fehlgeschlagen:', err);
|
||||
alert('Merkmal konnte nicht gelöscht werden: ' + (err.message || err));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ---------- Rückmeldung ----------
|
||||
|
||||
@@ -840,3 +1031,179 @@ function loadHolidaysIntoState(){
|
||||
})
|
||||
.catch(err => console.error('Feiertage neu laden fehlgeschlagen:', err));
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Papierkorb gelöschter Projekte
|
||||
// -----------------------------
|
||||
// Ein aus der Projektliste gelöschtes Projekt legt der Server beim Speichern
|
||||
// der Sitzung als Schnappschuss in deleted_projects ab (server.js,
|
||||
// /save-week). Hier lässt es sich 30 Tage lang in die aktuelle Sitzung
|
||||
// zurückholen oder vorzeitig endgültig entfernen.
|
||||
|
||||
// loadAdminPapierkorb: holt die Liste (GET /admin/deleted-projects) und
|
||||
// rendert sie in #adminPapierkorbList.
|
||||
async function loadAdminPapierkorb(){
|
||||
const el = document.getElementById('adminPapierkorbList');
|
||||
if(!el) return;
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/admin/deleted-projects');
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
if(!data.projekte.length){
|
||||
el.innerHTML = '<p class="hint">Der Papierkorb ist leer.</p>';
|
||||
return;
|
||||
}
|
||||
const tagMs = 24 * 60 * 60 * 1000;
|
||||
el.innerHTML = data.projekte.map(p => {
|
||||
const bez = ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() || '(ohne Bezeichnung)';
|
||||
const am = new Date(p.deletedAt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
const rest = Math.max(0, Math.ceil((Date.parse(p.endgueltigAm) - Date.now()) / tagMs));
|
||||
return `<div class="template-admin-row">
|
||||
<span class="template-admin-title">${esc(bez)}
|
||||
<span class="hint" style="margin:0"> · gelöscht ${esc(am)}${p.deletedBy ? ' von ' + esc(p.deletedBy) : ''} · aus ${esc(p.weekLabel)}</span>
|
||||
</span>
|
||||
<span class="sitzung-chip">noch ${rest} ${rest === 1 ? 'Tag' : 'Tage'}</span>
|
||||
<button class="secondary small" onclick="adminProjektWiederherstellen('${esc(p.id)}')">Wiederherstellen</button>
|
||||
<button class="danger small" onclick="adminProjektEndgueltigLoeschen('${esc(p.id)}')" title="Sofort endgültig löschen">×</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(err){
|
||||
el.innerHTML = '<p class="hint">Papierkorb konnte nicht geladen werden: ' + esc(err.message) + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// adminProjektWiederherstellen: holt ein Projekt in die aktuelle Sitzung
|
||||
// zurück. Danach wird neu geladen, damit die Projektliste das Projekt zeigt
|
||||
// und die lokale Revision der Sitzung zum Server passt (sonst meldete das
|
||||
// nächste Speichern einen Konflikt).
|
||||
async function adminProjektWiederherstellen(id){
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/admin/deleted-projects/' + encodeURIComponent(id) + '/restore', { method: 'POST' });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
load();
|
||||
loadAdminPapierkorb();
|
||||
alert('Projekt wiederhergestellt in „' + data.weekLabel + '".');
|
||||
} catch(err){
|
||||
alert('Wiederherstellen fehlgeschlagen: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// adminProjektEndgueltigLoeschen: entfernt einen Papierkorb-Eintrag sofort.
|
||||
async function adminProjektEndgueltigLoeschen(id){
|
||||
if(!confirm('Projekt endgültig löschen? Das lässt sich nicht rückgängig machen.')) return;
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/admin/deleted-projects/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
loadAdminPapierkorb();
|
||||
} catch(err){
|
||||
alert('Löschen fehlgeschlagen: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Teams (Admin → Teams, Server: /admin/teams*) ----------
|
||||
// Die Webhook-Adresse ist ein Geheimnis und kommt nie zurück in den Browser;
|
||||
// angezeigt wird nur, ob eine eingetragen ist (und an welchen Host).
|
||||
async function loadAdminTeams(){
|
||||
const el = document.getElementById('adminTeamsInhalt');
|
||||
if(!el) return;
|
||||
let d;
|
||||
try { d = await fetch(baseUrl() + '/admin/teams').then(r => r.json()); }
|
||||
catch(e){ el.innerHTML = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
|
||||
if(!d || !d.ok){ el.innerHTML = '<span class="settings-warn">Keine Berechtigung.</span>'; return; }
|
||||
// Ist schon eine Adresse hinterlegt, bleibt das Eingabefeld zu, bis man
|
||||
// „Ändern“ wählt — die Adresse selbst kommt ohnehin nie zurück.
|
||||
const feldOffen = !d.konfiguriert;
|
||||
el.innerHTML = `
|
||||
<div class="teams-status${d.konfiguriert ? ' ist-an' : ''}">
|
||||
<span class="teams-punkt" aria-hidden="true"></span>
|
||||
<span class="teams-status-text">${d.konfiguriert
|
||||
? '<b>Eingerichtet</b><span>Workflow bei ' + esc(d.host) + '</span>'
|
||||
: '<b>Noch nicht eingerichtet</b><span>Adresse aus dem Teams-Workflow unten einfügen</span>'}</span>
|
||||
${d.konfiguriert ? '<button type="button" class="teams-text-knopf" onclick="teamsFeldZeigen()">Adresse ändern</button>' : ''}
|
||||
</div>
|
||||
|
||||
<div class="teams-feld" id="teamsFeld"${feldOffen ? '' : ' hidden'}>
|
||||
<label for="teamsWebhook">${d.konfiguriert ? 'Neue Workflow-Adresse' : 'Workflow-Adresse'}</label>
|
||||
<textarea id="teamsWebhook" rows="3" spellcheck="false" autocomplete="off"
|
||||
placeholder="https://… (beim Auslöser des Teams-Workflows kopieren und hier einfügen)"
|
||||
oninput="teamsFeldPruefen()"></textarea>
|
||||
<div class="teams-feld-fuss">
|
||||
<span class="teams-feld-hinweis" id="teamsFeldHinweis">Bleibt geheim: Nach dem Speichern wird die Adresse nicht mehr angezeigt.</span>
|
||||
${d.konfiguriert ? '<button type="button" class="secondary small" onclick="loadAdminTeams()">Abbrechen</button>' : ''}
|
||||
<button type="button" class="small" id="teamsSpeichernKnopf" disabled onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="teams-schalter">
|
||||
<input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''} ${d.konfiguriert ? '' : 'disabled'}
|
||||
onchange="teamsSpeichern({ neueAufgaben: this.checked })">
|
||||
<span class="teams-schalter-bahn" aria-hidden="true"></span>
|
||||
<span class="teams-schalter-text"><b>Zuständige über neue Aufgaben benachrichtigen</b>
|
||||
<span>Persönliche Nachricht mit Link zur Aufgabe</span></span>
|
||||
</label>
|
||||
|
||||
<div class="teams-aktionen">
|
||||
<button type="button" class="secondary small" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht an mich senden</button>
|
||||
${d.konfiguriert ? '<button type="button" class="teams-text-knopf ist-gefahr" onclick="teamsEntfernen()">Anbindung entfernen</button>' : ''}
|
||||
</div>
|
||||
<p class="field-error" id="teamsFehler" hidden></p>`;
|
||||
}
|
||||
|
||||
// teamsFeldZeigen: blendet das Eingabefeld für eine neue Adresse ein.
|
||||
function teamsFeldZeigen(){
|
||||
const f = document.getElementById('teamsFeld');
|
||||
if(f){ f.hidden = false; document.getElementById('teamsWebhook').focus(); }
|
||||
}
|
||||
|
||||
// teamsFeldPruefen: Speichern erst bei einer https-Adresse; Hinweis passt sich an.
|
||||
function teamsFeldPruefen(){
|
||||
const wert = (document.getElementById('teamsWebhook').value || '').trim();
|
||||
const ok = /^https:\/\/\S+$/.test(wert);
|
||||
const knopf = document.getElementById('teamsSpeichernKnopf');
|
||||
const hinweis = document.getElementById('teamsFeldHinweis');
|
||||
if(knopf) knopf.disabled = !ok;
|
||||
if(hinweis){
|
||||
hinweis.classList.toggle('ist-fehler', !!wert && !ok);
|
||||
hinweis.textContent = !wert ? 'Bleibt geheim: Nach dem Speichern wird die Adresse nicht mehr angezeigt.'
|
||||
: ok ? 'Sieht gut aus — jetzt speichern und danach eine Testnachricht senden.'
|
||||
: 'Die Adresse muss mit https:// beginnen und darf keine Leerzeichen enthalten.';
|
||||
}
|
||||
}
|
||||
|
||||
async function teamsSpeichern(patch){
|
||||
if('webhook' in patch && !String(patch.webhook || '').trim()){ alert('Bitte die Adresse aus dem Teams-Workflow einfügen.'); return; }
|
||||
const r = await fetch(baseUrl() + '/admin/teams', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(patch)
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if(!r.ok || !d.ok){ alert(d.error || 'Speichern fehlgeschlagen.'); return; }
|
||||
// Nach dem ersten Eintragen der Adresse die Meldungen gleich einschalten.
|
||||
if('webhook' in patch && !document.getElementById('teamsNeueAufgaben').checked){
|
||||
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ neueAufgaben: true }) });
|
||||
}
|
||||
loadAdminTeams();
|
||||
}
|
||||
|
||||
async function teamsEntfernen(){
|
||||
if(!confirm('Teams-Anbindung entfernen? Es werden keine Nachrichten mehr gesendet.')) return;
|
||||
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ webhook: '', neueAufgaben: false }) });
|
||||
loadAdminTeams();
|
||||
}
|
||||
|
||||
async function teamsTesten(){
|
||||
const knopf = document.getElementById('teamsTestKnopf');
|
||||
const fehler = document.getElementById('teamsFehler');
|
||||
if(knopf){ knopf.disabled = true; knopf.textContent = 'Wird gesendet…'; }
|
||||
try {
|
||||
const d = await fetch(baseUrl() + '/admin/teams/test', { method: 'POST' }).then(r => r.json());
|
||||
if(fehler){ fehler.hidden = d.ok; fehler.textContent = d.ok ? '' : 'Nicht gesendet: ' + (d.error || 'unbekannter Fehler'); }
|
||||
if(d.ok) alert('Testnachricht gesendet an ' + (d.an || 'Sie') + ' — bitte im Teams-Chat mit dem Flow-Bot nachsehen.');
|
||||
} catch(e){
|
||||
if(fehler){ fehler.hidden = false; fehler.textContent = 'Der Server ist nicht erreichbar.'; }
|
||||
} finally {
|
||||
if(knopf){ knopf.disabled = false; knopf.textContent = 'Testnachricht senden'; }
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
// (p.beauftragteLP). Deshalb erfasst der Dialog "Beauftragung" beides in EINEM
|
||||
// Schritt — direkt danach ist der Terminplan rechenbar.
|
||||
//
|
||||
// Geladen von Test.html als gemeinsames Modul (klassisches Skript, alle
|
||||
// Geladen von App.html als gemeinsames Modul (klassisches Skript, alle
|
||||
// Funktionen liegen dadurch auf window). Genutzt werden LPHS aus
|
||||
// js/globals.js, esc() aus js/utils.js, currentWeek()/save()/markWeekDirty()
|
||||
// aus js/store.js, isWeekLocked() aus js/weeks.js und openProjectPlan() aus
|
||||
@@ -202,9 +202,7 @@ function openBeauftragungModal(pid){
|
||||
}
|
||||
|
||||
// Gruppenwechsel nur anbieten, wenn das Projekt noch in "In Anbahnung"
|
||||
// steht: Diese Gruppe wird von der Auslastungsrechnung ausdrücklich NICHT
|
||||
// eingerechnet (berechneAuslastung(), js/load/load.js). Bliebe ein
|
||||
// beauftragtes Projekt dort liegen, zählte die Arbeit daran nirgends mit.
|
||||
// steht: Ein beauftragtes Projekt gehört nicht mehr in die Anbahnung.
|
||||
const gruppeWrap = document.getElementById('beauftragungGruppeWrap');
|
||||
const gruppeCheck = document.getElementById('beauftragungGruppeCheck');
|
||||
if(gruppeWrap) gruppeWrap.style.display = (p.group === 'In Anbahnung') ? '' : 'none';
|
||||
|
||||
+144
-16
@@ -1,12 +1,12 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Authentifizierung (Login-Overlay, Session, Logout)
|
||||
// -----------------------------
|
||||
// Wird von Test.html direkt nach
|
||||
// Wird von App.html direkt nach
|
||||
// js/server.js geladen, da checkAuthAndInit() baseUrl() benötigt. Ersetzt den
|
||||
// früheren rein clientseitigen Passwortschutz (js/tabs-auth.js) durch eine
|
||||
// echte serverseitige Session (server.js: /login, /logout, /whoami). Ohne
|
||||
// gültige Session zeigt checkAuthAndInit() das blockierende Login-Overlay
|
||||
// (#loginOverlay/#loginForm, siehe Test.html); nach
|
||||
// (#loginOverlay/#loginForm, siehe App.html); nach
|
||||
// erfolgreichem Login läuft der eigentliche Seiteneinstieg (per Callback
|
||||
// übergeben) wie gewohnt weiter.
|
||||
//
|
||||
@@ -19,25 +19,62 @@
|
||||
// zeigte „Passwortwechsel erforderlich" an, erfüllen konnte ihn niemand.
|
||||
|
||||
// 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;
|
||||
|
||||
// renderUserBar: zeigt Namen + Rolle des angemeldeten Benutzers im Menueband
|
||||
// an (Element #userBarInfo) und blendet den gesamten Admin-Bereich der
|
||||
// Seitenleiste (#navAdminSection) nur fuer Administratoren ein. Dieser
|
||||
// Bereich fuehrt zu allen Admin-Aufgaben — Benutzerverwaltung, firmenweite
|
||||
// Vorgaben, Datenbank und Sitzungen. Es ist ein reines UI-Gate; die
|
||||
// eigentliche Sperre sitzt serverseitig in requireRole('admin') auf den
|
||||
// zugehoerigen Routen, und showTab() leitet Nicht-Administratoren zusaetzlich
|
||||
// vom Bereich „Admin" weg (js/tabs-auth.js).
|
||||
// Seitenleiste (#navAdminSection) nur bei mindestens einem Admin-Recht ein
|
||||
// (Rollen & Rechte). Es ist ein reines UI-Gate; die eigentliche Sperre sitzt
|
||||
// serverseitig in requireRecht() auf den zugehoerigen Routen, und showTab()
|
||||
// leitet ohne Recht zusaetzlich vom Bereich „Admin" weg (js/tabs-auth.js).
|
||||
function renderUserBar(){
|
||||
const info = document.getElementById('userBarInfo');
|
||||
if(info && currentUser){
|
||||
const roleLabel = currentUser.role === 'admin' ? 'Administrator' : 'Mitglied';
|
||||
const roleLabel = currentUser.rolleName || systemRolleText(currentUser.role);
|
||||
info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')';
|
||||
}
|
||||
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(); });
|
||||
}
|
||||
|
||||
// systemRolleText: Anzeigename einer Systemrolle (users.role). Auch von der
|
||||
// Benutzerverwaltung (js/admin-users.js) genutzt.
|
||||
function systemRolleText(role){
|
||||
return role === 'admin' ? 'Administrator'
|
||||
: role === 'projektleiter' ? 'Projektleiter'
|
||||
: role === 'kiosk' ? 'Kiosk' : 'Mitglied';
|
||||
}
|
||||
|
||||
// darfProjektlisteAendern: ob das angemeldete Konto Projekte anlegen und aus
|
||||
// der Projektliste löschen darf (Recht „Projekte anlegen und löschen“). Reines
|
||||
// UI-Gate — dieselbe Regel setzt /save-week serverseitig durch
|
||||
// (darfProjektlisteAendern() in server.js), dort mit 403. Knöpfe zum
|
||||
// Anlegen tragen die Klasse .nur-projektleitung und werden von
|
||||
// renderUserBar() ausgeblendet.
|
||||
function darfProjektlisteAendern(){
|
||||
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
|
||||
@@ -47,9 +84,63 @@ async function logout(){
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// showLoginOverlay / hideLoginOverlay: blenden das blockierende Login-Overlay ein/aus.
|
||||
// showLoginOverlay / hideLoginOverlay: blenden die blockierende Login-Seite ein/aus.
|
||||
function showLoginOverlay(){
|
||||
renderLoginWoche();
|
||||
document.getElementById('loginOverlay').style.display = 'flex';
|
||||
const feld = document.getElementById('loginUsername');
|
||||
if(feld && !feld.value) feld.focus();
|
||||
}
|
||||
|
||||
// renderLoginWoche: füllt die rechte Seite der Login-Seite — laufende
|
||||
// Kalenderwoche (getWeekNumber(), js/utils.js), Jahr der ISO-Woche, die
|
||||
// Arbeitstage Mo-Fr mit dem heutigen Tag markiert und das Datum.
|
||||
function renderLoginWoche(){
|
||||
const heute = new Date();
|
||||
const kw = getWeekNumber(heute);
|
||||
// ISO-Jahr: das Jahr des Donnerstags dieser Woche (Ende Dezember/Anfang
|
||||
// Januar weicht es vom Kalenderjahr ab, z. B. KW 01 / 2027 am 30.12.2026).
|
||||
const donnerstag = new Date(heute); donnerstag.setDate(heute.getDate() + 3 - ((heute.getDay() + 6) % 7));
|
||||
const zahl = document.getElementById('loginKwZahl');
|
||||
if(!zahl) return;
|
||||
zahl.textContent = String(kw).padStart(2, '0');
|
||||
document.getElementById('loginKwJahr').textContent = donnerstag.getFullYear();
|
||||
document.getElementById('loginDatum').textContent =
|
||||
heute.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const montag = new Date(heute); montag.setDate(heute.getDate() - ((heute.getDay() + 6) % 7));
|
||||
document.getElementById('loginTage').innerHTML = ['Mo', 'Di', 'Mi', 'Do', 'Fr'].map((n, i) => {
|
||||
const d = new Date(montag); d.setDate(montag.getDate() + i);
|
||||
const istHeute = d.toDateString() === heute.toDateString();
|
||||
return '<li' + (istHeute ? ' class="ist-heute" aria-current="date"' : '') + '><span>' + n + '</span><b>' + d.getDate() + '</b></li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// verdrahteLoginHilfen: „Anzeigen" für das Passwort und der Hinweis auf
|
||||
// eine aktive Feststelltaste. Einmalig.
|
||||
let loginHilfenVerdrahtet = false;
|
||||
function verdrahteLoginHilfen(){
|
||||
if(loginHilfenVerdrahtet) return;
|
||||
loginHilfenVerdrahtet = true;
|
||||
const pw = document.getElementById('loginPassword');
|
||||
const toggle = document.getElementById('loginPwToggle');
|
||||
const caps = document.getElementById('loginCaps');
|
||||
if(toggle) toggle.addEventListener('click', () => {
|
||||
const sichtbar = pw.type === 'text';
|
||||
pw.type = sichtbar ? 'password' : 'text';
|
||||
toggle.textContent = sichtbar ? 'Anzeigen' : 'Verbergen';
|
||||
toggle.setAttribute('aria-pressed', String(!sichtbar));
|
||||
pw.focus();
|
||||
});
|
||||
const capsPruefen = e => { if(caps && e.getModifierState) caps.hidden = !e.getModifierState('CapsLock'); };
|
||||
pw.addEventListener('keydown', capsPruefen);
|
||||
pw.addEventListener('keyup', capsPruefen);
|
||||
pw.addEventListener('blur', () => { if(caps) caps.hidden = true; });
|
||||
['loginUsername', 'loginPassword'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', e => {
|
||||
e.target.closest('.login-feld').classList.remove('hat-fehler');
|
||||
document.getElementById('loginError').hidden = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
function hideLoginOverlay(){
|
||||
document.getElementById('loginOverlay').style.display = 'none';
|
||||
@@ -64,7 +155,13 @@ function afterAuthenticated(onAuthenticated){
|
||||
hideLoginOverlay();
|
||||
renderUserBar();
|
||||
onAuthenticated();
|
||||
// "Termin aus Mail" (js/plan/plan-mail-eintrag.js) nur mit eingerichteter KI.
|
||||
if(typeof mailKiStatusLaden === 'function') mailKiStatusLaden();
|
||||
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) ----------
|
||||
@@ -145,6 +242,7 @@ async function submitPasswordChange(ev){
|
||||
// Der Bereich „Einstellungen" zeigt die Kennzeichnung mit an.
|
||||
if(typeof renderSettings === 'function' && typeof currentTab !== 'undefined' && currentTab === 'settings') renderSettings();
|
||||
alert('Das Passwort wurde geändert.');
|
||||
if(typeof einfuehrungBeiBedarf === 'function') einfuehrungBeiBedarf();
|
||||
}
|
||||
|
||||
// checkAuthAndInit: prüft per GET /whoami, ob eine gültige Session besteht.
|
||||
@@ -173,24 +271,54 @@ async function checkAuthAndInit(onAuthenticated){
|
||||
return;
|
||||
}
|
||||
showLoginOverlay();
|
||||
verdrahteLoginHilfen();
|
||||
const form = document.getElementById('loginForm');
|
||||
const errEl = document.getElementById('loginError');
|
||||
const btn = document.getElementById('loginSubmit');
|
||||
const zeigeFehler = (text, feldId) => {
|
||||
errEl.textContent = text;
|
||||
errEl.hidden = false;
|
||||
if(feldId){
|
||||
const feld = document.getElementById(feldId);
|
||||
feld.closest('.login-feld').classList.add('hat-fehler');
|
||||
feld.focus();
|
||||
}
|
||||
};
|
||||
form.onsubmit = async function(ev){
|
||||
ev.preventDefault();
|
||||
errEl.textContent = '';
|
||||
const username = document.getElementById('loginUsername').value;
|
||||
errEl.hidden = true;
|
||||
const username = document.getElementById('loginUsername').value.trim();
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
if(!username) return zeigeFehler('Bitte den Benutzernamen eingeben, z. B. m.mustermann.', 'loginUsername');
|
||||
if(!password) return zeigeFehler('Bitte das Passwort eingeben.', 'loginPassword');
|
||||
btn.disabled = true; btn.classList.add('laedt');
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/login', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const d = await r.json();
|
||||
if(!r.ok || !d.ok){ errEl.textContent = d.error || 'Anmeldung fehlgeschlagen.'; return; }
|
||||
if(!r.ok || !d.ok){
|
||||
zeigeFehler(d.error || 'Anmeldung fehlgeschlagen.', 'loginPassword');
|
||||
document.getElementById('loginPassword').select();
|
||||
return;
|
||||
}
|
||||
// Das vollständige Konto (Rechte, Rollenname, Gewerk, Rundgang …)
|
||||
// liefert nur /whoami — die Antwort von /login enthält nur das Nötigste.
|
||||
// Ohne diesen Abruf fehlten nach dem Anmelden alle Einträge der
|
||||
// Seitenleiste, die ein Recht brauchen, bis man die Seite neu lud.
|
||||
currentUser = d.user;
|
||||
try {
|
||||
const w = await fetch(baseUrl() + '/whoami');
|
||||
const wd = await w.json();
|
||||
if(wd && wd.user) currentUser = wd.user;
|
||||
} catch(e){ /* Rückfall auf d.user */ }
|
||||
document.getElementById('loginPassword').value = '';
|
||||
afterAuthenticated(onAuthenticated);
|
||||
} catch(err){
|
||||
errEl.textContent = 'Verbindung zum Server fehlgeschlagen.';
|
||||
zeigeFehler('Der Server ist nicht erreichbar. Bitte später erneut versuchen.');
|
||||
} finally {
|
||||
btn.disabled = false; btn.classList.remove('laedt');
|
||||
}
|
||||
};
|
||||
}
|
||||
+5
-3
@@ -3,7 +3,7 @@
|
||||
// -----------------------------
|
||||
// Stellt downloadBackup() bereit: lädt einen konsistenten Snapshot der
|
||||
// SQLite-Datenbank über GET /backup als .db-Datei herunter (VACUUM INTO,
|
||||
// WAL-sicher). Hängt im Seitenmenü von Test.html am Button "DB Backup" und
|
||||
// WAL-sicher). Hängt im Seitenmenü von App.html am Button "DB Backup" und
|
||||
// nutzt baseUrl() aus js/server.js. Ergänzt das automatische Backup, das der
|
||||
// Server beim Start in sessions/backups/ ablegt.
|
||||
|
||||
@@ -21,8 +21,10 @@ async function downloadBackup(){
|
||||
throw new Error(msg);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const filename = 'projektuebersicht_backup_'
|
||||
+ new Date().toISOString().slice(0, 10) + '.db';
|
||||
// heuteIso() (js/utils.js) statt toISOString(): Letzteres rechnet nach
|
||||
// UTC um und datierte das Backup zwischen 0 und 2 Uhr Ortszeit auf den
|
||||
// Vortag.
|
||||
const filename = 'projektuebersicht_backup_' + heuteIso() + '.db';
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
@@ -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) + '\'])">×</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);
|
||||
}
|
||||
+312
-150
@@ -1,25 +1,28 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Board (Listenansicht)
|
||||
// -----------------------------
|
||||
// Diese Datei rendert das Projektboard (Tab "Projektboard" in `Test.html`) als
|
||||
// Diese Datei rendert das Projektboard (Tab "Projektboard" in `App.html`) als
|
||||
// schlanke, aufklappbare Liste statt der früheren breiten Matrix (eine Spalte
|
||||
// je Person/Leistungsphase): jede Zeile zeigt auf einen Blick Kürzel-Avatare
|
||||
// der zugeordneten Personen, die aktuelle Leistungsphase samt Prozentwert und
|
||||
// eine einzeilige Status-Vorschau; ein Klick auf die Zeile klappt darunter das
|
||||
// Detailpanel auf (voller Status-Editor, vollständige Rollen-Zuordnung, alle
|
||||
// LPH-Balken). Sie wird von `Test.html` nach `js/weeks.js` (vor
|
||||
// LPH-Balken). Sie wird von `App.html` nach `js/weeks.js` (vor
|
||||
// `js/people/people.js`) geladen und stellt globale Funktionen wie
|
||||
// `renderBoard()`, `toggleBoardRow()`, `cycleAssign()`,
|
||||
// `renderBoard()`, `toggleBoardRow()`,
|
||||
// und `editLph()` bereit. Sie nutzt `currentWeek()`/
|
||||
// `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load/load.js`,
|
||||
// `assignKey()`, `statusFocus()`, `sanitizeStatusHtml()` und
|
||||
// `getProgressColor()` aus `js/utils.js` sowie `openProjectPlan()` aus
|
||||
// `js/gantt.js` — navigiert bewusst im selben Tab (kein window.open() mehr),
|
||||
// ein Klick auf den Rest der Zeile klappt stattdessen nur das Detailpanel
|
||||
// auf/zu (toggleBoardRow()). editLph()/cycleAssign() rufen zusätzlich
|
||||
// auf/zu (toggleBoardRow()). editLph() ruft zusätzlich
|
||||
// renderWochenbericht() (js/wochenbericht/wochenbericht.js) auf, da dessen
|
||||
// Karten dieselben renderBoardDrawer()-Bedienelemente nutzen, aber in einem
|
||||
// eigenen DOM-Container (#wochenberichtContent) liegen.
|
||||
// eigenen DOM-Container (#wochenberichtContent) liegen. Die Zuordnung der
|
||||
// Beteiligten (p.assign) ist hier und im Wochenbericht nur noch Anzeige;
|
||||
// editierbar ist sie allein über planCycleAssign() in
|
||||
// js/plan/plan-settings.js — siehe renderBoardDrawer()s Kopfkommentar.
|
||||
|
||||
// boardRowOpen: UI-Zustand, welche Projekt-Zeile ihr Detailpanel aufgeklappt
|
||||
// darstellt (Key = Projekt-ID, Wert = boolean). Reine Anzeige-Steuerung, lebt
|
||||
@@ -28,15 +31,36 @@
|
||||
// persistiert — jede Sitzung startet mit eingeklappten Zeilen.
|
||||
let boardRowOpen = {};
|
||||
|
||||
// berichtDisclosureState: merkt sich, ob die Anwesenheit im Wochenbericht
|
||||
// (renderAttendance(), js/wochenbericht/wochenbericht.js) gerade auf- oder
|
||||
// zugeklappt ist. Ursprünglich auch für Leistungsphasen/Beteiligte gedacht
|
||||
// (damals beide ebenfalls Aufklapper, Fokus-Modus "C"); seit Eriks
|
||||
// Rückmeldung zum Entwurfsvergleich ("Aktenregal") sind beide wieder
|
||||
// dauerhaft sichtbar (renderBoardDrawer()s dichte Fassung weiter unten) und
|
||||
// brauchen dieses Gedächtnis nicht mehr — nur die Anwesenheit bleibt ein
|
||||
// Aufklapper. Ohne dieses Gedächtnis klappte sie bei jedem Neu-Rendern wieder
|
||||
// auf ihren Vorgabewert zurück — und JEDE Änderung im Bericht (eine
|
||||
// Zuordnung, ein LPH-Wert, ein Schritt im Vorwoche-Scrollrad) baut den ganzen
|
||||
// Bericht neu auf. 'toggle' bubbelt nicht, ein Lauscher in der Capture-Phase
|
||||
// sieht das Ereignis trotzdem auf dem Weg nach unten (gleiches Muster wie der
|
||||
// scroll-Lauscher der Wandtafel).
|
||||
const berichtDisclosureState = { anwesenheit: false };
|
||||
document.addEventListener('toggle', function(e){
|
||||
const key = e.target && e.target.dataset && e.target.dataset.berichtAbschnitt;
|
||||
if(key) berichtDisclosureState[key] = e.target.open;
|
||||
}, true);
|
||||
|
||||
// toggleBoardRow: klappt das Detailpanel einer Projekt-Zeile auf bzw. zu.
|
||||
// Parameter: pid (Projekt-ID). Keine Rückgabe.
|
||||
function toggleBoardRow(pid){
|
||||
boardRowOpen[pid] = !boardRowOpen[pid];
|
||||
renderBoard();
|
||||
// Der Wochenbericht rendert dieselben Projektzeilen in einem eigenen
|
||||
// Container. Ohne diesen Aufruf merkte er sich den Klick zwar, zeigte die
|
||||
// Zeile aber erst nach einem Tab-Wechsel auf- bzw. zugeklappt.
|
||||
// Der Wochenbericht und die Wandtafel rendern dieselben Projektzeilen in
|
||||
// einem eigenen Container. Ohne diesen Aufruf merkten sie sich den Klick
|
||||
// zwar, zeigten die Zeile aber erst nach einem Tab-Wechsel auf- bzw.
|
||||
// zugeklappt.
|
||||
if(typeof renderWochenbericht === 'function') renderWochenbericht();
|
||||
if(typeof renderWandboard === 'function') renderWandboard();
|
||||
}
|
||||
|
||||
// statusPreviewText: liefert eine einzeilige Klartext-Vorschau eines
|
||||
@@ -82,27 +106,117 @@ function roleClass(role){
|
||||
// bei Bedarf das aufgeklappte Detailpanel darunter. Parameter: p (Projekt),
|
||||
// allPeople (Array `{person, role}`, alle Rollen), locked (Boolean).
|
||||
// Rückgabe: HTML-String.
|
||||
// BOARD_OPTS: die Einstellungen der Projektliste. Sie zeigt alle
|
||||
// Leistungsphasen (nicht nur die beauftragten) in der ausführlichen Fassung,
|
||||
// aber nichts davon ist hier bearbeitbar — siehe nurAnsicht in
|
||||
// renderBoardDrawer().
|
||||
const BOARD_OPTS = { nurAnsicht: true };
|
||||
// BOARD_OPTS: die Einstellungen der Projektliste. Sie zeigt wie der
|
||||
// Wochenbericht nur die beauftragten Leistungsphasen (Rückfall auf alle, wenn
|
||||
// keine hinterlegt sind — p.beauftragteLP leer, siehe renderBoardDrawer()),
|
||||
// nur in der ausführlichen statt der dichten Fassung, und nichts davon ist
|
||||
// hier bearbeitbar (nurAnsicht).
|
||||
const BOARD_OPTS = { nurAnsicht: true, nurBeauftragteLph: true };
|
||||
|
||||
const PROJEKT_PUNKTE_SVG = '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.2" cy="8" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12.8" cy="8" r="1.4"/></svg>';
|
||||
|
||||
// ---------- Zeilenmenü der Projektliste ("⋯") ----------
|
||||
// Öffnen und Löschen. Löschen fragt im Menü selbst nach (wie die
|
||||
// Benutzerverwaltung), nicht über confirm(). Das Menü hängt an <body> mit
|
||||
// position: fixed und klappt nach oben, wenn unten kein Platz ist.
|
||||
let projektMenue = null; // { el, knopf, zeile }
|
||||
|
||||
function schliesseProjektMenue(fokusZurueck){
|
||||
if(!projektMenue) return;
|
||||
const { el, knopf, zeile } = projektMenue;
|
||||
projektMenue = null;
|
||||
el.remove();
|
||||
knopf.setAttribute('aria-expanded', 'false');
|
||||
if(zeile) zeile.classList.remove('is-menu-open');
|
||||
if(fokusZurueck && document.contains(knopf)) knopf.focus();
|
||||
}
|
||||
|
||||
function projektMenuePositionieren(){
|
||||
if(!projektMenue) return;
|
||||
const { el, knopf } = projektMenue;
|
||||
const r = knopf.getBoundingClientRect();
|
||||
const m = el.getBoundingClientRect();
|
||||
const nachOben = (window.innerHeight - r.bottom) < m.height + 12 && r.top > m.height + 12;
|
||||
el.classList.toggle('nach-oben', nachOben);
|
||||
el.style.top = (nachOben ? r.top - m.height - 6 : r.bottom + 6) + 'px';
|
||||
el.style.left = Math.max(8, Math.min(r.right - m.width, window.innerWidth - m.width - 8)) + 'px';
|
||||
}
|
||||
|
||||
function oeffneProjektMenue(event, pid, locked){
|
||||
event.stopPropagation();
|
||||
const knopf = event.currentTarget;
|
||||
if(projektMenue && projektMenue.knopf === knopf){ schliesseProjektMenue(true); return; }
|
||||
schliesseProjektMenue(false);
|
||||
const w = currentWeek();
|
||||
const p = w && w.projects.find(x => x.id === pid);
|
||||
const bez = p ? ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() : '';
|
||||
const darfLoeschen = !locked && darfProjektlisteAendern();
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'user-menu';
|
||||
el.setAttribute('role', 'menu');
|
||||
document.body.appendChild(el);
|
||||
const zeile = knopf.closest('.board-row');
|
||||
projektMenue = { el, knopf, zeile };
|
||||
knopf.setAttribute('aria-expanded', 'true');
|
||||
if(zeile) zeile.classList.add('is-menu-open');
|
||||
|
||||
const eintraege = () => {
|
||||
el.innerHTML = `<button type="button" role="menuitem" class="user-menu-item" data-aktion="oeffnen">
|
||||
${icon('folder', 'user-menu-ic')}<span class="user-menu-text"><span>Öffnen</span><span class="user-menu-sub">Projektbereich</span></span></button>`
|
||||
+ (darfLoeschen ? `<hr class="user-menu-trenner"><button type="button" role="menuitem" class="user-menu-item is-danger" data-aktion="loeschen">
|
||||
${icon('trash', 'user-menu-ic')}<span class="user-menu-text"><span>Löschen</span><span class="user-menu-sub">30 Tage im Papierkorb</span></span></button>` : '');
|
||||
projektMenuePositionieren();
|
||||
};
|
||||
eintraege();
|
||||
|
||||
el.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const b = e.target.closest('button');
|
||||
if(!b) return;
|
||||
if(b.dataset.zurueck){ eintraege(); return; }
|
||||
if(b.dataset.aktion === 'oeffnen'){ schliesseProjektMenue(false); openProjectPlan(pid); return; }
|
||||
if(b.dataset.aktion === 'loeschen'){
|
||||
el.innerHTML = `<div class="user-menu-confirm"><p>Projekt ${bez ? '<b>' + esc(bez) + '</b> ' : ''}löschen? Ein Administrator kann es 30 Tage lang wiederherstellen.</p>
|
||||
<div class="user-menu-confirm-row"><button type="button" class="secondary small" data-zurueck="1">Abbrechen</button>
|
||||
<button type="button" class="danger small" data-aktion="endgueltig">Löschen</button></div></div>`;
|
||||
projektMenuePositionieren();
|
||||
el.querySelector('[data-aktion="endgueltig"]').focus();
|
||||
return;
|
||||
}
|
||||
if(b.dataset.aktion === 'endgueltig'){ schliesseProjektMenue(false); delProjectFromList(pid, true); }
|
||||
});
|
||||
|
||||
if(event.detail === 0){ const erstes = el.querySelector('button'); if(erstes) erstes.focus(); }
|
||||
}
|
||||
|
||||
document.addEventListener('click', () => schliesseProjektMenue(false));
|
||||
document.addEventListener('keydown', e => {
|
||||
if(!projektMenue) return;
|
||||
const ziele = Array.from(projektMenue.el.querySelectorAll('button'));
|
||||
const i = ziele.indexOf(document.activeElement);
|
||||
if(e.key === 'Escape'){ e.preventDefault(); schliesseProjektMenue(true); }
|
||||
else if(e.key === 'ArrowDown'){ e.preventDefault(); if(ziele.length) ziele[(i + 1) % ziele.length].focus(); }
|
||||
else if(e.key === 'ArrowUp'){ e.preventDefault(); if(ziele.length) ziele[(i - 1 + ziele.length) % ziele.length].focus(); }
|
||||
else if(e.key === 'Tab') schliesseProjektMenue(false);
|
||||
});
|
||||
window.addEventListener('resize', () => schliesseProjektMenue(false));
|
||||
window.addEventListener('scroll', () => schliesseProjektMenue(false), true);
|
||||
|
||||
function renderBoardRow(p, allPeople, locked, weekLabel, opts){
|
||||
const isOpen = !!boardRowOpen[p.id];
|
||||
const tags = Array.isArray(p.tags) ? p.tags : [];
|
||||
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';
|
||||
|
||||
// Avatar-Stapel der eingeklappten Zeile: nur tatsächlich zugeordnete
|
||||
// Personen (Zustand 1 = Haupt oder 2 = optional), volle Rollen-Matrix nur
|
||||
// im aufgeklappten Detailpanel (siehe renderBoardDrawer).
|
||||
const stackHtml = allPeople.map(entry=>{
|
||||
const state = p.assign[assignKey(entry.person.id, entry.role)] || 0;
|
||||
if(state === 0) return '';
|
||||
return `<span class="board-avatar ${roleClass(entry.role)} state-${state}" title="${esc(entry.person.fullName || entry.person.name)} · ${esc(entry.role)}">${esc(entry.person.name)}</span>`;
|
||||
}).join('');
|
||||
// Eine Person mit mehreren Rollen erscheint nur einmal (beteiligteJePerson()).
|
||||
const stackHtml = beteiligteJePerson(allPeople.map(entry => ({
|
||||
person: entry.person, role: entry.role, state: p.assign[assignKey(entry.person.id, entry.role)] || 0
|
||||
}))).map(b => beteiligtenAvatarHtml(b, roleClass(b.role))).join('');
|
||||
|
||||
const aktLp = lpActiveList(p)[0];
|
||||
const aktPct = aktLp && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? p.lph[aktLp] : null;
|
||||
@@ -130,9 +244,12 @@ function renderBoardRow(p, allPeople, locked, weekLabel, opts){
|
||||
// Listenoperationen am Zeilenende (aus dem früheren Projekte-Tab). Das
|
||||
// Bearbeiten der Projektangaben führt in den Projektbereich, wo sie ihren
|
||||
// einen Ort haben — hier steht nur, was die LISTE betrifft.
|
||||
// Öffnen/Löschen hinter einem Drei-Punkte-Menü (Eriks Wunsch), gebaut wie
|
||||
// das Zeilenmenü der Benutzerverwaltung (.user-kebab/.user-menu).
|
||||
html += `<div class="board-row-actions" onclick="event.stopPropagation()">
|
||||
<button type="button" class="secondary small" onclick="openProjectPlan('${p.id}')" title="Projektbereich öffnen">Öffnen</button>
|
||||
${locked ? '' : `<button type="button" class="danger small" onclick="delProjectFromList('${p.id}')" title="Projekt aus dieser Sitzung löschen">Löschen</button>`}
|
||||
<button type="button" class="user-kebab" aria-haspopup="menu" aria-expanded="false"
|
||||
aria-label="Aktionen für ${esc(p.name || 'Projekt')}" title="Aktionen"
|
||||
onclick="oeffneProjektMenue(event, '${p.id}', ${locked ? 'true' : 'false'})">${PROJEKT_PUNKTE_SVG}</button>
|
||||
</div>`;
|
||||
html += `</div>`; // board-row-main
|
||||
|
||||
@@ -161,21 +278,47 @@ function renderBoardRow(p, allPeople, locked, weekLabel, opts){
|
||||
// Wird bei jedem Aufbau der Schublade neu gesetzt.
|
||||
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
|
||||
// verfasst hat und wann. Bestandskommentare ohne Verfasser (vor der
|
||||
// Einführung von status_author) werden als "unbekannt" ausgewiesen, statt
|
||||
// stillschweigend die aktuell angemeldete Person zu behaupten.
|
||||
// Parameter: p (Projekt). Rückgabe: HTML-String.
|
||||
function commentAuthorLine(p){
|
||||
const hasText = statusPreviewText(p.status) !== '';
|
||||
const k = kommentarVon(p, kommentarGewerk());
|
||||
const hasText = statusPreviewText(k.status) !== '';
|
||||
if(!hasText){
|
||||
const me = (currentUser && currentUser.displayName) || '';
|
||||
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>neuer Eintrag</span>`;
|
||||
}
|
||||
const author = p.statusAuthor || '';
|
||||
const at = p.statusAt ? formatCommentStamp(p.statusAt) : '';
|
||||
const author = k.statusAuthor || '';
|
||||
const at = k.statusAt ? formatCommentStamp(k.statusAt) : '';
|
||||
const who = author
|
||||
? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(author))}</span>${esc(author)}</span>`
|
||||
: `<span class="comment-who comment-unknown">Verfasser unbekannt</span>`;
|
||||
@@ -207,13 +350,15 @@ function formatCommentStamp(iso){
|
||||
// Parameter: p (Projekt), weekLabel (String), locked (Boolean),
|
||||
// dicht (Boolean). Rückgabe: HTML-String.
|
||||
function renderCommentEditor(p, weekLabel, locked, dicht){
|
||||
const text = statusHtmlToEditText(p.status || '');
|
||||
boardCommentBaseline[p.id] = text;
|
||||
// Kommentar des gewählten Gewerks (Wochenbericht), siehe kommentarVon().
|
||||
const html = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || '');
|
||||
boardCommentBaseline[p.id] = html;
|
||||
boardCommentUebernommen[p.id] = kommentarAusVorwocheUebernommen(p, weekLabel);
|
||||
|
||||
if(locked){
|
||||
return `<div class="comment-block" data-pid="${esc(p.id)}">
|
||||
<div class="comment-head-line">${commentAuthorLine(p)}</div>
|
||||
<div class="comment-readonly">${text ? sanitizeStatusHtml(p.status) : '<span class="hint">Kein Kommentar erfasst.</span>'}</div>
|
||||
<div class="comment-readonly">${html || '<span class="hint">Kein Kommentar erfasst.</span>'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -229,14 +374,22 @@ function renderCommentEditor(p, weekLabel, locked, dicht){
|
||||
// jetzt renderProjektVerlauf() (js/utils.js) und zeigen damit dasselbe.
|
||||
const verlaufAufklapper = '';
|
||||
|
||||
// richtextToolbarHtml() (js/utils.js): Fett/Kursiv/Farbe für den Kommentar
|
||||
// (Eriks Wunsch) — anders als kopf (der Autor-/Tastenkürzel-Kopf) IMMER
|
||||
// gezeigt, auch im dichten Modus: "dicht" ist hier die reguläre, zweispaltig
|
||||
// kompakte Wochenbericht-Kartenansicht (nicht eine lange Liste vieler
|
||||
// Karten), genau die Stelle, an der der Kommentar normalerweise bearbeitet
|
||||
// wird — die Werkzeugleiste ist klein genug, um dort nicht zu stören.
|
||||
const formatLeiste = richtextToolbarHtml('.comment-field');
|
||||
const feldAttrs = `${dicht ? 'title="Strg+↵ speichern · Esc verwerfen" ' : ''}`
|
||||
+ `oninput="markCommentDirty(this)" onkeydown="boardCommentKey(event, this)" onblur="boardCommentBlur(this)"`;
|
||||
return `<div class="comment-block" data-pid="${esc(p.id)}">
|
||||
<div class="comment-editor">
|
||||
${kopf}
|
||||
<textarea class="comment-field" rows="3" placeholder="Was gibt es diese Woche zu diesem Projekt?"
|
||||
${dicht ? 'title="Strg+↵ speichern · Esc verwerfen"' : ''}
|
||||
oninput="markCommentDirty(this)"
|
||||
onkeydown="boardCommentKey(event, this)"
|
||||
onblur="boardCommentBlur(this)">${esc(text)}</textarea>
|
||||
<div class="richtext-wrap">
|
||||
${formatLeiste}
|
||||
${richtextFeldHtml('comment-field', html, 'Was gibt es diese Woche zu diesem Projekt?', feldAttrs)}
|
||||
</div>
|
||||
<div class="comment-editor-foot">
|
||||
<button type="button" class="small" onmousedown="event.preventDefault()" onclick="saveBoardComment(this)">Speichern</button>
|
||||
<button type="button" class="secondary small"
|
||||
@@ -279,9 +432,7 @@ function restorePendingCommentFocus(){
|
||||
const kandidaten = Array.from(document.querySelectorAll(sel));
|
||||
const field = kandidaten.find(f => f.offsetParent !== null) || kandidaten[0];
|
||||
if(!field) return;
|
||||
field.focus();
|
||||
const ende = field.value.length;
|
||||
try { field.setSelectionRange(ende, ende); } catch(e){ /* unterstützt nicht jedes Feld */ }
|
||||
richtextCursorAnsEnde(field);
|
||||
}
|
||||
|
||||
// commentParts: löst von einem beliebigen Bedienelement aus dessen
|
||||
@@ -318,7 +469,7 @@ function commentParts(el){
|
||||
function markCommentDirty(el){
|
||||
const c = commentParts(el);
|
||||
if(!c || !c.field || !c.dirty) return;
|
||||
c.dirty.hidden = c.field.value === (boardCommentBaseline[c.pid] || '');
|
||||
c.dirty.hidden = c.field.innerHTML === (boardCommentBaseline[c.pid] || '');
|
||||
}
|
||||
|
||||
// saveBoardComment: schreibt den Feldinhalt als neuen Kommentar des Projekts,
|
||||
@@ -330,26 +481,24 @@ function saveBoardComment(el){
|
||||
if(guardWeekLocked()) return;
|
||||
const c = commentParts(el);
|
||||
if(!c || !c.field) return;
|
||||
const text = c.field.value;
|
||||
if(text === (boardCommentBaseline[c.pid] || '')) return;
|
||||
const html = sanitizeStatusHtml(c.field.innerHTML);
|
||||
if(html === (boardCommentBaseline[c.pid] || '')) return;
|
||||
|
||||
const p = currentWeek().projects.find(x => x.id === c.pid);
|
||||
if(!p) return;
|
||||
|
||||
const html = statusEditTextToHtml(text);
|
||||
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';
|
||||
else if(html === '') delete p.statusState;
|
||||
|
||||
p.status = html;
|
||||
if(html === ''){
|
||||
p.statusAuthor = '';
|
||||
p.statusAt = '';
|
||||
} else {
|
||||
p.statusAuthor = (currentUser && currentUser.displayName) || '';
|
||||
p.statusAt = new Date().toISOString();
|
||||
}
|
||||
boardCommentBaseline[c.pid] = text;
|
||||
kommentarSetzen(p, gewerk, html === ''
|
||||
? { status: '', statusAuthor: '', statusAt: '' }
|
||||
: { status: html, statusAuthor: (currentUser && currentUser.displayName) || '', statusAt: new Date().toISOString() });
|
||||
boardCommentBaseline[c.pid] = html;
|
||||
save();
|
||||
renderBoard();
|
||||
if(typeof renderWochenbericht === 'function') renderWochenbericht();
|
||||
@@ -363,9 +512,11 @@ function saveBoardComment(el){
|
||||
function discardBoardComment(el){
|
||||
const c = commentParts(el);
|
||||
if(!c || !c.field) return;
|
||||
c.field.value = 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);
|
||||
c.field.focus();
|
||||
richtextCursorAnsEnde(c.field);
|
||||
}
|
||||
|
||||
// boardCommentKey: Tastaturbedienung des Kommentarfelds — Strg/Cmd+Enter
|
||||
@@ -378,6 +529,9 @@ function boardCommentKey(e, el){
|
||||
} else if(e.key === 'Escape'){
|
||||
discardBoardComment(el);
|
||||
e.preventDefault();
|
||||
} else {
|
||||
// Enter: neue Zeile beginnt mit "- " (js/utils.js)
|
||||
richtextNeueZeileMitStrich(e, el);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,13 +565,29 @@ function takeCommentFromHistory(el, weekLabel){
|
||||
const entry = projectStatusHistory(p.projectUid).find(e => e.week.label === weekLabel);
|
||||
const html = entry
|
||||
? 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));
|
||||
if(html === null || html === undefined) return;
|
||||
|
||||
c.field.value = statusHtmlToEditText(html);
|
||||
c.field.innerHTML = sanitizeStatusHtml(html);
|
||||
kommentarVorsatzEntfernen(c.field);
|
||||
markCommentDirty(c.field);
|
||||
c.field.focus();
|
||||
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 —
|
||||
@@ -436,49 +606,51 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
|
||||
// musste. Im Projektboard, wo die Karte nur beim Aufklappen einer Zeile
|
||||
// erscheint, bleibt es bei der ausführlichen Fassung.
|
||||
//
|
||||
// nurBeauftragteLph: im Wochenbericht werden nur die Leistungsphasen
|
||||
// gezeigt, für die das Projekt beauftragt ist (p.beauftragteLP, gesetzt
|
||||
// beim Anlegen oder im Honorar-Dialog). Ist nichts hinterlegt — was auf
|
||||
// alle Bestandsprojekte zutrifft —, bleibt es bei der vollständigen Liste,
|
||||
// sonst stünde dort gar nichts. Im Projektboard sind immer alle Phasen zu
|
||||
// sehen.
|
||||
// nurBeauftragteLph: sowohl im Wochenbericht als auch im Projektboard
|
||||
// werden nur die Leistungsphasen gezeigt, für die das Projekt beauftragt
|
||||
// ist (p.beauftragteLP, gesetzt beim Anlegen oder im Honorar-Dialog). Ist
|
||||
// nichts hinterlegt — was auf ältere Bestandsprojekte zutrifft —, bleibt es
|
||||
// bei der vollständigen Liste, sonst stünde dort gar nichts.
|
||||
// opts.nurAnsicht: die Karte zeigt, sie erfasst nicht.
|
||||
//
|
||||
// Kommentar, Fortschritt je Leistungsphase und die Zuordnung der
|
||||
// Beteiligten entstehen in der wöchentlichen Besprechung und werden deshalb
|
||||
// allein im Wochenbericht gepflegt. Zuvor waren dieselben drei Angaben an
|
||||
// drei Stellen bearbeitbar (Projektboard, Wochenbericht, Projektbereich) —
|
||||
// niemand wusste, welche die richtige ist, und zwei Personen konnten
|
||||
// dasselbe Feld an verschiedenen Stellen ändern. Dieselbe Karte, dieselben
|
||||
// Werte, nur ohne Eingabefelder und mit einem Verweis dorthin, wo sie
|
||||
// gepflegt werden. Eine gesperrte Sitzung wirkt weiterhin zusätzlich.
|
||||
// Kommentar und Fortschritt je Leistungsphase entstehen in der
|
||||
// wöchentlichen Besprechung und werden deshalb allein im Wochenbericht
|
||||
// gepflegt. Zuvor waren dieselben Angaben an drei Stellen bearbeitbar
|
||||
// (Projektboard, Wochenbericht, Projektbereich) — niemand wusste, welche
|
||||
// die richtige ist, und zwei Personen konnten dasselbe Feld an
|
||||
// verschiedenen Stellen ändern. Dieselbe Karte, dieselben Werte, nur ohne
|
||||
// Eingabefelder und mit einem Verweis dorthin, wo sie gepflegt werden.
|
||||
// Eine gesperrte Sitzung wirkt weiterhin zusätzlich.
|
||||
//
|
||||
// Die Zuordnung der Beteiligten (p.assign, roleRows unten) gehörte früher
|
||||
// zu dieser Gruppe, ist aber auf Eriks Wunsch umgezogen: ein Stammdatum,
|
||||
// nur noch in den Projekteinstellungen editierbar
|
||||
// (planCycleAssign(), js/plan/plan-settings.js). Hier — sowohl im
|
||||
// Projektboard als auch im Wochenbericht — ist sie deshalb IMMER nur
|
||||
// Anzeige, unabhängig von opts.nurAnsicht; cycleAssign() ist damit ohne
|
||||
// Aufrufer und entfallen.
|
||||
const ansicht = !!(opts && opts.nurAnsicht);
|
||||
const starr = ansicht || locked;
|
||||
|
||||
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
|
||||
const nurBeauftragte = !!(opts && opts.nurBeauftragteLph) && beauftragt.length > 0;
|
||||
const sichtbareLphs = nurBeauftragte ? LPHS.filter(l => beauftragt.includes(l)) : LPHS;
|
||||
const aktLp = lpActiveList(p)[0];
|
||||
const lphRows = sichtbareLphs.map(l=>{
|
||||
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
|
||||
const aktiv = l === aktLp;
|
||||
const inputAttr = starr ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
|
||||
const pct = v !== "" ? Math.min(100, parseFloat(v)||0) : 0;
|
||||
const feld = ansicht
|
||||
? `<span class="board-lph-wert">${v === "" ? '–' : esc(v) + ' %'}</span>`
|
||||
: `<input type="text" value="${esc(v)}" ${inputAttr} placeholder="-">`;
|
||||
return `<div class="board-lph-row${aktiv ? ' lp-aktiv' : ''}${ansicht ? ' is-ansicht' : ''}${locked ? ' locked-cell' : ''}"${aktiv ? ' title="Amtierende Leistungsphase"' : ''}>
|
||||
<span class="board-lph-label">${esc(l)}</span>
|
||||
<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>
|
||||
${feld}
|
||||
</div>`;
|
||||
}).join('');
|
||||
// Die amtierende Phase (aktLp) IMMER mit anzeigen, auch wenn sie nicht in
|
||||
// beauftragteLP steht: aktLp kommt unabhängig von beauftragteLP aus
|
||||
// lpActiveList() (js/load/load.js) - beide Felder können auseinanderlaufen
|
||||
// (z. B. ein Projekt auf LP 8, aber nur LP 1/2/3/6 als beauftragt
|
||||
// gepflegt). Ohne diese Ergänzung behauptete die Kopfzeile dieses
|
||||
// Abschnitts ("LP 8 aktiv"), während LP 8 in der aufgeklappten Liste
|
||||
// darunter gar nicht auftauchte und ihr Fortschritt dort nicht pflegbar
|
||||
// war - gefunden beim Live-Test mit echten Projektdaten.
|
||||
const sichtbareLphs = nurBeauftragte ? LPHS.filter(l => beauftragt.includes(l) || l === aktLp) : LPHS;
|
||||
|
||||
// lphLeiste: dieselben Werte wie lphRows, nur nebeneinander. Jede Phase ist
|
||||
// ein schmales Feld mit der Phasennummer und ihrem Eingabefeld; der
|
||||
// Füllstand steckt als senkrechter Verlauf im Feld-Hintergrund, damit der
|
||||
// Fortschrittsbalken nicht verlorengeht. Die amtierende Phase bleibt
|
||||
// hervorgehoben.
|
||||
// lphLeiste: die Leistungsphasen nebeneinander statt gestapelt — seit
|
||||
// Variante A (Eriks Wahl aus dem Layout-Vergleich) die EINE Darstellung für
|
||||
// Leistungsphasen in dieser Schublade, nicht mehr nur die dichte
|
||||
// Wochenbericht-Karte. Jede Phase ist ein schmales Feld mit der
|
||||
// Phasennummer und ihrem Eingabefeld; der Füllstand steckt als senkrechter
|
||||
// Verlauf im Feld-Hintergrund. Die amtierende Phase bleibt hervorgehoben.
|
||||
const lphLeiste = sichtbareLphs.map(l=>{
|
||||
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
|
||||
const aktiv = l === aktLp;
|
||||
@@ -490,7 +662,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
|
||||
const fuellung = pct > 0
|
||||
? ` style="background:linear-gradient(to top, ${getProgressColor(pct)}4d ${pct}%, transparent ${pct}%)"`
|
||||
: '';
|
||||
return `<div class="board-lph-cell${aktiv ? ' lp-aktiv' : ''}${locked ? ' locked-cell' : ''}"${fuellung} title="${esc(l)}${aktiv ? ' · amtierende Leistungsphase' : ''}">
|
||||
return `<div class="board-lph-cell${aktiv ? ' lp-aktiv' : ''}${ansicht ? ' is-ansicht' : ''}${locked ? ' locked-cell' : ''}"${fuellung} title="${esc(l)}${aktiv ? ' · amtierende Leistungsphase' : ''}">
|
||||
<span class="board-lph-cell-label">${esc(l.replace('LPH ', ''))}</span>
|
||||
<input type="text" value="${esc(v)}" ${inputAttr} placeholder="-">
|
||||
</div>`;
|
||||
@@ -500,41 +672,49 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
|
||||
const entries = allPeople.filter(e=>e.role===r);
|
||||
const chips = entries.length ? entries.map(entry=>{
|
||||
const state = p.assign[assignKey(entry.person.id, entry.role)] || 0;
|
||||
const click = starr ? '' : `onclick="cycleAssign('${p.id}','${entry.person.id}','${entry.role}')"`;
|
||||
return `<span class="board-avatar ${roleClass(entry.role)} state-${state}${ansicht ? ' is-ansicht' : ''}${locked ? ' locked-cell' : ''}" ${click} title="${esc(entry.person.fullName || entry.person.name)}">${esc(entry.person.name)}</span>`;
|
||||
return `<span class="board-avatar ${roleClass(entry.role)} state-${state} is-ansicht${locked ? ' locked-cell' : ''}" title="${esc(entry.person.fullName || entry.person.name)}">${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 ${roleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`;
|
||||
}).join('');
|
||||
|
||||
// Dichte Fassung: zwei Spalten — links der Kommentar, rechts die
|
||||
// Leistungsphasen-Leiste und darunter die Beteiligten.
|
||||
//
|
||||
// Zwischenzeitlich waren es drei Spalten, weil die gestapelten Beteiligten
|
||||
// die Karte höher machten und damals 29 Karten untereinander standen. Seit
|
||||
// der Wochenbericht eine Tagesordnung ist, ist immer nur EIN Projekt
|
||||
// geöffnet — die Höhe der Karte kostet nichts mehr, während die Breite
|
||||
// knapp ist: Neben der Projektliste blieben für drei Spalten nur noch
|
||||
// 137 px für die Beteiligten, zu wenig für Rollenname und Kürzel.
|
||||
// Dichte Fassung (Wochenbericht), zweite Fassung (Eriks Rückmeldung zum
|
||||
// Entwurfsvergleich "Aktenregal"): Leistungsphasen und Beteiligte stehen
|
||||
// jetzt dauerhaft sichtbar nebeneinander statt hinter Aufklappern verborgen
|
||||
// — "die Beteiligten sollen mit angezeigt werden". Die erste Fokus-Modus-
|
||||
// Fassung hatte beide hinter <details> versteckt (Reaktion auf "zu
|
||||
// überladen" bei allem gleichzeitig sichtbar); diesmal bleiben beide
|
||||
// bewusst KOMPAKT, damit sie nebeneinander Platz finden, ohne den Kommentar
|
||||
// ("im Vergleich zu groß") zu überragen: Leistungsphasen nutzen dieselbe
|
||||
// Kästchen-Leiste wie das Projektboard (lphLeiste, oben in dieser Funktion
|
||||
// berechnet — keine zweite, eigene LPH-Darstellung mehr für den
|
||||
// Wochenbericht), Beteiligte ihre ohnehin schon kompakten Rollenzeilen
|
||||
// (roleRows, s. u.).
|
||||
//
|
||||
// Die Beteiligten behalten ihre Zeile je Rolle — anders als im Entwurf, wo
|
||||
// sie zu einer flachen Reihe zusammengefasst waren: Ein Kürzel kann in
|
||||
// mehreren Rollen vorkommen, und cycleAssign() braucht die Rolle. Ohne sie
|
||||
// wäre beim Klicken nicht erkennbar, welche Zuordnung man durchschaltet.
|
||||
// mehreren Rollen vorkommen, und ohne die Zeile wäre nicht erkennbar, in
|
||||
// welcher Rolle genau jemand zugeordnet ist (dieselbe Zeilenform nutzt die
|
||||
// jetzt editierbare Matrix der Projekteinstellungen, planCycleAssign(),
|
||||
// js/plan/plan-settings.js).
|
||||
if(opts && opts.dicht){
|
||||
const lphInfo = aktLp
|
||||
? esc(aktLp.replace('LPH ', 'LP ')) + ' aktiv' + (p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? ' · ' + esc(p.lph[aktLp]) + '%' : '')
|
||||
: 'keine aktiv';
|
||||
|
||||
return `<div class="board-drawer board-drawer-dicht">
|
||||
<div class="board-drawer-grid">
|
||||
<div class="drawer-col drawer-col-comment">
|
||||
<h4>Kommentar${weekLabel ? ' zur Sitzung ' + esc(weekLabel) : ''}</h4>
|
||||
${renderCommentEditor(p, weekLabel, locked, true)}
|
||||
${renderCommentEditor(p, weekLabel, locked, true)}
|
||||
<div class="bericht-meta-grid">
|
||||
<div>
|
||||
<div class="bericht-meta-label">${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'} · ${lphInfo}</div>
|
||||
${lphLeiste ? `<div class="board-lph-leiste">${lphLeiste}</div>` : '<span class="hint" style="margin:0">Keine gültige Leistungsphase hinterlegt — bitte in den Projekteinstellungen prüfen.</span>'}
|
||||
</div>
|
||||
<div class="drawer-col drawer-col-kompakt">
|
||||
<h4>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</h4>
|
||||
<div class="board-lph-leiste">${lphLeiste}</div>
|
||||
<h4 class="drawer-h4-zweit">Beteiligte</h4>
|
||||
<div>
|
||||
<div class="bericht-meta-label">Beteiligte</div>
|
||||
<div class="board-role-mini">${roleRows}</div>
|
||||
${!locked ? `<button type="button" class="plan-verweis" style="margin-top:4px" onclick="springZuEinstellungen('${p.id}')">In den Projekteinstellungen bearbeiten →</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''}
|
||||
${(opts.ohneVerlauf || typeof renderProjektVerlauf !== 'function') ? '' : renderProjektVerlauf(p, weekLabel, locked)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -552,11 +732,12 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
|
||||
</div>
|
||||
<div class="drawer-col drawer-col-lph">
|
||||
<h4>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</h4>
|
||||
<div class="board-lph-list">${lphRows}</div>
|
||||
<div class="board-lph-leiste">${lphLeiste}</div>
|
||||
</div>
|
||||
<div class="drawer-col drawer-col-people">
|
||||
<h4>Beteiligte</h4>
|
||||
<div class="board-role-mini">${roleRows}</div>
|
||||
${!locked ? `<button type="button" class="plan-verweis" style="margin-top:4px" onclick="springZuEinstellungen('${p.id}')">In den Projekteinstellungen bearbeiten →</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${ansicht ? renderBerichtVerweis(p, locked) : ''}
|
||||
@@ -567,7 +748,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
|
||||
// renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen.
|
||||
// Parameter: p (Projekt), locked. Rückgabe: HTML-String.
|
||||
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 ||
|
||||
'<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`;
|
||||
}
|
||||
@@ -580,7 +761,7 @@ function renderBerichtVerweis(p, locked){
|
||||
if(locked) return '<div class="drawer-verweis"><span class="hint" style="margin:0">Die Sitzung ist gesperrt.</span></div>';
|
||||
const uid = esc(p.projectUid || '');
|
||||
return `<div class="drawer-verweis">
|
||||
<span class="hint" style="margin:0">Kommentar, Fortschritt und Zuordnung werden in der Besprechung erfasst.</span>
|
||||
<span class="hint" style="margin:0">Kommentar und Fortschritt werden in der Besprechung erfasst.</span>
|
||||
<button type="button" class="plan-verweis" onclick="springZumWochenbericht('${uid}')">Im Wochenbericht bearbeiten →</button>
|
||||
</div>`;
|
||||
}
|
||||
@@ -603,7 +784,8 @@ function renderBoard(){
|
||||
if(container) container.innerHTML = '<div class="hint">Keine Sitzung geladen.</div>';
|
||||
return;
|
||||
}
|
||||
const locked = isWeekLocked(w);
|
||||
// Ziehen zwischen Gruppen ändert Stammdaten (Rollen & Rechte).
|
||||
const locked = isWeekLocked(w) || !hatRecht('proj.stamm');
|
||||
const grouped = peopleByRole(w);
|
||||
const allPeople = [];
|
||||
ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry)));
|
||||
@@ -648,50 +830,30 @@ function renderBoard(){
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// cycleAssign: wechselt die Zuweisung einer Person zu einem Projekt zyklisch
|
||||
// durch die Zustände 0=keine → 1=Haupt → 2=optional → 0. Parameter `pid`
|
||||
// (Projekt-ID), `personId`, `role`; bei Zustand 0 wird der Eintrag gelöscht.
|
||||
// Keine Rückgabe.
|
||||
function cycleAssign(pid, personId, role){
|
||||
if(guardWeekLocked()) return;
|
||||
const p = currentWeek().projects.find(x=>x.id===pid);
|
||||
const key = assignKey(personId, role);
|
||||
const cur = p.assign[key]||0;
|
||||
const next = (cur+1)%3;
|
||||
if(next===0) delete p.assign[key]; else p.assign[key]=next;
|
||||
save(); renderBoard(); renderWochenbericht();
|
||||
}
|
||||
|
||||
// editLph: setzt oder entfernt den Prozentwert einer Leistungsphase für ein
|
||||
// Projekt. Parameter: `pid` (Projekt-ID), `lph` (Leistungsphasen-Schlüssel),
|
||||
// `val` (Eingabewert). Leere/`%`-Werte werden bereinigt; keine Rückgabe.
|
||||
function editLph(pid,lph,val){
|
||||
if(guardWeekLocked()) return;
|
||||
const p = currentWeek().projects.find(x=>x.id===pid);
|
||||
val = val.trim();
|
||||
if(val==="") delete p.lph[lph];
|
||||
else p.lph[lph]=val.replace('%','');
|
||||
save(); renderBoard(); renderWochenbericht();
|
||||
}
|
||||
|
||||
// editProj: aktualisiert ein Feld eines Projekts; für Status wird zusätzlich
|
||||
// ein flag `statusState` gesetzt (='new' beim Anlegen eines neuen Kommentars),
|
||||
// wenn zuvor noch kein Status existierte. Parameter: `pid` (Projekt-ID),
|
||||
// `field` (Feldname), `val` (neuer Wert). Keine Rückgabe.
|
||||
function editProj(pid,field,val){
|
||||
if(guardWeekLocked()) return;
|
||||
const p = currentWeek().projects.find(x=>x.id===pid);
|
||||
if(!p) return;
|
||||
if(field==='status'){
|
||||
const before = (p.status||"").trim();
|
||||
const after = val.trim();
|
||||
if(before === "" && after !== ""){
|
||||
p.statusState = 'new';
|
||||
} else if(after === ""){
|
||||
delete p.statusState;
|
||||
if(!p.lph) p.lph = {};
|
||||
val = String(val || '').trim();
|
||||
if(val==="") delete p.lph[lph];
|
||||
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))));
|
||||
}
|
||||
p[field]=val; save();
|
||||
save(); renderBoard();
|
||||
if(typeof renderWochenbericht === 'function') renderWochenbericht();
|
||||
}
|
||||
|
||||
// HINWEIS: delProj() ist entfallen — seit der neuen Zeilendarstellung des
|
||||
|
||||
+461
-125
@@ -1,7 +1,7 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Dashboard ("Meine Aufgaben"-Tab)
|
||||
// -----------------------------
|
||||
// Modul für den ersten Tab der Hauptseite (Test.html, Ladereihenfolge:
|
||||
// Modul für den ersten Tab der Hauptseite (App.html, Ladereihenfolge:
|
||||
// ... → load → dashboard → gantt → ...). Das Dashboard ist die Startübersicht
|
||||
// nach dem Login; alles darin stammt aus einem einzigen GET /my-dashboard.
|
||||
//
|
||||
@@ -13,14 +13,22 @@
|
||||
// 2. BAND Der Befund des Tages als SATZ, daneben vier Kennzahlen
|
||||
// (renderDashboardBand()). Ist nichts überfällig und nichts
|
||||
// heute fällig, bleibt das Band farblos.
|
||||
// 3. LINKS Der eigene Arbeitsstrom: "Meine Aufgaben" (persönlich
|
||||
// zugewiesene offene TODOs, als Zeilen —
|
||||
// renderDashboardAufgabe()) und "Termine meiner Projekte"
|
||||
// (Aufgaben und Meilensteine der Projekte, denen man zugeordnet
|
||||
// ist, nach Dringlichkeit gegliedert — renderDashboardFristen()).
|
||||
// 4. RECHTS Was man wissen muss, ohne selbst tätig zu werden: ausstehende
|
||||
// Freigaben und Abwesenheiten der Kollegen, beide in derselben
|
||||
// schmalen Zeilenform.
|
||||
// 3. LINKS "Meine Projekte": je Projekt, dem man zugeordnet ist, ein
|
||||
// Block mit der eigenen Rolle, der laufenden Leistungsphase und
|
||||
// den offenen Terminen des Projekts — eigene Aufgaben
|
||||
// hervorgehoben (renderDashProjekte()). Projekte, in denen man
|
||||
// nur eine einzelne Aufgabe hat, stehen ebenfalls dort.
|
||||
// 4. RECHTS Ein Monatskalender mit allen Terminen der eigenen Projekte,
|
||||
// den eigenen Aufgaben, Feiertagen und den Abwesenheiten der
|
||||
// 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
|
||||
// dem Bildschirm, vier gleich große Kennzahlkacheln zeigten meist drei Nullen
|
||||
@@ -109,20 +117,29 @@ 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>';
|
||||
}
|
||||
|
||||
// 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">';
|
||||
|
||||
// Linke Zone: der eigene Arbeitsstrom
|
||||
// Linke Zone: die eigenen Projekte mit ihren Terminen
|
||||
const daten = dashDaten();
|
||||
html += '<div>';
|
||||
html += dashAbschnitt('Meine Aufgaben', tasks.length, 'mir persönlich zugewiesen',
|
||||
!weekLabel ? dashLeer('Keine Sitzung vorhanden.')
|
||||
: (tasks.length === 0 ? dashLeer('Keine offenen Aufgaben — alles abgearbeitet.')
|
||||
: '<div class="dash-karte">' + tasks.map(t => renderDashboardAufgabe(weekLabel, t)).join('') + '</div>'));
|
||||
html += dashAbschnitt('Termine meiner Projekte', projectDates.length, 'nächste drei Wochen',
|
||||
renderDashboardFristen(weekLabel, projectDates));
|
||||
const zuAlle = dashZuLesen();
|
||||
const alleOffen = daten.projekte.some(x => !zuAlle.has(dashProjKey(x.p)));
|
||||
html += dashAbschnitt('Meine Projekte', daten.projekte.length, null,
|
||||
renderDashProjekte(daten),
|
||||
daten.projekte.length > 1
|
||||
? '<button type="button" class="dash-proj-alle" id="dashReihenfolgeReset" onclick="dashReihenfolgeZuruecksetzen()"'
|
||||
+ (dashEigeneReihenfolge().length ? '' : ' hidden') + ' title="Reihenfolge wieder wie in der Projektliste">Wie Projektliste</button>'
|
||||
+ '<button type="button" class="dash-proj-alle" id="dashAlleKnopf" onclick="dashAlleUmschalten()">'
|
||||
+ (alleOffen ? 'Alle einklappen' : 'Alle aufklappen') + '</button>'
|
||||
: '');
|
||||
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 += dashAbschnitt('Kalender', null, null, '<div class="dash-karte" id="dashKalender"></div>');
|
||||
html += dashAbschnitt('Ausstehende Freigaben', pending.length, null,
|
||||
pending.length === 0 ? dashLeer('Keine ausstehenden Freigaben.')
|
||||
: '<div class="dash-karte">' + pending.map(renderDashboardFreigabe).join('') + '</div>');
|
||||
@@ -133,9 +150,432 @@ async function renderDashboard(){
|
||||
html += '</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
if(typeof renderDashNeu === 'function'){ renderDashNeu(); ladeBenachrichtigungen(true); }
|
||||
dashKalDaten = daten;
|
||||
renderDashKalender();
|
||||
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 });
|
||||
});
|
||||
// Reihenfolge wie in der Projektliste (Gruppe, dann Position darin) —
|
||||
// oder die eigene, auf dem Dashboard gezogene (dashSortieren()).
|
||||
dashSortieren(projekte);
|
||||
|
||||
// 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; }
|
||||
|
||||
// ---------- Reihenfolge der Projekte ----------
|
||||
// Grundlage ist die Reihenfolge der Projektliste (GROUP_ORDER, dann p.order).
|
||||
// Jedes Konto kann sie auf dem Dashboard durch Ziehen am Griff ändern; die
|
||||
// eigene Reihenfolge liegt am Server (users.dashboard_reihenfolge, über
|
||||
// currentUser.dashboardReihenfolge), gilt also auf jedem Gerät. Projekte,
|
||||
// die darin noch fehlen (neu zugeordnet), stehen nach ihrer Stelle in der
|
||||
// Projektliste dahinter.
|
||||
function dashListenRang(p){
|
||||
const g = GROUP_ORDER.indexOf(p.group);
|
||||
return (g === -1 ? 999 : g) * 1e6 + (Number(p.order) || 0);
|
||||
}
|
||||
function dashEigeneReihenfolge(){
|
||||
return (currentUser && Array.isArray(currentUser.dashboardReihenfolge)) ? currentUser.dashboardReihenfolge : [];
|
||||
}
|
||||
function dashSortieren(projekte){
|
||||
const eigen = dashEigeneReihenfolge();
|
||||
const pos = new Map(eigen.map((k, i) => [k, i]));
|
||||
projekte.sort((a, b) => {
|
||||
const ia = pos.has(dashProjKey(a.p)) ? pos.get(dashProjKey(a.p)) : Infinity;
|
||||
const ib = pos.has(dashProjKey(b.p)) ? pos.get(dashProjKey(b.p)) : Infinity;
|
||||
if(ia !== ib) return ia - ib;
|
||||
return dashListenRang(a.p) - dashListenRang(b.p);
|
||||
});
|
||||
}
|
||||
function dashReihenfolgeSpeichern(liste){
|
||||
if(currentUser) currentUser.dashboardReihenfolge = liste;
|
||||
fetch(baseUrl() + '/dashboard-reihenfolge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ reihenfolge: liste })
|
||||
}).then(r => { if(!r.ok) throw new Error(r.status); })
|
||||
.catch(() => alert('Die Reihenfolge konnte nicht gespeichert werden.'));
|
||||
}
|
||||
// dashReihenfolgeZuruecksetzen: wieder wie in der Projektliste.
|
||||
function dashReihenfolgeZuruecksetzen(){
|
||||
dashReihenfolgeSpeichern([]);
|
||||
renderDashboard();
|
||||
}
|
||||
|
||||
// Ziehen: Der Block wird nur ziehbar, solange der Griff gedrückt ist —
|
||||
// sonst ließe sich Text in den Terminen nicht mehr markieren.
|
||||
let dashGezogen = null;
|
||||
function dashGriffDruck(griff){
|
||||
const block = griff.closest('.dash-proj');
|
||||
if(block) block.draggable = true;
|
||||
}
|
||||
document.addEventListener('mouseup', () => {
|
||||
if(dashGezogen) return;
|
||||
document.querySelectorAll('.dash-proj[draggable="true"]').forEach(b => { b.draggable = false; });
|
||||
});
|
||||
document.addEventListener('dragstart', e => {
|
||||
const block = e.target && e.target.closest && e.target.closest('.dash-proj[draggable="true"]');
|
||||
if(!block) return;
|
||||
dashGezogen = block;
|
||||
block.classList.add('ist-gezogen');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
try { e.dataTransfer.setData('text/plain', block.dataset.key || ''); } catch(err){ /* egal */ }
|
||||
});
|
||||
document.addEventListener('dragover', e => {
|
||||
if(!dashGezogen) return;
|
||||
const liste = dashGezogen.parentElement;
|
||||
if(!liste || !liste.contains(e.target)) return;
|
||||
e.preventDefault();
|
||||
const andere = [...liste.querySelectorAll('.dash-proj:not(.ist-gezogen)')];
|
||||
const danach = andere.find(b => { const r = b.getBoundingClientRect(); return e.clientY < r.top + r.height / 2; });
|
||||
if(danach) liste.insertBefore(dashGezogen, danach); else liste.appendChild(dashGezogen);
|
||||
});
|
||||
document.addEventListener('drop', e => { if(dashGezogen) e.preventDefault(); });
|
||||
document.addEventListener('dragend', () => {
|
||||
if(!dashGezogen) return;
|
||||
const block = dashGezogen;
|
||||
dashGezogen = null;
|
||||
block.classList.remove('ist-gezogen');
|
||||
block.draggable = false;
|
||||
const neu = [...block.parentElement.querySelectorAll('.dash-proj')].map(b => b.dataset.key);
|
||||
const alt = dashEigeneReihenfolge();
|
||||
// Schon bekannte, gerade nicht angezeigte Projekte (z. B. vorübergehend
|
||||
// ohne Zuordnung) behalten ihren Platz am Ende.
|
||||
const liste = neu.concat(alt.filter(k => !neu.includes(k)));
|
||||
if(JSON.stringify(liste) !== JSON.stringify(alt)) dashReihenfolgeSpeichern(liste);
|
||||
const reset = document.getElementById('dashReihenfolgeReset');
|
||||
if(reset) reset.hidden = false;
|
||||
});
|
||||
|
||||
// dashVerschieben: Tastatur-Alternative zum Ziehen (Griff fokussiert,
|
||||
// Pfeil hoch/runter).
|
||||
function dashGriffTaste(e, griff){
|
||||
if(e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
e.preventDefault();
|
||||
const block = griff.closest('.dash-proj');
|
||||
const liste = block && block.parentElement;
|
||||
if(!liste) return;
|
||||
if(e.key === 'ArrowUp' && block.previousElementSibling) liste.insertBefore(block, block.previousElementSibling);
|
||||
else if(e.key === 'ArrowDown' && block.nextElementSibling) liste.insertBefore(block.nextElementSibling, block);
|
||||
else return;
|
||||
griff.focus();
|
||||
const neu = [...liste.querySelectorAll('.dash-proj')].map(b => b.dataset.key);
|
||||
const alt = dashEigeneReihenfolge();
|
||||
dashReihenfolgeSpeichern(neu.concat(alt.filter(k => !neu.includes(k))));
|
||||
const reset = document.getElementById('dashReihenfolgeReset');
|
||||
if(reset) reset.hidden = false;
|
||||
}
|
||||
|
||||
// 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 '<div class="dash-proj-liste">' + 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">'
|
||||
+ (daten.projekte.length > 1 ? '<span class="dash-proj-griff" tabindex="0" role="button" title="Ziehen, um die Reihenfolge zu ändern (oder Pfeiltasten)" aria-label="Reihenfolge ändern" onmousedown="dashGriffDruck(this)" ontouchstart="dashGriffDruck(this)" onkeydown="dashGriffTaste(event, this)">' + icon('grip') + '</span>' : '')
|
||||
+ '<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('') + '</div>';
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
@@ -194,12 +634,15 @@ function renderDashboardBand(z){
|
||||
// dashAbschnitt / dashLeer: die immer gleiche Umrahmung eines Blocks —
|
||||
// Überschrift, Anzahl, ein erklärender Zusatz am rechten Rand.
|
||||
// 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">'
|
||||
+ '<div class="dash-abschnitt-kopf">'
|
||||
+ '<h3 class="dash-abschnitt-titel">' + esc(titel) + '</h3>'
|
||||
+ (zahl != null ? '<span class="dash-abschnitt-zahl">' + zahl + '</span>' : '')
|
||||
+ (zusatz ? '<span class="dash-abschnitt-zusatz">' + esc(zusatz) + '</span>' : '')
|
||||
+ (kopfHtml || '')
|
||||
+ '</div>' + inhalt + '</section>';
|
||||
}
|
||||
function dashLeer(text){
|
||||
@@ -243,46 +686,6 @@ function startDashboardClock(){
|
||||
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
|
||||
// ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht
|
||||
// sagt, ob das morgen oder in drei Wochen ist.
|
||||
@@ -296,73 +699,6 @@ function relativerTag(iso){
|
||||
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
|
||||
// zurück). Parameter: iso. Rückgabe: ganze Zahl.
|
||||
function tageBis(iso){
|
||||
|
||||
@@ -40,7 +40,7 @@ function openDatePicker(opts){
|
||||
document.body.appendChild(pop);
|
||||
|
||||
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
|
||||
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px';
|
||||
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 332)) + 'px';
|
||||
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
|
||||
|
||||
renderRangePicker();
|
||||
@@ -80,7 +80,7 @@ function openRangePicker(opts){
|
||||
|
||||
// Position: unterhalb des Ankers, innerhalb des Viewports halten
|
||||
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
|
||||
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px';
|
||||
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 332)) + 'px';
|
||||
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
|
||||
|
||||
renderRangePicker();
|
||||
@@ -160,6 +160,14 @@ function applyRangePicker(){
|
||||
const selected = start || end;
|
||||
if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (start ? formatDateISO(start) : '');
|
||||
if(endInput) endInput.value = end ? formatDateISO(end) : (start ? formatDateISO(start) : '');
|
||||
// Für umgeleitete native Datumsfelder (siehe unten): deren onchange/
|
||||
// oninput-Handler sollen wie bei einer Eingabe von Hand laufen.
|
||||
[startInput, endInput].forEach(el => {
|
||||
if(el && el.type === 'date'){
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
if(opts.displayId){
|
||||
const disp = document.getElementById(opts.displayId);
|
||||
if(disp){
|
||||
@@ -199,10 +207,15 @@ function renderRangePicker(){
|
||||
</div>
|
||||
<div class="range-picker-hint">${hint}</div>
|
||||
<div class="range-picker-grid">`;
|
||||
// Erste Spalte: Kalenderwoche (Eriks Wunsch — im Büro wird in KW gedacht).
|
||||
html += '<div class="range-picker-weekday range-picker-kw-kopf">KW</div>';
|
||||
['Mo','Di','Mi','Do','Fr','Sa','So'].forEach(w => html += `<div class="range-picker-weekday">${w}</div>`);
|
||||
const kwZelle = d => `<div class="range-picker-kw">${getWeekNumber(d)}</div>`;
|
||||
if(firstWeekday > 0) html += kwZelle(first);
|
||||
for(let i = 0; i < firstWeekday; i++) html += '<div></div>';
|
||||
for(let day = 1; day <= daysInMonth; day++){
|
||||
const d = new Date(st.viewYear, st.viewMonth, day);
|
||||
if(d.getDay() === 1) html += kwZelle(d);
|
||||
const iso = formatDateISO(d);
|
||||
const classes = ['range-picker-day'];
|
||||
if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend');
|
||||
@@ -218,4 +231,15 @@ function renderRangePicker(){
|
||||
${st.mode !== 'single' && st.start && !st.end ? '<button type="button" class="small" onclick="rangePickerState.end = rangePickerState.start; applyRangePicker()">Nur dieser Tag</button>' : ''}
|
||||
</div>`;
|
||||
pop.innerHTML = html;
|
||||
}
|
||||
}
|
||||
// Native Datumsfelder (<input type="date">) öffnen statt des Browser-
|
||||
// Kalenders (kennt keine KW) denselben Kalender wie der Rest der App. Das
|
||||
// Feld selbst bleibt — Tippen funktioniert weiter, der Picker schreibt den
|
||||
// ISO-Wert hinein und löst input/change aus. Ausnahme: data-nativ.
|
||||
document.addEventListener('click', e => {
|
||||
const input = e.target && e.target.closest ? e.target.closest('input[type="date"]') : null;
|
||||
if(!input || input.disabled || input.readOnly || input.hasAttribute('data-nativ')) return;
|
||||
e.preventDefault();
|
||||
if(!input.id) input.id = 'datum_' + Math.random().toString(36).slice(2, 9);
|
||||
openDatePicker({ anchor: input, inputId: input.id });
|
||||
}, true);
|
||||
@@ -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 & 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;
|
||||
}
|
||||
+122
-15
@@ -1,13 +1,13 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — GANTT-Diagramm (gemeinsam genutzt)
|
||||
// -----------------------------
|
||||
// Gemeinsames Modul für das GANTT-Diagramm. Es wird von Test.html nach
|
||||
// Gemeinsames Modul für das GANTT-Diagramm. Es wird von App.html nach
|
||||
// js/load/load.js geladen und ist die Grundlage sowohl der Übersichts-
|
||||
// Diagramme als auch des Terminplans im Projektbereich: die Module unter
|
||||
// js/plan/ setzen darauf auf und müssen deshalb NACH dieser Datei stehen.
|
||||
// Das Modul stellt u. a. buildGanttHtml(), projectAssignees(),
|
||||
// ganttRangeFromProjects() sowie openProjectPlan()/closePlanPage()/
|
||||
// findProjectForPlan() bereit. js/plan/plan-build-gantt.js umhüllt
|
||||
// bereit. js/plan/plan-build-gantt.js umhüllt
|
||||
// buildGanttHtml() und ergänzt sie um TODO- und Meilensteinzeilen; diese
|
||||
// Erweiterung greift nur bei gesetzter Option showTodos, die Aufrufe der
|
||||
// Übersicht bleiben also unverändert.
|
||||
@@ -150,6 +150,9 @@ function buildGanttHtml(projects, start, end, options = {}){
|
||||
|
||||
// Kopfzeile: Monate und Tage
|
||||
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>';
|
||||
let monthsHtml = '';
|
||||
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.
|
||||
// Positionierte Meilensteine werden vom Aufrufer (z.B. Projektplan) als separate Zeilen eingefügt.
|
||||
// Meilensteine in der Projektzeile nur ohne showTodos. Im Projektplan
|
||||
// bekommt jeder Meilenstein eine eigene Zeile (plan-build-gantt.js) —
|
||||
// sonst lagen mehrere ohne Aufgabenbezug übereinander in einer Zeile.
|
||||
milestones.forEach((m, mIdx) => {
|
||||
if(options.showTodos && m.position) return;
|
||||
if(options.showTodos) return;
|
||||
const d = parseDateISO(m.date);
|
||||
if(!d || d < start || d > end) return;
|
||||
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.
|
||||
let chartStyle = '';
|
||||
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>`;
|
||||
}
|
||||
@@ -323,6 +327,19 @@ function openProjectPlan(pid){
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
// springZuEinstellungen: wie openProjectPlan(), landet aber gezielt im
|
||||
// Bereich "Einstellungen" statt im Dashboard — der Verweis aus Board/
|
||||
// Wochenbericht zur jetzt dort editierbaren Beteiligten-Matrix
|
||||
// (planCycleAssign(), js/plan/plan-settings.js). Der Hash muss VOR
|
||||
// showTab('plan') gesetzt werden: dessen eigener updateRouteUrl() behält
|
||||
// einen bereits vorhandenen location.hash bei (dasselbe Prinzip wie bei
|
||||
// wandSpringeZuProjekt(), js/wand/wand.js). Parameter: pid (Projekt-ID der
|
||||
// aktuellen Sitzung). Kein Rückgabewert.
|
||||
function springZuEinstellungen(pid){
|
||||
location.hash = 'einstellungen';
|
||||
openProjectPlan(pid);
|
||||
}
|
||||
|
||||
// closePlanPage: Gegenstück zu openProjectPlan() für den "Schließen"-Knopf
|
||||
// in der Kopfzeile des Projektbereichs. Früher ging es hier in der
|
||||
// Browser-Historie zurück, weil der Projektbereich ein eigenes Dokument war.
|
||||
@@ -334,12 +351,102 @@ function closePlanPage(){
|
||||
showTab(back);
|
||||
}
|
||||
|
||||
// findProjectForPlan: findet Sitzung und Projekt anhand ihrer IDs im
|
||||
// geladenen Bestand.
|
||||
// Parameter: weekId, projectId; Rückgabe: { week, project } oder null, wenn
|
||||
// die Woche nicht auflösbar ist.
|
||||
function findProjectForPlan(weekId, projectId){
|
||||
const w = db.weeks.find(x => x.id === weekId);
|
||||
if(!w) return null;
|
||||
return { week: w, project: w.projects.find(x => x.id === projectId) };
|
||||
}
|
||||
// ---------- 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();
|
||||
}
|
||||
+16
-2
@@ -1,7 +1,7 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — globale Konstanten & Zustand
|
||||
// -----------------------------
|
||||
// Diese Datei wird als Erstes von Test.html geladen und
|
||||
// Diese Datei wird als Erstes von App.html geladen und
|
||||
// legt alle zentralen Konstanten (Leistungsphasen, Rollen, Gruppen, Abteilungen,
|
||||
// Farben, Passwort-Hash) sowie den globalen In-Memory-Zustand (db, currentTab)
|
||||
// fest. Sie stellt keine Funktionen, sondern nur Daten bereit, auf die alle
|
||||
@@ -45,6 +45,14 @@ let APP_SETTINGS = {
|
||||
vollzeitStundenProWoche: 40,
|
||||
auslastungSchwelleGelb: 0.75,
|
||||
auslastungSchwelleRot: 1.0,
|
||||
// referenzHonorar/verteilzeitraumWochen fehlten hier, obwohl
|
||||
// berechneAuslastung() (js/load/load.js) beide liest. Die Aufrufstellen
|
||||
// fingen das mit eigenen Rückfallwerten ab (|| 250000, || 12) — die Vorgabe
|
||||
// stand damit an drei Stellen, und eine im Admin-Bereich geänderte
|
||||
// Server-Vorgabe wäre an den Rückfallwerten vorbeigelaufen. Werte identisch
|
||||
// zu APP_SETTING_DEFS (server.js), wie bei allen Schlüsseln hier.
|
||||
referenzHonorar: 250000,
|
||||
verteilzeitraumWochen: 12,
|
||||
aufwandVorlage: {
|
||||
'LPH 1': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
|
||||
'LPH 2': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
|
||||
@@ -100,6 +108,9 @@ let saveSyncTimer = null;
|
||||
// In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung.
|
||||
// globalPeople ist ein flaches Array (keine Abteilungstrennung mehr).
|
||||
let db = { weeks: [], currentWeek: null, globalPeople: [],
|
||||
// wochenListe: ALLE Sitzungen schlank (id, label, date, locked, updatedAt) —
|
||||
// db.weeks hält nur die geladenen (siehe SITZUNGEN_START, js/store.js).
|
||||
wochenListe: [],
|
||||
// sandboxWeek: Platzhalter bis zum ersten erfolgreichen load() (js/store.js)
|
||||
// befüllt ihn — leer statt undefined, damit ein Zugriff vor dem Laden nicht
|
||||
// crasht, sondern nur eine leere Planung-Liste zeigt.
|
||||
@@ -135,9 +146,12 @@ const TAB_SLUGS = {
|
||||
board: 'Projektübersicht',
|
||||
wochenbericht: 'Wochenbericht',
|
||||
load: 'Auslastung',
|
||||
abwesenheit: 'Abwesenheiten',
|
||||
people: 'Projektbeteiligte',
|
||||
planung: 'Planung',
|
||||
planchecker: 'PlanChecker',
|
||||
dashboard:'MeineAufgaben',
|
||||
wand: 'Wandtafel',
|
||||
settings: 'Einstellungen',
|
||||
admin: 'Admin'
|
||||
};
|
||||
@@ -148,7 +162,7 @@ const TAB_BY_SLUG = Object.fromEntries(Object.entries(TAB_SLUGS).map(([k,v]) =>
|
||||
// aber nicht mehr erzeugt, weil er in TAB_SLUGS fehlt.
|
||||
TAB_BY_SLUG['Projekte'] = 'board';
|
||||
|
||||
// PLAN_SLUG: URL-Schlüssel des Projektbereichs (Bereich #plan von Test.html),
|
||||
// PLAN_SLUG: URL-Schlüssel des Projektbereichs (Bereich #plan von App.html),
|
||||
// im gleichen Deep-Link-Schema wie die Tabs: "/Projektplan/<Projektname>",
|
||||
// dazu ?weekLabel=…&projNr=… als Parameter. Steht nicht in TAB_SLUGS, weil
|
||||
// die Adresse das geöffnete Projekt mitträgt und deshalb nicht fest ist —
|
||||
|
||||
+5
-17
@@ -2,10 +2,10 @@
|
||||
// Projektübersicht — Init / Start
|
||||
// -----------------------------
|
||||
// Einstiegsmodul der Hauptseite: wird als letztes Skript ausschließlich von
|
||||
// Test.html geladen und startet direkt beim Laden den Initialisierungsablauf
|
||||
// App.html geladen und startet direkt beim Laden den Initialisierungsablauf
|
||||
// (automatische Ausführung unten in der Datei, nur wenn ein 'board'-Element
|
||||
// existiert). Es stellt renderAll(),
|
||||
// initUiState() und syncRangeDisplay() bereit und nutzt loadUiState()/
|
||||
// initUiState() bereit und nutzt loadUiState()/
|
||||
// loadTheme()/loadHeaderState()/loadSidebarCollapseState() aus js/ui.js, load() aus js/store.js,
|
||||
// showTab()/TAB_PANELS aus js/tabs-auth.js sowie die Render-Funktionen
|
||||
// renderBoard()/renderWochenbericht()/renderLoad()/renderProjects()/renderPeople()
|
||||
@@ -18,10 +18,13 @@ function renderAll(){
|
||||
const activeTab = document.querySelector('.tab.active')?.dataset.tab || currentTab;
|
||||
if(activeTab === 'wochenbericht') renderWochenbericht();
|
||||
else if(activeTab === 'load') renderLoad();
|
||||
else if(activeTab === 'abwesenheit') renderAbwesenheiten();
|
||||
else if(activeTab === 'people') renderPeople();
|
||||
else if(activeTab === 'dashboard') renderDashboard();
|
||||
else if(activeTab === 'settings') renderSettings();
|
||||
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') —
|
||||
// load() ruft direkt im Anschluss showTab(currentTab) auf, und erst dort
|
||||
// ist der Bereich sichtbar (das GANTT braucht die Containerbreite).
|
||||
@@ -58,21 +61,6 @@ function initUiState(){
|
||||
showTab(tab);
|
||||
}
|
||||
|
||||
// syncRangeDisplay: schreibt den aus zwei Datumsfeldern (startId/endId)
|
||||
// berechneten Zeitraum als deutschen Text (z. B. "05.04.2026 – 11.04.2026")
|
||||
// in das Anzeigefeld displayId. Parameter: die drei Element-IDs; kein Rückgabewert.
|
||||
function syncRangeDisplay(displayId, startId, endId){
|
||||
const disp = document.getElementById(displayId);
|
||||
if(!disp) return;
|
||||
const sEl = document.getElementById(startId);
|
||||
const eEl = document.getElementById(endId);
|
||||
const s = sEl ? parseDateISO(sEl.value) : null;
|
||||
const e = eEl ? parseDateISO(eEl.value) : null;
|
||||
const from = s ? formatDateDE(s) : '';
|
||||
const to = e ? formatDateDE(e) : from;
|
||||
disp.value = from ? (from + ' – ' + to) : '';
|
||||
}
|
||||
|
||||
// Automatische Initialisierung der Hauptseite — seit Projektbereich und
|
||||
// Benutzerverwaltung eigene Bereiche sind, ist sie die einzige Seite. Der eigentliche
|
||||
// Start läuft erst nach erfolgreicher Anmeldung (checkAuthAndInit(), js/auth.js) —
|
||||
|
||||
+321
-568
@@ -1,39 +1,10 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Auslastung (Load) & Aufwand je Leistungsphase
|
||||
// -----------------------------
|
||||
// Modul für den Auslastungs-Tab der Hauptseite (Test.html, Ladereihenfolge:
|
||||
// globals → utils → ui → date-picker → csv → server → store → weeks → board →
|
||||
// people → projects → load → gantt → …). Es stellt renderLoad(), das LP-
|
||||
// Aufwand-Modal und das Neues-Projekt-Modal bereit. Genutzt werden LPHS/ROLES
|
||||
// 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,
|
||||
// pdfLoadAmpelColor, 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.
|
||||
// Modul für den Auslastungs-Tab (renderLoad()). Die Auslastung kommt allein
|
||||
// aus Aufgaben mit Aufwand, gleich geteilt auf ihre Zuständigen (siehe
|
||||
// "Auslastung aus Aufgaben" unten). aufgabenVerlaufJePerson() nutzen auch
|
||||
// die Ressourcenansicht im Terminplan (plan-gantt.js) und die Wandtafel.
|
||||
|
||||
// AUFWAND_LPS: Phasen des Aufwandsmodells — alle echten Leistungsphasen
|
||||
// (inkl. LPH 9), ohne die Sammelkategorie "W+M". Bewusst per Filter statt per
|
||||
@@ -41,19 +12,6 @@
|
||||
// Raster fällt.
|
||||
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
|
||||
// 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)
|
||||
@@ -98,128 +56,17 @@ function lpActiveList(pr){
|
||||
return [];
|
||||
}
|
||||
|
||||
// aufwandMatrixWert: Rollenwert eines Projekts für eine Leistungsphase
|
||||
// (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 ----------
|
||||
// ---------- Auslastung aus Aufgaben ----------
|
||||
//
|
||||
// Die Auslastung wird als WOCHENSTUNDEN gerechnet und erst zum Schluss auf
|
||||
// die Kapazität der Person bezogen. Vorher war sie eine Summe dimensionsloser
|
||||
// Gewichte: Ein Projekt in LPH 5 zählte für die Planung 1,0 — ob es 20.000 €
|
||||
// oder 2 Mio. € umfasste. Zwei kleine Projekte wogen damit schwerer als ein
|
||||
// großes, und die Zahl sagte nichts darüber, wie viel Arbeit tatsächlich
|
||||
// anliegt.
|
||||
//
|
||||
// Der Weg von einem Projekt zu den Wochenstunden einer Person:
|
||||
//
|
||||
// 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);
|
||||
}
|
||||
// Eriks Vorgabe (10/2026): Die Auslastung entsteht ALLEIN aus Aufgaben mit
|
||||
// eingetragenem Aufwand (t.aufwand, Stunden). Hat eine Aufgabe 8 h und eine
|
||||
// zuständige Person, ist diese Person mit 8 h ausgelastet; bei zwei
|
||||
// Zuständigen je 4 h, bei drei je ein Drittel. Aufgaben ohne Aufwand zählen
|
||||
// nicht. Das frühere Phasenbudget (Honorar × Phasenanteil ÷ Stundensatz,
|
||||
// verteilt über eine Gewichtungstabelle Leistungsphase × Rolle) ist
|
||||
// entfallen — samt Tabelle (firmenweit im Admin-Bereich und je Projekt).
|
||||
// Die Stunden verteilen sich auf die Arbeitstage des Aufgabenzeitraums
|
||||
// (verteileAufBalken()); ohne Termine auf die nächsten UEBERFAELLIG_WOCHEN.
|
||||
|
||||
// fortschrittProzent: Fortschritt einer Leistungsphase am Projekt (0–100).
|
||||
// Die Werte kommen aus einem Textfeld und können "80", "80 %" oder "12,5"
|
||||
@@ -254,77 +101,138 @@ function arbeitsPhase(projekt, amtierend){
|
||||
return folgende.find(l => fortschrittProzent(projekt, l) < 100) || null;
|
||||
}
|
||||
|
||||
// berechneAuslastung: Wochenstunden EINER Zuordnung (Person/Rolle) zu einem
|
||||
// Projekt. Parameter: mitarbeiter (ungenutzt, Signatur beibehalten), projekt,
|
||||
// aktuelleLP, rolle, status (1 = Haupt, 2 = Unterstützung), alleZuordnungen
|
||||
// (optional: das assign-Objekt des Projekts, für die Aufteilung auf mehrere
|
||||
// Köpfe derselben Rolle).
|
||||
// Rückgabe: { stunden, load, anbahnung?, unbewertbar?, grund? }.
|
||||
// stunden = Wochenstunden dieser Person aus diesem Projekt
|
||||
// load = dieselbe Größe als Anteil einer Vollzeitstelle (Rückwärts-
|
||||
// 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' });
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wochenvorschau: VORSCHAU_WOCHEN Kalenderwochen ab dieser Woche; "diese
|
||||
// Woche" ist Woche 0 — die Zahl, die Wandtafel und Projektbereich zeigen.
|
||||
// ---------------------------------------------------------------------------
|
||||
const VORSCHAU_WOCHEN = 12;
|
||||
// Eine offene Aufgabe, deren Ende schon vorbei ist (oder die keine Termine
|
||||
// hat): ihre Reststunden fallen in die nächsten Wochen an.
|
||||
const UEBERFAELLIG_WOCHEN = 4;
|
||||
|
||||
const basis = projektBasisHonorar(projekt);
|
||||
if(basis.betrag <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar und ohne Projektgröße' });
|
||||
|
||||
// Gerechnet wird mit der Phase, in der tatsächlich gearbeitet wird — der
|
||||
// amtierenden oder, wenn die fertig ist, der nächsten offenen.
|
||||
const phase = arbeitsPhase(projekt, aktuelleLP);
|
||||
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);
|
||||
const stunden = personStunden / wochen;
|
||||
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
|
||||
|
||||
return { stunden, load: stunden / vollzeit, quelle: basis.quelle, wochen, phase };
|
||||
// vorschauMontag: Montag der aktuellen Woche, 0 Uhr Ortszeit.
|
||||
function vorschauMontag(){
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
|
||||
return d;
|
||||
}
|
||||
|
||||
// groesseText: beschriftet die Projektgröße für die Eingabemasken.
|
||||
//
|
||||
// Nur die Zahl, KEIN Eurobetrag daneben. Intern wird die Größe zwar über das
|
||||
// Referenzhonorar in einen Betrag umgerechnet (projektBasisHonorar()) — die
|
||||
// Zahl neben dem Feld auszuweisen, gäbe der Schätzung aber eine Genauigkeit,
|
||||
// die sie nicht hat: "0,40 · rund 100.000 €" liest sich wie ein Honorar,
|
||||
// obwohl es ein grober Umfang ist.
|
||||
// Parameter: wert (0–1). Rückgabe: String.
|
||||
function groesseText(wert){
|
||||
const n = Math.max(0, Math.min(1, parseFloat(wert) || 0));
|
||||
if(n <= 0) return '— keine Angabe';
|
||||
return n.toFixed(2).replace('.', ',');
|
||||
// vorschauWochen: die Kalenderwochen der Vorschau. Rückgabe: [{montag, kw}].
|
||||
function vorschauWochen(){
|
||||
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) };
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -359,131 +267,12 @@ function loadAmpelColor(x){
|
||||
// nicht mehr nur einen Balken, sondern die große Zahl am Zeilenende. Die
|
||||
// früheren Festwerte (#c53030/#d69e2e/#2f855a) waren für den hellen Grund
|
||||
// gewählt und standen im dunklen Thema zu dunkel; --red/--amber/--green
|
||||
// sind dort hellere Töne (siehe styles.css). Für den PDF-Export gilt
|
||||
// weiterhin pdfLoadAmpelColor() mit druckfreundlichen Festwerten — ein PDF
|
||||
// hat kein Thema.
|
||||
// sind dort hellere Töne (siehe styles.css). Ein Gegenstück mit
|
||||
// druckfreundlichen Festwerten gab es für den PDF-Export (pdfLoadAmpelColor);
|
||||
// es ist mit dem gewerkeübergreifenden Sitzungs-PDF entfallen — die
|
||||
// verbliebenen Exporte zeigen keine Auslastung mehr.
|
||||
return x >= schwelleRot() ? 'var(--red)' : x >= schwelleGelb() ? 'var(--amber)' : 'var(--green)';
|
||||
}
|
||||
// pdfLoadAmpelColor: Ampel-Farbe für den PDF-Export (js/pdf.js) mit gleichen
|
||||
// Schwellen wie loadAmpelColor, aber dunkleren, druckfreundlichen Farbtönen.
|
||||
function pdfLoadAmpelColor(x){
|
||||
return x >= schwelleRot() ? '#D70015' : x >= schwelleGelb() ? '#B25000' : '#248A3D';
|
||||
}
|
||||
|
||||
// ---------- 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) ----------
|
||||
|
||||
@@ -494,9 +283,6 @@ function saveLpModal(){
|
||||
// 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
|
||||
// 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
|
||||
// und vom Ampelspektrum (grün/gelb/rot) getrennt — ein Segment sagt aus,
|
||||
@@ -530,237 +316,204 @@ function projektFarbe(name){
|
||||
// und Balkenskala sind firmenweit einstellbar (Bereich „Einstellungen").
|
||||
// Mittelwert: geteilt wird durch die Zahl der Sitzungen, in denen die Person
|
||||
// ü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(){
|
||||
const scope = document.getElementById('loadScope').value;
|
||||
const selectedWeek = currentWeek();
|
||||
const weeks = scope==='all' ? db.weeks : (selectedWeek ? [selectedWeek] : []);
|
||||
if(weeks.length === 0){
|
||||
document.getElementById('loadGrid').innerHTML = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>';
|
||||
const w = currentWeek();
|
||||
const grid = document.getElementById('loadGrid');
|
||||
if(!grid) return;
|
||||
if(!w){
|
||||
grid.innerHTML = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>';
|
||||
return;
|
||||
}
|
||||
const grouped = scope==='all' ? peopleByRole(db.weeks) : peopleByRole(selectedWeek);
|
||||
const stats = {};
|
||||
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};
|
||||
});
|
||||
});
|
||||
const wochen = vorschauWochen();
|
||||
const grouped = peopleByRole(w);
|
||||
|
||||
// Je Person zählen, in wie vielen der ausgewerteten Sitzungen sie im
|
||||
// Teilnehmerkreis stand. Ohne das wurde durch ALLE Sitzungen geteilt: Ein
|
||||
// neuer Kollege, der seit zwei von zwanzig Sitzungen voll eingeplant ist,
|
||||
// erschien mit 0,30 statt 3,00 — also als praktisch unbeschäftigt.
|
||||
weeks.forEach(w => {
|
||||
(Array.isArray(w.people) ? w.people : []).forEach(person => {
|
||||
ROLES.forEach(r => {
|
||||
const key = assignKey(person.id, r);
|
||||
if(stats[key]) stats[key].weeks++;
|
||||
});
|
||||
// Je MENSCH (über alle Rollen): Stunden je Woche, Projekte, Rollen.
|
||||
const koepfe = {};
|
||||
const kopfVon = person => {
|
||||
const schluessel = person.fullName || person.name;
|
||||
return koepfe[schluessel] || (koepfe[schluessel] = {
|
||||
person, ids: new Set(), rollen: new Set(), verlauf: new Array(VORSCHAU_WOCHEN).fill(0),
|
||||
projekte: {}, anbahnung: new Set()
|
||||
});
|
||||
});
|
||||
};
|
||||
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
|
||||
// ohne amtierende Phase, ohne Honorar und ohne Größe trug bisher still 0
|
||||
// bei und war von echter Unterbeschäftigung nicht zu unterscheiden. Die
|
||||
// Seite weist die Lücke jetzt oben aus.
|
||||
const luecken = {}; // Projektname -> Grund
|
||||
// Nicht bewertbare Projekte werden ausgewiesen statt still als 0 gezählt.
|
||||
const luecken = {};
|
||||
let bewertbareProjekte = 0, alleProjekte = 0;
|
||||
|
||||
weeks.forEach(w=>{
|
||||
w.projects.forEach(pr=>{
|
||||
if(pr.group === 'In Anbahnung') return;
|
||||
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;
|
||||
if(probe.unbewertbar){ luecken[projektName] = probe.grund; }
|
||||
else bewertbareProjekte++;
|
||||
(w.projects || []).forEach(pr => {
|
||||
const anbahnung = pr.group === 'In Anbahnung';
|
||||
const assign = pr.assign || {};
|
||||
const projektName = pr.nr ? pr.nr + ' ' + pr.name : pr.name;
|
||||
|
||||
// Vergebene Stunden je Projekt mitzählen: Ein Projekt kann bewertbar
|
||||
// sein und trotzdem bei niemandem ankommen — etwa wenn es in LPH 8
|
||||
// steht (die die Bauüberwachung trägt), aber nur ein Planer zugeordnet
|
||||
// ist. Die Arbeit fällt an, wird aber niemandem zugerechnet; das ist
|
||||
// eine Lücke und keine Null.
|
||||
let vergeben = 0;
|
||||
// Projektanzahl je Person: jede Zuordnung zählt, unabhängig davon, ob
|
||||
// das Projekt bewertbar ist — "wer hat wie viele Projekte".
|
||||
Object.keys(assign).forEach(key => {
|
||||
const st = assign[key];
|
||||
if(st !== 1 && st !== 2) return;
|
||||
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=>{
|
||||
const s = stats[aKey];
|
||||
if(!s) return;
|
||||
const st = pr.assign[aKey];
|
||||
const rolle = aKey.slice(aKey.indexOf('|') + 1);
|
||||
const al = berechneAuslastung(null, pr, aktivePhase, rolle, st, pr.assign);
|
||||
if(al.anbahnung){
|
||||
if(st===1 || st===2) s.anbahnung++;
|
||||
return;
|
||||
}
|
||||
if(al.stunden > 0){
|
||||
vergeben += al.stunden;
|
||||
s.stunden += al.stunden;
|
||||
// Der Stundenbeitrag des einzelnen Projekts wird behalten, nicht
|
||||
// nur gezählt: Das Lastband zeigt die Summe aufgeteilt nach
|
||||
// Projekten. Gerechnet wird nichts Zusätzliches — derselbe Wert,
|
||||
// der ohnehin aufaddiert wird, wurde bisher nur verworfen.
|
||||
const eintrag = s.projects[pr.name] || (s.projects[pr.name] = { anzahl:0, stunden:0, nr: pr.nr, quelle: al.quelle });
|
||||
eintrag.anzahl++;
|
||||
eintrag.stunden += al.stunden;
|
||||
if(!eintrag.nr && pr.nr) eintrag.nr = pr.nr;
|
||||
}
|
||||
// A: Zuständige offener Aufgaben zählen als Beteiligte des Projekts,
|
||||
// auch wenn sie unter "Beteiligte" nicht eingetragen sind.
|
||||
const todos = (pr.plan && Array.isArray(pr.plan.todos)) ? pr.plan.todos : [];
|
||||
todos.forEach(t => {
|
||||
if(!t || t.done || balkenPhase(t)) return;
|
||||
(Array.isArray(t.assignees) ? t.assignees : []).forEach(id => {
|
||||
const k = kopfFuerId(id);
|
||||
if(!k) return;
|
||||
if(anbahnung){ k.anbahnung.add(projektName); return; }
|
||||
if(!k.projekte[projektName]) k.projekte[projektName] = { haupt: false, stunden: 0 };
|
||||
});
|
||||
|
||||
if(!probe.unbewertbar && !probe.fertig && vergeben === 0 && !luecken[projektName]){
|
||||
// Welche Rolle trüge die Arbeit? Die mit dem höchsten Gewicht in der
|
||||
// Phase, in der gerade gearbeitet wird.
|
||||
const phase = arbeitsPhase(pr, aktivePhase);
|
||||
const tragend = phase
|
||||
? ROLES.slice().sort((a,b)=> rollenGewicht(pr, phase, b) - rollenGewicht(pr, phase, a))[0]
|
||||
: null;
|
||||
luecken[projektName] = tragend
|
||||
? 'niemand für ' + tragend + ' zugeordnet (' + phase + ')'
|
||||
: 'niemand zugeordnet';
|
||||
bewertbareProjekte--;
|
||||
}
|
||||
});
|
||||
// B: Aufgaben mit Aufwand bringen ihre Stunden mit — auch in Projekten,
|
||||
// die (noch) nicht bewertbar oder schon fertig gerechnet sind.
|
||||
const aufgabenStd = aufgabenVerlaufJePerson(pr);
|
||||
Object.keys(aufgabenStd).forEach(id => {
|
||||
const k = kopfFuerId(id);
|
||||
if(!k) return;
|
||||
aufgabenStd[id].forEach((h, i) => { k.verlauf[i] += h; });
|
||||
const e = k.projekte[projektName];
|
||||
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';
|
||||
}
|
||||
});
|
||||
|
||||
// Wochenstunden: über die Sitzungen gemittelt, in denen die Person im
|
||||
// Teilnehmerkreis stand.
|
||||
const wochenStunden = k => stats[k].stunden / Math.max(1, stats[k].weeks || weeks.length);
|
||||
// Karten vorbereiten: Kapazität je Woche (ohne Urlaub/Feiertage), Quote,
|
||||
// Spitze.
|
||||
const MAX_QUOTE = 1.4;
|
||||
const karten = Object.values(koepfe).map(k => {
|
||||
const kap = wochenKapazitaet(k.person);
|
||||
const quoten = k.verlauf.map((h, i) => kap[i].stunden > 0 ? h / kap[i].stunden : null);
|
||||
let spitze = -1, spitzeIdx = -1;
|
||||
quoten.forEach((q, i) => { if(q !== null && q > spitze){ spitze = q; spitzeIdx = i; } });
|
||||
const projektNamen = Object.keys(k.projekte);
|
||||
return { k, kap, quoten, spitze: Math.max(0, spitze), spitzeIdx, projektNamen,
|
||||
haupt: projektNamen.filter(n => k.projekte[n].haupt).length };
|
||||
}).sort((a, b) => (b.projektNamen.length > 0) - (a.projektNamen.length > 0) || b.spitze - a.spitze);
|
||||
|
||||
// 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 prozent = q => Math.round(q * 100) + ' %';
|
||||
const ueberlastet = karten.filter(c => c.spitze >= schwelleRot());
|
||||
const luckenNamen = Object.keys(luecken);
|
||||
const ueberlastet = Object.values(koepfe).filter(k => k.stunden / kapazitaetStunden(k.person) >= schwelleRot());
|
||||
html += `<div class="load-kopf">
|
||||
<div class="load-kopf-zahl">
|
||||
<strong>${bewertbareProjekte}</strong> von ${alleProjekte} Projekten bewertet
|
||||
</div>
|
||||
${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>` : ''}
|
||||
|
||||
let html = `<div class="load-kopf">
|
||||
<div class="load-kopf-zahl"><strong>${bewertbareProjekte}</strong> von ${alleProjekte} Projekten mit Aufwand in Aufgaben</div>
|
||||
${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>` : ''}
|
||||
${luckenNamen.length ? `<button type="button" class="secondary small" onclick="toggleLoadLuecken()">${luckenNamen.length} ohne Aufwand…</button>` : ''}
|
||||
</div>`;
|
||||
if(luckenNamen.length){
|
||||
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('')}
|
||||
</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 ---
|
||||
const kopfListe = Object.values(koepfe)
|
||||
.filter(k => k.stunden > 0)
|
||||
.sort((a,b)=> b.stunden / kapazitaetStunden(b.person) - a.stunden / kapazitaetStunden(a.person));
|
||||
if(kopfListe.length){
|
||||
html += `<div class="load-rolle"><div class="load-rolle-titel">Gesamt je Person</div>`;
|
||||
kopfListe.forEach(k=>{
|
||||
const kap = kapazitaetStunden(k.person);
|
||||
const quote = kap > 0 ? k.stunden / kap : 0;
|
||||
const ampel = loadAmpelColor(quote);
|
||||
const pct = Math.min(100, quote * 100);
|
||||
html += `<div class="load-zeile load-zeile-summe">
|
||||
<div class="load-wer">
|
||||
<div class="load-wer-name">${esc(k.person.fullName || k.person.name)}</div>
|
||||
<div class="load-wer-meta">${esc(k.rollen.join(' · ') || '—')}</div>
|
||||
</div>
|
||||
<div class="load-band" title="Kapazität: ${stundenFormat(kap)} h/Woche">
|
||||
<div class="load-seg load-seg-summe" style="width:${pct}%;background:${ampel}"></div>
|
||||
<div class="load-kapazitaet" title="Volle Kapazität"></div>
|
||||
</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('');
|
||||
|
||||
html += `<div class="load-zeile">
|
||||
<div class="load-wer">
|
||||
<div class="load-wer-name">${esc(entry.person.fullName || entry.person.name)}</div>
|
||||
<div class="load-wer-meta">${esc(entry.person.name)}${stelleBadge}${anbahnungBadge}</div>
|
||||
html += '<div class="load-karten">' + karten.map(c => {
|
||||
const k = c.k;
|
||||
const name = k.person.fullName || k.person.name;
|
||||
const anzahl = c.projektNamen.length;
|
||||
const jetztKap = c.kap[0].stunden;
|
||||
const jetzt = c.quoten[0];
|
||||
const jetztText = jetzt === null ? 'abwesend' : prozent(jetzt);
|
||||
const jetztFarbe = jetzt === null ? 'var(--text-muted)' : (k.verlauf[0] > 0 ? loadAmpelColor(jetzt) : 'var(--text-subtle)');
|
||||
const teilzeit = Math.abs(kapazitaetsFaktor(k.person) - 1) < 0.005 ? ''
|
||||
: `<span class="load-capacity" title="Vertragliche Wochenstunden aus der Benutzerverwaltung">${stundenFormat(kapazitaetsFaktor(k.person) * (Number(APP_SETTINGS.vollzeitStundenProWoche) || 40))} h/Woche</span>`;
|
||||
const saeulen = c.quoten.map((q, i) => {
|
||||
const kap = c.kap[i];
|
||||
const h = k.verlauf[i];
|
||||
const titel = 'KW ' + wochen[i].kw + ': ' + (kap.stunden > 0
|
||||
? stundenFormat(h) + ' h von ' + stundenFormat(kap.stunden) + ' h (' + prozent(q) + ')' + (kap.abwesend ? ', ' + kap.abwesend + ' Tag(e) abwesend' : '')
|
||||
: 'abwesend' + (h > 0 ? ', ' + stundenFormat(h) + ' h geplant' : ''));
|
||||
if(q === null) return `<span class="load-saeule ist-abwesend" title="${esc(titel)}"></span>`;
|
||||
const hoehe = Math.max(h > 0 ? 3 : 0, Math.min(q, MAX_QUOTE) / MAX_QUOTE * 100);
|
||||
return `<span class="load-saeule${i === 0 ? ' ist-jetzt' : ''}${kap.abwesend ? ' ist-teil' : ''}" style="height:${hoehe}%;background:${loadAmpelColor(q)}" title="${esc(titel)}"></span>`;
|
||||
}).join('');
|
||||
const kws = wochen.map((wo, i) => `<span${i === 0 ? ' class="ist-jetzt"' : ''}>${wo.kw}</span>`).join('');
|
||||
const hinweis = anzahl === 0 ? '<div class="load-hinweis ist-leer">Keinem Projekt zugeordnet.</div>'
|
||||
: c.spitze >= schwelleRot()
|
||||
? `<div class="load-hinweis ist-rot">Spitze ${prozent(c.spitze)} in KW ${wochen[c.spitzeIdx].kw}: Aufgaben verschieben oder abgeben.</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 class="load-band" title="Volles Band = ${stundenFormat(kap)} h/Woche">
|
||||
${segmente || '<span class="load-band-leer">keine Zuordnung</span>'}
|
||||
<div class="load-saeulen" role="img" aria-label="${esc('Auslastung ' + name + ' der nächsten ' + VORSCHAU_WOCHEN + ' Wochen')}">
|
||||
<span class="load-linie" style="bottom:${100 / MAX_QUOTE}%"></span>${saeulen}
|
||||
</div>
|
||||
<div class="load-wert" style="color:${ampel}">${stundenFormat(std)}<span class="load-einheit">h</span></div>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
});
|
||||
<div class="load-kws" aria-hidden="true">${kws}</div>
|
||||
${hinweis}
|
||||
${chips ? `<div class="load-chips${offen ? ' ist-offen' : ''}">${chips}</div>` : ''}
|
||||
</article>`;
|
||||
}).join('') + '</div>';
|
||||
|
||||
const legende = Object.keys(benutzteProjekte).sort().map(name=>{
|
||||
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;
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
+354
-195
@@ -4,7 +4,7 @@
|
||||
// Dieses Modul stellt die drei PDF-Exporte der Anwendung bereit — jeder in
|
||||
// seinem eigenen, klar abgegrenzten Abschnitt dieser Datei:
|
||||
// * 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)
|
||||
// Ein früherer, inzwischen entfernter dritter Export, exportPDF() (eine
|
||||
// gewerkeübergreifende A3-Tabellenübersicht der ganzen Sitzung, samt
|
||||
@@ -19,10 +19,9 @@
|
||||
// PDF_PAD_X/PDF_LINE_W).
|
||||
//
|
||||
// Was unten bleibt, wird von MEHREREN der drei Exporte gebraucht (nicht
|
||||
// zwingend allen): Farbkonstanten, die A3-quer-Seitengeometrie
|
||||
// PDF_PAGE_W/PDF_MARGIN_X/PDF_USABLE_W (nur exportSandboxPlanPDF() — die
|
||||
// beiden anderen sind A4 hochkant, eigene Konstanten PDF_WB_PAGE_W/
|
||||
// PDF_WB_MARGIN_X weiter unten), PDF_STYLES, pdfStatusText()
|
||||
// zwingend allen): Farbkonstanten, PDF_STYLES (der Terminplan bringt seine
|
||||
// eigene Geometrie in PDF_TP mit, Wochenbericht/Protokoll sind A4 hochkant
|
||||
// mit PDF_WB_PAGE_W/PDF_WB_MARGIN_X weiter unten), pdfStatusText()
|
||||
// (Kommentar-HTML -> pdfmake-Rich-Text), pdfNormalizeColor() und
|
||||
// loadLogoPng().
|
||||
|
||||
@@ -35,14 +34,6 @@ const PDF_ASSIGN_MAIN = '#248A3D';
|
||||
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)
|
||||
// für die pdfmake-Dokumentdefinition
|
||||
const PDF_STYLES = {
|
||||
@@ -62,7 +53,15 @@ function pdfStatusText(htmlStatus){
|
||||
if(!htmlStatus) return { text: '', style: 'td' };
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = htmlStatus;
|
||||
// sanitizeStatusHtml() ist hier PFLICHT, nicht Kosmetik: Der Server
|
||||
// speichert status/text unverändert (keine serverseitige Bereinigung), und
|
||||
// innerHTML führt Handler auch dann aus, wenn das Element gar nicht im
|
||||
// Dokument hängt — ein <img onerror=…>, das ein angemeldeter Nutzer über
|
||||
// die API in einen Kommentar oder Protokollpunkt schreibt, liefe sonst in
|
||||
// der Sitzung JEDER Person, die daraus ein PDF erzeugt. Die Anzeige am
|
||||
// Bildschirm bereinigt an ihren eigenen Stellen bereits; nur dieser Pfad
|
||||
// fehlte. Geprüft: ohne diese Zeile feuert der Handler tatsächlich.
|
||||
root.innerHTML = sanitizeStatusHtml(htmlStatus);
|
||||
|
||||
const parts = [];
|
||||
|
||||
@@ -175,13 +174,13 @@ function pdfNormalizeColor(v){
|
||||
return null;
|
||||
}
|
||||
|
||||
// loadLogoPng: lädt dein_bild.ico und konvertiert es via Canvas nach PNG.
|
||||
// loadLogoPng: lädt logo.ico und konvertiert es via Canvas nach PNG.
|
||||
// pdfmake unterstützt nur JPEG und PNG, kein ICO.
|
||||
// Gibt null zurück, wenn Laden oder Dekodieren fehlschlägt.
|
||||
async function loadLogoPng(size){
|
||||
size = size || 256;
|
||||
try {
|
||||
const res = await fetch(baseUrl() + '/dein_bild.ico');
|
||||
const res = await fetch(baseUrl() + '/logo.ico');
|
||||
if(!res.ok) return null;
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -385,6 +384,15 @@ function pdfWbGruppentitel(name, topMargin){
|
||||
// Landkarte der Sitzung — was hat sich geändert, was ist offen — bevor ab
|
||||
// Seite 2 der vollständige Text folgt. Parameter: liste (aus
|
||||
// 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){
|
||||
const groups = {};
|
||||
liste.forEach(p => (groups[p.group] || (groups[p.group] = [])).push(p));
|
||||
@@ -399,11 +407,11 @@ function pdfWbUebersichtBlock(liste, prevWeek){
|
||||
{ text: 'Projekt', style: 'th' },
|
||||
{ text: 'Änderung', style: 'th' },
|
||||
{ text: 'Amt. LPH', style: 'th' },
|
||||
{ text: '✓', style: 'th', alignment: 'center' }
|
||||
{ text: 'Bespr.', style: 'th', alignment: 'center' }
|
||||
];
|
||||
const body = [header];
|
||||
projekte.forEach(p => {
|
||||
const change = classifyProjectChange(p, prevWeek);
|
||||
const change = classifyProjectChange(p, prevWeek, wochenberichtGewerk);
|
||||
const info = pdfWbAktuelleLphInfo(p);
|
||||
body.push([
|
||||
pdfWbChangeDot(change.bucket),
|
||||
@@ -411,10 +419,10 @@ function pdfWbUebersichtBlock(liste, prevWeek){
|
||||
{ text: p.name, style: 'td', bold: true },
|
||||
{ text: change.bucket !== 'unchanged' ? change.reason : '', 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] });
|
||||
});
|
||||
return blocks;
|
||||
@@ -428,11 +436,12 @@ function pdfWbUebersichtBlock(liste, prevWeek){
|
||||
// ausdrückliches false — es gibt keinen dritten Zustand. Parameter: w
|
||||
// (Sitzung). Rückgabe: Array von pdfmake-Content-Blöcken.
|
||||
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){
|
||||
return [
|
||||
pdfWbGruppentitel('Anwesenheit der Sitzung', 18),
|
||||
{ text: 'Für diese Sitzung sind keine Projektbeteiligten hinterlegt.',
|
||||
pdfWbGruppentitel('Anwesenheit ' + (wochenberichtGewerk || ''), 18),
|
||||
{ text: 'Für dieses Gewerk sind in dieser Sitzung keine Projektbeteiligten hinterlegt.',
|
||||
fontSize: 8, italics: true, color: '#86868B' }
|
||||
];
|
||||
}
|
||||
@@ -452,7 +461,7 @@ function pdfWbAnwesenheitBlock(w){
|
||||
body.push(row);
|
||||
}
|
||||
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] },
|
||||
{ table: { widths: [16, '*', 16, '*', 16, '*'], body }, layout: pdfWbGridLayout(false) }
|
||||
];
|
||||
@@ -464,7 +473,7 @@ function pdfWbAnwesenheitBlock(w){
|
||||
// Beteiligten-Tabelle. Parameter: p (Projekt), w (Sitzung), prevWeek
|
||||
// (Vorwoche-Objekt oder null), allePersonen. Rückgabe: pdfmake-Content-Block.
|
||||
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 statusChip = auftragsstatusOf(p) === 'beauftragt' ? '' : ' · ' + auftragsstatusLabel(auftragsstatusOf(p));
|
||||
const tags = Array.isArray(p.tags) && p.tags.length ? ' [' + p.tags.join(', ') + ']' : '';
|
||||
@@ -475,7 +484,7 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
|
||||
{ text: (p.nr ? p.nr + ' ' : '') + p.name, bold: true, fontSize: 10 },
|
||||
{ text: tags + statusChip, fontSize: 7.5, color: '#6E6E73' }
|
||||
], 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 }
|
||||
], margin: [0, 0, 0, 3]
|
||||
};
|
||||
@@ -484,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: '', 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)
|
||||
? Object.assign({}, kommentarInhalt, { fontSize: 8 })
|
||||
: { 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 lphTable = pdfWbLphMiniTable(p, aktLp);
|
||||
const beteiligteTable = pdfWbBeteiligteTable(p, allePersonen);
|
||||
@@ -499,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(beteiligteTable);
|
||||
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] });
|
||||
}
|
||||
|
||||
const innerContent = {
|
||||
columns: [
|
||||
{ width: '*', stack: [kopf, aenderung, kommentar] },
|
||||
{ width: '*', stack: [kopf, aenderung, kommentar].concat(vorher ? [vorher] : []) },
|
||||
{ width: 190, stack: rightStack }
|
||||
],
|
||||
columnGap: 12
|
||||
@@ -533,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
|
||||
// aktuellen Sitzung ein ablagefähiges, zweiteiliges PDF — Seite 1 die
|
||||
// Übersicht samt Anwesenheit, ab Seite 2 die vollständige Akte je Projekt,
|
||||
@@ -566,7 +611,7 @@ async function exportWochenberichtPDF(){
|
||||
ROLES.forEach(r => grouped[r].forEach(e => allePersonen.push(e)));
|
||||
|
||||
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 kopf = [];
|
||||
@@ -632,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
|
||||
// (der erste ist exportWochenberichtPDF() oben): ein klassischer Balkenplan
|
||||
// (Entwurf A der Design-Vergleiche, siehe CLAUDE.md "Planung —
|
||||
// projektunabhängige Terminplan-Sandbox") für EINEN Terminplan der
|
||||
// Planung-Sandbox — durchgehende Zeitachse, Balken je Aufgabe, Rauten für
|
||||
// Meilensteine, eine rote Linie für "heute", genau wie am Bildschirm im
|
||||
// Terminplan des Projektbereichs. A3 quer — eine Zeitachse braucht die
|
||||
// Breite —, aber kein Übersichts-Raster wie eine Sitzungstabelle, sondern
|
||||
// EIN Terminplan, deshalb kein eigenes Deckblatt.
|
||||
// Balkenplan EINES Terminplans, A3 quer, Gestaltung „A2 — volles Raster“
|
||||
// (Eriks Wahl aus dem Entwurfsvergleich): links eine Vorgangsliste (Vorgang,
|
||||
// Start, Ende), rechts die Balken über Monaten (im Wechsel hinterlegt) und
|
||||
// Kalenderwochen, Gitterlinien über das ganze Blatt. Gegliedert nach
|
||||
// Leistungsphasen wie der Terminplan am Bildschirm: jeder Phasenbalken
|
||||
// (balkenPhase()) wird eine dunkelblaue Kopfzeile mit Sammelbalken und
|
||||
// Fortschritt aus dem Wochenbericht, darunter die Aufgaben, die in ihm
|
||||
// beginnen (aufgabenPhase()), und die Meilensteine in seinem Zeitraum. Der
|
||||
// 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 —
|
||||
// nur flache Prozent-Balken (LPH-/Auslastungs-Balken oben). Die Technik ist
|
||||
// dieselbe (canvas-Rechtecke mit berechnetem x/w), nur nach Datum statt nach
|
||||
// Prozent positioniert. Lange Zeiträume werden NICHT auf mehrere Blätter
|
||||
// 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.
|
||||
// Technik: Jede Seite wird frei positioniert (absolutePosition) — ein
|
||||
// Canvas für Flächen, Linien und Balken, Texte als eigene Knoten darüber.
|
||||
// pdfmake kann Text nicht innerhalb eines Canvas setzen. Passen nicht alle
|
||||
// Zeilen auf ein Blatt, geht es auf weiteren Seiten mit demselben Kopf weiter.
|
||||
|
||||
const PDF_PLAN_LABEL_W = 190;
|
||||
const PDF_PLAN_ROW_H = 18;
|
||||
const PDF_TP = {
|
||||
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
|
||||
// Datums innerhalb der Chart-Spalte. Parameter: date (Date), rangeStart
|
||||
// (Date), totalDays (Zahl), chartW (pt). Rückgabe: Zahl (pt), auf den
|
||||
// sichtbaren Bereich begrenzt.
|
||||
function pdfPlanDayX(date, rangeStart, totalDays, chartW){
|
||||
const tage = Math.max(0, Math.min(totalDays, daysBetween(rangeStart, date)));
|
||||
return chartW * tage / totalDays;
|
||||
const PDF_TP_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'
|
||||
};
|
||||
|
||||
// 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
|
||||
// die Kopfzeile der Zeitachse. Rückgabe: Array {label, x1, x2}.
|
||||
function pdfPlanMonatsAchse(rangeStart, rangeEnd, totalDays, chartW){
|
||||
const monate = [];
|
||||
let cur = new Date(rangeStart.getFullYear(), rangeStart.getMonth(), 1);
|
||||
let sicherheit = 0;
|
||||
while(cur <= rangeEnd && sicherheit++ < 60){
|
||||
const naechster = new Date(cur.getFullYear(), cur.getMonth() + 1, 1);
|
||||
const segStart = cur < rangeStart ? rangeStart : cur;
|
||||
const segEnd = naechster > rangeEnd ? addDays(rangeEnd, 1) : naechster;
|
||||
monate.push({
|
||||
label: segStart.toLocaleDateString('de-DE', { month: 'short', year: '2-digit' }),
|
||||
x1: pdfPlanDayX(segStart, rangeStart, totalDays, chartW),
|
||||
x2: pdfPlanDayX(segEnd, rangeStart, totalDays, chartW)
|
||||
});
|
||||
cur = naechster;
|
||||
}
|
||||
return monate;
|
||||
// pdfTpKuerzen: kürzt Text auf eine Breite (pt) bei gegebener Schriftgröße —
|
||||
// grob geschätzt, Roboto ist im Mittel gut eine halbe Schriftgröße breit.
|
||||
function pdfTpKuerzen(text, breite, groesse){
|
||||
const max = Math.floor(breite / (groesse * 0.52));
|
||||
const t = String(text || '');
|
||||
return t.length > max ? t.slice(0, Math.max(1, max - 1)) + '…' : t;
|
||||
}
|
||||
|
||||
// pdfPlanAchsenZeile: die Kopfzeile der Zeitachse (Monatsbeschriftungen),
|
||||
// über pdfmake columns statt canvas — Textlabel lassen sich in pdfmake nicht
|
||||
// frei innerhalb eines Canvas positionieren, wohl aber über Spaltenbreiten.
|
||||
function pdfPlanAchsenZeile(monate){
|
||||
// pdfTpText: frei positionierter Text in einem Kasten der Breite w.
|
||||
// opts: size, bold, italics, color, align.
|
||||
function pdfTpText(x, y, w, text, opts){
|
||||
const o = opts || {};
|
||||
w = Math.max(1, Math.min(w, PDF_TP.W - PDF_TP.M - x)); // nie über den rechten Rand
|
||||
return {
|
||||
columns: [
|
||||
{ width: PDF_PLAN_LABEL_W, text: 'Aufgabe / Meilenstein', fontSize: 7.5, bold: true, color: '#6E6E73' },
|
||||
...monate.map(m => ({
|
||||
width: Math.max(2, m.x2 - m.x1), text: m.label, fontSize: 7, color: '#6E6E73'
|
||||
}))
|
||||
],
|
||||
margin: [0, 0, 0, 3]
|
||||
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 }]
|
||||
};
|
||||
}
|
||||
|
||||
// pdfPlanBarFarbe: Balkenfarbe nach Aufgabenstand — dieselben drei Zustände,
|
||||
// die die Aufgabe tatsächlich kennt (t.done, t.progress); anders als der
|
||||
// Entwurf im Design-Vergleich gibt es hier keine vierte, erfundene Kategorie
|
||||
// "geplant", weil dafür kein echtes Datenfeld existiert.
|
||||
function pdfPlanBarFarbe(t){
|
||||
if(t.done) return PDF_ASSIGN_MAIN;
|
||||
if((Number(t.progress) || 0) > 0) return '#0071E3';
|
||||
return '#D2D2D7';
|
||||
}
|
||||
|
||||
// pdfPlanZeile: eine Zeile des Balkenplans (Aufgabe ODER Meilenstein) als
|
||||
// pdfmake columns-Objekt. Parameter: label (String), aktiv (Boolean, fett),
|
||||
// canvasInhalt (Array von canvas-Primitiven für die Chart-Spalte).
|
||||
function pdfPlanZeile(label, kursiv, canvasInhalt){
|
||||
return {
|
||||
columns: [
|
||||
{ width: PDF_PLAN_LABEL_W, text: label, fontSize: 7.5, italics: !!kursiv,
|
||||
color: kursiv ? '#6E6E73' : '#1D1D1F', margin: [0, (PDF_PLAN_ROW_H - 9) / 2, 4, 0] },
|
||||
{ width: '*', canvas: canvasInhalt }
|
||||
]
|
||||
// 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);
|
||||
});
|
||||
gruppe(null, '');
|
||||
return zeilen;
|
||||
}
|
||||
|
||||
function pdfPlanTodoZeile(t, rangeStart, totalDays, chartW, todayX){
|
||||
const s = parseDateISO(t.start) || rangeStart;
|
||||
const e = parseDateISO(t.end) || s;
|
||||
const x1 = pdfPlanDayX(s, rangeStart, totalDays, chartW);
|
||||
const x2 = Math.max(x1 + 2, pdfPlanDayX(addDays(e, 1), rangeStart, totalDays, chartW));
|
||||
const canvasInhalt = [
|
||||
{ type: 'rect', x: x1, y: 4, w: x2 - x1, h: PDF_PLAN_ROW_H - 8, color: pdfPlanBarFarbe(t) },
|
||||
{ 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' });
|
||||
// pdfTpLegende: die Legende als Spalten für die Fußzeile (Zeichen + Text).
|
||||
function pdfTpLegende(){
|
||||
const T = PDF_TP, sp = [];
|
||||
[
|
||||
[{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.phase }, 'Leistungsphase'],
|
||||
[{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.inArbeit }, 'in Arbeit'],
|
||||
[{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.erledigt }, 'erledigt'],
|
||||
[{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: '#ffffff', lineColor: T.aufgabeRand, lineWidth: 0.8 }, 'offen'],
|
||||
[{ 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] });
|
||||
sp.push({ width: text.length * 4.4 + 16, text, fontSize: 8, color: T.grau });
|
||||
});
|
||||
return sp;
|
||||
}
|
||||
|
||||
// pdfTpSeite: alle Knoten EINER Seite. Parameter: zeilen (die dieser Seite),
|
||||
// rowH, x (Datum → x), range {start, ende}, kopf (Knoten des Seitenkopfs),
|
||||
// ersteSeite. Rückgabe: Array von pdfmake-Knoten.
|
||||
function pdfTpSeite(zeilen, rowH, x, range, kopf, ersteSeite){
|
||||
const T = PDF_TP, M = T.M, cx0 = M + T.spalten.reduce((s, c) => s + c[1], 0), cx1 = T.W - M;
|
||||
const bodyTop = T.top + T.kopfH, bodyBot = bodyTop + zeilen.length * rowH;
|
||||
const plus1 = d => addDays(d, 1);
|
||||
const flaechen = [], linien = [], balken = [], texte = [];
|
||||
|
||||
// Monate im Wechsel hinterlegt, mit Namen
|
||||
let m = new Date(range.start.getFullYear(), range.start.getMonth(), 1), i = 0;
|
||||
while(m <= range.ende && i < 120){
|
||||
const naechster = new Date(m.getFullYear(), m.getMonth() + 1, 1);
|
||||
const a = Math.max(cx0, x(m)), e = Math.min(cx1, x(naechster));
|
||||
if(e > a){
|
||||
if(i % 2 === 0) flaechen.push({ type: 'rect', x: a, y: T.top, w: e - a, h: bodyBot - T.top, color: T.monatHell });
|
||||
const name = m.toLocaleDateString('de-DE', { month: 'short' }).replace('.', '') + ' ' + String(m.getFullYear()).slice(2);
|
||||
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++;
|
||||
}
|
||||
return pdfPlanZeile(t.title || 'Aufgabe', false, canvasInhalt);
|
||||
}
|
||||
|
||||
function pdfPlanMeilensteinZeile(m, rangeStart, totalDays, chartW, todayX){
|
||||
const d = parseDateISO(m.date) || rangeStart;
|
||||
const x = pdfPlanDayX(d, rangeStart, totalDays, chartW);
|
||||
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' });
|
||||
// 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' }));
|
||||
}
|
||||
return pdfPlanZeile('◆ ' + (m.name || 'Meilenstein'), true, canvasInhalt);
|
||||
linien.push({ type: 'line', x1: cx0, y1: T.top + 17, x2: cx1, y2: T.top + 17, lineWidth: 0.6, lineColor: T.gitter });
|
||||
|
||||
// Tabellenkopf links
|
||||
flaechen.push({ type: 'rect', x: M, y: T.top, w: cx0 - M, h: T.kopfH, color: '#e9edf1' });
|
||||
let sx = M;
|
||||
T.spalten.forEach(([name, w]) => {
|
||||
texte.push(pdfTpText(sx + 6, T.top + 12, w - 8, name, { bold: true }));
|
||||
sx += w;
|
||||
if(sx < cx0) linien.push({ type: 'line', x1: sx, y1: T.top, x2: sx, y2: bodyBot, lineWidth: 0.6, lineColor: T.gitter });
|
||||
});
|
||||
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 }
|
||||
];
|
||||
|
||||
const knoten = [{ canvas: [...flaechen, ...linien, ...balken, ...rahmen], absolutePosition: { x: 0, y: 0 } }];
|
||||
if(!ersteSeite) knoten[0].pageBreak = 'before';
|
||||
return [...knoten, ...kopf, ...texte];
|
||||
}
|
||||
|
||||
// exportSandboxPlanPDF: Hauptfunktion. Erzeugt den Balkenplan-PDF-Export
|
||||
// EINES Terminplans der Planung-Sandbox. Parameter: planId. Keine Rückgabe.
|
||||
// exportSandboxPlanPDF: Einstieg vom Knopf „Ansicht → Als PDF exportieren“.
|
||||
// Findet einen Planung-Plan oder das im Projektbereich geöffnete Projekt.
|
||||
// Parameter: planId. Keine Rückgabe.
|
||||
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){
|
||||
alert('PDF-Export nicht verfügbar: pdfmake wurde nicht geladen. '
|
||||
+ 'Bitte Internetverbindung bzw. Skript-Einbindung prüfen.');
|
||||
return;
|
||||
}
|
||||
const p = (db.sandboxWeek && Array.isArray(db.sandboxWeek.projects))
|
||||
? db.sandboxWeek.projects.find(x => x.id === planId) : null;
|
||||
if(!p){
|
||||
alert('Dieser Terminplan wurde nicht gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const plan = p.plan || {};
|
||||
const todos = (Array.isArray(plan.todos) ? plan.todos.slice() : [])
|
||||
.sort((a, b) => (parseDateISO(a.start) || 0) - (parseDateISO(b.start) || 0));
|
||||
const milestones = (Array.isArray(plan.milestones) ? plan.milestones.slice() : [])
|
||||
.sort((a, b) => (parseDateISO(a.date) || 0) - (parseDateISO(b.date) || 0));
|
||||
if(!todos.length && !milestones.length){
|
||||
alert('Dieser Terminplan enthält noch keine Aufgaben oder Meilensteine — es gibt nichts abzubilden.');
|
||||
const T = PDF_TP, M = T.M;
|
||||
const zeilen = pdfTpZeilen(p);
|
||||
const daten = [];
|
||||
zeilen.forEach(z => { if(z.von) daten.push(z.von, z.bis); if(z.datum) daten.push(z.datum); });
|
||||
if(!daten.length){
|
||||
alert('Dieser Terminplan enthält noch keine Aufgaben mit Termin oder Meilensteine — es gibt nichts abzubilden.');
|
||||
return;
|
||||
}
|
||||
|
||||
const range = ganttRangeFromProjects([p], 7);
|
||||
const totalDays = Math.max(1, daysBetween(range.start, range.end) + 1);
|
||||
const chartW = PDF_USABLE_W - PDF_PLAN_LABEL_W - 10;
|
||||
const heute = new Date();
|
||||
heute.setHours(0, 0, 0, 0);
|
||||
const todayX = (heute >= range.start && heute <= range.end)
|
||||
? pdfPlanDayX(heute, range.start, totalDays, chartW) : null;
|
||||
// Zeitraum: Montag der ersten bis Sonntag der letzten Woche
|
||||
const min = new Date(Math.min(...daten)), max = new Date(Math.max(...daten));
|
||||
const start = addDays(min, -((min.getDay() + 6) % 7));
|
||||
const ende = addDays(max, 6 - ((max.getDay() + 6) % 7));
|
||||
const range = { start, ende };
|
||||
const cx0 = M + T.spalten.reduce((s, c) => s + c[1], 0), cx1 = T.W - M;
|
||||
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 kopf = [];
|
||||
if(logo) kopf.push({ image: logo, width: 46, margin: [0, 0, 0, 6] });
|
||||
kopf.push(
|
||||
{ text: p.name, fontSize: 17, bold: true, color: '#1D1D1F' },
|
||||
{ text: 'Terminplan — Planung, unabhängig von einem laufenden Projekt',
|
||||
fontSize: 9, color: '#6E6E73', margin: [0, 2, 0, 0] }
|
||||
);
|
||||
const honorar = Number(p.honorar) || 0;
|
||||
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
|
||||
if(honorar > 0){
|
||||
kopf.push({ text: 'Honorar ' + honorar.toLocaleString('de-DE') + ' €'
|
||||
+ (beauftragt.length ? ' · ' + beauftragt.join(', ') : ''),
|
||||
fontSize: 8, color: '#6E6E73', margin: [0, 2, 0, 0] });
|
||||
const titel = (!sandbox && p.nr ? p.nr + ' ' : '') + (p.name || 'Terminplan');
|
||||
const teile = [sandbox ? 'Terminplan — Planung, unabhängig von einem laufenden Projekt' : 'Terminplan'];
|
||||
if(!sandbox && week && week.label) teile.push('Stand ' + week.label);
|
||||
if(Number(p.honorar) > 0) teile.push('Honorar ' + Number(p.honorar).toLocaleString('de-DE') + ' €');
|
||||
if(Array.isArray(p.beauftragteLP) && p.beauftragteLP.length) teile.push('beauftragt ' + p.beauftragteLP.join(', '));
|
||||
// Je Seite neu gebaut: pdfmake verändert die Knoten beim Setzen, ein
|
||||
// wiederverwendeter Kopf fehlte ab Seite 2.
|
||||
const kopf = () => {
|
||||
const k = [
|
||||
pdfTpText(M, 30, 900, pdfTpKuerzen(titel, 900, 18), { size: 18, bold: true }),
|
||||
pdfTpText(M, 54, 900, teile.join(' · '), { size: 10, color: T.grau })
|
||||
];
|
||||
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 = {
|
||||
pageSize: 'A3',
|
||||
pageOrientation: 'landscape',
|
||||
pageMargins: [PDF_MARGIN_X, 30, PDF_MARGIN_X, 30],
|
||||
defaultStyle: { font: 'Roboto', fontSize: 8, color: '#1D1D1F' },
|
||||
styles: PDF_STYLES,
|
||||
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: 9, color: T.ink },
|
||||
// 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) => ({
|
||||
text: 'Planung · ' + p.name + ' · Seite ' + page + ' von ' + count,
|
||||
alignment: 'right', fontSize: 6.5, color: '#6E6E73',
|
||||
margin: [0, 8, PDF_MARGIN_X, 0]
|
||||
margin: [M, 12, M, 0],
|
||||
columns: [
|
||||
...pdfTpLegende(),
|
||||
{ width: '*', text: fussText + ' · Seite ' + page + ' von ' + count, alignment: 'right', fontSize: 8, color: T.grau }
|
||||
]
|
||||
}),
|
||||
info: { title: 'Terminplan ' + p.name, creator: 'Projektübersicht' },
|
||||
content: content
|
||||
info: { title: 'Terminplan ' + (p.name || ''), creator: 'Projektübersicht' },
|
||||
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);
|
||||
|
||||
} catch(err){
|
||||
console.error('Planung-PDF-Export fehlgeschlagen:', err);
|
||||
console.error('Terminplan-PDF-Export fehlgeschlagen:', err);
|
||||
alert('PDF-Export fehlgeschlagen: ' + (err.message || err));
|
||||
}
|
||||
}
|
||||
@@ -966,12 +1099,38 @@ async function exportProtokollPDF(protokollId){
|
||||
const nummerParts = [nummer];
|
||||
if(p.neu) nummerParts.push({ text: '\nNEU', fontSize: 5.5, bold: true, color: '#8A6D00' });
|
||||
if(erstelltText) nummerParts.push({ text: '\n' + erstelltText, fontSize: 5.5, bold: false, color: '#86868B' });
|
||||
// entfaelltNaechstes: Gnadenrunde eines erledigten Punkts (Eriks
|
||||
// Wunsch) — derselbe Hinweis wie am Bildschirm
|
||||
// (.protokoll-chip-entfaellt, plan-protokoll.js), hier unter dem
|
||||
// Erstellungsdatum statt in einer eigenen Spalte, aus demselben
|
||||
// Platzgrund wie erstelltText oben.
|
||||
if(p.entfaelltNaechstes) nummerParts.push({ text: '\nEntfällt im nächsten Protokoll', fontSize: 5.5, bold: true, color: '#D70015' });
|
||||
const nummerText = { text: nummerParts };
|
||||
const statusLabel = { offen: 'offen', erledigt: 'erledigt', info: 'Info' }[p.status] || 'offen';
|
||||
const statusCell = zelle({ text: statusLabel, alignment: 'center' });
|
||||
// themaGeaendert/textGeaendert: wie am Bildschirm (.ist-geaendert,
|
||||
// plan-styles.css) markiert nur die Thema/Vereinbarung-Zelle gelb,
|
||||
// nicht die ganze Zeile wie bei einem echten neuen Punkt (p.neu,
|
||||
// oben in zelle()) — pdfmake kennt keine Hintergrundfarbe je
|
||||
// Textlauf innerhalb einer Zelle, nur je ganzer Zelle, deshalb
|
||||
// bekommt hier ausnahmsweise die ganze Thema-Zelle den Ton, auch
|
||||
// wenn nur eines der beiden Felder sich geändert hat. Bei p.neu
|
||||
// erübrigt sich das (die ganze Zeile ist ohnehin schon gelb).
|
||||
// p.text ist bereits sanitiziertes HTML (contenteditable-Feld mit
|
||||
// Fett/Kursiv/Farbe, richtextToolbarHtml()/js/utils.js, Eriks
|
||||
// Wunsch) — dieselbe pdfStatusText() wie der Wochenbericht-
|
||||
// Kommentar wandelt es direkt in eine pdfmake-Textstruktur mit
|
||||
// echten Auszeichnungen, ohne Zwischenschritt.
|
||||
const textLaeufe = p.text ? pdfStatusText(p.text).text : '';
|
||||
const themaZelle = zelle({ text: [
|
||||
p.thema ? { text: p.thema, bold: true } : '',
|
||||
p.thema && p.text ? '\n' : '',
|
||||
...(Array.isArray(textLaeufe) ? textLaeufe : (textLaeufe ? [{ text: textLaeufe }] : []))
|
||||
] });
|
||||
if(!p.neu && (p.themaGeaendert || p.textGeaendert)) themaZelle.fillColor = '#FFF6DB';
|
||||
body.push([
|
||||
zelle(nummerText, { bold: true }),
|
||||
zelle({ text: [p.thema ? { text: p.thema, bold: true } : '', p.text ? { text: (p.thema ? '\n' : '') + p.text } : ''] }),
|
||||
themaZelle,
|
||||
zelle({ text: p.zustaendig || '' }),
|
||||
zelle({ text: formatDateDE(parseDateISO(p.termin)) || '—' }),
|
||||
statusCell
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Personen-Ansicht (global, NUR LESEND)
|
||||
// -----------------------------
|
||||
// Diese Datei rendert den Personen-Tab (Tab "Projektbeteiligte" in `Test.html`)
|
||||
// Diese Datei rendert den Personen-Tab (Tab "Projektbeteiligte" in `App.html`)
|
||||
// als reine Anzeige. Seit der Ablösung von people/person_roles/person_absences
|
||||
// (server.js) werden Projektbeteiligte ausschließlich in der zentralen
|
||||
// Benutzerverwaltung (Bereich „Admin“) gepflegt; Anlegen/Bearbeiten/Löschen/
|
||||
@@ -15,7 +15,7 @@
|
||||
// Auslastung noch in der Anwesenheit. Die Personendaten selbst kommen unverändert über `db.globalPeople`
|
||||
// (befüllt via `GET /load-all`, serverseitig jetzt aus `users`/`user_roles`/
|
||||
// `user_absences` zusammengesetzt, firmenweit ohne Abteilungstrennung). Sie
|
||||
// wird von `Test.html` nach `js/board/board.js` (vor `js/projects/projects.js`)
|
||||
// wird von `App.html` nach `js/board/board.js` (vor `js/projects/projects.js`)
|
||||
// geladen und stellt die globale Funktion `renderPeople()` bereit, die u.a.
|
||||
// von `js/tabs-auth.js` (`showTab()`) und `js/init.js` aufgerufen wird.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
// Parameter, keine Rückgabe.
|
||||
function renderPeople(){
|
||||
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 inWeek = new Set((w && Array.isArray(w.people) ? w.people : []).map(p => p.id));
|
||||
const locked = !w || isWeekLocked(w);
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
// aufgerufen. Mit showTodos wird deren HTML per DOM-Bearbeitung um die
|
||||
// TODO- und Meilensteinzeilen aller übergebenen Projekte ergänzt und als
|
||||
// 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.
|
||||
window.buildGanttHtml = function(projects, start, end, options = {}){
|
||||
if(!options.showTodos) return originalBuildGanttHtml(projects, start, end, options);
|
||||
@@ -169,12 +169,11 @@
|
||||
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 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 conflictTitle = conflicts.length > 0
|
||||
? '\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.
|
||||
@@ -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>`
|
||||
: '';
|
||||
|
||||
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>' : ''}
|
||||
<span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}${depBadge}</span>
|
||||
${conflictBadge}
|
||||
<span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}</span>
|
||||
${vorgaengerKonflikt ? '<span class="gantt-vorgaenger-warn" title="Beginnt vor dem Ende eines Vorgängers">!</span>' : ''}
|
||||
${conflictBadge}${leuteHtml}
|
||||
</div>
|
||||
<div class="gantt-timeline">${barHtml}</div>`;
|
||||
return row;
|
||||
@@ -260,16 +271,73 @@
|
||||
}
|
||||
|
||||
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;
|
||||
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
|
||||
milestones.filter(m => m.position === 'before:' + todo.id).forEach(m => {
|
||||
const mIdx = milestones.indexOf(m);
|
||||
const row = buildMilestoneRow(m, mIdx);
|
||||
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
|
||||
insertAfter = row;
|
||||
if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu');
|
||||
einfuegen(row);
|
||||
insertedMilestoneIds.add(m.id);
|
||||
});
|
||||
|
||||
@@ -281,18 +349,51 @@
|
||||
// Terminplan nicht mit leeren Zeilen ohne Balken zustellen. Nicht
|
||||
// jede Aufgabe aus "Aufgaben" landet also im GANTT, wohl aber jede
|
||||
// Aufgabe aus dem GANTT in "Aufgaben" (dieselbe Liste, siehe oben).
|
||||
if(todo.start && todo.end){
|
||||
// UND showInGantt darf nicht ausdrücklich false sein — die Checkbox
|
||||
// "Im Terminplan anzeigen" im Aufgaben-Dialog (plan-modals.js) blendet
|
||||
// eine terminierte Aufgabe trotz gültigem Zeitraum gezielt aus dem
|
||||
// GANTT aus (Erik: ein einzelner "TODOs aus Protokoll"-Import,
|
||||
// plan-protokoll-todos.js, legt leicht 15-25 datierte Aufgaben auf
|
||||
// einmal an, die sonst alle zwingend als Balken erschienen). Fehlt
|
||||
// das Feld (ältere Aufgaben vor dieser Spalte), gilt weiterhin
|
||||
// sichtbar — !== false statt === true, damit kein Bestandsverhalten
|
||||
// kippt.
|
||||
if(todo.start && todo.end && todo.showInGantt !== false){
|
||||
const todoRow = buildTodoRow(todo, tIdx);
|
||||
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
|
||||
insertAfter = todoRow;
|
||||
if(phasenKoepfe.length && istPhase(todo)){
|
||||
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
|
||||
milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => {
|
||||
const mIdx = milestones.indexOf(m);
|
||||
const row = buildMilestoneRow(m, mIdx);
|
||||
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
|
||||
insertAfter = row;
|
||||
if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu');
|
||||
einfuegen(row);
|
||||
insertedMilestoneIds.add(m.id);
|
||||
});
|
||||
});
|
||||
|
||||
+29
-14
@@ -113,25 +113,28 @@
|
||||
const plan = app.ensurePlanLocal();
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
|
||||
// Nur terminierte Aufgaben (Start UND Ende gesetzt) haben überhaupt eine
|
||||
// eigene GANTT-Zeile (siehe plan-build-gantt.js) und damit einen
|
||||
// Ziehgriff — Aufgaben ohne Termin bleiben an ihrer Stelle
|
||||
// in plan.todos stehen, sie können hier gar nicht bewegt worden sein.
|
||||
const scheduledIds = new Set(todos.filter(t => t.start && t.end).map(t => t.id));
|
||||
// Nur terminierte Aufgaben (Start UND Ende gesetzt) MIT showInGantt !==
|
||||
// false haben überhaupt eine eigene GANTT-Zeile (exakt dieselbe
|
||||
// Bedingung wie in plan-build-gantt.js) und damit einen Ziehgriff —
|
||||
// Aufgaben ohne Termin oder bewusst aus dem GANTT ausgeblendete bleiben
|
||||
// an ihrer Stelle in plan.todos stehen, sie können hier gar nicht bewegt
|
||||
// worden sein. Ohne diesen zweiten Teil der Bedingung würde scheduledIds
|
||||
// mehr Einträge zählen als tatsächlich .gantt-todo-Zeilen im DOM stehen,
|
||||
// und der Sicherheitscheck unten (todoRowIds.length !== scheduledIds.size)
|
||||
// würde JEDE Umsortierung stillschweigend verwerfen, sobald auch nur eine
|
||||
// Aufgabe ausgeblendet ist.
|
||||
const scheduledIds = new Set(todos.filter(t => t.start && t.end && t.showInGantt !== false).map(t => t.id));
|
||||
|
||||
// Alle beweglichen Zeilen in aktueller DOM-Reihenfolge
|
||||
const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)];
|
||||
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);
|
||||
|
||||
// Sicherheitscheck: Anzahl muss stimmen. Nicht jedes Meilenstein wird als
|
||||
// eigene .gantt-milestone-row gerendert – nur die mit einer TODO-basierten
|
||||
// Position (before:/after:) sind eigene Zeilen. Meilensteine ohne Position
|
||||
// liegen in der Projektzeile und werden hier nicht umsortiert, deshalb nur
|
||||
// die positionierten zählen.
|
||||
// Sicherheitscheck: Anzahl muss stimmen. Jeder Meilenstein hat eine
|
||||
// eigene .gantt-milestone-row (auch ohne Position, dann oben unter
|
||||
// "Projekt gesamt").
|
||||
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 !== positionedIds.length) return;
|
||||
if(milestoneRowIds.length !== milestones.length) return;
|
||||
|
||||
// TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden
|
||||
// gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne
|
||||
@@ -147,7 +150,7 @@
|
||||
// Regeln:
|
||||
// - Steht der Meilenstein direkt vor einer TODO -> before: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
|
||||
rows.forEach((row, idx) => {
|
||||
if(!row.classList.contains('gantt-milestone-row')) return;
|
||||
@@ -159,7 +162,11 @@
|
||||
const prevRow = 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;
|
||||
} else if(nextRow && nextRow.classList.contains('gantt-todo')){
|
||||
position = 'before:' + nextRow.dataset.todoId;
|
||||
@@ -443,6 +450,14 @@
|
||||
const globalAbsence = globalPerson && Array.isArray(globalPerson.absences)
|
||||
? globalPerson.absences.find(x => x.id === absenceId) : null;
|
||||
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 note = globalAbsence.note || '';
|
||||
// Ursprungstermine merken: Scheitert das Speichern (Netz, fehlende
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
const hint = document.getElementById('filesNoProjectHint');
|
||||
const uploadSection = document.getElementById('filesUploadSection');
|
||||
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');
|
||||
if(fileInput) fileInput.value = '';
|
||||
if(hasNr) loadReviewerCheckboxes();
|
||||
@@ -175,7 +175,7 @@
|
||||
|
||||
// renderFilesSummary: füllt die Kompakt-Vorschau der "Dateien & Freigaben"-
|
||||
// Karte unter dem GANTT (Cockpit-Layout, #planFilesSummary in
|
||||
// Test.html) aus filesCache — Name der zuletzt hochgeladenen Datei
|
||||
// App.html) aus filesCache — Name der zuletzt hochgeladenen Datei
|
||||
// (Server liefert /project-files bereits nach created_at DESC sortiert,
|
||||
// filesCache[0] ist also die neueste) sowie einen Hinweis auf noch
|
||||
// ausstehende Prüfungen.
|
||||
@@ -212,7 +212,7 @@
|
||||
// Dieselbe Regel wie im Server (DELETE /project-files/:id): der Uploader,
|
||||
// solange die Datei noch offen ist — oder ein Administrator. Vorher stand
|
||||
// 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 canDelete = isAdmin || (isUploader && f.status === 'pending');
|
||||
const reviewerChips = (f.reviewers || []).map(r =>
|
||||
|
||||
+232
-101
@@ -9,9 +9,12 @@
|
||||
// (computeResourceLoads/renderResourceLoad/openResourceModal, Cockpit-Layout:
|
||||
// Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter)
|
||||
// und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt,
|
||||
// offene Aufgaben, nächster Meilenstein, Team-Auslastung, alle aus denselben
|
||||
// Plan-Daten abgeleitet). renderPlanGantt ist die zentrale Neu-Render-
|
||||
// Funktion (rendert am Ende auch Auslastung und KPIs neu) und wird nach
|
||||
// offene Aufgaben und nächster Meilenstein aus denselben Plan-Daten;
|
||||
// Team-Auslastung dagegen über aufgabenVerlaufJePerson() aus js/load/load.js,
|
||||
// siehe computeResourceLoads() weiter unten — dieselbe Rechnung wie im
|
||||
// Auslastungs-Tab, nicht aus den Plan-Daten selbst). renderPlanGantt ist die
|
||||
// zentrale Neu-Render-Funktion (rendert am Ende auch Auslastung und KPIs
|
||||
// neu) und wird nach
|
||||
// Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js erneut
|
||||
// aufgerufen.
|
||||
//
|
||||
@@ -23,7 +26,7 @@
|
||||
|
||||
// renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts
|
||||
// neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den
|
||||
// Projektzeitraum), berücksichtigt Urlaub, kritischen Pfad, Sperrstatus und
|
||||
// Projektzeitraum), berücksichtigt Urlaub, Sperrstatus und
|
||||
// Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die
|
||||
// Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert.
|
||||
window.renderPlanGantt = function(){
|
||||
@@ -38,16 +41,23 @@
|
||||
document.getElementById('planGanttStart').value = formatDateISO(start);
|
||||
document.getElementById('planGanttEnd').value = formatDateISO(end);
|
||||
}
|
||||
// Zeitraum auch sichtbar ins Feld schreiben (es zeigte sonst nur den
|
||||
// Platzhalter, solange niemand einen Zeitraum gewählt hatte).
|
||||
const zeitraumAnzeige = document.getElementById('planGanttRange');
|
||||
if(zeitraumAnzeige) zeitraumAnzeige.value = formatDateDE(start) + ' – ' + formatDateDE(end);
|
||||
const showVacation = document.getElementById('planShowVacation').checked;
|
||||
const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id);
|
||||
const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : [];
|
||||
const ghost = collectGhostDates();
|
||||
container.innerHTML = buildGanttHtml([app.project], start, end, {
|
||||
showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id,
|
||||
showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath,
|
||||
showTodos: true, vacationPeople, pxPerDay: app.pxPerDay,
|
||||
ghostTodos: ghost.todos, ghostMilestones: ghost.milestones, ghostLabel: ghost.label,
|
||||
filter: app.ganttFilter || 'alle', phasenZu: app.phasenZu || (app.phasenZu = {}),
|
||||
eigenePersonId: (typeof currentUser !== 'undefined' && currentUser && currentUser.personId) || '',
|
||||
});
|
||||
renderOhneTermin();
|
||||
container.scrollLeft = prevScroll;
|
||||
if(typeof ganttGriffeEinsetzen === 'function') ganttGriffeEinsetzen(container);
|
||||
requestAnimationFrame(() => renderGanttDependencies(container));
|
||||
renderPlanWarnings();
|
||||
renderResourceLoad();
|
||||
@@ -58,6 +68,7 @@
|
||||
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
|
||||
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
|
||||
if(typeof renderPlanTasks === 'function') renderPlanTasks();
|
||||
if(typeof renderPlanPhasen === 'function') renderPlanPhasen();
|
||||
if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
|
||||
};
|
||||
|
||||
@@ -91,7 +102,7 @@
|
||||
}
|
||||
|
||||
// renderPlanWarnings: füllt das Hinweisband über dem GANTT
|
||||
// (#planWarnings in Test.html) mit den Urlaubs-Kollisionen des
|
||||
// (#planWarnings in App.html) mit den Urlaubs-Kollisionen des
|
||||
// Projekts — Aufgaben, deren Zeitraum in die Abwesenheit einer zugeordneten
|
||||
// Person fällt. Die Prüfung selbst liegt in plan-build-gantt.js
|
||||
// (app.absenceConflictsForTodo), damit Balken-Markierung und diese Liste
|
||||
@@ -118,71 +129,68 @@
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// kpiTile: baut das HTML einer einzelnen Kachel für renderPlanKpis().
|
||||
// Parameter: label (Kachel-Titel), value (Hauptzahl), sub (Unterzeile),
|
||||
// warn (optional, hebt den Wert farblich hervor, z. B. bei überfälligen
|
||||
// Aufgaben). Rückgabe: HTML-String.
|
||||
function kpiTile(label, value, sub, warn){
|
||||
return `<div class="plan-kpi-tile${warn ? ' warn' : ''}">
|
||||
// bandZelle: eine Kennzahl im Lageband des Dashboards. Parameter: label,
|
||||
// value, sub (Texte), warn (Unterzeile rot). Rückgabe: HTML-String.
|
||||
function bandZelle(label, value, sub, warn){
|
||||
return `<div class="plan-lage-zelle">
|
||||
<div class="kpi-label">${esc(label)}</div>
|
||||
<div class="kpi-value">${esc(value)}</div>
|
||||
<div class="kpi-sub">${esc(sub)}</div>
|
||||
<div class="kpi-sub${warn ? ' ist-rot' : ''}">${esc(sub)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout,
|
||||
// #planKpiRow in Test.html) mit vier Kennzahlen, die alle aus den
|
||||
// bereits vorhandenen Plan-Daten abgeleitet sind (keine neue Datenquelle):
|
||||
// Gesamtfortschritt (Ø-Fortschritt aller TODOs), offene Aufgaben (inkl.
|
||||
// überfällige), nächster anstehender Meilenstein und Team-Auslastung
|
||||
// (Ø-Prozentwert aus computeResourceLoads(), dieselbe Zahl wie im
|
||||
// Auslastungs-Modal). Wird nach jedem GANTT-Rendering aufgerufen, damit sie
|
||||
// nach TODO-/Meilenstein-/Zuordnungsänderungen sofort aktuell ist.
|
||||
// renderPlanKpis: füllt das Lageband oben im Dashboard (#planKpiRow,
|
||||
// Anordnung "Lagebild"): links die laufende Leistungsphase mit Ring
|
||||
// (amtierende LPH und ihr Fortschritt aus dem Wochenbericht), daneben offene
|
||||
// Aufgaben (mit Überfälligem), nächster Meilenstein und Team-Auslastung
|
||||
// (computeResourceLoads(), dieselbe Zahl wie im Auslastungs-Tab). Der
|
||||
// frühere "Gesamtfortschritt" (Ø der Aufgaben) ist entfallen — für den
|
||||
// Stand eines Projekts zählt die Leistungsphase. Wird nach jedem
|
||||
// GANTT-Rendering aufgerufen, damit es sofort aktuell ist.
|
||||
window.renderPlanKpis = function(){
|
||||
const row = document.getElementById('planKpiRow');
|
||||
if(!row || !app.project) return;
|
||||
const p = app.project;
|
||||
const plan = app.ensurePlanLocal();
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let progressTile;
|
||||
if(todos.length === 0){
|
||||
progressTile = kpiTile('Gesamtfortschritt', '–', 'Keine Aufgaben angelegt');
|
||||
} else {
|
||||
const avg = Math.round(todos.reduce((s, t) => s + (parseInt(t.progress, 10) || 0), 0) / todos.length);
|
||||
const doneCount = todos.filter(t => t.done).length;
|
||||
progressTile = kpiTile('Gesamtfortschritt', avg + '%', doneCount + ' von ' + todos.length + ' Aufgaben erledigt');
|
||||
}
|
||||
const lph = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || '';
|
||||
const roh = lph && p.lph ? p.lph[lph] : '';
|
||||
const pct = roh === undefined || roh === '' ? null : Math.max(0, Math.min(100, Number(roh) || 0));
|
||||
const phase = `<div class="plan-lage-zelle plan-lage-phase">
|
||||
<span class="plan-lage-ring" style="--p:${pct || 0}"></span>
|
||||
<div><div class="kpi-label">Laufende Phase</div>
|
||||
<div class="kpi-value">${lph ? esc(lph) + (pct === null ? '' : ' · ' + pct + ' %') : '–'}</div>
|
||||
<div class="kpi-sub">${lph ? (pct === 100 ? 'abgeschlossen' : 'Fortschritt laut Wochenbericht') : 'Keine amtierende Phase gesetzt'}</div></div>
|
||||
</div>`;
|
||||
|
||||
const open = todos.filter(t => !t.done);
|
||||
const overdue = open.filter(t => t.end && parseDateISO(t.end) < today);
|
||||
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'Im Zeitplan' : 'Alle Aufgaben erledigt');
|
||||
const openTile = kpiTile('Offene Aufgaben', String(open.length), openSub, overdue.length > 0);
|
||||
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'im Zeitplan' : 'alle erledigt');
|
||||
|
||||
const upcoming = milestones
|
||||
.filter(m => m.date && parseDateISO(m.date) >= today)
|
||||
.sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date));
|
||||
let milestoneTile;
|
||||
if(upcoming.length > 0){
|
||||
milestoneTile = kpiTile('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein');
|
||||
} else {
|
||||
milestoneTile = kpiTile('Nächster Meilenstein', '–', milestones.length > 0 ? 'Keine anstehenden Meilensteine' : 'Keine Meilensteine angelegt');
|
||||
}
|
||||
const msZelle = upcoming.length > 0
|
||||
? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein')
|
||||
: bandZelle('Nächster Meilenstein', '–', milestones.length > 0 ? 'keine anstehenden' : 'keine angelegt');
|
||||
|
||||
const { loads, maxDays } = computeResourceLoads();
|
||||
const { loads } = computeResourceLoads();
|
||||
const loadValues = Object.values(loads);
|
||||
let loadTile;
|
||||
let loadZelle;
|
||||
if(loadValues.length === 0){
|
||||
loadTile = kpiTile('Team-Auslastung', '–', 'Keine Beteiligten zugeordnet');
|
||||
loadZelle = bandZelle('Team-Auslastung', '–', 'niemand zugeordnet');
|
||||
} else {
|
||||
const avgPct = Math.round(loadValues.reduce((s, l) => s + (l.days / maxDays * 100), 0) / loadValues.length);
|
||||
const top = loadValues.slice().sort((a, b) => b.days - a.days)[0];
|
||||
loadTile = kpiTile('Team-Auslastung', avgPct + '%', top.days > 0 ? 'Höchste Auslastung: ' + top.person.name : 'Noch keine Aufgaben zugewiesen');
|
||||
const avgPct = Math.round(loadValues.reduce((s, l) => s + l.quote * 100, 0) / loadValues.length);
|
||||
const top = loadValues.slice().sort((a, b) => b.quote - a.quote)[0];
|
||||
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
|
||||
@@ -199,7 +207,13 @@
|
||||
let svg = chart.querySelector('.gantt-dependencies-svg');
|
||||
if(svg) svg.remove();
|
||||
|
||||
const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'));
|
||||
// Linien abgeschaltet (Menü "Mehr") — dann nichts zeichnen.
|
||||
const pfeile = document.getElementById('planShowPfeile');
|
||||
if(pfeile && !pfeile.checked) return;
|
||||
// Ausgeblendete Zeilen (zugeklappte Phase, Filter) haben keine Lage —
|
||||
// Linien dorthin würden ins Leere zeigen.
|
||||
const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'))
|
||||
.filter(b => b.offsetParent !== null);
|
||||
if(nodeBars.length === 0) return;
|
||||
|
||||
const chartRect = chart.getBoundingClientRect();
|
||||
@@ -280,6 +294,97 @@
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ruhige Leiste (Eriks Wahl "A"): Maßstab, Heute, Filter, Menüs.
|
||||
// ---------------------------------------------------------------------------
|
||||
const TP_STUFEN = { fit: null, woche: 8, tag: 32 };
|
||||
|
||||
// tpMassstab: Maßstab "Übersicht" (an die Breite angepasst), "Wochen" oder
|
||||
// "Tage" — feste Pixel je Tag statt der früheren −/+-Knöpfe.
|
||||
window.tpMassstab = function(stufe){
|
||||
app.pxPerDay = TP_STUFEN[stufe] === undefined ? null : TP_STUFEN[stufe];
|
||||
document.querySelectorAll('#tpMassstab button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.stufe === stufe)));
|
||||
renderPlanGantt();
|
||||
if(app.pxPerDay) tpZuHeuteScrollen();
|
||||
};
|
||||
|
||||
function tpZuHeuteScrollen(){
|
||||
const c = document.getElementById('planGanttContainer');
|
||||
const heute = c && c.querySelector('.gantt-grid-today');
|
||||
if(!c || !heute) return;
|
||||
const r = heute.getBoundingClientRect(), cr = c.getBoundingClientRect();
|
||||
c.scrollLeft += (r.left - cr.left) - c.clientWidth / 3;
|
||||
}
|
||||
|
||||
// tpHeute: Zeitraum ab zwei Wochen vor heute, gleiche Länge wie bisher
|
||||
// (mindestens 12 Wochen), und heute ins Bild holen.
|
||||
window.tpHeute = function(){
|
||||
const s = parseDateISO(document.getElementById('planGanttStart').value);
|
||||
const e = parseDateISO(document.getElementById('planGanttEnd').value);
|
||||
const tage = s && e ? Math.max(84, daysBetween(s, e)) : 84;
|
||||
const von = new Date(); von.setHours(0, 0, 0, 0); von.setDate(von.getDate() - 14);
|
||||
const bis = new Date(von); bis.setDate(bis.getDate() + tage);
|
||||
document.getElementById('planGanttStart').value = formatDateISO(von);
|
||||
document.getElementById('planGanttEnd').value = formatDateISO(bis);
|
||||
const anzeige = document.getElementById('planGanttRange');
|
||||
if(anzeige) anzeige.value = formatDateDE(von) + ' – ' + formatDateDE(bis);
|
||||
renderPlanGantt();
|
||||
if(app.pxPerDay) tpZuHeuteScrollen();
|
||||
};
|
||||
|
||||
// tpFilter: Alle / Meine (eigene Zuständigkeit) / Überfällig. Gefilterte
|
||||
// Zeilen werden nur ausgeblendet, nicht weggelassen — das Umsortieren per
|
||||
// Ziehen (plan-drag.js) zählt die Zeilen im DOM.
|
||||
window.tpFilter = function(filter){
|
||||
app.ganttFilter = filter;
|
||||
document.querySelectorAll('#tpFilter button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.filter === filter)));
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// tpPhaseUmschalten: klappt eine Leistungsphase im Terminplan auf/zu.
|
||||
window.tpPhaseUmschalten = function(phase){
|
||||
if(!app.phasenZu) app.phasenZu = {};
|
||||
app.phasenZu[phase] = !app.phasenZu[phase];
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// tpMenueZu: schließt die Menüs "Mehr" und "+ Neu".
|
||||
window.tpMenueZu = function(){
|
||||
document.querySelectorAll('.tp-menue[open]').forEach(d => d.removeAttribute('open'));
|
||||
};
|
||||
// Ein Klick außerhalb schließt ein offenes Menü; ein Menü öffnen schließt
|
||||
// das andere.
|
||||
document.addEventListener('click', e => {
|
||||
document.querySelectorAll('.tp-menue[open]').forEach(d => { if(!d.contains(e.target)) d.removeAttribute('open'); });
|
||||
});
|
||||
document.addEventListener('keydown', e => { if(e.key === 'Escape') window.tpMenueZu(); });
|
||||
// Die Liste hängt rechtsbündig unter dem Knopf. Bricht die Leiste in einem
|
||||
// schmalen Fenster um, steht "+ Neu" links und die Liste rutschte unter die
|
||||
// Seitenleiste — dann linksbündig aufklappen (Klasse .nach-rechts).
|
||||
document.addEventListener('toggle', e => {
|
||||
const d = e.target;
|
||||
if(!d.classList || !d.classList.contains('tp-menue') || !d.open) return;
|
||||
const liste = d.querySelector('.tp-menue-liste');
|
||||
if(!liste) return;
|
||||
liste.classList.remove('nach-rechts');
|
||||
const seite = document.querySelector('.side-menu');
|
||||
const r = seite ? seite.getBoundingClientRect() : null;
|
||||
const linkeGrenze = r && r.width && getComputedStyle(seite).position !== 'fixed' ? r.right : 0;
|
||||
if(liste.getBoundingClientRect().left < linkeGrenze + 8) liste.classList.add('nach-rechts');
|
||||
}, true);
|
||||
|
||||
// renderOhneTermin: Hinweis unter dem Diagramm auf Aufgaben, die keinen
|
||||
// Balken haben (ohne Zeitraum oder bewusst ausgeblendet) — sie stehen nur
|
||||
// unter "Aufgaben". Vorher fehlte jeder Hinweis, dass es sie gibt.
|
||||
function renderOhneTermin(){
|
||||
const el = document.getElementById('planOhneTermin');
|
||||
if(!el) return;
|
||||
const todos = (app.project && app.project.plan && Array.isArray(app.project.plan.todos)) ? app.project.plan.todos : [];
|
||||
const ohne = todos.filter(t => !t.done && !(t.start && t.end && t.showInGantt !== false));
|
||||
el.hidden = ohne.length === 0;
|
||||
el.innerHTML = ohne.length ? `<span class="tp-ohne-zahl">${ohne.length}</span> ${ohne.length === 1 ? 'offene Aufgabe' : 'offene Aufgaben'} ohne Termin <span class="tp-ohne-hinweis">stehen nur unter „Aufgaben“ →</span>` : '';
|
||||
}
|
||||
|
||||
// zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms.
|
||||
// Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit).
|
||||
window.zoomPlanGantt = function(dir){
|
||||
@@ -288,7 +393,7 @@
|
||||
let end = parseDateISO(document.getElementById('planGanttEnd').value);
|
||||
if(!start || !end) return;
|
||||
const totalDays = Math.max(1, daysBetween(start, end) + 1);
|
||||
const cw = container ? Math.max(200, container.clientWidth - 250) : 900;
|
||||
const cw = container ? Math.max(200, container.clientWidth - (ganttSpaltenBreite() + 30)) : 900;
|
||||
const fitPx = Math.max(3, cw / totalDays);
|
||||
const base = app.pxPerDay || fitPx;
|
||||
let next;
|
||||
@@ -304,66 +409,87 @@
|
||||
|
||||
// ---------- Ressourcen-Auslastung ----------
|
||||
|
||||
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten in
|
||||
// Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs.
|
||||
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten
|
||||
// NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel:
|
||||
// Stunden dieser Woche aus Aufgaben mit Aufwand, gleich geteilt auf die
|
||||
// Zuständigen (aufgabenVerlaufJePerson(), js/load/load.js), bezogen auf die
|
||||
// Kapazität der Person. Für JEDES Projekt der Sitzung, aufgeteilt in den
|
||||
// Anteil DIESES Projekts und den der übrigen — sonst sähe jemand hier
|
||||
// entspannt aus, der in vier anderen Projekten ausgebucht ist.
|
||||
//
|
||||
// Gemeinsam genutzt von renderResourceLoad() (Detailkarten im
|
||||
// Auslastungs-Modal) und renderPlanKpis() (Team-Auslastung-Kachel), damit
|
||||
// beide dieselbe Zahl zeigen.
|
||||
//
|
||||
// Neben der Auslastung IN DIESEM Projekt (days/todos) wird zusätzlich
|
||||
// erfasst, wie stark dieselbe Person in den ÜBRIGEN Projekten derselben
|
||||
// Sitzung gebunden ist (otherDays/otherProjects). Ohne diesen Blick über den
|
||||
// Projektrand ist eine Überbuchung strukturell unsichtbar: jemand kann hier
|
||||
// entspannt aussehen und trotzdem in vier weiteren Projekten verplant sein.
|
||||
// Die Daten dafür sind bereits vorhanden — app.week.projects enthält alle
|
||||
// Projekte der Sitzung (GET /load-all lädt sie ohnehin komplett).
|
||||
//
|
||||
// Rückgabe: { loads, maxDays } — loads ist ein Objekt Person-ID ->
|
||||
// { person, roles, days, todos, otherDays, otherProjects }, maxDays das
|
||||
// Maximum über alle Personen (mindestens 1, für Prozent-Berechnungen; bezieht
|
||||
// sich bewusst nur auf die projekteigenen Tage, damit die Balken im Modal
|
||||
// untereinander vergleichbar bleiben).
|
||||
// Terminplan-Bereich) und renderPlanKpis() (Team-Auslastung-Kachel), damit
|
||||
// beide dieselbe Zahl zeigen. Rückgabe: { loads } — loads ist ein Objekt
|
||||
// Person-ID -> { person, roles, kapazitaet, stundenProjekt, stundenAndere,
|
||||
// stunden (Summe), quote (stunden/kapazitaet), andereProjekte, todos
|
||||
// (Titel der eigenen Aufgaben in DIESEM Projekt, ohne Phasenbalken),
|
||||
// offenProjekt (offene Aufgabenstunden dieses Projekts, alle Wochen).
|
||||
function computeResourceLoads(){
|
||||
const plan = app.ensurePlanLocal();
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos.filter(t => t.start && t.end) : [];
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
const assignees = projectAssignees(app.project, app.week);
|
||||
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
|
||||
|
||||
const loads = {};
|
||||
assignees.forEach(a => {
|
||||
if(!loads[a.person.id]){
|
||||
loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [], otherDays: 0, otherProjects: [] };
|
||||
loads[a.person.id] = {
|
||||
person: a.person, roles: [], todos: [],
|
||||
kapazitaet: kapazitaetsFaktor(a.person) * vollzeit,
|
||||
stundenProjekt: 0, stundenAndere: 0, andereProjekte: [],
|
||||
};
|
||||
}
|
||||
if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role);
|
||||
});
|
||||
|
||||
// Zuständige einer Aufgabe zählen mit, auch wenn sie unter „Beteiligte“
|
||||
// fehlen — wie auf der Auslastungsseite. Sonst fielen ihre
|
||||
// Aufgabenstunden hier stillschweigend weg.
|
||||
const personen = (app.week && app.week.people) || [];
|
||||
todos.forEach(t => {
|
||||
if(!Array.isArray(t.assignees) || t.assignees.length === 0) return;
|
||||
const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end));
|
||||
if(!Array.isArray(t.assignees) || balkenPhase(t)) return;
|
||||
t.assignees.forEach(pid => {
|
||||
if(!loads[pid]) return;
|
||||
loads[pid].days += days;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// Auslastung in den anderen Projekten derselben Sitzung
|
||||
((app.week && app.week.projects) || []).forEach(other => {
|
||||
if(other.id === app.project.id) return;
|
||||
const otherTodos = (other.plan && Array.isArray(other.plan.todos)) ? other.plan.todos : [];
|
||||
otherTodos.forEach(t => {
|
||||
if(!t.start || !t.end || !Array.isArray(t.assignees)) return;
|
||||
const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end));
|
||||
t.assignees.forEach(pid => {
|
||||
if(!loads[pid]) return; // nur Personen, die auch hier beteiligt sind
|
||||
loads[pid].otherDays += days;
|
||||
const label = other.nr ? (other.nr + ' ' + (other.name || '')) : (other.name || 'Projekt');
|
||||
if(!loads[pid].otherProjects.includes(label)) loads[pid].otherProjects.push(label);
|
||||
});
|
||||
// Offene Aufgabenstunden dieses Projekts insgesamt (nicht nur diese
|
||||
// Woche) — liegt eine Aufgabe erst später, zeigte die Wochenzahl sonst 0.
|
||||
Object.values(loads).forEach(l => { l.offenProjekt = 0; });
|
||||
offeneAufwandsAufgaben(app.project).forEach(({ t, rest }) => {
|
||||
t.assignees.forEach(pid => { if(loads[pid]) loads[pid].offenProjekt += rest / t.assignees.length; });
|
||||
});
|
||||
|
||||
// Stunden dieser Woche allein aus Aufgaben mit Aufwand — dieselbe
|
||||
// Rechnung wie auf der Auslastungsseite (aufgabenVerlaufJePerson(), load.js).
|
||||
((app.week && app.week.projects) || []).forEach(pr => {
|
||||
const av = typeof aufgabenVerlaufJePerson === 'function' ? aufgabenVerlaufJePerson(pr) : {};
|
||||
Object.keys(av).forEach(pid => {
|
||||
if(!loads[pid] || !(av[pid][0] > 0)) return;
|
||||
if(pr.id === app.project.id) loads[pid].stundenProjekt += av[pid][0];
|
||||
else {
|
||||
loads[pid].stundenAndere += av[pid][0];
|
||||
const label = pr.nr ? (pr.nr + ' ' + (pr.name || '')) : (pr.name || 'Projekt');
|
||||
if(!loads[pid].andereProjekte.includes(label)) loads[pid].andereProjekte.push(label);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const maxDays = Math.max(1, Object.values(loads).reduce((m, l) => Math.max(m, l.days), 0));
|
||||
return { loads, maxDays };
|
||||
Object.values(loads).forEach(l => {
|
||||
l.stunden = l.stundenProjekt + l.stundenAndere;
|
||||
l.quote = l.kapazitaet > 0 ? l.stunden / l.kapazitaet : 0;
|
||||
});
|
||||
|
||||
return { loads };
|
||||
}
|
||||
|
||||
// openResourceModal: springt in den Bereich "Terminplan", in dem die
|
||||
@@ -387,7 +513,7 @@
|
||||
const container = document.getElementById('resourceLoadContainer');
|
||||
const summaryEl = document.getElementById('planResourceSummary');
|
||||
if(!container || !app.project) return;
|
||||
const { loads, maxDays } = computeResourceLoads();
|
||||
const { loads } = computeResourceLoads();
|
||||
const loadValues = Object.values(loads);
|
||||
|
||||
if(loadValues.length === 0){
|
||||
@@ -398,11 +524,10 @@
|
||||
|
||||
if(summaryEl){
|
||||
const cells = loadValues.map(l => {
|
||||
const pct = Math.round(l.days / maxDays * 100);
|
||||
const otherHint = l.otherDays > 0 ? ` (+${l.otherDays} anderswo)` : '';
|
||||
return `<div class="plan-heat-cell" style="opacity:${Math.max(.15, pct / 100)}" title="${esc(l.person.name)}: ${l.days} Tage${otherHint}"></div>`;
|
||||
const otherHint = l.stundenAndere > 0 ? ` (+${stundenFormat(l.stundenAndere)} h anderswo)` : '';
|
||||
return `<div class="plan-heat-cell" style="opacity:${Math.max(.15, Math.min(1, l.quote))}" title="${esc(l.person.name)}: ${stundenFormat(l.stunden)} h${otherHint}"></div>`;
|
||||
}).join('');
|
||||
const alsoBooked = loadValues.filter(l => l.otherDays > 0).length;
|
||||
const alsoBooked = loadValues.filter(l => l.stundenAndere > 0).length;
|
||||
const chip = alsoBooked > 0
|
||||
? `<span class="plan-mini-chip warn">${alsoBooked} auch in anderen Projekten</span>`
|
||||
: `<span class="plan-mini-chip">nur in diesem Projekt</span>`;
|
||||
@@ -410,10 +535,11 @@
|
||||
<div class="plan-mini-chip-row">${chip}</div>`;
|
||||
}
|
||||
|
||||
let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)</div>';
|
||||
let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Wochenstunden, wie im Auslastungs-Tab)</div>';
|
||||
html += '<div class="load-grid plan-resource-grid">';
|
||||
Object.values(loads).forEach(l => {
|
||||
const pct = Math.round(l.days / maxDays * 100);
|
||||
const ampel = loadAmpelColor(l.quote);
|
||||
const breite = Math.min(100, l.quote * 100);
|
||||
const absCount = app.countPersonAbsences(l.person.id);
|
||||
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">${absCount} Abwesenheit(en)</div>` : '';
|
||||
|
||||
@@ -427,19 +553,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Gegenüberstellung: Tage in diesem Projekt vs. in den übrigen Projekten
|
||||
// derselben Sitzung (siehe computeResourceLoads) — macht sichtbar, wenn
|
||||
// jemand hier entspannt aussieht, aber anderswo ausgebucht ist.
|
||||
const otherBadge = l.otherDays > 0
|
||||
? `<div class="plan-resource-other" title="${esc(l.otherProjects.join('\n'))}">
|
||||
+${l.otherDays} Tage in ${l.otherProjects.length} weiteren Projekt${l.otherProjects.length === 1 ? '' : 'en'}
|
||||
// Gegenüberstellung: Stunden aus diesem Projekt vs. aus den übrigen
|
||||
// Projekten der Sitzung (siehe computeResourceLoads) — macht sichtbar,
|
||||
// wenn jemand hier entspannt aussieht, aber anderswo ausgebucht ist.
|
||||
const otherBadge = l.stundenAndere > 0
|
||||
? `<div class="plan-resource-other" title="${esc(l.andereProjekte.join('\n'))}">
|
||||
+${stundenFormat(l.stundenAndere)} h in ${l.andereProjekte.length} weiteren Projekt${l.andereProjekte.length === 1 ? '' : 'en'}
|
||||
</div>`
|
||||
: `<div class="plan-resource-other muted">keine weiteren Projekte</div>`;
|
||||
|
||||
const offenText = l.offenProjekt > 0
|
||||
? `<div class="plan-resource-other muted">${stundenFormat(l.offenProjekt)} h offen in Aufgaben dieses Projekts</div>`
|
||||
: `<div class="plan-resource-other muted">keine Aufgaben mit Aufwand</div>`;
|
||||
|
||||
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
|
||||
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3>
|
||||
<div class="load-num" style="font-size:18px">${l.days}</div>
|
||||
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${pct}%;background:${pct>75?'#c53030':pct>45?'#d69e2e':'#2f855a'}"></div></div>
|
||||
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h diese Woche</span></div>
|
||||
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
|
||||
${offenText}
|
||||
${otherBadge}
|
||||
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
|
||||
${absBadge}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// -----------------------------
|
||||
// Projektplan — Verlauf über mehrere Sitzungen
|
||||
// -----------------------------
|
||||
// Dieses Modul macht die Sitzungs-Historie eines Projekts sichtbar: die
|
||||
// Kompakt-Karte "Verlauf" unter dem GANTT zeigt den letzten Kommentarwechsel,
|
||||
// ein Klick öffnet #planHistoryModal mit dem vollständigen Verlauf.
|
||||
// Dieses Modul liefert die Fassungen eines Projekts über die Sitzungen
|
||||
// (Vorwochen-Helfer für den GANTT). Angezeigt wird der Kommentar-Verlauf
|
||||
// im Projekt-Dashboard von plan-info.js.
|
||||
//
|
||||
// Der Verlauf selbst wird NICHT hier gezeichnet, sondern von
|
||||
// renderProjektVerlauf() in js/utils.js — derselben Darstellung, die auch der
|
||||
// Wochenbericht unter der Projektkarte und die Projektliste zeigen. Zuvor
|
||||
// Der Verlauf in Wochenbericht und Projektliste wird von
|
||||
// renderProjektVerlauf() in js/utils.js gezeichnet. Zuvor
|
||||
// hatte jede dieser Stellen ihre eigene Fassung mit eigener Auswahl (hier nur
|
||||
// Sitzungen mit Kommentarwechsel, im Bericht auch Termine und
|
||||
// Leistungsphasen), weshalb derselbe Projektverlauf je nach Aufrufort anders
|
||||
// aussah und sich widersprechen konnte. Der Dialog übergibt kein weekLabel:
|
||||
// Er kennt keine "aktuelle" Fassung, die auszulassen wäre, und zeigt deshalb
|
||||
// alle Sitzungen einschließlich der geöffneten.
|
||||
// aussah und sich widersprechen konnte.
|
||||
//
|
||||
// Hintergrund: Beim wöchentlichen cloneWeek() (js/weeks.js) wird der
|
||||
// Kommentartext in die neue Sitzung kopiert und dort überschrieben. Der alte
|
||||
@@ -86,75 +83,10 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
// historyEntries: die Verlaufsliste des aktuell geöffneten Projekts
|
||||
// (neueste zuerst, Wiederholungen desselben Textes gefiltert). Logik in
|
||||
// js/utils.js, siehe projectStatusHistory().
|
||||
function historyEntries(){
|
||||
return projectStatusHistory(app.project && app.project.projectUid);
|
||||
}
|
||||
|
||||
// renderPlanHistory: füllt die Kompakt-Karte "Verlauf" (#planHistorySummary
|
||||
// in Test.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';
|
||||
};
|
||||
// Die Kompakt-Karte "Verlauf" (renderPlanHistory()) und der Dialog
|
||||
// #planHistoryModal (openHistoryModal()/closeHistoryModal()) sind entfallen:
|
||||
// Der Kommentar-Verlauf steht jetzt in der Kachel "Kommentar der Sitzung"
|
||||
// des Projekt-Dashboards (renderPlanKommentarVerlauf(), plan-info.js).
|
||||
|
||||
// Export für die anderen plan-Module (plan-gantt.js/plan-build-gantt.js:
|
||||
// Geister-Balken der letzten geplanten Sitzung).
|
||||
|
||||
@@ -94,7 +94,11 @@
|
||||
}
|
||||
|
||||
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;
|
||||
while(!isWorkday(cursor)) cursor = addDays(cursor, 1);
|
||||
let summeStunden = 0, summeTage = 0, summeHonorar = 0;
|
||||
|
||||
const zeilen = phasen.map(lph => {
|
||||
@@ -137,7 +141,7 @@
|
||||
// Eingaben gelten als 0 = "kein Honorar hinterlegt".
|
||||
// Parameter: val (Eingabewert). Keine Rückgabe.
|
||||
window.setHonorarBetrag = function(val){
|
||||
if(app.isLocked()) return;
|
||||
if(app.stammGesperrt()) return;
|
||||
const v = parseFloat(String(val).replace(',', '.'));
|
||||
app.project.honorar = Number.isFinite(v) && v > 0 ? v : 0;
|
||||
app.savePlan();
|
||||
@@ -152,7 +156,7 @@
|
||||
// Reihenfolge von LPHS gehalten, damit der erzeugte Terminplan
|
||||
// chronologisch aufsteigt. Parameter: lph, checked (Boolean).
|
||||
window.toggleBeauftragteLp = function(lph, checked){
|
||||
if(app.isLocked()) return;
|
||||
if(app.stammGesperrt()) return;
|
||||
const p = app.project;
|
||||
const set = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
|
||||
if(checked) set.add(lph); else set.delete(lph);
|
||||
@@ -260,7 +264,7 @@
|
||||
function renderHonorarHint(){
|
||||
const el = document.getElementById('honorarAdminHint');
|
||||
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
|
||||
@@ -326,6 +330,9 @@
|
||||
plan.todos.push({
|
||||
id: uid(),
|
||||
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),
|
||||
end: formatDateISO(z.ende),
|
||||
done: false,
|
||||
|
||||
+170
-42
@@ -4,16 +4,15 @@
|
||||
// Dieses Modul füllt die Projektangaben des Projektbereichs. Es unterscheidet
|
||||
// dabei zwei Arten von Angaben, und das ist der Kern der Sache:
|
||||
//
|
||||
// * NUR ANZEIGE — Kommentar der Sitzung (#planStatusEdit), Fortschritt je
|
||||
// Leistungsphase (#planLphList) und die Zuordnung der Beteiligten
|
||||
// (#planAssignMatrix). Diese drei entstehen in der wöchentlichen
|
||||
// * NUR ANZEIGE — Kommentar der Sitzung (#planStatusEdit) und Fortschritt
|
||||
// je Leistungsphase (#planLphList). Beide entstehen in der wöchentlichen
|
||||
// Besprechung und werden ausschließlich im Wochenbericht erfasst. Neben
|
||||
// jeder von ihnen steht ein Verweis dorthin (setzeBerichtVerweis()), der
|
||||
// hinüberwechselt UND dort dieses Projekt einstellt.
|
||||
// * STAMMDATEN, hier pflegbar — Gruppe (#planGroupSelect) und amtierende
|
||||
// Leistungsphase (#planActiveLpSelect), beide im Bereich "Einstellungen".
|
||||
//
|
||||
// Warum: Dieselben drei Angaben waren zuvor an DREI Stellen bearbeitbar —
|
||||
// Warum: Dieselben Angaben waren zuvor an DREI Stellen bearbeitbar —
|
||||
// Projektboard, Wochenbericht und hier. Niemand wusste, welche davon die
|
||||
// richtige ist, und zwei Personen konnten dasselbe Feld an verschiedenen
|
||||
// Stellen ändern. Seitdem hat jede Angabe genau einen Ort, an dem sie
|
||||
@@ -21,11 +20,20 @@
|
||||
// früheren Schreibfunktionen dieses Moduls (planStatusBlur(), setPlanLph(),
|
||||
// setPlanAssign()) sind damit entfallen.
|
||||
//
|
||||
// BEWUSST NICHT hier: Projektnummer, Bezeichnung, Schlagwörter, Honorar und
|
||||
// beauftragte Leistungsphasen. Sie sind ebenfalls Stammdaten, werden aber von
|
||||
// js/plan/plan-settings.js gerendert (Honorar und beauftragte Phasen
|
||||
// geschrieben von setHonorarBetrag()/toggleBeauftragteLp() aus
|
||||
// js/plan/plan-honorar.js).
|
||||
// Die Zuordnung der Beteiligten (p.assign) gehörte früher ebenfalls zu
|
||||
// dieser "nur Anzeige"-Gruppe — auf Eriks ausdrücklichen Wunsch ist sie
|
||||
// seitdem umgekehrt: kein reines Anzeigefeld mehr, sondern ein echtes
|
||||
// Stammdatum, und zwar NUR HIER im Projektbereich editierbar (Bereich
|
||||
// "Einstellungen", gerendert von js/plan/plan-settings.js —
|
||||
// #planAssignMatrix liegt dort im Markup, nicht im Dashboard). Der
|
||||
// Wochenbericht zeigt die Zuordnung seitdem nur noch zum Lesen, mit einem
|
||||
// Verweis hierher.
|
||||
//
|
||||
// BEWUSST NICHT hier: Projektnummer, Bezeichnung, Schlagwörter, Honorar,
|
||||
// beauftragte Leistungsphasen und die Beteiligten-Matrix. Sie sind
|
||||
// Stammdaten und werden von js/plan/plan-settings.js gerendert (Honorar und
|
||||
// beauftragte Phasen geschrieben von setHonorarBetrag()/toggleBeauftragteLp()
|
||||
// aus js/plan/plan-honorar.js, die Beteiligten-Matrix von planCycleAssign()).
|
||||
//
|
||||
// renderPlanInfo() wird von
|
||||
// renderPlanPage() (plan-state.js) nach jedem Laden aufgerufen; Änderungen
|
||||
@@ -42,7 +50,7 @@
|
||||
// alle echten Leistungsphasen (inkl. LPH 9), aber ohne die Sammelkategorie
|
||||
// "W+M". Entspricht AUFWAND_LPS in js/load/load.js, hier separat definiert,
|
||||
// weil load.js auf der Projektplan-Seite bewusst nicht geladen wird (siehe
|
||||
// Kopfkommentar in Test.html). Bewusst per Filter statt per
|
||||
// Kopfkommentar in App.html). Bewusst per Filter statt per
|
||||
// Index-Schnitt, damit eine spätere Ergänzung von LPHS nicht stillschweigend
|
||||
// durchs Raster fällt.
|
||||
const PLAN_ACTIVE_LPS = LPHS.filter(l => l !== 'W+M');
|
||||
@@ -62,9 +70,102 @@
|
||||
// amtierende Leistungsphase, LPH-Prozentliste) aus app.project auf. Wird
|
||||
// nach jedem Laden (renderPlanPage(), plan-state.js) sowie nach jeder
|
||||
// Ä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];
|
||||
}
|
||||
|
||||
|
||||
function renderPlanKommentarVerlauf(verlauf){
|
||||
const el = document.getElementById('planStatusVerlauf');
|
||||
if(!el) return;
|
||||
// Gezeigt wird, was geladen ist — beim Start die drei Sitzungen vor der
|
||||
// aktuellen; „Weitere laden“ holt je fünf ältere (weitereLadenKnopf(),
|
||||
// js/store.js). Die Sandbox hat keine früheren Fassungen.
|
||||
const weitere = app.isSandboxPlan() ? '' : weitereLadenKnopf();
|
||||
if(!verlauf.length && !weitere){ el.hidden = true; el.innerHTML = ''; return; }
|
||||
el.hidden = false;
|
||||
const zeigen = verlauf;
|
||||
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 = weitere ? `<div class="plan-verlauf-mehr-zeile">${weitere}</div>` : '';
|
||||
el.innerHTML = `
|
||||
<div class="plan-verlauf-kopf">
|
||||
<span class="plan-verlauf-titel">Verlauf</span>
|
||||
<span class="plan-verlauf-anzahl">${verlauf.length === 0 ? 'in den geladenen Sitzungen kein früherer Kommentar' : verlauf.length === 1 ? '1 früherer Kommentar' : verlauf.length + ' frühere Kommentare'}</span>
|
||||
</div>
|
||||
${zeilen ? `<ol class="plan-verlauf-liste">${zeilen}</ol>` : ''}${mehr}`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
window.renderPlanInfo = function(){
|
||||
const p = app.project;
|
||||
const locked = app.isLocked();
|
||||
const locked = app.stammGesperrt();
|
||||
|
||||
// Kommentar, Fortschritt und Zuordnung sind hier NUR ZU SEHEN.
|
||||
//
|
||||
@@ -75,15 +176,42 @@
|
||||
// Personen konnten dasselbe Feld an verschiedenen Stellen pflegen.
|
||||
// Was hier bleibt, sind die Stammdaten des Projekts (Bereich
|
||||
// "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');
|
||||
if(editEl){
|
||||
editEl.className = 'status-edit is-ansicht';
|
||||
editEl.contentEditable = 'false';
|
||||
const text = sanitizeStatusHtml(p.status || '');
|
||||
editEl.innerHTML = text || '<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>';
|
||||
// Alle Gewerke zusammen, je mit Gewerk davor (kommentarVon(), js/utils.js).
|
||||
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');
|
||||
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);
|
||||
|
||||
// Gruppe — Stammdatum, bleibt hier pflegbar
|
||||
@@ -101,11 +229,22 @@
|
||||
lpSel.disabled = locked;
|
||||
}
|
||||
|
||||
// Fortschritt je Leistungsphase — nur Anzeige, ohne Eingabefeld
|
||||
// Fortschritt je Leistungsphase — nur Anzeige, ohne Eingabefeld.
|
||||
// Zeigt nur die BEAUFTRAGTEN Leistungsphasen (p.beauftragteLP, siehe
|
||||
// "Einstellungen"), genau wie die Wochenbericht-/Board-Karte
|
||||
// (BOARD_OPTS.nurBeauftragteLph, js/board/board.js) — dieselbe Regel,
|
||||
// hier nur fest statt als opt-in, da das Dashboard keine zweite Fassung
|
||||
// mit allen Phasen kennt. Ist nichts beauftragt (ältere Bestands-
|
||||
// projekte), bleibt es bei der vollständigen Liste, sonst stünde dort
|
||||
// gar nichts. Vorher zeigte diese Liste IMMER alle Leistungsphasen,
|
||||
// unabhängig von den Einstellungen — von Erik gemeldet.
|
||||
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
|
||||
const sichtbareLphs = beauftragt.length > 0 ? LPHS.filter(l => beauftragt.includes(l)) : LPHS;
|
||||
|
||||
const lphList = document.getElementById('planLphList');
|
||||
if(lphList){
|
||||
if(!p.lph) p.lph = {};
|
||||
lphList.innerHTML = LPHS.map(l => {
|
||||
lphList.innerHTML = sichtbareLphs.map(l => {
|
||||
const v = (p.lph[l] !== undefined && p.lph[l] !== '') ? p.lph[l] : '';
|
||||
const aktiv = l === aktLp;
|
||||
const pct = v !== '' ? Math.min(100, parseFloat(v)||0) : 0;
|
||||
@@ -117,25 +256,8 @@
|
||||
}).join('');
|
||||
}
|
||||
setzeBerichtVerweis('planLphVerweis', locked);
|
||||
|
||||
// Beteiligte — nur Anzeige. Nicht zugeordnete Personen erscheinen
|
||||
// weiterhin blass, damit erkennbar bleibt, wer überhaupt zur Auswahl
|
||||
// stünde; anklickbar sind sie hier aber nicht mehr.
|
||||
const assignMatrix = document.getElementById('planAssignMatrix');
|
||||
if(assignMatrix){
|
||||
const grouped = peopleByRole(app.week);
|
||||
if(!p.assign) p.assign = {};
|
||||
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 title = (entry.person.fullName || entry.person.name) + ' · ' + r;
|
||||
return `<span class="board-avatar ${app.planRoleClass(r)} state-${state} is-ansicht" 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('');
|
||||
}
|
||||
setzeBerichtVerweis('planAssignVerweis', locked);
|
||||
// Beteiligte: siehe js/plan/plan-settings.js (renderPlanSettings()) —
|
||||
// Stammdatum, nicht mehr hier.
|
||||
};
|
||||
|
||||
// setzeBerichtVerweis: füllt einen Hinweis-Platz mit dem Weg zur
|
||||
@@ -174,7 +296,7 @@
|
||||
// setPlanGroup: ändert die Gruppe des Projekts. Parameter: val (String).
|
||||
// Keine Rückgabe; bei gesperrter Woche abgebrochen.
|
||||
window.setPlanGroup = function(val){
|
||||
if(app.isLocked()) return;
|
||||
if(app.stammGesperrt()) return;
|
||||
app.project.group = val;
|
||||
app.savePlan();
|
||||
};
|
||||
@@ -183,17 +305,23 @@
|
||||
// Projekts auf. Parameter: val (LPH-Name oder leer). Rendert das
|
||||
// Infopanel neu, damit die Markierung in der LPH-Liste sofort mitzieht.
|
||||
window.setPlanActiveLp = function(val){
|
||||
if(app.isLocked()) return;
|
||||
if(app.stammGesperrt()) return;
|
||||
if(val) app.project.aktuelleLP = [val];
|
||||
else delete app.project.aktuelleLP;
|
||||
app.savePlan();
|
||||
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
|
||||
// entfallen. Fortschritt, Zuordnung und Kommentar werden allein im
|
||||
// Wochenbericht erfasst (siehe den Kopf dieser Datei); hier gab es damit
|
||||
// keinen Aufrufer mehr. Die gleichnamigen Funktionen des Projektboards
|
||||
// (editLph()/cycleAssign()/saveBoardComment() in js/board/board.js) sind
|
||||
// davon unberührt — dort hängt die Erfassung des Wochenberichts dran.
|
||||
// entfallen. Fortschritt und Kommentar werden allein im Wochenbericht
|
||||
// erfasst (siehe den Kopf dieser Datei); hier gab es damit keinen Aufrufer
|
||||
// mehr. Die gleichnamigen Funktionen des Projektboards
|
||||
// (editLph()/saveBoardComment() in js/board/board.js) sind davon
|
||||
// unberührt — dort hängt die Erfassung des Wochenberichts dran. Die
|
||||
// Zuordnung der Beteiligten (ehemals setPlanAssign()) läuft seit ihrer
|
||||
// Verlagerung in die Projekteinstellungen über planCycleAssign()
|
||||
// (js/plan/plan-settings.js) — ebenfalls kein Fall für dieses Modul.
|
||||
})();
|
||||
@@ -0,0 +1,326 @@
|
||||
// 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
|
||||
// Einträge.
|
||||
//
|
||||
// Nichts wird ohne Prüfung angelegt: Die Vorschläge stehen erst in einer
|
||||
// Liste — je Zeile abhaken und „Aufgabe | Meilenstein“ wählen (Eriks
|
||||
// Wunsch; die KI schlägt die Art vor) —, dann öffnet sich je Eintrag das
|
||||
// gewohnte Panel vorausgefüllt (openTodoModal/openMilestoneModal mit
|
||||
// prefill), die Mail steht als Notiz darin (t.notiz bzw. m.notiz).
|
||||
// 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;
|
||||
// kiAnbieter: die Anbieter mit hinterlegtem Schlüssel. Ohne eingerichtete
|
||||
// KI ist die Funktion ganz aus (Eriks Wunsch) — keine Ablagefläche, kein
|
||||
// Abfangen von Strg+V. Geladen nach der Anmeldung und nach jeder Änderung
|
||||
// unter Einstellungen → KI-Zugangsdaten (mailKiStatusLaden()).
|
||||
let kiAnbieter = [];
|
||||
|
||||
window.mailKiStatusLaden = function(){
|
||||
return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => {
|
||||
kiAnbieter = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured);
|
||||
}).catch(() => { kiAnbieter = []; });
|
||||
};
|
||||
|
||||
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 kiAnbieter.length > 0 && 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"></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(){
|
||||
let zuletzt = '';
|
||||
try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
|
||||
return Promise.resolve(kiAnbieter.includes(zuletzt) ? zuletzt : (kiAnbieter[0] || ''));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// Auch bei nur einem Vorschlag erst die Liste — dort wird gewählt, ob es
|
||||
// eine Aufgabe oder ein Meilenstein wird.
|
||||
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;
|
||||
if(e.art === 'meilenstein'){
|
||||
openMilestoneModal(null, {
|
||||
name: e.titel,
|
||||
date: e.ende || e.start,
|
||||
notiz: notizText(d.mail || {}, e),
|
||||
mailHinweis: (gesamt > 1 ? `Vorschlag ${nr} von ${gesamt}. ` : '') + hinweisText(d),
|
||||
nachSchliessen: naechster
|
||||
});
|
||||
return;
|
||||
}
|
||||
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;
|
||||
};
|
||||
const arten = eintraege.map(e => e.art === 'meilenstein' ? 'meilenstein' : 'aufgabe');
|
||||
bg.innerHTML = `<div class="modal mail-auswahl" role="dialog" aria-label="Einträge aus der Mail">
|
||||
<h2>${eintraege.length === 1 ? '1 Eintrag' : eintraege.length + ' Einträge'} in der Mail</h2>
|
||||
<p class="hint">${esc(hinweisText(d))} Wähle je Eintrag, ob er eine Aufgabe oder ein Meilenstein wird. Die ausgewählten öffnen sich danach nacheinander zum Prüfen.</p>
|
||||
<div class="mail-auswahl-liste">${eintraege.map((e, i) => `
|
||||
<div class="mail-auswahl-zeile">
|
||||
<input type="checkbox" checked data-i="${i}" aria-label="${esc(e.titel)} übernehmen">
|
||||
<span class="mail-auswahl-text"><span class="mail-auswahl-titel">${esc(e.titel)}</span>
|
||||
<span class="mail-auswahl-datum">${esc(zeitraum(e))}</span></span>
|
||||
<span class="segmented mail-auswahl-art" role="group" aria-label="Art" data-i="${i}">
|
||||
<button type="button" class="mail-art-knopf" data-art="aufgabe" aria-pressed="${arten[i] === 'aufgabe'}">Aufgabe</button>
|
||||
<button type="button" class="mail-art-knopf" data-art="meilenstein" aria-pressed="${arten[i] === 'meilenstein'}">Meilenstein</button>
|
||||
</span>
|
||||
</div>`).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.querySelectorAll('.mail-auswahl-art button').forEach(b => {
|
||||
b.onclick = () => {
|
||||
const i = Number(b.parentElement.dataset.i);
|
||||
arten[i] = b.dataset.art;
|
||||
b.parentElement.querySelectorAll('button').forEach(x => x.setAttribute('aria-pressed', String(x === b)));
|
||||
};
|
||||
});
|
||||
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 => { const i = Number(cb.dataset.i); return Object.assign({}, eintraege[i], { art: arten[i] }); });
|
||||
bg.style.display = 'none';
|
||||
if(gewaehlt.length) nacheinanderOeffnen(d, gewaehlt);
|
||||
};
|
||||
bg.style.display = 'flex';
|
||||
}
|
||||
})();
|
||||
+296
-40
@@ -33,8 +33,15 @@
|
||||
// (Index des Meilensteins oder null für einen neuen). Befüllt Name, Datum,
|
||||
// Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und
|
||||
// TODOs) aus dem Plan.
|
||||
window.openMilestoneModal = function(index){
|
||||
// msNachSchliessen: wie todoNachSchliessen — "Termin aus Mail" öffnet
|
||||
// damit nach dem Schließen den nächsten Vorschlag.
|
||||
let msNachSchliessen = null;
|
||||
|
||||
// openMilestoneModal: Parameter index (null = neu), prefill (optional, nur
|
||||
// bei neu: name, date, notiz, mailHinweis, nachSchliessen).
|
||||
window.openMilestoneModal = function(index, prefill){
|
||||
if(app.isLocked()) return;
|
||||
const pre = prefill || {};
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!Array.isArray(plan.milestones)) plan.milestones = [];
|
||||
// Migration alter Meilensteine
|
||||
@@ -43,10 +50,18 @@
|
||||
|
||||
const isNew = index === null || plan.milestones[index] === undefined;
|
||||
const currentMs = isNew ? null : plan.milestones[index];
|
||||
document.getElementById('milestoneEditName').value = isNew ? '' : (currentMs.name || '');
|
||||
document.getElementById('milestoneEditName').value = isNew ? (pre.name || '') : (currentMs.name || '');
|
||||
const msNotiz = document.getElementById('milestoneEditNotiz');
|
||||
if(msNotiz) msNotiz.value = isNew ? (pre.notiz || '') : (currentMs.notiz || '');
|
||||
const msMailHinweis = document.getElementById('milestoneEditMailHinweis');
|
||||
if(msMailHinweis){
|
||||
msMailHinweis.hidden = !(isNew && pre.mailHinweis);
|
||||
msMailHinweis.textContent = (isNew && pre.mailHinweis) || '';
|
||||
}
|
||||
msNachSchliessen = isNew && typeof pre.nachSchliessen === 'function' ? pre.nachSchliessen : null;
|
||||
// Verstecktes ISO-Feld + sichtbares Anzeigefeld (deutsches Format) —
|
||||
// dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite.
|
||||
const msDateIso = isNew ? '' : (currentMs.date || '');
|
||||
const msDateIso = isNew ? (pre.date || '') : (currentMs.date || '');
|
||||
document.getElementById('milestoneEditDate').value = msDateIso;
|
||||
const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
|
||||
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
|
||||
@@ -55,7 +70,7 @@
|
||||
if(positionSelect){
|
||||
const currentPosition = currentMs && currentMs.position ? currentMs.position : '';
|
||||
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){
|
||||
plan.todos.forEach(t => {
|
||||
const beforeVal = 'before:' + t.id;
|
||||
@@ -69,6 +84,14 @@
|
||||
}
|
||||
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');
|
||||
if(dependsOnList){
|
||||
@@ -110,11 +133,43 @@
|
||||
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
|
||||
// Bearbeitungsindex zurück.
|
||||
window.closeMilestoneModal = function(){
|
||||
document.getElementById('milestoneModal').style.display = 'none';
|
||||
app.currentMilestoneIndex = null;
|
||||
const danach = msNachSchliessen;
|
||||
msNachSchliessen = null;
|
||||
if(danach) setTimeout(danach, 0);
|
||||
};
|
||||
|
||||
// saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position,
|
||||
@@ -132,18 +187,21 @@
|
||||
|
||||
const positionSelect = document.getElementById('milestoneEditPosition');
|
||||
const position = positionSelect ? positionSelect.value : '';
|
||||
const msNotizEl = document.getElementById('milestoneEditNotiz');
|
||||
const notiz = msNotizEl ? msNotizEl.value.trim() : '';
|
||||
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!Array.isArray(plan.milestones)) plan.milestones = [];
|
||||
|
||||
if(app.currentMilestoneIndex === null){
|
||||
plan.milestones.push({ id: uid(), name, date, dependsOn, position });
|
||||
plan.milestones.push({ id: uid(), name, date, dependsOn, position, notiz });
|
||||
} else if(plan.milestones[app.currentMilestoneIndex]){
|
||||
const m = plan.milestones[app.currentMilestoneIndex];
|
||||
m.name = name;
|
||||
m.date = date;
|
||||
m.dependsOn = dependsOn;
|
||||
m.position = position;
|
||||
m.notiz = notiz;
|
||||
}
|
||||
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
|
||||
app.savePlan();
|
||||
@@ -186,6 +244,9 @@
|
||||
// todoModalSnapshot: Stand der Eingaben beim Öffnen — Vergleichsbasis für
|
||||
// die Rückfrage beim Abbrechen (siehe todoFormSnapshot()).
|
||||
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
|
||||
// "Erledigt" haben feste Werte (0/100); wer von dort zurück auf "In Arbeit"
|
||||
@@ -358,12 +419,173 @@
|
||||
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
|
||||
// oder null für eine neue), prefill (optional, Vorbelegung für "Speichern &
|
||||
// Neu": { start, end, assignees }). Befüllt Titel, Zeitraum samt sichtbarer
|
||||
// Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die
|
||||
// Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim
|
||||
// Anlegen.
|
||||
// ---------- Aufwand: −/+, Schnellwahl, Umrechnung ----------
|
||||
// Ein Arbeitstag = APP_SETTINGS.arbeitsstundenProTag (Admin → Termine).
|
||||
function stundenProTag(){ return Number(APP_SETTINGS && APP_SETTINGS.arbeitsstundenProTag) || 8; }
|
||||
function aufwandLesen(){
|
||||
const el = document.getElementById('todoEditAufwand');
|
||||
return el ? Math.max(0, parseFloat(String(el.value).replace(',', '.')) || 0) : 0;
|
||||
}
|
||||
function aufwandSchreiben(h){
|
||||
const el = document.getElementById('todoEditAufwand');
|
||||
if(!el) return;
|
||||
const w = Math.max(0, Math.round(h * 2) / 2);
|
||||
el.value = w > 0 ? String(w) : '';
|
||||
todoAufwandInfo();
|
||||
}
|
||||
window.todoAufwandAendern = function(delta){ aufwandSchreiben(aufwandLesen() + delta); };
|
||||
// todoAufwandSetzen(h) oder todoAufwandSetzen('tag', n): fester Wert; ein
|
||||
// zweiter Klick auf den gewählten Wert leert das Feld.
|
||||
window.todoAufwandSetzen = function(a, n){
|
||||
const h = a === 'tag' ? n * stundenProTag() : Number(a);
|
||||
aufwandSchreiben(aufwandLesen() === h ? 0 : h);
|
||||
};
|
||||
// todoAufwandInfo: Umrechnung in Arbeitstage und Markierung der Schnellwahl.
|
||||
window.todoAufwandInfo = function(){
|
||||
const h = aufwandLesen();
|
||||
const tag = stundenProTag();
|
||||
const info = document.getElementById('todoAufwandInfoText');
|
||||
if(info){
|
||||
const tage = h / tag;
|
||||
const zahl = x => (Math.round(x * 10) / 10).toLocaleString('de-DE');
|
||||
info.textContent = h > 0 ? '≈ ' + zahl(tage) + (Math.abs(tage - 1) < 0.05 ? ' Arbeitstag' : ' Arbeitstage') + ' (' + zahl(tag) + ' h je Tag)' : 'Ohne Aufwand zählt die Aufgabe nicht zur Auslastung.';
|
||||
}
|
||||
document.querySelectorAll('#todoAufwandSchnell button').forEach(b => {
|
||||
const wert = b.dataset.tage ? Number(b.dataset.tage) * tag : Number(b.dataset.h);
|
||||
b.setAttribute('aria-pressed', String(h > 0 && Math.abs(wert - h) < 0.01));
|
||||
});
|
||||
};
|
||||
|
||||
// ---------- Zuständige: Projekt und „Externe Personen“ ----------
|
||||
// todoProjektPersonen: die dem Projekt zugeordneten Personen (je einmal).
|
||||
function todoProjektPersonen(){
|
||||
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 todoPersonById(id){
|
||||
return ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
|
||||
}
|
||||
function todoPersonChip(person, gedrueckt, extern){
|
||||
const short = person.name || initialsForName(person.fullName || '') || '?';
|
||||
const full = person.fullName || person.name || '';
|
||||
const klick = extern ? `todoExternEntfernen('${esc(person.id)}')` : 'toggleTodoAssignee(this)';
|
||||
const titel = extern ? ' title="Nicht im Projekt — Klick entfernt die Person"' : '';
|
||||
return `<button type="button" class="person-chip${extern ? ' ist-fremd' : ''}"${titel} data-person-id="${esc(person.id)}" aria-pressed="${gedrueckt ? 'true' : 'false'}" onclick="${klick}">`
|
||||
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}${extern ? ' <span class="person-chip-x" aria-hidden="true">×</span>' : ''}</button>`;
|
||||
}
|
||||
// todoExterneSetzen: zeichnet die gewählten externen Personen und die
|
||||
// Auswahl der übrigen Kollegen (Teilnehmerkreis der Sitzung ohne Projekt
|
||||
// und ohne bereits gewählte), nach Gewerk gruppiert.
|
||||
function todoExterneSetzen(ids){
|
||||
const box = document.getElementById('todoEditExterne');
|
||||
const wahl = document.getElementById('todoEditExterneWahl');
|
||||
if(!box || !wahl) return;
|
||||
box.innerHTML = ids.map(id => todoPersonChip(todoPersonById(id), true, true)).join('');
|
||||
const projektIds = new Set(todoProjektPersonen().map(p => p.id));
|
||||
const frei = ((app.week && app.week.people) || [])
|
||||
.filter(p => p && p.id && !projektIds.has(p.id) && !ids.includes(p.id))
|
||||
.sort((a, b) => String(a.fullName || a.name).localeCompare(String(b.fullName || b.name), 'de'));
|
||||
const gruppen = new Map();
|
||||
frei.forEach(p => {
|
||||
const g = (typeof personGewerk === 'function' && personGewerk(p)) || 'Ohne Gewerk';
|
||||
if(!gruppen.has(g)) gruppen.set(g, []);
|
||||
gruppen.get(g).push(p);
|
||||
});
|
||||
const reihenfolge = [...(typeof DEPARTMENTS !== 'undefined' ? Object.keys(DEPARTMENTS) : []), 'Ohne Gewerk'];
|
||||
const schluessel = [...gruppen.keys()].sort((a, b) => reihenfolge.indexOf(a) - reihenfolge.indexOf(b));
|
||||
wahl.innerHTML = `<option value="">${frei.length ? '+ Person hinzufügen…' : 'Keine weiteren Kollegen'}</option>`
|
||||
+ schluessel.map(g => `<optgroup label="${esc(g)}">${gruppen.get(g).map(p =>
|
||||
`<option value="${esc(p.id)}">${esc(p.fullName || p.name)}${p.name && p.fullName ? ' (' + esc(p.name) + ')' : ''}</option>`).join('')}</optgroup>`).join('');
|
||||
wahl.disabled = !frei.length;
|
||||
}
|
||||
function todoExterneIds(){
|
||||
return Array.from(document.querySelectorAll('#todoEditExterne .person-chip')).map(b => b.dataset.personId);
|
||||
}
|
||||
window.todoExternHinzufuegen = function(id){
|
||||
if(!id) return;
|
||||
const ids = todoExterneIds();
|
||||
if(!ids.includes(id)) ids.push(id);
|
||||
todoExterneSetzen(ids);
|
||||
};
|
||||
window.todoExternEntfernen = function(id){
|
||||
todoExterneSetzen(todoExterneIds().filter(x => x !== id));
|
||||
};
|
||||
|
||||
window.openTodoModal = function(index, prefill){
|
||||
if(app.isLocked()) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
@@ -380,7 +602,18 @@
|
||||
const titleEl = document.getElementById('todoModalTitle');
|
||||
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
|
||||
// Anzeige gesetzt werden — sonst stünde dort der Platzhalter bzw. der
|
||||
@@ -397,6 +630,21 @@
|
||||
}
|
||||
refreshTodoDuration();
|
||||
|
||||
// Im Terminplan anzeigen: Vorgabe sichtbar (!== false, nicht === true —
|
||||
// eine Aufgabe ohne das Feld, z. B. aus einem alten Bestand, gilt
|
||||
// weiterhin als sichtbar, kein Bestandsverhalten kippt rückwirkend).
|
||||
// Bei "Speichern & Neu" übernimmt pre.showInGantt den zuletzt gesetzten
|
||||
// Wert (dieselben Rahmendaten wie Zeitraum/Zuständige, s. saveTodoEdit()).
|
||||
const showInGanttEl = document.getElementById('todoEditShowInGantt');
|
||||
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) : '';
|
||||
}
|
||||
todoAufwandInfo();
|
||||
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
|
||||
|
||||
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
|
||||
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
|
||||
applyTodoStatus(todoStatusFrom(currentTodo), storedProgress);
|
||||
@@ -445,27 +693,20 @@
|
||||
|
||||
const assigneesDiv = document.getElementById('todoEditAssignees');
|
||||
if(assigneesDiv){
|
||||
const assignees = projectAssignees(app.project, app.week);
|
||||
const currentAssignees = currentTodo
|
||||
? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [])
|
||||
: (Array.isArray(pre.assignees) ? pre.assignees : []);
|
||||
const seenPeople = new Set();
|
||||
const uniquePeople = [];
|
||||
assignees.forEach(a => {
|
||||
if(!seenPeople.has(a.person.id)){
|
||||
seenPeople.add(a.person.id);
|
||||
uniquePeople.push(a.person);
|
||||
}
|
||||
});
|
||||
const projektLeute = todoProjektPersonen();
|
||||
const projektIds = new Set(projektLeute.map(p => p.id));
|
||||
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
|
||||
// Personenspalten im Projektboard.
|
||||
assigneesDiv.innerHTML = uniquePeople.map(person => {
|
||||
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false';
|
||||
const short = person.name || initialsForName(person.fullName || '') || '?';
|
||||
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)">`
|
||||
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`;
|
||||
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
|
||||
assigneesDiv.innerHTML = projektLeute.map(person =>
|
||||
todoPersonChip(person, currentAssignees.includes(person.id), false)
|
||||
).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
|
||||
// Zuständige außerhalb des Projekts stehen unter „Externe Personen“ —
|
||||
// auch solche, die das Projekt inzwischen verlassen haben (sonst gingen
|
||||
// sie beim Speichern stillschweigend verloren).
|
||||
todoExterneSetzen(currentAssignees.filter(id => !projektIds.has(id)));
|
||||
}
|
||||
|
||||
const deleteBtn = document.getElementById('todoEditDelete');
|
||||
@@ -503,9 +744,11 @@
|
||||
function todoFormSnapshot(){
|
||||
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 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"], #todoEditExterne .person-chip')).map(b => b.dataset.personId);
|
||||
const gantt = document.getElementById('todoEditShowInGantt');
|
||||
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
|
||||
@@ -519,6 +762,9 @@
|
||||
document.getElementById('todoModal').style.display = 'none';
|
||||
app.currentTodoIndex = null;
|
||||
todoModalSnapshot = '';
|
||||
const danach = todoNachSchliessen;
|
||||
todoNachSchliessen = null;
|
||||
if(danach) setTimeout(danach, 0);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -561,13 +807,26 @@
|
||||
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
|
||||
|
||||
const assigneesDiv = document.getElementById('todoEditAssignees');
|
||||
const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : [];
|
||||
const assignees = (assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : [])
|
||||
.concat(todoExterneIds());
|
||||
|
||||
const showInGanttEl = document.getElementById('todoEditShowInGantt');
|
||||
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();
|
||||
if(!Array.isArray(plan.todos)) plan.todos = [];
|
||||
|
||||
if(app.currentTodoIndex === null){
|
||||
plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees });
|
||||
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]){
|
||||
const t = plan.todos[app.currentTodoIndex];
|
||||
t.title = title;
|
||||
@@ -577,6 +836,10 @@
|
||||
t.progress = progress;
|
||||
t.dependsOn = dependsOn;
|
||||
t.assignees = assignees;
|
||||
t.showInGantt = showInGantt;
|
||||
t.aufwand = aufwand;
|
||||
t.notiz = notiz;
|
||||
if(todoPhasenModus) t.lph = lph;
|
||||
}
|
||||
app.savePlan();
|
||||
// Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt
|
||||
@@ -584,7 +847,7 @@
|
||||
todoModalSnapshot = todoFormSnapshot();
|
||||
closeTodoModal();
|
||||
renderPlanGantt();
|
||||
if(andNew) openTodoModal(null, { start, end, assignees });
|
||||
if(andNew) openTodoModal(null, { start, end, assignees, showInGantt });
|
||||
};
|
||||
|
||||
// deleteTodoEdit: löscht nach Bestätigung die aktuell bearbeitete Aufgabe,
|
||||
@@ -615,25 +878,18 @@
|
||||
openTodoModal(null);
|
||||
};
|
||||
|
||||
// updateTodo: setzt ein einzelnes Feld (z. B. done) einer Aufgabe direkt
|
||||
// und speichert sowie rendert neu. Parameter: idx (Index), field
|
||||
// (Feldname), value (neuer Wert).
|
||||
window.updateTodo = function(idx, field, value){
|
||||
if(app.isLocked()) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!plan.todos[idx]) return;
|
||||
plan.todos[idx][field] = value;
|
||||
app.savePlan();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// toggleTodoDone: schaltet den Erledigt-Status der Aufgabe am Index um,
|
||||
// speichert und rendert neu.
|
||||
window.toggleTodoDone = function(idx){
|
||||
if(app.isLocked()) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
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();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,464 @@
|
||||
// -----------------------------
|
||||
// Projektplan — "TODOs aus Protokoll" (externes PDF per KI)
|
||||
// -----------------------------
|
||||
// Eigenständige Funktion im Bereich "Aufgaben" (+ GANTT-Toolbar, gleichrangig
|
||||
// neben "+ Vorlage"/"+ Terminplan"): ein EXTERNES Besprechungsprotokoll im
|
||||
// PDF-Format wird per KI eingelesen, und daraus werden — in einem zweiten,
|
||||
// eigenen KI-Schritt — Aufgaben für dieses Projekt abgeleitet. Bewusst
|
||||
// KOMPLETT unabhängig von js/plan/plan-protokoll.js: nichts hier liest aus
|
||||
// oder schreibt in project_protokolle/protokoll_punkte, nichts wird auf dem
|
||||
// Server dauerhaft abgelegt (weder das PDF noch die erkannten Punkte) — ein
|
||||
// reines Ad-hoc-Werkzeug wie der Plan Checker, kein Dokumentenarchiv. Wer das
|
||||
// externe Protokoll dauerhaft als Projektunterlage ablegen will, nutzt den
|
||||
// normalen Protokoll-Import (Bereich "Protokolle").
|
||||
//
|
||||
// Ein Dialog (#protokollTodosModal), DREI Schritte, feste Fußzeile
|
||||
// (Zurück/Abbrechen/Weiter) — Entwurfsrichtung "A — Schritt-Dialog" aus dem
|
||||
// Entwurfsvergleich (Taste-Skill), von Erik gewählt: ein sichtbarer
|
||||
// Fortschrittsbalken (#protokollTodosSteps) und derselbe Knopf-Platz über
|
||||
// alle Schritte hinweg, statt je Phase ein eigenes Knopf-Set. Nur der
|
||||
// Inhalt zwischen Fortschrittsbalken und Fußzeile (#protokollTodosBody)
|
||||
// wird je Schritt neu gerendert; Titel/Untertitel/Fortschritt/Fußzeile
|
||||
// bleiben derselbe Rahmen. Der rechte Fußzeilen-Knopf ("Weiter") führt
|
||||
// zugleich die KI-Aufrufe aus — ein Klick macht Schritt und Netzwerkaufruf
|
||||
// in einem:
|
||||
// 0. Hochladen — PDF + KI-Anbieter wählen → "Einlesen" ruft POST
|
||||
// /plan-todos-aus-protokoll/einlesen auf (server.js, nutzt dieselbe
|
||||
// Erkennung wie der Protokoll-Import: erkennePunkteMitKI()/
|
||||
// erkennePunkteAusText()).
|
||||
// 1. Punkte wählen — die erkannten Punkte als Checkliste (alle offenen
|
||||
// standardmäßig angehakt) → "Aufgaben ableiten" ruft POST
|
||||
// /plan-todos-aus-protokoll/aufgaben-vorschlag mit genau diesen
|
||||
// Punkten im Body (kein erneuter Server-Lookup, nichts ist
|
||||
// persistiert).
|
||||
// 2. Aufgaben bestätigen — die Vorschläge als kompakte Zeilenliste
|
||||
// (Entwurfsrichtung "B — Kompakte Zeilen" aus einem zweiten
|
||||
// Taste-Skill-Vergleich, nur für diesen Schritt; siehe
|
||||
// renderVorschlaegeHtml()): Titel/Termin-Abzeichen/Zuständig-Kürzel auf
|
||||
// einen Blick, Beschreibung/Bearbeitung erst nach Antippen der Zeile —
|
||||
// ein echter Testlauf lieferte bereits 19 Vorschläge, als ausführliche
|
||||
// Karten wäre das nur noch Scrollen gewesen. "N Aufgaben einfügen"
|
||||
// erzeugt normale plan_todos MIT Start- UND Enddatum (sonst keine
|
||||
// GANTT-Zeile, siehe CLAUDE.md) und ruft danach fitPlanGanttRange() wie
|
||||
// applyTemplate(), damit die neuen Balken im sichtbaren GANTT-Zeitraum
|
||||
// liegen.
|
||||
// "Zurück" springt ohne erneuten Serveraufruf auf den vorherigen Schritt
|
||||
// zurück (die Daten bleiben im Modul-Zustand); einzige Ausnahme: ein
|
||||
// Datei-Eingabefeld lässt sich aus Sicherheitsgründen nicht per Skript
|
||||
// erneut vorbelegen — wer von Schritt 1 zurück zu Schritt 0 geht, muss die
|
||||
// Datei bei Bedarf erneut auswählen.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// Modul-Zustand — lebt nur, während der Dialog offen ist; ein erneutes
|
||||
// Öffnen startet immer wieder bei Schritt 0.
|
||||
let step = 0;
|
||||
let erkanntePunkte = [];
|
||||
let punkteAuswahl = {}; // Index in erkanntePunkte -> angehakt (Boolean), überlebt Zurück/Weiter
|
||||
let gewaehlterAnbieter = '';
|
||||
let ausgewaehltePunkte = []; // die Punkte, die tatsächlich an die KI gingen (für die Quelle-Referenz in Schritt 2)
|
||||
let vorschlaege = [];
|
||||
|
||||
const STEP_TITEL = ['Externes Protokoll einlesen', 'Punkte wählen', 'Aufgaben bestätigen'];
|
||||
|
||||
// openProtokollTodosModal: öffnet den Dialog bei Schritt 0. Bei gesperrter
|
||||
// Sitzung gar nicht erst geöffnet (wie bei den anderen Bearbeitungs-
|
||||
// Dialogen, siehe plan-modals.js).
|
||||
window.openProtokollTodosModal = function(){
|
||||
if(app.isLocked()) return;
|
||||
step = 0;
|
||||
erkanntePunkte = [];
|
||||
punkteAuswahl = {};
|
||||
gewaehlterAnbieter = '';
|
||||
ausgewaehltePunkte = [];
|
||||
vorschlaege = [];
|
||||
renderStep();
|
||||
document.getElementById('protokollTodosModal').style.display = 'flex';
|
||||
};
|
||||
|
||||
window.closeProtokollTodosModal = function(){
|
||||
document.getElementById('protokollTodosModal').style.display = 'none';
|
||||
};
|
||||
|
||||
function zeigeFehler(msg){
|
||||
const el = document.getElementById('protokollTodosError');
|
||||
if(el){ el.textContent = msg; el.hidden = false; }
|
||||
}
|
||||
function verbergeFehler(){
|
||||
const el = document.getElementById('protokollTodosError');
|
||||
if(el){ el.textContent = ''; el.hidden = true; }
|
||||
}
|
||||
// zeigeFallbackHinweis: eigener, bernsteinfarbener Hinweis für einen
|
||||
// GLÜCKTEN, aber degradierten Durchlauf (KI nicht erreichbar, auf die
|
||||
// einfache Übernahme zurückgefallen) — bewusst NICHT dieselbe rote
|
||||
// Fehlermeldung wie zeigeFehler() oben, dieselbe Unterscheidung wie der
|
||||
// Protokoll-Import (.protokoll-ki-fallback-banner, plan-protokoll.js):
|
||||
// ein Fallback ist ein akzeptiertes Ergebnis, kein Fehlschlag.
|
||||
function zeigeFallbackHinweis(msg){
|
||||
const el = document.getElementById('protokollTodosFallback');
|
||||
if(el){ el.textContent = '⚠️ ' + msg; el.hidden = false; }
|
||||
}
|
||||
function verbergeFallbackHinweis(){
|
||||
const el = document.getElementById('protokollTodosFallback');
|
||||
if(el){ el.textContent = ''; el.hidden = true; }
|
||||
}
|
||||
|
||||
// setzeLadezustand: sperrt während eines laufenden KI-Aufrufs den
|
||||
// gesamten Dialog (Fußzeile inklusive, damit kein zweiter Aufruf
|
||||
// überlappt) — ohne das gäbe es während eines länger laufenden Aufrufs
|
||||
// (bis zu KI_TIMEOUT_MS_LOKAL = 30 Minuten bei einem eigenen Server,
|
||||
// server.js) keinerlei Rückmeldung, derselbe Grund wie bei
|
||||
// setzeProtokollImportLadezustand() in plan-protokoll.js.
|
||||
function setzeLadezustand(aktiv, text){
|
||||
const modal = document.getElementById('protokollTodosModal');
|
||||
if(modal) modal.querySelectorAll('input, select, button').forEach(el => { el.disabled = aktiv; });
|
||||
const ladeEl = document.getElementById('protokollTodosLoading');
|
||||
if(ladeEl) ladeEl.hidden = !aktiv;
|
||||
const textEl = document.getElementById('protokollTodosLoadingText');
|
||||
if(textEl && text) textEl.textContent = text;
|
||||
// setzeLadezustand(false) würde sonst auch "Zurück" auf Schritt 0
|
||||
// wieder aktivieren — das macht erst renderStep() richtig.
|
||||
if(!aktiv) renderStep();
|
||||
}
|
||||
|
||||
// anbieterOptionenHtml: baut die Options-Liste des KI-Anbieter-Felds aus
|
||||
// GET /my-ai-settings — derselbe Aufbau wie #protokollImportErkennung in
|
||||
// plan-protokoll.js.
|
||||
function anbieterOptionenHtml(){
|
||||
return fetch(baseUrl() + '/my-ai-settings')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if(!d.ok) throw new Error('nicht ok');
|
||||
return `<option value="chatgpt">ChatGPT${d.chatgpt && d.chatgpt.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>
|
||||
<option value="claude">Claude${d.claude && d.claude.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>`;
|
||||
})
|
||||
.catch(() => '<option value="chatgpt">ChatGPT</option><option value="claude">Claude</option>');
|
||||
}
|
||||
|
||||
// renderStep: zeichnet Titel, Fortschrittsbalken, Fußzeilen-Beschriftung
|
||||
// und den schrittspezifischen Inhalt. Macht keinen Netzwerkaufruf — das
|
||||
// übernimmt protokollTodosWeiter(). Keine Parameter, keine Rückgabe.
|
||||
function renderStep(){
|
||||
verbergeFehler();
|
||||
verbergeFallbackHinweis();
|
||||
document.getElementById('protokollTodosTitle').textContent = STEP_TITEL[step];
|
||||
document.querySelectorAll('#protokollTodosSteps .seg').forEach(s => {
|
||||
s.classList.toggle('done', +s.dataset.i <= step);
|
||||
});
|
||||
document.getElementById('protokollTodosBackBtn').disabled = (step === 0);
|
||||
|
||||
const primaryBtn = document.getElementById('protokollTodosPrimaryBtn');
|
||||
const subtitleEl = document.getElementById('protokollTodosSubtitle');
|
||||
const body = document.getElementById('protokollTodosBody');
|
||||
|
||||
if(step === 0){
|
||||
subtitleEl.textContent = 'PDF hochladen, KI-Anbieter wählen.';
|
||||
primaryBtn.textContent = 'Einlesen';
|
||||
body.innerHTML = `
|
||||
<div class="form-row">
|
||||
<label for="protokollTodosFile">Externes Protokoll (PDF)</label>
|
||||
<input type="file" id="protokollTodosFile" accept=".pdf">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="protokollTodosAnbieter">KI-Anbieter</label>
|
||||
<select id="protokollTodosAnbieter"><option value="chatgpt">ChatGPT</option><option value="claude">Claude</option></select>
|
||||
</div>`;
|
||||
anbieterOptionenHtml().then(html => {
|
||||
const sel = document.getElementById('protokollTodosAnbieter');
|
||||
if(sel){ sel.innerHTML = html; if(gewaehlterAnbieter) sel.value = gewaehlterAnbieter; }
|
||||
});
|
||||
} else if(step === 1){
|
||||
subtitleEl.textContent = erkanntePunkte.length + ' Punkte erkannt — offene sind vorausgewählt.';
|
||||
primaryBtn.textContent = 'Aufgaben ableiten';
|
||||
body.innerHTML = `
|
||||
<div class="todo-depends-list" id="protokollTodosPunkteListe" style="max-height:280px">
|
||||
${erkanntePunkte.map((p, i) => {
|
||||
const checked = punkteAuswahl[i] !== undefined ? punkteAuswahl[i] : ((p.status || (p.erledigt ? 'erledigt' : 'offen')) === 'offen');
|
||||
punkteAuswahl[i] = checked;
|
||||
return `<label class="todo-depends-item">
|
||||
<input type="checkbox" value="${i}" ${checked ? 'checked' : ''}>
|
||||
<span style="flex:1">
|
||||
<b>${esc(p.thema || 'Ohne Thema')}</b>
|
||||
${p.text ? `<br><span class="hint" style="margin:0">${esc(String(p.text).slice(0, 160))}</span>` : ''}
|
||||
</span>
|
||||
<span class="hint" style="margin:0">${esc(p.zustaendig || '')}${p.termin ? ' · ' + esc(formatDateDE(parseDateISO(p.termin)) || p.termin) : ''}</span>
|
||||
</label>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
document.getElementById('protokollTodosPunkteListe').querySelectorAll('input[type=checkbox]').forEach(cb => {
|
||||
cb.addEventListener('change', () => { punkteAuswahl[cb.value] = cb.checked; });
|
||||
});
|
||||
} else {
|
||||
subtitleEl.textContent = vorschlaege.length + ' Vorschläge aus den gewählten Punkten.';
|
||||
body.innerHTML = renderVorschlaegeHtml();
|
||||
const liste = document.getElementById('protokollTodosVorschlaege');
|
||||
if(liste){
|
||||
// Abwahl blendet die Zeile ab (statt sie zu entfernen) — dieselbe
|
||||
// "noch da, aber erkennbar nicht dabei"-Behandlung wie bei den
|
||||
// Punkten in Schritt 1.
|
||||
liste.addEventListener('change', e => {
|
||||
if(!e.target.classList.contains('pt-vorschlag-check')) return;
|
||||
e.target.closest('.protokoll-todo-vorschlag').classList.toggle('off', !e.target.checked);
|
||||
aktualisierePrimaerKnopf();
|
||||
});
|
||||
// Klick auf die Zeile klappt Beschreibung/Termin/Zuständige auf —
|
||||
// außer der Klick galt der Checkbox oder dem Titel-Eingabefeld
|
||||
// selbst, die sollen nicht zugleich die Zeile zu-/aufklappen.
|
||||
liste.addEventListener('click', e => {
|
||||
if(e.target.closest('.pt-vorschlag-check, .pt-vorschlag-titel')) return;
|
||||
const main = e.target.closest('.pt-row-main');
|
||||
if(main) main.closest('.protokoll-todo-vorschlag').classList.toggle('open');
|
||||
});
|
||||
}
|
||||
aktualisierePrimaerKnopf();
|
||||
}
|
||||
}
|
||||
|
||||
function aktualisierePrimaerKnopf(){
|
||||
const checks = document.querySelectorAll('.pt-vorschlag-check');
|
||||
const n = checks.length ? Array.from(checks).filter(c => c.checked).length : vorschlaege.length;
|
||||
const btn = document.getElementById('protokollTodosPrimaryBtn');
|
||||
btn.textContent = n + ' Aufgabe' + (n === 1 ? '' : 'n') + ' einfügen';
|
||||
btn.disabled = n === 0;
|
||||
const selCount = document.getElementById('protokollTodosSelCount');
|
||||
if(selCount) selCount.innerHTML = `<b>${n}</b> von ${vorschlaege.length} ausgewählt`;
|
||||
}
|
||||
|
||||
// protokollTodosAuswahlAlle: markiert alle Vorschlagszeilen auf einen
|
||||
// Schlag an oder ab ("Alle"/"Keine" neben der Auswahl-Zählung) — bei 19
|
||||
// Vorschlägen (im echten Testlauf beobachtet) sonst 19 Einzelklicks.
|
||||
// Parameter: alle (Boolean). Keine Rückgabe.
|
||||
window.protokollTodosAuswahlAlle = function(alle){
|
||||
document.querySelectorAll('#protokollTodosVorschlaege .pt-vorschlag-check').forEach(cb => {
|
||||
cb.checked = alle;
|
||||
cb.closest('.protokoll-todo-vorschlag').classList.toggle('off', !alle);
|
||||
});
|
||||
aktualisierePrimaerKnopf();
|
||||
};
|
||||
|
||||
// matchePersonenVonName: einfacher Namensabgleich zwischen einem
|
||||
// KI-Textvorschlag ("zustaendig" aus dem externen Protokoll — oft eine
|
||||
// externe Firma, kein Teammitglied) und den diesem Projekt zugeordneten
|
||||
// Personen. Case-insensitive Teilstring auf Name/vollem Namen; kein
|
||||
// Treffer ist der Normalfall und bleibt als reiner Text sichtbar.
|
||||
function matchePersonenVonName(text, kandidaten){
|
||||
const t = String(text || '').trim().toLowerCase();
|
||||
if(!t) return [];
|
||||
const treffer = new Set();
|
||||
kandidaten.forEach(k => {
|
||||
const name = String(k.person.name || '').toLowerCase();
|
||||
const voll = String(k.person.fullName || '').toLowerCase();
|
||||
if((name && t.includes(name)) || (voll && t.includes(voll))) treffer.add(k.person.id);
|
||||
});
|
||||
return Array.from(treffer);
|
||||
}
|
||||
|
||||
// renderVorschlaegeHtml: die Vorschlagsliste aus Entwurfsrichtung
|
||||
// "B — Kompakte Zeilen" (Taste-Skill-Vergleich) — eine Zeile je Vorschlag
|
||||
// (Titel, Termin-Abzeichen, Zuständig-Kürzel), Beschreibung/Termin-Feld/
|
||||
// Zuständigen-Auswahl erst nach Antippen der Zeile sichtbar. Gewählt, weil
|
||||
// ein echter Testlauf bereits 19 Vorschläge aus einem Protokoll lieferte —
|
||||
// als ausführliche Karte wäre das ganze Fenster voll Scrollen gewesen,
|
||||
// als Zeilenliste passt die komplette Übersicht auf einen Blick, ohne
|
||||
// Termin/Zuständig ganz zu verstecken (beide stehen als Abzeichen schon
|
||||
// in der eingeklappten Zeile). Eine Auswahl-Zeile oben
|
||||
// ("N von M ausgewählt" + Alle/Keine) erspart bei vielen Vorschlägen das
|
||||
// einzelne Abwählen.
|
||||
function renderVorschlaegeHtml(){
|
||||
if(vorschlaege.length === 0){
|
||||
return '<p class="hint">Keine Aufgaben vorgeschlagen — die ausgewählten Punkte waren rein informativ.</p>';
|
||||
}
|
||||
// Kandidaten für die Zuständigen-Auswahl: die diesem Projekt bereits
|
||||
// zugeordneten Personen (projectAssignees(), js/gantt.js), einmalig
|
||||
// entdoppelt (eine Person kann in mehreren Rollen zugeordnet sein).
|
||||
const kandidaten = [];
|
||||
const gesehen = new Set();
|
||||
projectAssignees(app.project, app.week).forEach(k => {
|
||||
if(!gesehen.has(k.person.id)){ gesehen.add(k.person.id); kandidaten.push(k); }
|
||||
});
|
||||
|
||||
const zeilen = vorschlaege.map((v, idx) => {
|
||||
const quelle = (v.quellRefs || []).map(r => ausgewaehltePunkte[r] && ausgewaehltePunkte[r].thema).filter(Boolean).join(', ');
|
||||
const vorausgewaehlt = matchePersonenVonName(v.zustaendigVorschlag, kandidaten);
|
||||
const erstePerson = vorausgewaehlt.length ? kandidaten.find(k => k.person.id === vorausgewaehlt[0]) : null;
|
||||
return `<div class="protokoll-todo-vorschlag" data-idx="${idx}">
|
||||
<div class="pt-row-main">
|
||||
<input type="checkbox" class="pt-vorschlag-check" checked>
|
||||
<input type="text" class="pt-vorschlag-titel" value="${esc(v.titel)}">
|
||||
<span class="pt-row-badges">
|
||||
${v.faelligkeit ? `<span class="pt-badge-date">${esc(formatDateDE(parseDateISO(v.faelligkeit)) || v.faelligkeit)}</span>` : ''}
|
||||
${erstePerson ? `<span class="pt-badge-person" title="${esc(erstePerson.person.fullName || erstePerson.person.name)}">${esc(erstePerson.person.name || initialsForName(erstePerson.person.fullName || '') || '?')}</span>` : ''}
|
||||
</span>
|
||||
<span class="pt-chevron">▸</span>
|
||||
</div>
|
||||
<div class="pt-row-detail">
|
||||
${v.beschreibung ? `<div class="hint protokoll-todo-vorschlag-beschreibung">${esc(v.beschreibung)}</div>` : ''}
|
||||
<div class="protokoll-todo-vorschlag-felder">
|
||||
<label class="form-row" style="margin-bottom:6px">
|
||||
<span>Fällig am</span>
|
||||
<input type="date" class="pt-vorschlag-termin" value="${esc(v.faelligkeit)}" style="max-width:180px">
|
||||
</label>
|
||||
<!-- Dieselben .person-chip/.person-chip-ini-Chips mit
|
||||
aria-pressed und toggleTodoAssignee() (js/plan/plan-modals.js)
|
||||
wie im echten TODO-Dialog ("Zuständige") — die erste Fassung
|
||||
hier nutzte stattdessen eine rohe, unformatierte Checkbox
|
||||
neben dem Kürzel, was neben den übrigen, bewusst gestalteten
|
||||
Zeilen schlicht unfertig aussah (Eriks Rückmeldung). -->
|
||||
<div class="person-chips">
|
||||
${kandidaten.length ? kandidaten.map(k => {
|
||||
const kuerzel = k.person.name || initialsForName(k.person.fullName || '') || '?';
|
||||
const voll = k.person.fullName || k.person.name || '';
|
||||
return `<button type="button" class="person-chip pt-vorschlag-person" data-person-id="${esc(k.person.id)}"
|
||||
aria-pressed="${vorausgewaehlt.includes(k.person.id)}" onclick="toggleTodoAssignee(this)">
|
||||
<span class="person-chip-ini">${esc(kuerzel)}</span>${esc(voll)}
|
||||
</button>`;
|
||||
}).join('') : '<span class="hint" style="margin:0">Niemand diesem Projekt zugeordnet.</span>'}
|
||||
</div>
|
||||
${!vorausgewaehlt.length && v.zustaendigVorschlag ? `<span class="hint" style="margin:0">KI-Vorschlag „${esc(v.zustaendigVorschlag)}“ — kein passendes Teammitglied gefunden.</span>` : ''}
|
||||
${quelle ? `<span class="hint" style="margin:0">aus: ${esc(quelle)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="pt-sel-bar">
|
||||
<span class="pt-sel-count" id="protokollTodosSelCount"></span>
|
||||
<span class="pt-sel-actions">
|
||||
<button type="button" onclick="protokollTodosAuswahlAlle(true)">Alle</button> ·
|
||||
<button type="button" onclick="protokollTodosAuswahlAlle(false)">Keine</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="todo-depends-list" id="protokollTodosVorschlaege" style="max-height:360px">${zeilen}</div>`;
|
||||
}
|
||||
|
||||
// protokollTodosZurueck: ein Schritt zurück, ohne erneuten Serveraufruf —
|
||||
// alle Daten bleiben im Modul-Zustand. Keine Parameter, keine Rückgabe.
|
||||
window.protokollTodosZurueck = function(){
|
||||
if(step === 0) return;
|
||||
step--;
|
||||
renderStep();
|
||||
};
|
||||
|
||||
// protokollTodosWeiter: der rechte Fußzeilen-Knopf führt Schritt UND
|
||||
// Netzwerkaufruf in einem aus — "Einlesen"/"Aufgaben ableiten" lösen den
|
||||
// jeweils nächsten KI-Schritt aus, der letzte Klick übernimmt die
|
||||
// Aufgaben. Keine Parameter, keine Rückgabe.
|
||||
window.protokollTodosWeiter = function(){
|
||||
if(step === 0) return schrittEinlesen();
|
||||
if(step === 1) return schrittAbleiten();
|
||||
return schrittUebernehmen();
|
||||
};
|
||||
|
||||
function schrittEinlesen(){
|
||||
verbergeFehler();
|
||||
const fileInput = document.getElementById('protokollTodosFile');
|
||||
const file = fileInput && fileInput.files && fileInput.files[0];
|
||||
if(!file) return zeigeFehler('Bitte eine PDF-Datei auswählen.');
|
||||
gewaehlterAnbieter = (document.getElementById('protokollTodosAnbieter') || {}).value || '';
|
||||
|
||||
setzeLadezustand(true, 'Wird eingelesen … bei KI-Erkennung über einen eigenen Server kann dies bis zu 30 Minuten dauern.');
|
||||
fetch(baseUrl() + '/plan-todos-aus-protokoll/einlesen?anbieter=' + encodeURIComponent(gewaehlterAnbieter), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': file.type || 'application/pdf' },
|
||||
body: file
|
||||
})
|
||||
.then(r => r.json().then(d => ({ ok: r.ok, d })))
|
||||
.then(({ ok, d }) => {
|
||||
if(!ok || !d.ok) throw new Error((d && d.error) || 'Einlesen fehlgeschlagen.');
|
||||
erkanntePunkte = Array.isArray(d.punkte) ? d.punkte : [];
|
||||
punkteAuswahl = {};
|
||||
if(erkanntePunkte.length === 0){ setzeLadezustand(false); zeigeFehler('Es wurden keine Punkte erkannt.'); return; }
|
||||
step = 1;
|
||||
setzeLadezustand(false);
|
||||
if(d.erkennungsart !== 'chatgpt' && d.erkennungsart !== 'claude' && d.erkennungshinweis){
|
||||
zeigeFallbackHinweis('KI-Erkennung nicht verfügbar (' + d.erkennungshinweis + ') — Punkte per Textmuster erkannt.');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('TODOs aus Protokoll (Einlesen) fehlgeschlagen:', err);
|
||||
setzeLadezustand(false);
|
||||
zeigeFehler(err.message || String(err));
|
||||
});
|
||||
}
|
||||
|
||||
function schrittAbleiten(){
|
||||
verbergeFehler();
|
||||
const listEl = document.getElementById('protokollTodosPunkteListe');
|
||||
const checkedIdx = listEl
|
||||
? Array.from(listEl.querySelectorAll('input[type="checkbox"]:checked')).map(cb => parseInt(cb.value, 10))
|
||||
: [];
|
||||
if(checkedIdx.length === 0) return zeigeFehler('Bitte mindestens einen Punkt auswählen.');
|
||||
|
||||
ausgewaehltePunkte = checkedIdx.map(i => erkanntePunkte[i]);
|
||||
setzeLadezustand(true, 'Aufgaben werden abgeleitet …');
|
||||
fetch(baseUrl() + '/plan-todos-aus-protokoll/aufgaben-vorschlag', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ anbieter: gewaehlterAnbieter, punkte: ausgewaehltePunkte })
|
||||
})
|
||||
.then(r => r.json().then(d => ({ ok: r.ok, d })))
|
||||
.then(({ ok, d }) => {
|
||||
if(!ok || !d.ok) throw new Error((d && d.error) || 'Ableiten fehlgeschlagen.');
|
||||
vorschlaege = Array.isArray(d.vorschlaege) ? d.vorschlaege : [];
|
||||
step = 2;
|
||||
setzeLadezustand(false);
|
||||
if(d.erkennungsart === 'direkt' && d.erkennungshinweis){
|
||||
zeigeFallbackHinweis('KI-Ableitung nicht verfügbar (' + d.erkennungshinweis + ') — Punkte direkt übernommen, ohne Zusammenfassung.');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('TODOs aus Protokoll (Ableiten) fehlgeschlagen:', err);
|
||||
setzeLadezustand(false);
|
||||
zeigeFehler(err.message || String(err));
|
||||
});
|
||||
}
|
||||
|
||||
// schrittUebernehmen: legt für jede angehakte Vorschlagszeile einen
|
||||
// normalen plan_todo-Eintrag an — IMMER mit gesetztem Start UND Ende,
|
||||
// sonst bleibt die Aufgabe ein reiner Checklisten-Eintrag ohne GANTT-Zeile
|
||||
// (CLAUDE.md: "Ein TODO ist nur dann eine GANTT-Zeile, wenn es BEIDE Daten
|
||||
// hat"). Gleiche Form wie applyTemplate() (plan-templates.js) und
|
||||
// saveTodoAusKommentar() (wochenbericht.js). Kein Netzwerkaufruf hier
|
||||
// selbst — app.savePlan() übernimmt das Speichern wie überall sonst im
|
||||
// Projektbereich.
|
||||
function schrittUebernehmen(){
|
||||
if(app.isLocked()) return;
|
||||
const rows = document.querySelectorAll('#protokollTodosVorschlaege .protokoll-todo-vorschlag');
|
||||
const plan = app.ensurePlanLocal();
|
||||
const heute = formatDateISO(new Date());
|
||||
let anzahl = 0;
|
||||
rows.forEach(row => {
|
||||
const check = row.querySelector('.pt-vorschlag-check');
|
||||
if(!check || !check.checked) return;
|
||||
const titel = ((row.querySelector('.pt-vorschlag-titel') || {}).value || '').trim();
|
||||
if(!titel) return;
|
||||
const faelligkeit = (row.querySelector('.pt-vorschlag-termin') || {}).value || '';
|
||||
const personen = Array.from(row.querySelectorAll('.pt-vorschlag-person[aria-pressed="true"]')).map(el => el.dataset.personId);
|
||||
plan.todos.push({
|
||||
id: uid(),
|
||||
title: titel,
|
||||
start: heute,
|
||||
end: faelligkeit || heute,
|
||||
done: false,
|
||||
progress: 0,
|
||||
dependsOn: [],
|
||||
assignees: personen,
|
||||
});
|
||||
anzahl++;
|
||||
});
|
||||
|
||||
closeProtokollTodosModal();
|
||||
if(anzahl === 0) return;
|
||||
app.savePlan();
|
||||
// Wie applyTemplate(): der sichtbare GANTT-Zeitraum muss die neuen
|
||||
// Balken einschließen, sonst erscheinen sie zwar in der Aufgabenliste,
|
||||
// aber außerhalb des aktuellen Zooms unsichtbar im Terminplan.
|
||||
fitPlanGanttRange();
|
||||
app.showToast(anzahl + ' Aufgabe(n) aus Protokoll angelegt.');
|
||||
}
|
||||
})();
|
||||
+461
-122
@@ -2,7 +2,7 @@
|
||||
// Projektplan — Protokolle (Besprechungsprotokolle je Projekt)
|
||||
// -----------------------------
|
||||
// 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
|
||||
// #planFilesSummary/openFilesPanel() in plan-files.js).
|
||||
//
|
||||
@@ -77,28 +77,103 @@
|
||||
});
|
||||
}
|
||||
|
||||
// renderProtokolleSummary: füllt die Kompakt-Vorschau der Dashboard-Karte
|
||||
// (#planProtokolleSummary) — letztes Protokoll + Anzahl offener Punkte,
|
||||
// gleiches Muster wie renderFilesSummary() in plan-files.js.
|
||||
// renderProtokolleSummary: füllt die Dashboard-Kachel "Protokolle"
|
||||
// (#planDashProtokolle, Eriks Wunsch — vorher nur eine Zeile in
|
||||
// "Unterlagen"). Oben das letzte Protokoll, darunter dessen OFFENE Punkte
|
||||
// (die Frage nach einer Besprechung ist "was ist noch offen?"), unten die
|
||||
// früheren Protokolle als Kurzliste. Die Punkte kommen aus einem zweiten
|
||||
// Abruf (GET /project-protokolle/:id) — die Liste liefert nur Zahlen.
|
||||
const DASH_PROTOKOLL_PUNKTE = 5;
|
||||
const DASH_PROTOKOLL_FRUEHERE = 3;
|
||||
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('planProtokolleSummary');
|
||||
const el = document.getElementById('planDashProtokolle');
|
||||
const panel = document.getElementById('planDashProtokollePanel');
|
||||
if(!el) return;
|
||||
if(!app.project || !app.project.nr){
|
||||
el.innerHTML = '<span class="hint">Keine Projektnummer.</span>';
|
||||
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){
|
||||
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;
|
||||
}
|
||||
const letztes = protokolleCache[0]; // Server liefert nach nummer DESC
|
||||
const chip = letztes.offenCount > 0
|
||||
? `<span class="plan-mini-chip warn">${letztes.offenCount} offen</span>`
|
||||
: '<span class="plan-mini-chip">Alle erledigt</span>';
|
||||
el.innerHTML = `<div class="plan-mini-card-preview">Nr. ${esc(letztes.nummer)} · ${esc(letztes.betreff || 'ohne Betreff')}</div>
|
||||
<div class="plan-mini-chip-row">${chip}</div>`;
|
||||
const frueher = protokolleCache.slice(1, 1 + DASH_PROTOKOLL_FRUEHERE);
|
||||
const zahlen = [
|
||||
letztes.offenCount ? `<span class="plan-mini-chip warn">${letztes.offenCount} offen</span>` : '<span class="plan-mini-chip">nichts offen</span>',
|
||||
letztes.erledigtCount ? `<span class="plan-mini-chip">${letztes.erledigtCount} erledigt</span>` : '',
|
||||
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
|
||||
// openFilesPanel()/openResourceModal().
|
||||
window.openProtokollePanel = function(){
|
||||
@@ -144,17 +219,20 @@
|
||||
};
|
||||
|
||||
// gruppenReihenfolge: liefert die vorkommenden Gruppennamen in der
|
||||
// Reihenfolge ihres ersten Auftretens unter den Punkten (array_order) —
|
||||
// "Ohne Gruppe" (leerer String) immer zuerst, wenn vorhanden.
|
||||
// Reihenfolge ihres ersten Auftretens unter den Punkten (array_order).
|
||||
// Jeder Punkt gehört jetzt zu einer Gruppe (siehe fuegeNeueGruppeHinzu()/
|
||||
// "+ Neue Gruppe" unten) — eine frühere Sonderrolle "Ohne Gruppe" (leerer
|
||||
// String, immer zuerst) gibt es nicht mehr; ein einzelner Punkt mit leerer
|
||||
// Gruppe (nur noch aus Altbeständen von vor dieser Umstellung möglich)
|
||||
// bekommt ganz normal eine eigene, leer betitelte Gruppenzeile wie jede
|
||||
// andere — kein Sonderfall im Code nötig.
|
||||
function gruppenReihenfolge(){
|
||||
const namen = [];
|
||||
let hatOhneGruppe = false;
|
||||
(currentProtokoll.punkte || []).forEach(p => {
|
||||
const g = String(p.gruppe || '').trim();
|
||||
if(!g){ hatOhneGruppe = true; return; }
|
||||
if(!namen.includes(g)) namen.push(g);
|
||||
});
|
||||
return (hatOhneGruppe ? [''] : []).concat(namen);
|
||||
return namen;
|
||||
}
|
||||
|
||||
// ---------- Nachträgliches Umordnen ----------
|
||||
@@ -162,19 +240,17 @@
|
||||
// Die Reihenfolge in `currentProtokoll.punkte` (= array_order) bestimmt
|
||||
// sowohl die Anzeige-Reihenfolge als auch — über berechneProtokollNummern()
|
||||
// — die Nummern. Zwei getrennte, aufeinander abgestimmte Stellschrauben:
|
||||
// ein Punkt lässt sich nur INNERHALB seiner eigenen Gruppe (bzw. innerhalb
|
||||
// "Ohne Gruppe") verschieben (die Zeile steht dort ohnehin nur im passenden
|
||||
// Abschnitt), eine ganze Gruppe lässt sich als Block gegenüber den anderen
|
||||
// Gruppen verschieben — das ändert, welche Gruppe die Nummer "1" bekommt.
|
||||
// "Ohne Gruppe" bleibt bewusst immer der erste Abschnitt (siehe
|
||||
// gruppenReihenfolge()) und ist deshalb hier nicht mitverschiebbar.
|
||||
// ein Punkt lässt sich nur INNERHALB seiner eigenen Gruppe verschieben (die
|
||||
// Zeile steht dort ohnehin nur im passenden Abschnitt), eine ganze Gruppe
|
||||
// lässt sich als Block gegenüber den anderen Gruppen verschieben — das
|
||||
// ändert, welche Gruppe die Nummer "1" bekommt.
|
||||
|
||||
// verschiebePunkt: vertauscht einen Punkt mit seinem Vorgänger/Nachfolger
|
||||
// INNERHALB derselben Gruppe (bzw. "Ohne Gruppe"). Parameter: idx (Index in
|
||||
// INNERHALB derselben Gruppe. Parameter: idx (Index in
|
||||
// currentProtokoll.punkte), richtung (-1 = nach oben, +1 = nach unten).
|
||||
// Keine Rückgabe; rendert nur den Punkte-Block neu.
|
||||
window.verschiebePunkt = function(idx, richtung){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
const punkte = currentProtokoll.punkte;
|
||||
const gruppe = String(punkte[idx].gruppe || '').trim();
|
||||
const gleicheGruppeIdx = punkte.map((p, i) => i).filter(i => String(punkte[i].gruppe || '').trim() === gruppe);
|
||||
@@ -192,24 +268,24 @@
|
||||
// verschiebeGruppe: verschiebt eine ganze Gruppe (alle ihre Punkte als
|
||||
// Block, ihre eigene Reihenfolge bleibt untereinander erhalten) gegenüber
|
||||
// den anderen Gruppen — ändert damit auch die Gruppennummer. Baut die
|
||||
// Punkteliste dafür komplett neu auf: "Ohne Gruppe" zuerst (unverändert),
|
||||
// danach jede Gruppe in der neuen Reihenfolge. Parameter: gruppe (Name),
|
||||
// richtung (-1 = nach vorn, +1 = nach hinten). Keine Rückgabe.
|
||||
// Punkteliste dafür komplett in der neuen Gruppenreihenfolge neu auf.
|
||||
// Parameter: gruppe (Name), richtung (-1 = nach vorn, +1 = nach hinten).
|
||||
// Keine Rückgabe.
|
||||
window.verschiebeGruppe = function(gruppe, richtung){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
const punkte = currentProtokoll.punkte;
|
||||
const namen = [];
|
||||
punkte.forEach(p => {
|
||||
const g = String(p.gruppe || '').trim();
|
||||
if(g && !namen.includes(g)) namen.push(g);
|
||||
if(!namen.includes(g)) namen.push(g);
|
||||
});
|
||||
const pos = namen.indexOf(gruppe);
|
||||
const pos = namen.indexOf(String(gruppe || '').trim());
|
||||
const zielPos = pos + richtung;
|
||||
if(pos === -1 || zielPos < 0 || zielPos >= namen.length) return;
|
||||
const tmpName = namen[pos];
|
||||
namen[pos] = namen[zielPos];
|
||||
namen[zielPos] = tmpName;
|
||||
const neu = punkte.filter(p => !String(p.gruppe || '').trim());
|
||||
const neu = [];
|
||||
namen.forEach(g => {
|
||||
punkte.filter(p => String(p.gruppe || '').trim() === g).forEach(p => neu.push(p));
|
||||
});
|
||||
@@ -229,7 +305,7 @@
|
||||
function renderProtokolleListe(){
|
||||
const container = document.getElementById('protokolleContainer');
|
||||
if(!container) return;
|
||||
const locked = app.isLocked();
|
||||
const locked = app.protokolleGesperrt();
|
||||
const anlegeBtns = locked ? '' : `<button onclick="neuesProtokoll()">+ Neues Protokoll</button>
|
||||
<button class="secondary" onclick="oeffneProtokollImportDialog()">Protokoll importieren</button>`;
|
||||
if(protokolleCache.length === 0){
|
||||
@@ -261,7 +337,7 @@
|
||||
// "anwesend" zurückgesetzt), sonst Fallback auf projectAssignees(), sonst
|
||||
// leer. Das eigentliche Fortschreiben der Punkte übernimmt der Server.
|
||||
window.neuesProtokoll = async function(){
|
||||
if(app.isLocked()) return;
|
||||
if(app.protokolleGesperrt()) return;
|
||||
let teilnehmer = [];
|
||||
try {
|
||||
if(protokolleCache.length > 0){
|
||||
@@ -316,7 +392,7 @@
|
||||
// firmenweiten Stand, und kann sich seit dem letzten Öffnen geändert
|
||||
// haben.
|
||||
window.oeffneProtokollImportDialog = function(){
|
||||
if(app.isLocked()) return;
|
||||
if(app.protokolleGesperrt()) return;
|
||||
document.getElementById('protokollImportFile').value = '';
|
||||
document.getElementById('protokollImportBetreff').value = '';
|
||||
document.getElementById('protokollImportOrt').value = '';
|
||||
@@ -459,7 +535,7 @@
|
||||
const container = document.getElementById('protokolleContainer');
|
||||
if(!container || !currentProtokoll) return;
|
||||
const p = currentProtokoll;
|
||||
const locked = app.isLocked();
|
||||
const locked = app.protokolleGesperrt();
|
||||
|
||||
const datumDisplay = formatDateDE(parseDateISO(p.datum)) || '';
|
||||
const quelleHinweis = p.quelleDateiname
|
||||
@@ -538,97 +614,275 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderPunkteBlockHtml: baut nur den Punkte-Block (in Abschnitten je
|
||||
// Gruppe) — eigene Funktion, weil er nach einer Gruppenänderung isoliert
|
||||
// renderPunkteBlockHtml: baut nur den Punkte-Block — EINE durchgehende
|
||||
// Tabelle (dieselben Spalten wie der PDF-Export, js/pdf.js), mit einer
|
||||
// vollbreiten Gruppen-Kopfzeile je Abschnitt statt eigener Tabellen je
|
||||
// Gruppe — eigene Funktion, weil sie nach einer Gruppenänderung isoliert
|
||||
// neu gerendert wird, ohne den Rest der Detailansicht (und damit den Fokus
|
||||
// in anderen Feldern) anzufassen.
|
||||
// PROTOKOLL_GRUPPEN_FARBEN/protokollGruppeFarbe: der Farbbalken der
|
||||
// Gruppen-Kopfzeile wird — wie projektFarbe() in js/load/load.js für die
|
||||
// Auslastung — aus dem Gruppennamen gehasht statt fest zugeordnet, damit
|
||||
// eine Gruppe ihre Farbe behält, egal an welcher Position sie steht.
|
||||
// Eigene, von den Status-Farben (Amber/Grün/Teal/Rot) getrennte Palette,
|
||||
// damit eine Gruppenfarbe nie wie ein Status aussieht.
|
||||
const PROTOKOLL_GRUPPEN_FARBEN = ['#5b63d6', '#8b5cf6', '#c2419a', '#4f8ef7', '#7a52c7'];
|
||||
function protokollGruppeFarbe(name){
|
||||
let hash = 0;
|
||||
const s = String(name || '');
|
||||
for(let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) >>> 0;
|
||||
return PROTOKOLL_GRUPPEN_FARBEN[hash % PROTOKOLL_GRUPPEN_FARBEN.length];
|
||||
}
|
||||
|
||||
// ausgewaehlterPunkt: der gerade im Schreibbereich geöffnete Punkt, als
|
||||
// OBJEKT-Referenz (nicht als Index). Die Punkte wandern beim Verschieben
|
||||
// und beim Umsortieren einer ganzen Gruppe durch das Array; eine gemerkte
|
||||
// Zahl zeigte danach auf einen anderen Punkt, die Referenz überlebt jede
|
||||
// dieser Umstellungen unverändert. Nur ein Speichern tauscht die Objekte
|
||||
// aus (der Server liefert frische) — dort wird die Auswahl über ihre
|
||||
// Position wiederhergestellt, siehe speichereProtokoll().
|
||||
let ausgewaehlterPunkt = null;
|
||||
|
||||
// waehlePunkt: öffnet einen Punkt im Schreibbereich. Parameter: idx (Index
|
||||
// in currentProtokoll.punkte). Keine Rückgabe.
|
||||
window.waehlePunkt = function(idx){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
ausgewaehlterPunkt = currentProtokoll.punkte[idx];
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
};
|
||||
|
||||
// listenUnterzeile: die kleine zweite Zeile eines Listeneintrags —
|
||||
// Zuständig und Termin, so weit vorhanden. Parameter: punkt. Rückgabe:
|
||||
// String.
|
||||
function listenUnterzeile(punkt){
|
||||
const teile = [];
|
||||
const zust = String(punkt.zustaendig || '').trim();
|
||||
if(zust) teile.push(zust);
|
||||
const termin = formatDateDE(parseDateISO(punkt.termin));
|
||||
if(termin) teile.push(termin);
|
||||
return teile.join(' · ') || 'noch nichts erfasst';
|
||||
}
|
||||
|
||||
// aktualisiereListenZeile: schreibt Thema, Zuständig/Termin und den
|
||||
// Status-Punkt eines Listeneintrags neu — gezielt am vorhandenen Element,
|
||||
// OHNE den Block neu zu rendern. Nötig, weil im Schreibbereich getippter
|
||||
// Text sofort links in der Liste erscheinen soll, ein Neu-Rendern dabei
|
||||
// aber den Eingabefokus (und die Schreibmarke) zerstören würde — dieselbe
|
||||
// Begründung wie im Datei-Kopfkommentar. Parameter: idx. Keine Rückgabe.
|
||||
function aktualisiereListenZeile(idx){
|
||||
const punkt = currentProtokoll && currentProtokoll.punkte[idx];
|
||||
const zeile = document.getElementById('protokollListeZeile_' + idx);
|
||||
if(!punkt || !zeile) return;
|
||||
const titel = zeile.querySelector('.titel');
|
||||
if(titel){
|
||||
const thema = String(punkt.thema || '').trim();
|
||||
titel.textContent = thema || 'Ohne Thema';
|
||||
titel.classList.toggle('ist-leer', !thema);
|
||||
}
|
||||
const sub = zeile.querySelector('.sub');
|
||||
if(sub) sub.textContent = listenUnterzeile(punkt);
|
||||
const dot = zeile.querySelector('.protokoll-punkt-dot');
|
||||
if(dot) dot.setAttribute('data-status', punkt.status || 'offen');
|
||||
}
|
||||
|
||||
// renderPunkteBlockHtml: baut den Punkte-Block als ZWEI nebeneinander
|
||||
// liegende Bereiche (Eriks Auswahl „Liste + Schreibbereich" aus dem
|
||||
// Protokoll-schreiben-Mockup) statt der früheren durchgehenden Tabelle:
|
||||
// links eine schmale, überfliegbare Liste aller Punkte (nach Gruppen
|
||||
// gegliedert, je Zeile Nummer, Thema, Status-Punkt und darunter
|
||||
// Zuständig/Termin), rechts der Schreibbereich für den EINEN gerade
|
||||
// ausgewählten Punkt mit großem Thema-Feld, mehrzeiligem Textfeld und
|
||||
// Zuständig/Termin als ordentlichen Formularfeldern.
|
||||
//
|
||||
// Warum weg von der Tabelle: ein Protokollpunkt ist ein kleiner Fließtext,
|
||||
// keine Tabellenzelle. In der Spalte blieben für den Text rund 60 % der
|
||||
// Breite bei drei, vier sichtbaren Zeilen; Thema und Text klebten
|
||||
// übereinander, und die Formatierungsleiste saß eingeklemmt dazwischen.
|
||||
// Der Schreibbereich gibt dem Text eine lesbare Zeilenlänge und jedem Feld
|
||||
// eine eigene Beschriftung.
|
||||
//
|
||||
// Eigene Funktion, weil sie nach jeder strukturellen Änderung (Punkt/Gruppe
|
||||
// anlegen, löschen, verschieben, auswählen) isoliert neu gerendert wird,
|
||||
// ohne den Rest der Detailansicht anzufassen. Reine Feldänderungen rendern
|
||||
// dagegen NICHT neu, sondern aktualisieren die Liste gezielt über
|
||||
// aktualisiereListenZeile() oben.
|
||||
function renderPunkteBlockHtml(){
|
||||
const p = currentProtokoll;
|
||||
const locked = app.isLocked();
|
||||
const locked = app.protokolleGesperrt();
|
||||
const gruppen = gruppenReihenfolge();
|
||||
const gruppenNamen = gruppen.filter(g => g); // ohne '' für die Datalist-Vorschläge
|
||||
|
||||
const nummern = window.berechneProtokollNummern(p.punkte);
|
||||
const gruppenHtml = gruppen.map(gruppe => {
|
||||
|
||||
// Auswahl nachziehen: fehlt sie oder zeigt sie auf einen inzwischen
|
||||
// gelöschten Punkt, rückt der erste vorhandene nach.
|
||||
let aktivIdx = p.punkte.indexOf(ausgewaehlterPunkt);
|
||||
if(aktivIdx === -1){
|
||||
aktivIdx = p.punkte.length ? 0 : -1;
|
||||
ausgewaehlterPunkt = aktivIdx >= 0 ? p.punkte[aktivIdx] : null;
|
||||
}
|
||||
|
||||
const offeneAnzahl = p.punkte.filter(x => (x.status || 'offen') === 'offen').length;
|
||||
const kopfText = p.punkte.length
|
||||
? `${p.punkte.length} ${p.punkte.length === 1 ? 'Punkt' : 'Punkte'} · ${offeneAnzahl} offen`
|
||||
: 'Noch keine Punkte';
|
||||
|
||||
const listeHtml = gruppen.map(gruppe => {
|
||||
const punkteDerGruppe = p.punkte
|
||||
.map((punkt, idx) => ({ punkt, idx }))
|
||||
.filter(x => String(x.punkt.gruppe || '').trim() === gruppe);
|
||||
const titel = gruppe ? esc(gruppe) : 'Ohne Gruppe';
|
||||
const addBtn = locked ? '' : `<span class="protokoll-gruppe-add" onclick="fuegePunktHinzu('${esc(gruppe)}')">+ Punkt</span>`;
|
||||
// "Ohne Gruppe" bleibt fest der erste Abschnitt (siehe gruppenReihenfolge()) —
|
||||
// nur echte Gruppen lassen sich gegeneinander verschieben.
|
||||
let moveBtns = '';
|
||||
if(gruppe && !locked){
|
||||
const gPos = gruppenNamen.indexOf(gruppe);
|
||||
const hochDisabled = gPos <= 0 ? 'disabled' : '';
|
||||
const runterDisabled = gPos >= gruppenNamen.length - 1 ? 'disabled' : '';
|
||||
if(!locked){
|
||||
const gPos = gruppen.indexOf(gruppe);
|
||||
moveBtns = `<span class="protokoll-gruppe-move">
|
||||
<button type="button" class="secondary small" ${hochDisabled} onclick="verschiebeGruppe('${esc(gruppe)}', -1)" title="Gruppe nach vorn">↑</button>
|
||||
<button type="button" class="secondary small" ${runterDisabled} onclick="verschiebeGruppe('${esc(gruppe)}', 1)" title="Gruppe nach hinten">↓</button>
|
||||
<button type="button" class="secondary small" ${gPos <= 0 ? 'disabled' : ''} onclick="verschiebeGruppe('${esc(gruppe)}', -1)" title="Gruppe nach vorn">↑</button>
|
||||
<button type="button" class="secondary small" ${gPos >= gruppen.length - 1 ? 'disabled' : ''} onclick="verschiebeGruppe('${esc(gruppe)}', 1)" title="Gruppe nach hinten">↓</button>
|
||||
</span>`;
|
||||
}
|
||||
const zeilen = punkteDerGruppe.map(({ punkt, idx }, pos) => renderPunktZeileHtml(punkt, idx, nummern[idx], pos === 0, pos === punkteDerGruppe.length - 1)).join('');
|
||||
return `<div class="protokoll-gruppe">
|
||||
<div class="protokoll-gruppe-head"><span class="protokoll-gruppe-name${gruppe ? '' : ' is-leer'}">${titel}</span>${moveBtns}${addBtn}</div>
|
||||
${zeilen || '<span class="hint">Keine Punkte in dieser Gruppe.</span>'}
|
||||
const kopf = `<div class="protokoll-liste-gruppe" style="--protokoll-gruppe-farbe: ${protokollGruppeFarbe(gruppe)}">
|
||||
<input class="protokoll-gruppe-heading" value="${esc(gruppe)}" placeholder="Gruppenname …" ${locked ? 'disabled' : ''}
|
||||
onchange="currentProtokollUmbenenneGruppe('${esc(gruppe)}', this.value)">
|
||||
<span class="count">${punkteDerGruppe.length}</span>${moveBtns}
|
||||
</div>`;
|
||||
const zeilen = punkteDerGruppe.map(({ punkt, idx }) =>
|
||||
renderListenZeileHtml(punkt, idx, nummern[idx], idx === aktivIdx)).join('');
|
||||
const addZeile = locked ? '' : `<div class="protokoll-liste-neu" onclick="fuegePunktHinzu('${esc(gruppe)}')">+ Punkt</div>`;
|
||||
return kopf + zeilen + addZeile;
|
||||
}).join('');
|
||||
|
||||
const datalist = `<datalist id="protokollGruppenListe">${gruppenNamen.map(g => `<option value="${esc(g)}">`).join('')}</datalist>`;
|
||||
const addOhneGruppe = locked ? '' : `<div class="add-row-btn" onclick="fuegePunktHinzu('')">+ Punkt (ohne Gruppe)</div>`;
|
||||
const leerHinweis = listeHtml ? '' : '<div class="protokoll-liste-leer">Noch keine Punkte erfasst.</div>';
|
||||
const neueGruppe = locked ? '' : `<div class="protokoll-liste-neu ist-gruppe" onclick="fuegeNeueGruppeHinzu()">+ Neue Gruppe</div>`;
|
||||
|
||||
return `<div class="protokoll-block-head"><h4>Punkte</h4></div>
|
||||
${gruppenHtml || '<span class="hint">Noch keine Punkte.</span>'}
|
||||
${datalist}
|
||||
${addOhneGruppe}
|
||||
<div class="protokoll-split">
|
||||
<div class="protokoll-liste">
|
||||
<div class="protokoll-liste-kopf">${esc(kopfText)}</div>
|
||||
${listeHtml}${leerHinweis}${neueGruppe}
|
||||
</div>
|
||||
${renderSchreibbereichHtml(aktivIdx, nummern)}
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="legend-item"><span class="legend-swatch neu"></span>Neu in diesem Protokoll</span>
|
||||
<span class="legend-item"><span class="legend-swatch fortgeschrieben"></span>Aus vorigem Protokoll fortgeschrieben</span>
|
||||
<span class="legend-item"><span class="legend-swatch erledigt"></span>Erledigt oder Info (läuft nicht weiter mit)</span>
|
||||
<span class="legend-item"><span class="legend-swatch info"></span>Info (läuft nicht weiter mit)</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// PROTOKOLL_PUNKT_STATUS_LABEL: Anzeigetext je Status im <select> unten —
|
||||
// einzige Stelle, die die drei Werte kennt, damit ein vierter Status
|
||||
// (sollte es den je geben) nur hier ergänzt werden müsste.
|
||||
// renderListenZeileHtml: ein Eintrag der linken Liste. Bewusst knapp —
|
||||
// Nummer, Thema, Status-Punkt und darunter Zuständig/Termin; alles
|
||||
// Weitere steht im Schreibbereich. Parameter: punkt, idx, nummer,
|
||||
// istAktiv. Rückgabe: HTML-String.
|
||||
function renderListenZeileHtml(punkt, idx, nummer, istAktiv){
|
||||
const thema = String(punkt.thema || '').trim();
|
||||
const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : '';
|
||||
return `<div class="protokoll-liste-zeile${istAktiv ? ' ist-aktiv' : ''}" id="protokollListeZeile_${idx}" onclick="waehlePunkt(${idx})">
|
||||
<span class="nr">${esc(nummer)}</span>
|
||||
<span class="titel${thema ? '' : ' ist-leer'}">${esc(thema || 'Ohne Thema')}</span>
|
||||
<span class="protokoll-punkt-dot" data-status="${esc(punkt.status || 'offen')}" title="${esc(PROTOKOLL_PUNKT_STATUS_LABEL[punkt.status || 'offen'] || 'Offen')}"></span>
|
||||
<span class="sub">${esc(listenUnterzeile(punkt))}</span>
|
||||
${neuChip}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// PROTOKOLL_PUNKT_STATUS_LABEL: Anzeigetext je Status im Status-Umschalter
|
||||
// unten — einzige Stelle, die die drei Werte kennt, damit ein vierter
|
||||
// Status (sollte es den je geben) nur hier ergänzt werden müsste.
|
||||
const PROTOKOLL_PUNKT_STATUS_LABEL = { offen: 'Offen', erledigt: 'Erledigt', info: 'Info' };
|
||||
|
||||
function renderPunktZeileHtml(punkt, idx, nummer, istErste, istLetzte){
|
||||
const locked = app.isLocked();
|
||||
// renderSchreibbereichHtml: die rechte Hälfte — der EINE gerade
|
||||
// ausgewählte Punkt, mit echtem Platz zum Schreiben. Parameter: idx (Index
|
||||
// in currentProtokoll.punkte, -1 = kein Punkt vorhanden), nummern (das
|
||||
// Ergebnis von berechneProtokollNummern()). Rückgabe: HTML-String.
|
||||
function renderSchreibbereichHtml(idx, nummern){
|
||||
const locked = app.protokolleGesperrt();
|
||||
if(idx < 0){
|
||||
return `<div class="protokoll-schreiben ist-leer">
|
||||
<p>Noch kein Punkt angelegt.</p>
|
||||
${locked ? '' : '<p class="hint">Links über „+ Neue Gruppe" beginnen — jeder Punkt gehört zu einer Gruppe.</p>'}
|
||||
</div>`;
|
||||
}
|
||||
const punkt = currentProtokoll.punkte[idx];
|
||||
const terminDisplay = formatDateDE(parseDateISO(punkt.termin)) || '';
|
||||
const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : '';
|
||||
// entfaelltNaechstes: vom Server gesetzt, wenn dieser Punkt beim
|
||||
// Fortschreiben aus einem erledigten Punkt des vorigen Protokolls
|
||||
// entstand — seine Gnadenrunde (Eriks Wunsch: ein erledigter Punkt
|
||||
// taucht noch EIN Protokoll lang auf, bevor er entfällt). Nie vom
|
||||
// Client gesetzt (siehe PUT .../:id, server.js).
|
||||
const entfaelltChip = punkt.entfaelltNaechstes
|
||||
? '<span class="protokoll-chip-entfaellt" title="Dieser Punkt wurde bereits als erledigt fortgeschrieben — im übernächsten Protokoll taucht er nicht mehr auf.">Entfällt im nächsten Protokoll</span>'
|
||||
: '';
|
||||
// themaGeaendert/textGeaendert: vom Server gesetzt (siehe
|
||||
// loadProtokollDetail(), server.js) für einen fortgeschriebenen Punkt
|
||||
// (neu=0), dessen Thema bzw. Text sich gegenüber der Fassung im
|
||||
// UNMITTELBAR vorangegangenen Protokoll geändert hat — markiert NUR das
|
||||
// jeweils geänderte Feld, der Punkt selbst ist ja derselbe geblieben.
|
||||
const themaGeaendertClass = punkt.themaGeaendert ? ' ist-geaendert' : '';
|
||||
const textGeaendertClass = punkt.textGeaendert ? ' ist-geaendert' : '';
|
||||
const status = punkt.status || 'offen';
|
||||
const moveBtns = locked ? '' : `<span class="protokoll-punkt-move">
|
||||
<button type="button" class="secondary small" ${istErste ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, -1)" title="Innerhalb der Gruppe nach oben">↑</button>
|
||||
<button type="button" class="secondary small" ${istLetzte ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, 1)" title="Innerhalb der Gruppe nach unten">↓</button>
|
||||
</span>`;
|
||||
const statusOptions = Object.keys(PROTOKOLL_PUNKT_STATUS_LABEL)
|
||||
.map(s => `<option value="${s}" ${s === status ? 'selected' : ''}>${PROTOKOLL_PUNKT_STATUS_LABEL[s]}</option>`).join('');
|
||||
return `<div class="protokoll-punkt${punkt.neu ? ' is-neu' : ''}${status === 'erledigt' ? ' is-erledigt' : ''}${status === 'info' ? ' is-info' : ''}" id="protokollPunkt_${idx}">
|
||||
<div class="protokoll-punkt-top-row">
|
||||
<span class="protokoll-punkt-nr" title="Errechnet sich automatisch aus Gruppe und Reihenfolge">${esc(nummer)}</span>
|
||||
${moveBtns}
|
||||
<input type="text" class="protokoll-punkt-gruppe" list="protokollGruppenListe" value="${esc(punkt.gruppe)}"
|
||||
placeholder="Gruppe (z. B. ELT, HLS) …" ${locked ? 'disabled' : ''}
|
||||
onchange="currentProtokollSetzePunktGruppe(${idx}, this.value)">
|
||||
${neuChip}
|
||||
<input type="text" class="protokoll-punkt-thema" value="${esc(punkt.thema)}" placeholder="Thema" ${locked ? 'disabled' : ''}
|
||||
oninput="currentProtokollSetzePunktFeld(${idx}, 'thema', this.value)">
|
||||
<select class="protokoll-punkt-status" ${locked ? 'disabled' : ''}
|
||||
onchange="currentProtokollSetzePunktStatus(${idx}, this.value)">${statusOptions}</select>
|
||||
${locked ? '' : `<span class="protokoll-row-remove" onclick="entfernePunkt(${idx})" title="Punkt entfernen">✕</span>`}
|
||||
|
||||
// Verschieben gilt wie bisher nur INNERHALB der eigenen Gruppe — die
|
||||
// Knöpfe sind an den Rändern dieser Gruppe gesperrt, nicht an denen der
|
||||
// Gesamtliste.
|
||||
const gruppe = String(punkt.gruppe || '').trim();
|
||||
const idxDerGruppe = currentProtokoll.punkte
|
||||
.map((x, i) => ({ x, i }))
|
||||
.filter(e => String(e.x.gruppe || '').trim() === gruppe)
|
||||
.map(e => e.i);
|
||||
const posInGruppe = idxDerGruppe.indexOf(idx);
|
||||
const kopfAktionen = locked ? '' : `<span class="protokoll-punkt-move">
|
||||
<button type="button" class="secondary small" ${posInGruppe <= 0 ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, -1)" title="Innerhalb der Gruppe nach oben">↑</button>
|
||||
<button type="button" class="secondary small" ${posInGruppe >= idxDerGruppe.length - 1 ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, 1)" title="Innerhalb der Gruppe nach unten">↓</button>
|
||||
</span>
|
||||
<span class="protokoll-row-remove" onclick="entfernePunkt(${idx})" title="Punkt entfernen">✕</span>`;
|
||||
|
||||
const statusButtons = Object.keys(PROTOKOLL_PUNKT_STATUS_LABEL).map(s =>
|
||||
`<button type="button" class="${s}${s === status ? ' is-active' : ''}" ${locked ? 'disabled' : ''}
|
||||
onclick="currentProtokollSetzePunktStatus(${idx}, '${s}')">${PROTOKOLL_PUNKT_STATUS_LABEL[s]}</button>`).join('');
|
||||
|
||||
return `<div class="protokoll-schreiben">
|
||||
<div class="protokoll-schreiben-kopf">
|
||||
<span class="nr" title="Errechnet sich automatisch aus Gruppe und Reihenfolge">${esc(nummern[idx])}</span>
|
||||
<span class="gruppe">${esc(gruppe)}</span>
|
||||
${neuChip}${entfaelltChip}
|
||||
<span class="rechts">
|
||||
<div class="protokoll-status-switch ist-waagerecht">${statusButtons}</div>
|
||||
${kopfAktionen}
|
||||
</span>
|
||||
</div>
|
||||
<textarea class="protokoll-punkt-text" rows="2" placeholder="Text / Vereinbarung" ${locked ? 'disabled' : ''}
|
||||
oninput="currentProtokollSetzePunktFeld(${idx}, 'text', this.value)">${esc(punkt.text)}</textarea>
|
||||
<div class="protokoll-punkt-foot">
|
||||
<label>Zuständig <input type="text" value="${esc(punkt.zustaendig)}" ${locked ? 'disabled' : ''}
|
||||
oninput="currentProtokollSetzePunktFeld(${idx}, 'zustaendig', this.value)"></label>
|
||||
<label>Termin
|
||||
<input type="text" id="protokollPunktTerminDisplay_${idx}" class="range-picker-display" value="${esc(terminDisplay)}" placeholder="—" readonly
|
||||
|
||||
<div class="protokoll-schreiben-feld">
|
||||
<span class="protokoll-feld-label">Thema</span>
|
||||
<input type="text" class="protokoll-punkt-thema${themaGeaendertClass}" value="${esc(punkt.thema)}"
|
||||
placeholder="Worum geht es?" ${locked ? 'disabled' : ''}
|
||||
oninput="currentProtokollSetzePunktFeld(${idx}, 'thema', this.value)">
|
||||
</div>
|
||||
|
||||
<div class="richtext-wrap">
|
||||
${locked ? '' : richtextToolbarHtml('.protokoll-punkt-text')}
|
||||
<div class="protokoll-schreiben-feld">
|
||||
<span class="protokoll-feld-label">Vereinbarung · Text</span>
|
||||
${richtextFeldHtml('protokoll-punkt-text' + textGeaendertClass, sanitizeStatusHtml(punkt.text),
|
||||
'Was wurde besprochen oder vereinbart?',
|
||||
`oninput="currentProtokollSetzePunktFeld(${idx}, 'text', this.innerHTML)"`, !locked)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="protokoll-schreiben-raster">
|
||||
<div>
|
||||
<span class="protokoll-feld-label">Zuständig</span>
|
||||
<input type="text" value="${esc(punkt.zustaendig)}" placeholder="Name oder Firma" ${locked ? 'disabled' : ''}
|
||||
oninput="currentProtokollSetzePunktFeld(${idx}, 'zustaendig', this.value)">
|
||||
</div>
|
||||
<div>
|
||||
<span class="protokoll-feld-label">Termin</span>
|
||||
<input type="text" id="protokollPunktTerminDisplay_${idx}" class="range-picker-display" value="${esc(terminDisplay)}" placeholder="— kein Termin" readonly
|
||||
onclick="${locked ? '' : `openDatePicker({ anchor: this, inputId: 'protokollPunktTermin_${idx}', displayId: 'protokollPunktTerminDisplay_${idx}', onApply: function(){ syncProtokollPunktTermin(${idx}); } })`}">
|
||||
<input type="hidden" id="protokollPunktTermin_${idx}" value="${esc(punkt.termin)}">
|
||||
</label>
|
||||
<span class="protokoll-punkt-erstellt" title="Datum, an dem dieser Punkt zum ersten Mal erfasst wurde — bleibt beim Fortschreiben unverändert">Erstellt ${esc(formatDateDE(parseDateISO(punkt.erstelltAm)) || '—')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="protokoll-schreiben-fuss">
|
||||
Erstellt am ${esc(formatDateDE(parseDateISO(punkt.erstelltAm)) || '—')}
|
||||
<span class="hint">— bleibt beim Fortschreiben unverändert</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -647,32 +901,47 @@
|
||||
if(!currentProtokoll || !currentProtokoll.teilnehmer[idx]) return;
|
||||
currentProtokoll.teilnehmer[idx][feld] = wert;
|
||||
};
|
||||
// Thema/Zuständig schlagen sofort auf den Listeneintrag links durch, ohne
|
||||
// den Block neu zu rendern (sonst wäre der Fokus beim Tippen weg, siehe
|
||||
// aktualisiereListenZeile() oben).
|
||||
window.currentProtokollSetzePunktFeld = function(idx, feld, wert){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
currentProtokoll.punkte[idx][feld] = wert;
|
||||
if(feld === 'thema' || feld === 'zustaendig') aktualisiereListenZeile(idx);
|
||||
};
|
||||
window.syncProtokollPunktTermin = function(idx){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
const el = document.getElementById('protokollPunktTermin_' + idx);
|
||||
if(el) currentProtokoll.punkte[idx].termin = el.value;
|
||||
aktualisiereListenZeile(idx);
|
||||
};
|
||||
window.currentProtokollSetzePunktStatus = function(idx, status){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
currentProtokoll.punkte[idx].status = status;
|
||||
const row = document.getElementById('protokollPunkt_' + idx);
|
||||
if(row){
|
||||
row.classList.toggle('is-erledigt', status === 'erledigt');
|
||||
row.classList.toggle('is-info', status === 'info');
|
||||
}
|
||||
// Gezielt die beiden betroffenen Stellen nachziehen statt neu zu rendern:
|
||||
// der aktive Knopf im Umschalter und der farbige Punkt im Listeneintrag.
|
||||
document.querySelectorAll('.protokoll-schreiben .protokoll-status-switch button').forEach(btn => {
|
||||
btn.classList.toggle('is-active', btn.classList.contains(status));
|
||||
});
|
||||
aktualisiereListenZeile(idx);
|
||||
};
|
||||
// currentProtokollSetzePunktGruppe: einzige Punkt-Feldänderung, die neu
|
||||
// rendert (nur den Punkte-Block, siehe renderPunkteBlockHtml) — ändert
|
||||
// sich die Gruppe, muss der Punkt sichtbar in den passenden Abschnitt
|
||||
// wandern. Läuft über onchange (Blur/Enter), nicht oninput, damit während
|
||||
// des Tippens nicht bei jedem Zeichen neu gerendert wird.
|
||||
window.currentProtokollSetzePunktGruppe = function(idx, wert){
|
||||
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
|
||||
currentProtokoll.punkte[idx].gruppe = wert;
|
||||
// currentProtokollUmbenenneGruppe: ersetzt currentProtokollSetzePunktGruppe
|
||||
// (bis eben: ein Feld PRO Punkt, das genau diesen einen Punkt umhängte).
|
||||
// Jetzt ist die Gruppen-Kopfzeile selbst das Eingabefeld (Eriks Wunsch) —
|
||||
// ein Umbenennen wirkt auf ALLE Punkte dieser Gruppe gleichzeitig. Trägt
|
||||
// der neue Name den einer bereits bestehenden anderen Gruppe, verschmelzen
|
||||
// beide Gruppen schlicht dadurch, dass ab jetzt beide Punktmengen denselben
|
||||
// gruppe-Wert tragen — kein Sonderfall nötig. Läuft über onchange
|
||||
// (Blur/Enter), nicht oninput, damit während des Tippens nicht bei jedem
|
||||
// Zeichen neu gerendert wird.
|
||||
window.currentProtokollUmbenenneGruppe = function(alterName, neuerName){
|
||||
if(!currentProtokoll) return;
|
||||
const alt = String(alterName || '').trim();
|
||||
const neu = String(neuerName || '').trim();
|
||||
if(alt === neu) return;
|
||||
currentProtokoll.punkte.forEach(punkt => {
|
||||
if(String(punkt.gruppe || '').trim() === alt) punkt.gruppe = neu;
|
||||
});
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
};
|
||||
@@ -680,32 +949,81 @@
|
||||
// ---------- Strukturelle Änderungen (Zeile hinzufügen/entfernen) ----------
|
||||
|
||||
window.fuegeTeilnehmerHinzu = function(){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
currentProtokoll.teilnehmer.push({ name: '', firma: '', anwesend: true });
|
||||
renderProtokollDetail();
|
||||
};
|
||||
window.entferneTeilnehmer = function(idx){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
currentProtokoll.teilnehmer.splice(idx, 1);
|
||||
renderProtokollDetail();
|
||||
};
|
||||
window.fuegePunktHinzu = function(gruppe){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
// nummer wird nicht gesetzt — sie ergibt sich beim Rendern automatisch
|
||||
// aus Gruppe und Position (siehe berechneProtokollNummern() oben).
|
||||
// erstelltAm nur als Vorschau bis zum nächsten Speichern — maßgeblich ist
|
||||
// immer, was der Server beim Speichern setzt (server.js, PUT .../:id).
|
||||
currentProtokoll.punkte.push({
|
||||
const neuerPunkt = {
|
||||
id: null, nummer: '', gruppe: gruppe || '', neu: true,
|
||||
thema: '', text: '', zustaendig: '', termin: '', status: 'offen',
|
||||
erstelltAm: currentProtokoll.datum || ''
|
||||
});
|
||||
erstelltAm: currentProtokoll.datum || '', entfaelltNaechstes: false
|
||||
};
|
||||
// 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
|
||||
// springen — sonst stünde er zwar links in der Liste, rechts bliebe
|
||||
// aber der vorige Punkt stehen und man müsste ihn erst anklicken.
|
||||
ausgewaehlterPunkt = neuerPunkt;
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
const themaFeld = block && block.querySelector('.protokoll-schreiben .protokoll-punkt-thema');
|
||||
if(themaFeld) themaFeld.focus();
|
||||
};
|
||||
// fuegeNeueGruppeHinzu: eigener, ausdrücklicher Weg, eine neue Gruppe
|
||||
// anzulegen (Eriks Wunsch). Gruppe bleibt weiterhin freier Text ohne eigene
|
||||
// Tabelle (siehe CLAUDE.md "### Protokolle") — eine Gruppe ohne jeden
|
||||
// Punkt hätte in diesem Modell nirgends eine Repräsentation, deshalb legt
|
||||
// dieser Knopf die Gruppe zusammen mit ihrem ersten (leeren) Punkt an. Ein
|
||||
// nicht kollidierender Platzhaltername ("Neue Gruppe", bei Bedarf
|
||||
// "Neue Gruppe 2" …) wird direkt danach in der großen Gruppen-Überschrift
|
||||
// (nicht mehr einem Feld am Punkt, siehe currentProtokollUmbenenneGruppe()
|
||||
// oben) fokussiert und markiert, damit er sich sofort überschreiben lässt.
|
||||
window.fuegeNeueGruppeHinzu = function(){
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
const bestehende = new Set(gruppenReihenfolge());
|
||||
let name = 'Neue Gruppe';
|
||||
let n = 2;
|
||||
while(bestehende.has(name)){ name = 'Neue Gruppe ' + n; n++; }
|
||||
fuegePunktHinzu(name);
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
const input = block
|
||||
? Array.from(block.querySelectorAll('.protokoll-gruppe-heading')).find(el => el.value === name)
|
||||
: null;
|
||||
if(input){ input.focus(); input.select(); }
|
||||
};
|
||||
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
|
||||
// getretene nach (bzw. der letzte, wenn es der letzte war) — sonst
|
||||
// stünde der Schreibbereich leer da, obwohl noch Punkte vorhanden sind.
|
||||
const warAusgewaehlt = currentProtokoll.punkte[idx] === ausgewaehlterPunkt;
|
||||
currentProtokoll.punkte.splice(idx, 1);
|
||||
if(warAusgewaehlt){
|
||||
const ersatz = Math.min(idx, currentProtokoll.punkte.length - 1);
|
||||
ausgewaehlterPunkt = ersatz >= 0 ? currentProtokoll.punkte[ersatz] : null;
|
||||
}
|
||||
const block = document.getElementById('protokollPunkteBlock');
|
||||
if(block) block.innerHTML = renderPunkteBlockHtml();
|
||||
};
|
||||
@@ -716,13 +1034,22 @@
|
||||
// Punkte komplett (siehe server.js). Ausdrückliches Speichern, kein
|
||||
// Auto-Save, wie die Board-Kommentare.
|
||||
window.speichereProtokoll = function(){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
// Errechnete Nummern vor dem Senden in die Punkte schreiben, damit die
|
||||
// gespeicherten Werte mit der Anzeige übereinstimmen (maßgeblich ist beim
|
||||
// Anzeigen aber immer die Neuberechnung, nie dieser gespeicherte Wert —
|
||||
// siehe berechneProtokollNummern()).
|
||||
const nummern = window.berechneProtokollNummern(currentProtokoll.punkte);
|
||||
currentProtokoll.punkte.forEach((p, idx) => { p.nummer = nummern[idx]; });
|
||||
// text kommt live aus dem contenteditable-Feld (this.innerHTML,
|
||||
// currentProtokollSetzePunktFeld()) — ein Einfügen von außerhalb könnte
|
||||
// dort ungefiltertes HTML hinterlassen (contenteditable übernimmt
|
||||
// eingefügte Formatierung standardmäßig); vor dem Senden deshalb
|
||||
// dieselbe Bereinigung wie beim Board-Kommentar (saveBoardComment(),
|
||||
// js/board/board.js).
|
||||
currentProtokoll.punkte.forEach((p, idx) => {
|
||||
p.nummer = nummern[idx];
|
||||
p.text = sanitizeStatusHtml(p.text || '');
|
||||
});
|
||||
const body = {
|
||||
datum: currentProtokoll.datum,
|
||||
betreff: currentProtokoll.betreff,
|
||||
@@ -730,6 +1057,11 @@
|
||||
teilnehmer: currentProtokoll.teilnehmer.filter(t => String(t.name || '').trim()),
|
||||
punkte: currentProtokoll.punkte
|
||||
};
|
||||
// Die Auswahl hängt an der OBJEKT-Referenz des Punkts (siehe dort) — der
|
||||
// Server liefert gleich frische Objekte zurück, die Referenz zeigt dann
|
||||
// ins Leere. Die Reihenfolge bleibt beim Speichern erhalten (array_order),
|
||||
// also lässt sich die Auswahl über ihre Position wiederherstellen.
|
||||
const auswahlPos = currentProtokoll.punkte.indexOf(ausgewaehlterPunkt);
|
||||
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
@@ -739,6 +1071,7 @@
|
||||
.then(({ ok, d }) => {
|
||||
if(!ok || !d.ok){ alert('Protokoll konnte nicht gespeichert werden: ' + (d && d.error ? d.error : 'unbekannter Fehler')); return; }
|
||||
currentProtokoll = d.protokoll;
|
||||
ausgewaehlterPunkt = (auswahlPos >= 0 && currentProtokoll.punkte[auswahlPos]) || null;
|
||||
renderProtokollDetail();
|
||||
ladeProtokollListe();
|
||||
})
|
||||
@@ -749,7 +1082,7 @@
|
||||
};
|
||||
|
||||
window.loescheProtokoll = function(){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
if(!confirm('Protokoll Nr. ' + currentProtokoll.nummer + ' wirklich löschen?')) return;
|
||||
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), { method: 'DELETE' })
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
@@ -764,6 +1097,12 @@
|
||||
});
|
||||
};
|
||||
|
||||
// Die Rückmeldung der Formatierungsleiste (hervorgehoben + "✓ Markiert"
|
||||
// sobald Text ausgewählt ist, sonst blass + "Erst Text markieren") ist ein
|
||||
// gemeinsamer, dokumentweiter Lauscher in js/utils.js geworden (gilt jetzt
|
||||
// auch für den Board-Kommentar) — hier war er ursprünglich fest auf den
|
||||
// Protokoll-Schreibbereich zugeschnitten.
|
||||
|
||||
// renderPlanProtokolle: zentrale Render-Funktion, von showPlanSection()
|
||||
// (plan-sections.js) beim Wechsel in den Bereich aufgerufen — zeigt je
|
||||
// nach Zustand Liste oder Detail (rendert dabei nichts neu, nur aus dem
|
||||
|
||||
@@ -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
|
||||
// kritischen Pfad (längste Vorgänger-Kette) und hebt sie im GANTT hervor,
|
||||
// verschiebt bei autoScheduleTodos TODOs und Meilensteine automatisch hinter
|
||||
// ihre Vorgänger und erstellt bei todoMail einen mailto-Link an alle
|
||||
// zugeordneten Personen. Die Hervorhebung übergibt renderPlanGantt als
|
||||
// Option criticalPath an buildGanttHtml (plan-build-gantt.js); den Schalter
|
||||
// criticalPathActive hält der Zustand in window.PlanApp (plan-state.js).
|
||||
// Dieses Modul verschiebt bei autoScheduleTodos TODOs und Meilensteine
|
||||
// automatisch hinter ihre Vorgänger (aufgerufen beim Ziehen eines Balkens,
|
||||
// plan-drag.js, und über "Auto-Schedule" im Bereich Aufgaben) und erstellt
|
||||
// bei todoMail einen mailto-Link an alle zugeordneten Personen. Die frühere
|
||||
// Hervorhebung des kritischen Pfads ist auf Eriks Wunsch entfallen.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
@@ -15,113 +13,7 @@
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// ---------- Kritischer Pfad & 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.');
|
||||
}
|
||||
}
|
||||
};
|
||||
// ---------- Auto-Schedule ----------
|
||||
|
||||
// autoScheduleTodos: verschiebt alle TODOs und Meilensteine, die von
|
||||
// anderen Elementen abhängen, automatisch auf den frühesten möglichen
|
||||
|
||||
+118
-46
@@ -3,8 +3,8 @@
|
||||
// -----------------------------
|
||||
// Der Projektbereich ist der EIGENE BEREICH eines Projekts — seit dem Umbau
|
||||
// kein eigenes Dokument mehr, sondern der Bereich #plan der Hauptseite
|
||||
// Test.html. Er gliedert sich in sieben Unterbereiche, zwischen denen die
|
||||
// Leiste .plan-nav umschaltet (Markup in Test.html):
|
||||
// App.html. Er gliedert sich in sieben Unterbereiche, zwischen denen die
|
||||
// Leiste .plan-nav umschaltet (Markup in App.html):
|
||||
//
|
||||
// dashboard — Kennzahlen, Sitzungskommentar, Leistungsphasen-
|
||||
// Fortschritt, nächste Termine, Team, Kompakt-Karten
|
||||
@@ -35,8 +35,8 @@
|
||||
|
||||
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
|
||||
// Navigationsleiste. Muss zu den data-section-Attributen in
|
||||
// Test.html passen.
|
||||
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
|
||||
// App.html passen.
|
||||
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'phasen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
|
||||
|
||||
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
|
||||
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
|
||||
@@ -63,6 +63,7 @@
|
||||
|
||||
if(!app.project) return;
|
||||
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
|
||||
if(key === 'phasen' && typeof renderPlanPhasen === 'function') renderPlanPhasen();
|
||||
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
|
||||
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
|
||||
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
|
||||
@@ -106,53 +107,88 @@
|
||||
if(nav) nav.style.display = '';
|
||||
}
|
||||
|
||||
// upcomingEntries: sammelt die nächsten Termine des Projekts — offene
|
||||
// Aufgaben mit Endtermin und Meilensteine mit Datum — in einer gemeinsamen,
|
||||
// chronologisch sortierten Liste. Überfällige Einträge stehen zuerst, damit
|
||||
// sie im Dashboard nicht untergehen. Rückgabe: Array von
|
||||
// { kind, title, date, overdue }.
|
||||
// upcomingEntries: offene Aufgaben mit Endtermin und Meilensteine ab heute
|
||||
// (vergangene Meilensteine haben kein Erledigt-Kennzeichen und gelten als
|
||||
// gewesen), chronologisch. idx verweist in plan.todos/plan.milestones, damit
|
||||
// ein Klick denselben Dialog öffnet wie im Terminplan.
|
||||
// Rückgabe: Array von { art, idx, titel, datum, iso, wer, meins }.
|
||||
function upcomingEntries(){
|
||||
const plan = app.ensurePlanLocal();
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const heute = heuteIso();
|
||||
const ich = currentUser && currentUser.personId;
|
||||
const namen = {};
|
||||
((app.week && app.week.people) || []).forEach(x => { namen[x.id] = x.fullName || x.name; });
|
||||
const rows = [];
|
||||
|
||||
(plan.todos || []).forEach(t => {
|
||||
if(t.done) return;
|
||||
const d = parseDateISO(t.end);
|
||||
if(!d) return;
|
||||
rows.push({ kind: 'Aufgabe', title: t.title || 'Aufgabe', date: d, overdue: d < today });
|
||||
(plan.todos || []).forEach((t, idx) => {
|
||||
if(t.done || !t.end) return;
|
||||
const wer = (t.assignees || []).map(id => namen[id] || id);
|
||||
rows.push({ art: 'aufgabe', idx, titel: t.title || 'Aufgabe', iso: t.end, wer,
|
||||
meins: !!ich && (t.assignees || []).includes(ich) });
|
||||
});
|
||||
(plan.milestones || []).forEach(m => {
|
||||
const d = parseDateISO(m.date);
|
||||
if(!d) return;
|
||||
rows.push({ kind: 'Meilenstein', title: m.name || 'Meilenstein', date: d, overdue: d < today });
|
||||
(plan.milestones || []).forEach((m, idx) => {
|
||||
if(!m.date || m.date < heute) return;
|
||||
rows.push({ art: 'meilenstein', idx, titel: m.name || 'Meilenstein', iso: m.date, wer: [], meins: false });
|
||||
});
|
||||
|
||||
rows.sort((a, b) => a.date - b.date);
|
||||
rows.sort((a, b) => a.iso.localeCompare(b.iso));
|
||||
return rows;
|
||||
}
|
||||
|
||||
// renderPlanDashboard: füllt die beiden eigenen Karten des Dashboards
|
||||
// ("Nächste Termine" und "Team"). Die übrigen Inhalte des Bereichs
|
||||
// stammen aus bereits vorhandenen Render-Funktionen: die KPI-Kacheln aus
|
||||
// renderPlanKpis() (plan-gantt.js), Kommentar und Leistungsphasen aus
|
||||
// renderPlanInfo() (plan-info.js), die Kompakt-Karten aus
|
||||
// renderResourceLoad()/renderProjectFiles()/renderPlanHistory().
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
// PLAN_DASH_ANTEIL: wie viele Einträge je Gruppe offen dastehen — jede
|
||||
// Gruppe bekommt ihren eigenen Anteil, sonst füllten 25 überfällige
|
||||
// Aufgaben die Liste und "Diese Woche" stand ohne Einträge da. Der Rest
|
||||
// steckt hinter "… weitere in Aufgaben".
|
||||
const PLAN_DASH_ANTEIL = { ueber: 4, woche: 4, spaeter: 3 };
|
||||
|
||||
// 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(){
|
||||
if(!app.project) return;
|
||||
const locked = app.isLocked();
|
||||
|
||||
const upcomingEl = document.getElementById('planDashUpcoming');
|
||||
if(upcomingEl){
|
||||
const rows = upcomingEntries().slice(0, 8);
|
||||
upcomingEl.innerHTML = rows.length === 0
|
||||
? '<div class="plan-task-empty">Keine offenen Termine hinterlegt.</div>'
|
||||
: '<div class="plan-upcoming-list">' + rows.map(r => `<div class="plan-upcoming-item${r.overdue ? ' overdue' : ''}">
|
||||
<span class="plan-upcoming-date">${esc(formatDateDE(r.date))}</span>
|
||||
<span>${esc(r.title)}</span>
|
||||
<span class="plan-upcoming-kind">${esc(r.kind)}</span>
|
||||
</div>`).join('') + '</div>';
|
||||
const heute = heuteIso();
|
||||
const wocheEnde = isoPlusTage(7);
|
||||
const alle = upcomingEntries();
|
||||
if(alle.length === 0){
|
||||
upcomingEl.innerHTML = '<div class="plan-task-empty">Keine offenen Termine. Aufgaben und Meilensteine entstehen im Terminplan.</div>';
|
||||
} else {
|
||||
const gruppen = [
|
||||
{ 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');
|
||||
@@ -162,14 +198,50 @@
|
||||
teamEl.innerHTML = '<div class="plan-task-empty">Diesem Projekt ist noch niemand zugeordnet. Zuordnung im Bereich „Einstellungen“.</div>';
|
||||
} else {
|
||||
const plan = app.ensurePlanLocal();
|
||||
teamEl.innerHTML = '<div class="plan-dash-team">' + assignees.map(a => {
|
||||
const offen = (plan.todos || []).filter(t => !t.done && Array.isArray(t.assignees) && t.assignees.indexOf(a.person.id) !== -1).length;
|
||||
const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : '');
|
||||
return `<div class="plan-dash-team-row">
|
||||
<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>
|
||||
<span class="plan-upcoming-kind">${esc(a.role || '')}</span>
|
||||
<span class="plan-task-meta" style="margin-left:auto">${offen} offen</span>
|
||||
const jePerson = new Map();
|
||||
assignees.forEach(a => {
|
||||
if(!jePerson.has(a.person.id)) jePerson.set(a.person.id, { person: a.person, rollen: [] });
|
||||
jePerson.get(a.person.id).rollen.push({ rolle: a.role, state: a.state });
|
||||
});
|
||||
const zeile = e => {
|
||||
const offen = (plan.todos || []).filter(t => !t.done && Array.isArray(t.assignees) && t.assignees.indexOf(e.person.id) !== -1).length;
|
||||
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>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
+356
-77
@@ -13,7 +13,7 @@
|
||||
// gespeichert von renderPlanInfo()/setPlanGroup()/setPlanActiveLp() in
|
||||
// js/plan/plan-info.js — dieses Modul rührt sie nicht an)
|
||||
// * Honorar (netto) und die BEAUFTRAGTEN LEISTUNGSPHASEN
|
||||
// * Beteiligte (Rollen-/Personen-Matrix, ebenfalls aus plan-info.js)
|
||||
// * Beteiligte (Rollen-/Personen-Matrix, #planAssignMatrix)
|
||||
//
|
||||
// Honorar und beauftragte Leistungsphasen standen früher ausschließlich im
|
||||
// Dialog "Terminplan aus Honorar" (js/plan/plan-honorar.js). Sie sind
|
||||
@@ -24,9 +24,21 @@
|
||||
// aus plan-honorar.js und rendern einander nach jeder Änderung neu, sodass
|
||||
// sie nicht auseinanderlaufen können.
|
||||
//
|
||||
// Die Beteiligten-Matrix war früher ein reines Anzeigefeld, gefüllt von
|
||||
// plan-info.js, mit einem Verweis in den Wochenbericht — dort entstand die
|
||||
// Zuordnung. Auf Eriks ausdrücklichen Wunsch ("In den Einstellungen der
|
||||
// Projekte will ich auch die Projektbeteiligten einstellen können") ist das
|
||||
// umgekehrt: Zuordnung ist jetzt ein Stammdatum wie Honorar, und zwar NUR
|
||||
// HIER editierbar — der Wochenbericht zeigt sie seitdem nur noch zum Lesen
|
||||
// (board.js, renderBoardDrawer()) mit einem Verweis hierher. planCycleAssign()
|
||||
// unten ersetzt für diesen Zweck cycleAssign() (js/board/board.js): jene
|
||||
// Funktion sucht das Projekt über currentWeek() und fände es bei einem
|
||||
// Tiefenlink auf eine andere Sitzung oder einem Planung-Sandbox-Plan dort
|
||||
// nicht — dieselbe Falle, vor der planOpenAufwand() unten warnt.
|
||||
//
|
||||
// Gespeichert wird über den normalen Weg app.savePlan() (die Felder sind Teil
|
||||
// des Wochen-JSON, Tabellen projects.honorar bzw. project_beauftragte_lph) —
|
||||
// es gibt dafür keinen eigenen Endpunkt.
|
||||
// des Wochen-JSON, Tabellen projects.honorar/project_assign bzw.
|
||||
// project_beauftragte_lph) — es gibt dafür keinen eigenen Endpunkt.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
@@ -39,10 +51,289 @@
|
||||
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) und nach
|
||||
// jeder Änderung an Honorar oder beauftragten Leistungsphasen aufgerufen.
|
||||
// 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(){
|
||||
const p = app.project;
|
||||
if(!p) return;
|
||||
const locked = app.isLocked();
|
||||
// Kürzel in der Titelleiste mitziehen — sonst zeigte sie eine geänderte
|
||||
// Rolle erst nach dem Neuladen (renderPlanPage() baut sie nur beim Öffnen).
|
||||
const kopfTeam = document.getElementById('planPeopleList');
|
||||
if(kopfTeam) kopfTeam.innerHTML = app.renderProjectAssignees();
|
||||
const locked = app.stammGesperrt();
|
||||
const sandbox = app.isSandboxPlan();
|
||||
|
||||
const lockHint = document.getElementById('planSettingsLockHint');
|
||||
@@ -50,16 +341,20 @@
|
||||
|
||||
// Felder, die nur bei einem echten Projekt Bedeutung haben (Auftrags-
|
||||
// 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
|
||||
// Terminplan-Sandbox". Honorar + beauftragte Leistungsphasen (Grundlage
|
||||
// des Terminplan-Rechners) und die Beteiligten-Matrix bleiben sichtbar.
|
||||
['settingsProjNr', 'settingsAuftragsstatus', 'settingsGewerke', 'settingsGroesse']
|
||||
['settingsProjNr', 'settingsAuftragsstatus', 'settingsGewerke']
|
||||
.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
const row = el && el.closest('.form-row');
|
||||
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
|
||||
// (dieselbe Vorsichtsmaßnahme wie beim Honorar weiter unten).
|
||||
@@ -74,25 +369,6 @@
|
||||
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.
|
||||
const tagsEl = document.getElementById('settingsProjTags');
|
||||
if(tagsEl){
|
||||
@@ -112,11 +388,12 @@
|
||||
const gewerkeEl = document.getElementById('settingsGewerke');
|
||||
if(gewerkeEl && typeof GEWERK_LABELS === 'object'){
|
||||
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])}">
|
||||
<input type="checkbox" value="${esc(g)}" ${gesetzt.has(g) ? 'checked' : ''} ${locked ? 'disabled' : ''}
|
||||
onchange="togglePlanGewerk('${esc(g)}', this.checked)">
|
||||
${esc(g)}
|
||||
</label>`).join('');
|
||||
// Als Umschalt-Knöpfe (Einstellungen "C"): angetippt = gesetzt.
|
||||
gewerkeEl.innerHTML = Object.keys(GEWERK_LABELS).map(g => {
|
||||
const an = gesetzt.has(g);
|
||||
return `<button type="button" class="plan-einst-chip${an ? ' an' : ''}" aria-pressed="${an}" title="${esc(GEWERK_LABELS[g])}"
|
||||
${locked ? 'disabled' : ''} onclick="togglePlanGewerk('${esc(g)}', ${!an})">${esc(g)}</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Auftragsstatus: Das Auswahlfeld kommt fertig aus js/auftrag.js, damit
|
||||
@@ -127,8 +404,6 @@
|
||||
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
|
||||
// Tippen der Cursor (gleiche Vorsichtsmaßnahme wie im Honorar-Dialog).
|
||||
@@ -140,17 +415,54 @@
|
||||
honorarEl.disabled = locked;
|
||||
}
|
||||
|
||||
// Beauftragte Leistungsphasen: dieselbe Kreuzchen-Liste wie im
|
||||
// Honorar-Dialog, gespeichert über toggleBeauftragteLp() (plan-honorar.js).
|
||||
// Beauftragte Leistungsphasen: je Phase eine Zeile mit Umschalter
|
||||
// "Beauftragt | Nicht beauftragt", die laufende Phase trägt "läuft".
|
||||
// Gespeichert über toggleBeauftragteLp() (plan-honorar.js).
|
||||
const lpEl = document.getElementById('settingsBeauftragteLp');
|
||||
if(lpEl){
|
||||
const gesetzt = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
|
||||
lpEl.innerHTML = LPHS.map(l => `<label class="beauftragte-lp-item">
|
||||
<input type="checkbox" value="${esc(l)}" ${gesetzt.has(l) ? 'checked' : ''} ${locked ? 'disabled' : ''}
|
||||
onchange="toggleBeauftragteLp('${esc(l)}', this.checked)">
|
||||
${esc(l)}
|
||||
</label>`).join('');
|
||||
const laeuft = Array.isArray(p.aktuelleLP) ? p.aktuelleLP[0] : '';
|
||||
lpEl.innerHTML = LPHS.map(l => {
|
||||
const an = gesetzt.has(l);
|
||||
return `<div class="plan-einst-phase${an ? ' ist-an' : ''}">
|
||||
<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
|
||||
// zugeordnete Personen erscheinen blass, damit erkennbar bleibt, wer zur
|
||||
// Auswahl stünde; ein Klick schaltet den Zustand durch (planCycleAssign()).
|
||||
const assignMatrix = document.getElementById('planAssignMatrix');
|
||||
if(assignMatrix) renderEinstTeam(p, locked);
|
||||
|
||||
renderEinstUebersicht();
|
||||
};
|
||||
|
||||
// planCycleAssign: wechselt die Zuordnung einer Person zu DIESEM Projekt
|
||||
// zyklisch durch 0=keine → 1=Haupt → 2=optional → 0 (dieselbe Zustandsfolge
|
||||
// wie cycleAssign() in js/board/board.js, hier aber auf app.project statt
|
||||
// currentWeek() — siehe Kopfkommentar). Parameter: personId, role. Keine
|
||||
// Rückgabe.
|
||||
window.planCycleAssign = function(personId, role){
|
||||
const p = app.project;
|
||||
if(!p || app.stammGesperrt()) return;
|
||||
if(!p.assign) p.assign = {};
|
||||
const key = assignKey(personId, role);
|
||||
const cur = p.assign[key] || 0;
|
||||
const next = (cur + 1) % 3;
|
||||
if(next === 0) delete p.assign[key]; else p.assign[key] = next;
|
||||
app.savePlan();
|
||||
renderPlanSettings();
|
||||
// Dieselbe Zuordnung erscheint im Projektboard und im Wochenbericht
|
||||
// (dort jetzt nur noch zum Lesen) — beide sollen sofort nachziehen,
|
||||
// nicht erst beim nächsten vollständigen Neuladen.
|
||||
if(typeof renderBoard === 'function') renderBoard();
|
||||
if(typeof renderWochenbericht === 'function') renderWochenbericht();
|
||||
};
|
||||
|
||||
// setPlanProjNr: ändert die Projektnummer.
|
||||
@@ -162,7 +474,7 @@
|
||||
// Dateien nicht mehr; deshalb die Rückfrage. Parameter: val. Keine Rückgabe.
|
||||
window.setPlanProjNr = function(val){
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked()) return;
|
||||
if(!p || app.stammGesperrt()) return;
|
||||
const neuVal = (val || '').trim();
|
||||
if(neuVal === (p.nr || '')) return;
|
||||
if(p.nr && !confirm('Die Projektnummer "' + p.nr + '" wird zu "' + neuVal + '".\n\n'
|
||||
@@ -178,7 +490,7 @@
|
||||
// setPlanProjName: ändert die Projektbezeichnung. Parameter: val.
|
||||
window.setPlanProjName = function(val){
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked()) return;
|
||||
if(!p || app.stammGesperrt()) return;
|
||||
p.name = (val || '').trim();
|
||||
app.savePlan();
|
||||
// Die Bezeichnung steht an drei weiteren Stellen: in der Kopfzeile des
|
||||
@@ -196,28 +508,12 @@
|
||||
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.
|
||||
window.planTagKeydown = function(event){
|
||||
if(event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked()) return;
|
||||
if(!p || app.stammGesperrt()) return;
|
||||
const tag = (event.target.value || '').trim();
|
||||
if(!tag) return;
|
||||
if(!Array.isArray(p.tags)) p.tags = [];
|
||||
@@ -230,7 +526,7 @@
|
||||
// planRemoveTag: entfernt ein Schlagwort. Parameter: tag.
|
||||
window.planRemoveTag = function(tag){
|
||||
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);
|
||||
app.savePlan();
|
||||
renderPlanSettings();
|
||||
@@ -243,7 +539,7 @@
|
||||
// GEWERK_LABELS (js/projekt-wizard.js), damit sie nicht bei jeder Änderung
|
||||
// durcheinandergerät. Parameter: gewerk, checked (Boolean).
|
||||
window.togglePlanGewerk = function(gewerk, checked){
|
||||
if(app.isLocked()) return;
|
||||
if(app.stammGesperrt()) return;
|
||||
const p = app.project;
|
||||
const set = new Set(Array.isArray(p.gewerke) ? p.gewerke : []);
|
||||
if(checked) set.add(gewerk); else set.delete(gewerk);
|
||||
@@ -251,21 +547,4 @@
|
||||
app.savePlan();
|
||||
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);
|
||||
};
|
||||
})();
|
||||
@@ -1,7 +1,7 @@
|
||||
// -----------------------------
|
||||
// Projektbereich — Zustand, Einstieg & Projektauswahl
|
||||
// -----------------------------
|
||||
// Dieses Modul wird als erstes der plan-*-Module von public/Test.html geladen
|
||||
// Dieses Modul wird als erstes der plan-*-Module von public/App.html geladen
|
||||
// und definiert window.PlanApp als gemeinsamen Zustand aller Module des
|
||||
// Projektbereichs.
|
||||
//
|
||||
@@ -9,7 +9,7 @@
|
||||
// einmal eine eigene Seite (public/project-plan.html) mit eigenem
|
||||
// Einstiegspunkt. Sie las Sitzung und Projekt aus der URL und lud sich ihren
|
||||
// Datenbestand über GET /load-all selbst. Beides ist entfallen: Der
|
||||
// Projektbereich ist jetzt ein Bereich der Hauptseite (#plan in Test.html),
|
||||
// Projektbereich ist jetzt ein Bereich der Hauptseite (#plan in App.html),
|
||||
// der über showTab('plan') eingeblendet wird wie jeder andere. Er arbeitet
|
||||
// deshalb auf dem bereits geladenen In-Memory-Bestand db.weeks (js/store.js)
|
||||
// und braucht weder einen eigenen Ladevorgang noch eine eigene
|
||||
@@ -36,7 +36,6 @@ window.PlanApp = {
|
||||
week: null, // planWeek
|
||||
project: null, // planProject
|
||||
pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen)
|
||||
criticalPathActive: false, // Hervorhebung des kritischen Pfads im GANTT aktiv
|
||||
// Drag-Zustände
|
||||
todoDrag: null, // Drag-and-Drop von TODO/Meilenstein-Zeilen
|
||||
ganttDrag: null, // Balken/Meilenstein/Urlaub verschieben
|
||||
@@ -100,8 +99,21 @@ window.PlanApp = {
|
||||
// showTab('plan') gerufen (und von dort aus auch nach jedem erneuten
|
||||
// Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt
|
||||
// gefunden und gerendert wurde.
|
||||
// startAufgabe: Link mit ?aufgabe=<id> (z. B. aus einer Teams-Nachricht):
|
||||
// diese Aufgabe nach dem Öffnen des Projekts gleich im Aufgaben-Panel
|
||||
// zeigen — einmalig, beim ersten passenden Öffnen.
|
||||
let startAufgabe = '';
|
||||
try { startAufgabe = new URLSearchParams(window.location.search).get('aufgabe') || ''; } catch(e){ /* egal */ }
|
||||
|
||||
window.openPlanView = function(){
|
||||
app.showPlanNav();
|
||||
// Ein Aufgaben-Link stammt aus der Sitzung, in der zugewiesen wurde. Ist
|
||||
// inzwischen eine neuere da, dort öffnen — Aufgaben behalten über den
|
||||
// Sitzungswechsel ihre id (ensureCurrentWeekRollover()).
|
||||
if(startAufgabe && app.weekLabel !== SANDBOX_WEEK_LABEL && Array.isArray(db.wochenListe) && db.wochenListe.length){
|
||||
const juengste = db.wochenListe[db.wochenListe.length - 1].label;
|
||||
if(juengste && juengste !== app.weekLabel) app.weekLabel = juengste;
|
||||
}
|
||||
|
||||
// Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte
|
||||
// "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js)
|
||||
@@ -122,6 +134,9 @@ window.PlanApp = {
|
||||
app.projNr = '';
|
||||
app.projName = app.project.name || '';
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -141,6 +156,19 @@ window.PlanApp = {
|
||||
}
|
||||
app.week = db.weeks.find(w => w.label === app.weekLabel) || null;
|
||||
if(!app.week){
|
||||
// Eine ältere Sitzung, die beim Start nicht mitgeladen wurde (nur die
|
||||
// jüngsten, js/store.js): einzeln nachholen und dann neu öffnen.
|
||||
const bekannt = (db.wochenListe || []).some(w => w.label === app.weekLabel);
|
||||
if(bekannt && typeof sitzungSicherLaden === 'function'){
|
||||
const t = document.getElementById('planSubtitle');
|
||||
if(t) t.textContent = 'Sitzung ' + app.weekLabel + ' wird geladen…';
|
||||
const label = app.weekLabel;
|
||||
sitzungSicherLaden(label).then(w => {
|
||||
if(w && app.weekLabel === label && currentTab === 'plan') showTab('plan');
|
||||
else if(!w) planFail('Nicht gefunden', 'Sitzung "' + label + '" konnte nicht geladen werden.');
|
||||
}).catch(() => planFail('Nicht gefunden', 'Sitzung "' + label + '" konnte nicht geladen werden.'));
|
||||
return false;
|
||||
}
|
||||
planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.');
|
||||
return false;
|
||||
}
|
||||
@@ -177,10 +205,21 @@ window.PlanApp = {
|
||||
return app.project.plan;
|
||||
}
|
||||
|
||||
// isLocked: liefert true, wenn die aktuelle Woche gesperrt ist (dann sind
|
||||
// Bearbeitungen im Plan gesperrt). Rückgabe: Boolescher Wert.
|
||||
// isLocked: liefert true, wenn der TERMINPLAN nicht bearbeitet werden darf
|
||||
// — gesperrte Sitzung oder fehlendes Recht „Terminplan bearbeiten“ (bei
|
||||
// einem Planung-Plan: „Planung“; Rollen & Rechte). Rückgabe: Boolean.
|
||||
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
|
||||
@@ -238,8 +277,6 @@ window.PlanApp = {
|
||||
// Bereichs), lädt bei vorhandener Projektnummer die Protokollliste.
|
||||
if(typeof initPlanProtokoll === 'function') initPlanProtokoll();
|
||||
|
||||
// Verlauf über alle Sitzungen (Kommentar-Historie) — siehe js/plan/plan-history.js.
|
||||
renderPlanHistory();
|
||||
|
||||
// Einstellungen-Bereich (Honorar, beauftragte Leistungsphasen,
|
||||
// Projektangaben) — siehe js/plan/plan-settings.js.
|
||||
@@ -247,10 +284,12 @@ window.PlanApp = {
|
||||
|
||||
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
|
||||
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
|
||||
// Terminplan (…Btn) und Aufgaben (…BtnList), siehe Test.html.
|
||||
// Terminplan (…Btn), Aufgaben (…BtnList) und Leistungsphasen (…BtnPhasen),
|
||||
// siehe App.html.
|
||||
const locked = isLocked();
|
||||
['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'autoScheduleBtn',
|
||||
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'autoScheduleBtnList']
|
||||
['addMilestoneBtn', 'addPhaseBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn',
|
||||
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList',
|
||||
'honorarBtnPhasen', 'addPhaseBtnPhasen']
|
||||
.forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
if(btn) btn.style.display = locked ? 'none' : '';
|
||||
@@ -263,10 +302,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"]')
|
||||
.forEach(btn => { btn.style.display = sandbox ? 'none' : ''; });
|
||||
|
||||
// Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gibt es nur für
|
||||
// Sandbox-Pläne der Planung — ein echtes Projekt hat kein Äquivalent.
|
||||
const sandboxPdfBtn = document.getElementById('sandboxPdfBtn');
|
||||
if(sandboxPdfBtn) sandboxPdfBtn.style.display = sandbox ? '' : 'none';
|
||||
// Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gilt für
|
||||
// Planung-Pläne UND echte Projekte, der Knopf ist immer sichtbar.
|
||||
|
||||
// GANTT inkl. TODOs (rendert am Ende auch Aufgabenliste und Dashboard mit)
|
||||
fitPlanGanttRange();
|
||||
@@ -275,6 +312,22 @@ window.PlanApp = {
|
||||
// js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle
|
||||
// Bereiche befüllt sind, wird einer davon eingeblendet.
|
||||
app.initPlanSections();
|
||||
|
||||
if(startAufgabe){
|
||||
const id = startAufgabe;
|
||||
startAufgabe = '';
|
||||
// Adresszeile auf die tatsächlich geöffnete Sitzung bringen (der Link
|
||||
// nannte evtl. eine ältere, siehe openPlanView()).
|
||||
if(typeof updateRouteUrl === 'function') updateRouteUrl('plan');
|
||||
const idx = (plan.todos || []).findIndex(t => String(t.id) === id);
|
||||
if(idx < 0){ showToast('Die Aufgabe aus dem Link gibt es in diesem Projekt nicht mehr.'); return; }
|
||||
if(location.hash !== '#aufgaben') showPlanSection('aufgaben');
|
||||
// Panel erst nach dem Zeichnen öffnen (das Gantt misst seine Breite).
|
||||
setTimeout(() => {
|
||||
if(isLocked()){ showToast('Aufgabe: ' + (plan.todos[idx].title || '') + ' — diese Sitzung ist gesperrt.'); return; }
|
||||
openTodoModal(idx);
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
// Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine)
|
||||
@@ -302,10 +355,8 @@ window.PlanApp = {
|
||||
function renderProjectAssignees(){
|
||||
const assignees = projectAssignees(app.project, app.week);
|
||||
if(assignees.length === 0) return '';
|
||||
return assignees.map(a => {
|
||||
const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : '');
|
||||
return `<span class="board-avatar ${planRoleClass(a.role)} state-${a.state}" title="${esc(title)}">${esc(a.person.name)}</span>`;
|
||||
}).join('');
|
||||
// Eine Person mit mehreren Rollen nur einmal (beteiligteJePerson(), utils.js).
|
||||
return beteiligteJePerson(assignees).map(b => beteiligtenAvatarHtml(b, planRoleClass(b.role))).join('');
|
||||
}
|
||||
|
||||
// savePlan: merkt die Sitzung des Projektbereichs als geändert vor und
|
||||
@@ -394,6 +445,8 @@ window.PlanApp = {
|
||||
// (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können.
|
||||
app.ensurePlanLocal = ensurePlanLocal;
|
||||
app.isLocked = isLocked;
|
||||
app.stammGesperrt = stammGesperrt;
|
||||
app.protokolleGesperrt = protokolleGesperrt;
|
||||
app.isSandboxPlan = isSandboxPlan;
|
||||
app.removeDependencyRefs = removeDependencyRefs;
|
||||
app.renderProjectAssignees = renderProjectAssignees;
|
||||
|
||||
@@ -75,6 +75,18 @@
|
||||
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
|
||||
// "Aufgaben" neu auf und schreibt die Zähler in die Kartenüberschriften.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
@@ -85,7 +97,25 @@
|
||||
|
||||
// ---- Aufgaben ----
|
||||
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){
|
||||
// In gesperrten Sitzungen sind die Anlege-Schaltflächen ausgeblendet
|
||||
// (renderPlanPage(), plan-state.js) — dann darf der Leertext auch nicht
|
||||
@@ -95,7 +125,9 @@
|
||||
: 'Noch keine Aufgaben. Über „+ Aufgabe“, „+ Vorlage“ oder „+ Terminplan“ anlegen.';
|
||||
todoEl.innerHTML = todos.length === 0
|
||||
? '<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 range = dateRangeText(t);
|
||||
const overdue = isOverdue(t);
|
||||
@@ -109,6 +141,8 @@
|
||||
${check}
|
||||
<span class="plan-task-dot ${status}"></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)}
|
||||
${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>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// In einem Ingenieurbüro wiederholt sich der Ablauf einer Leistungsphase über
|
||||
// alle Projekte hinweg. Dieses Modul macht daraus wiederverwendbare Vorlagen:
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// Admins — das Einfügen darf jeder, das Pflegen der firmenweiten Vorlagen
|
||||
// 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.
|
||||
//
|
||||
// WICHTIG: Eingefügte Aufgaben sind danach ganz normale plan_todos ohne
|
||||
@@ -46,13 +47,28 @@
|
||||
if(app.isLocked()) return;
|
||||
|
||||
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
|
||||
const aktLp = Array.isArray(app.project.aktuelleLP) ? app.project.aktuelleLP[0] : '';
|
||||
lphSelect.innerHTML = TEMPLATE_LPHS.map(l =>
|
||||
`<option value="${esc(l)}" ${l === aktLp ? 'selected' : ''}>${esc(l)}</option>`
|
||||
).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
|
||||
const startInput = document.getElementById('templateStart');
|
||||
const startDisplay = document.getElementById('templateStartDisplay');
|
||||
@@ -62,6 +78,7 @@
|
||||
if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today));
|
||||
}
|
||||
|
||||
templatePhaseVorbelegen();
|
||||
document.getElementById('lphTemplateModal').style.display = 'flex';
|
||||
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
|
||||
// Leistungsphase, in ihrer gepflegten Reihenfolge. Rückgabe: Array.
|
||||
function templatesForSelectedLph(){
|
||||
const lphSelect = document.getElementById('templateLphSelect');
|
||||
const gewSelect = document.getElementById('templateGewerkSelect');
|
||||
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
|
||||
@@ -107,18 +200,23 @@
|
||||
const entries = templatesForSelectedLph();
|
||||
const startIso = (document.getElementById('templateStart') || {}).value || '';
|
||||
const startDate = parseDateISO(startIso);
|
||||
const phasenTage = parseInt((document.getElementById('templatePhasenTage') || {}).value, 10) || 0;
|
||||
const plan = templatePlan(entries, startDate, phasenTage);
|
||||
|
||||
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 {
|
||||
listEl.innerHTML = entries.map(t => {
|
||||
// Vorschau der konkreten Termine (nur wenn ein Startdatum gesetzt ist)
|
||||
let dateHint = `Tag +${t.offsetDays}, ${t.durationDays} Tag${t.durationDays === 1 ? '' : 'e'}`;
|
||||
if(startDate){
|
||||
const s = addDays(startDate, t.offsetDays);
|
||||
const e = addDays(s, Math.max(0, t.durationDays - 1));
|
||||
dateHint = formatDateDE(s) + '–' + formatDateDE(e);
|
||||
}
|
||||
const termin = plan.get(t.id);
|
||||
let dateHint;
|
||||
if(termin) dateHint = formatDateDE(termin.s) + '–' + formatDateDE(termin.e)
|
||||
+ (termin.anteil ? ' · ' + String(t.anteil).replace('.', ',') + ' %' : '')
|
||||
+ (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">
|
||||
<input type="checkbox" value="${esc(t.id)}" checked>
|
||||
<span style="flex:1">${esc(t.title)}</span>
|
||||
@@ -131,13 +229,13 @@
|
||||
};
|
||||
|
||||
// renderTemplateHint: blendet für Administratoren den Verweis auf den
|
||||
// Bereich „Einstellungen" ein. Das Anlegen und Löschen von Schritten stand
|
||||
// bis zum Umbau hier im Dialog; es liegt jetzt in js/settings/settings.js.
|
||||
// Admin-Bereich „Aufgaben-Vorlagen" ein. Das Anlegen und Löschen von Schritten stand
|
||||
// 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.
|
||||
function renderTemplateHint(){
|
||||
const el = document.getElementById('templateAdminHint');
|
||||
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
|
||||
@@ -149,7 +247,6 @@
|
||||
if(app.isLocked()) return;
|
||||
const startIso = (document.getElementById('templateStart') || {}).value || '';
|
||||
const startDate = parseDateISO(startIso);
|
||||
if(!startDate){ alert('Bitte ein Startdatum wählen.'); return; }
|
||||
|
||||
const listEl = document.getElementById('templateTaskList');
|
||||
const checkedIds = listEl
|
||||
@@ -157,16 +254,26 @@
|
||||
: [];
|
||||
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();
|
||||
entries.forEach(t => {
|
||||
const s = addDays(startDate, t.offsetDays);
|
||||
const e = addDays(s, Math.max(0, t.durationDays - 1));
|
||||
const termin = termine.get(t.id);
|
||||
const s = termin ? termin.s : null;
|
||||
const e = termin ? termin.e : null;
|
||||
plan.todos.push({
|
||||
id: uid(),
|
||||
title: t.title,
|
||||
start: formatDateISO(s),
|
||||
end: formatDateISO(e),
|
||||
start: s ? formatDateISO(s) : '',
|
||||
end: e ? formatDateISO(e) : '',
|
||||
done: false,
|
||||
progress: 0,
|
||||
dependsOn: [],
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Plan Checker (Bereich "Studenten")
|
||||
// -----------------------------
|
||||
// Eigenständiges Prüf-Werkzeug: lädt einen Bauplan als PDF hoch und lässt
|
||||
// eine KI (ChatGPT oder Claude, Anbieter wählbar — exakt wie beim
|
||||
// bestehenden Protokoll-Import, js/plan/plan-protokoll.js) ihn gegen eine
|
||||
// admin-gepflegte Liste von Merkmalen prüfen (z. B. "Maßketten vorhanden",
|
||||
// siehe Admin → Plan-Checker-Merkmale, js/admin/admin.js). Zusätzlich lässt
|
||||
// sich je Prüfung EIN weiteres Merkmal ergänzen, das nur für diese eine
|
||||
// Prüfung gilt und nicht gespeichert wird - und optional ein Projekt der
|
||||
// aktuellen Sitzung wählen: Dann kommt serverseitig automatisch ein
|
||||
// weiteres, ebenso nicht gespeichertes Merkmal dazu, das den Plankopf gegen
|
||||
// Projektnummer/Bezeichnung dieses Projekts abgleicht (ladePlanCheckerProjekte()/
|
||||
// planCheckerPruefen() unten, server.js POST /plan-checker-pruefen).
|
||||
//
|
||||
// Darstellung "Arbeitsfläche" (Eriks Wahl "B" aus dem Design-Vergleich):
|
||||
// zwei Spalten dauerhaft nebeneinander — links Upload/Konfiguration, rechts
|
||||
// das Ergebnis. Nach einer Korrektur am Plan lässt sich erneut hochladen und
|
||||
// prüfen, ohne das vorherige Ergebnis aus den Augen zu verlieren.
|
||||
//
|
||||
// Server-Gegenstück: POST /plan-checker-pruefen (server.js) — Roh-Binärkörper
|
||||
// wie beim Protokoll-Import, rendert die erste PDF-Seite serverseitig zu
|
||||
// mehreren Bildern (Übersicht + hochaufgelöste Kachel-Ausschnitte,
|
||||
// renderPdfAlsBildsatz()) und schickt die an die KI (Maßketten/Plankopf sind
|
||||
// optische Merkmale, kein Fließtext). Die KI nennt zu jedem Merkmal
|
||||
// zusätzlich den Index DESJENIGEN der gesendeten Bilder, auf dem der
|
||||
// Nachweis am besten zu sehen ist (bildIndex) — genau dieses, bereits
|
||||
// gerenderte Bild kommt unverändert als "screenshot"-Feld je Ergebnis
|
||||
// zurück, s. renderPlanCheckerErgebnis() unten. (Ein erster Versuch mit
|
||||
// einem Beschriftungsraster + nachträglichem Zuschnitt einer genannten
|
||||
// Rasterzelle erwies sich als nicht gut genug und wurde durch diese
|
||||
// einfachere Variante ersetzt.) Weder die PDF- noch die Bilddateien werden
|
||||
// gespeichert — kein Dokumentenarchiv, ein Ad-hoc-Werkzeug.
|
||||
//
|
||||
// Läuft wie js/planung/planung.js als klassisches globales Skript (kein
|
||||
// Modul/IIFE), geladen nach js/store.js/js/weeks.js, vor js/tabs-auth.js
|
||||
// (das renderPlanChecker() beim Tab-Wechsel aufruft) — siehe App.html.
|
||||
|
||||
// planCheckerDatei: die aktuell gewählte PDF-Datei (File-Objekt) oder null.
|
||||
let planCheckerDatei = null;
|
||||
|
||||
// planCheckerMerkmale: die zuletzt geladene Grundliste der Merkmale
|
||||
// ({id, titel, prompt}), für die Vorschau links und als Grundlage der
|
||||
// Ergebnisliste rechts (die Reihenfolge der Antwort folgt derselben Liste).
|
||||
let planCheckerMerkmale = [];
|
||||
|
||||
// renderPlanChecker: Einstiegspunkt, von showTab('planchecker') gerufen.
|
||||
// Lädt die Merkmale-Grundliste, die KI-Anbieter-Auswahl und die Projektliste
|
||||
// neu — alle drei können sich seit dem letzten Besuch geändert haben
|
||||
// (Admin-Pflege, eigener Schlüssel unter Einstellungen → Mein Konto, bzw.
|
||||
// ein inzwischen angelegtes/umbenanntes Projekt). Keine Parameter, keine
|
||||
// Rückgabe.
|
||||
function renderPlanChecker(){
|
||||
ladePlanCheckerMerkmale();
|
||||
ladePlanCheckerAnbieter();
|
||||
ladePlanCheckerProjekte();
|
||||
}
|
||||
|
||||
// ladePlanCheckerProjekte: füllt die optionale Projekt-Auswahl aus der
|
||||
// bereits geladenen aktuellen Sitzung (currentWeek().projects, js/weeks.js)
|
||||
// — kein eigener Server-Aufruf nötig, die Liste liegt dem Client ohnehin
|
||||
// schon vor. Gruppiert wie die Projektliste selbst (GROUP_ORDER/sortGroups(),
|
||||
// js/globals.js bzw. js/utils.js). Der Options-Wert ist die Projekt-ID
|
||||
// (stabil innerhalb der Sitzung) statt Nummer/Name direkt zu kodieren — bei
|
||||
// der Prüfung wird das Projekt darüber erneut in currentWeek().projects
|
||||
// nachgeschlagen (planCheckerPruefen() unten). Keine Parameter, keine
|
||||
// Rückgabe.
|
||||
function ladePlanCheckerProjekte(){
|
||||
const sel = document.getElementById('planCheckerProjekt');
|
||||
if(!sel) return;
|
||||
const w = typeof currentWeek === 'function' ? currentWeek() : null;
|
||||
const projekte = (w && Array.isArray(w.projects)) ? w.projects : [];
|
||||
const bisher = sel.value;
|
||||
const gruppenNamen = sortGroups([...new Set(projekte.map(p => p.group || 'Ohne Gruppe'))]);
|
||||
sel.innerHTML = '<option value="">Kein Projekt wählen</option>' + gruppenNamen.map(g => {
|
||||
const opts = projekte.filter(p => (p.group || 'Ohne Gruppe') === g)
|
||||
.slice().sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(p => `<option value="${esc(p.id)}">${esc(p.nr ? p.nr + ' · ' : '')}${esc(p.name)}</option>`).join('');
|
||||
return opts ? `<optgroup label="${esc(g)}">${opts}</optgroup>` : '';
|
||||
}).join('');
|
||||
// Auswahl erhalten, falls sie nach dem Neuladen noch existiert (z. B. nach
|
||||
// erneutem Tab-Wechsel innerhalb derselben Sitzung).
|
||||
if(bisher && [...sel.options].some(o => o.value === bisher)) sel.value = bisher;
|
||||
}
|
||||
|
||||
// ladePlanCheckerMerkmale: holt die admin-gepflegte Grundliste (GET
|
||||
// /plan-checker-merkmale, jeder angemeldete Nutzer darf lesen) und zeigt sie
|
||||
// read-only als Vorschau, damit vor dem Prüfen klar ist, was geprüft wird.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function ladePlanCheckerMerkmale(){
|
||||
const el = document.getElementById('planCheckerMerkmaleListe');
|
||||
if(el) el.innerHTML = '<span class="hint" style="margin:0">Lade Merkmale…</span>';
|
||||
fetch(baseUrl() + '/plan-checker-merkmale')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(data => {
|
||||
planCheckerMerkmale = Array.isArray(data.merkmale) ? data.merkmale : [];
|
||||
if(!el) return;
|
||||
el.innerHTML = planCheckerMerkmale.length === 0
|
||||
? '<span class="hint" style="margin:0">Noch kein Merkmal hinterlegt (siehe Admin → Plan-Checker-Merkmale).</span>'
|
||||
: planCheckerMerkmale.map(m => `<div class="pc-merkmal-chip">${esc(m.titel)}</div>`).join('');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Plan-Checker-Merkmale laden fehlgeschlagen:', err);
|
||||
if(el) el.innerHTML = '<span class="hint" style="margin:0">Merkmale konnten nicht geladen werden.</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// ladePlanCheckerAnbieter: baut die Anbieter-Auswahl aus GET /my-ai-settings
|
||||
// auf — derselbe Weg wie beim Protokoll-Import-Dialog
|
||||
// (oeffneProtokollImportDialog(), js/plan/plan-protokoll.js): der Hinweis
|
||||
// "(kein Schlüssel hinterlegt)" bezieht sich auf den EIGENEN Schlüssel des
|
||||
// angemeldeten Kontos. Keine Parameter, keine Rückgabe.
|
||||
function ladePlanCheckerAnbieter(){
|
||||
const sel = document.getElementById('planCheckerAnbieter');
|
||||
if(!sel) return;
|
||||
sel.innerHTML = `<option value="chatgpt">ChatGPT</option><option value="claude">Claude</option>`;
|
||||
fetch(baseUrl() + '/my-ai-settings')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if(!d.ok) return;
|
||||
sel.innerHTML = `
|
||||
<option value="chatgpt">ChatGPT${d.chatgpt && d.chatgpt.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>
|
||||
<option value="claude">Claude${d.claude && d.claude.configured ? '' : ' (kein Schlüssel hinterlegt)'}</option>`;
|
||||
})
|
||||
.catch(err => console.error('KI-Einstellungen laden fehlgeschlagen:', err));
|
||||
}
|
||||
|
||||
// ---------- Datei-Auswahl (Klick oder Ziehen-und-Ablegen) ----------
|
||||
|
||||
// planCheckerDateiGewaehlt: übernimmt eine gewählte/abgelegte Datei, prüft
|
||||
// grob die Endung (die eigentliche Formatprüfung — PDF-Magic-Bytes — läuft
|
||||
// serverseitig) und zeigt den Dateinamen in der Ablagefläche an.
|
||||
// Parameter: file (File oder undefined). Keine Rückgabe.
|
||||
function planCheckerDateiGewaehlt(file){
|
||||
const fehlerEl = document.getElementById('planCheckerError');
|
||||
if(fehlerEl){ fehlerEl.hidden = true; fehlerEl.textContent = ''; }
|
||||
if(!file) return;
|
||||
if(!/\.pdf$/i.test(file.name)){
|
||||
if(fehlerEl){ fehlerEl.textContent = 'Bitte eine PDF-Datei auswählen.'; fehlerEl.hidden = false; }
|
||||
planCheckerDatei = null;
|
||||
return;
|
||||
}
|
||||
planCheckerDatei = file;
|
||||
const nameEl = document.getElementById('planCheckerDateiname');
|
||||
const textEl = document.getElementById('planCheckerDropText');
|
||||
if(nameEl){ nameEl.textContent = file.name; nameEl.hidden = false; }
|
||||
if(textEl) textEl.textContent = 'Andere Datei wählen';
|
||||
}
|
||||
|
||||
function planCheckerDragOver(e){
|
||||
e.preventDefault();
|
||||
const drop = document.getElementById('planCheckerDrop');
|
||||
if(drop) drop.classList.add('is-dragover');
|
||||
}
|
||||
|
||||
function planCheckerDragLeave(e){
|
||||
const drop = document.getElementById('planCheckerDrop');
|
||||
if(drop) drop.classList.remove('is-dragover');
|
||||
}
|
||||
|
||||
function planCheckerDrop(e){
|
||||
e.preventDefault();
|
||||
const drop = document.getElementById('planCheckerDrop');
|
||||
if(drop) drop.classList.remove('is-dragover');
|
||||
const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||||
planCheckerDateiGewaehlt(file);
|
||||
}
|
||||
|
||||
// ---------- Prüfen ----------
|
||||
|
||||
// planCheckerLadezustand: sperrt Eingaben/Knopf während der KI-Aufruf läuft
|
||||
// — kann bei einem lokal laufenden Modell mehrere Minuten dauern (siehe
|
||||
// KI_TIMEOUT_MS_LOKAL, server.js); ohne Rückmeldung wirkte der Bildschirm
|
||||
// sonst hängengeblieben, und ein zweiter Klick hätte dieselbe Prüfung
|
||||
// doppelt losgeschickt. Parameter: aktiv (Boolean). Keine Rückgabe.
|
||||
function planCheckerLadezustand(aktiv){
|
||||
const btn = document.getElementById('planCheckerPruefenBtn');
|
||||
if(btn){ btn.disabled = aktiv; btn.textContent = aktiv ? 'Wird geprüft…' : 'Plan prüfen'; }
|
||||
['planCheckerFile', 'planCheckerZusatz', 'planCheckerAnbieter', 'planCheckerProjekt'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if(el) el.disabled = aktiv;
|
||||
});
|
||||
}
|
||||
|
||||
// planCheckerPruefen: schickt die gewählte Datei als rohen Binär-Body an
|
||||
// POST /plan-checker-pruefen (gleiches Vorgehen wie importiereProtokoll(),
|
||||
// js/plan/plan-protokoll.js) und zeigt das Ergebnis rechts an. Keine
|
||||
// Parameter, keine Rückgabe.
|
||||
function planCheckerPruefen(){
|
||||
const fehlerEl = document.getElementById('planCheckerError');
|
||||
const zeigeFehler = msg => { if(fehlerEl){ fehlerEl.textContent = msg; fehlerEl.hidden = false; } };
|
||||
if(fehlerEl){ fehlerEl.hidden = true; fehlerEl.textContent = ''; }
|
||||
|
||||
if(!planCheckerDatei) return zeigeFehler('Bitte zuerst eine PDF-Datei wählen.');
|
||||
const anbieterEl = document.getElementById('planCheckerAnbieter');
|
||||
const provider = anbieterEl ? anbieterEl.value : '';
|
||||
const zusatzEl = document.getElementById('planCheckerZusatz');
|
||||
const zusatz = zusatzEl ? zusatzEl.value.trim() : '';
|
||||
|
||||
// Projekt-Abgleich: die Auswahl trägt nur die Projekt-ID (s.
|
||||
// ladePlanCheckerProjekte() oben) - Nummer/Bezeichnung werden hier erneut
|
||||
// aus currentWeek().projects nachgeschlagen, statt sie schon beim Aufbau
|
||||
// der Auswahl im value zu kodieren.
|
||||
const projektEl = document.getElementById('planCheckerProjekt');
|
||||
const projektId = projektEl ? projektEl.value : '';
|
||||
const w = typeof currentWeek === 'function' ? currentWeek() : null;
|
||||
const projekt = projektId && w ? (w.projects || []).find(p => p.id === projektId) : null;
|
||||
|
||||
const url = baseUrl() + '/plan-checker-pruefen'
|
||||
+ '?provider=' + encodeURIComponent(provider)
|
||||
+ '&zusatzMerkmal=' + encodeURIComponent(zusatz)
|
||||
+ '&projektNr=' + encodeURIComponent(projekt ? (projekt.nr || '') : '')
|
||||
+ '&projektName=' + encodeURIComponent(projekt ? projekt.name : '');
|
||||
|
||||
const datei = planCheckerDatei;
|
||||
planCheckerLadezustand(true);
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': datei.type || 'application/pdf' },
|
||||
body: datei
|
||||
})
|
||||
.then(r => r.json().then(d => ({ ok: r.ok, d })))
|
||||
.then(({ ok, d }) => {
|
||||
if(!ok || !d.ok) throw new Error((d && d.error) || 'Prüfung fehlgeschlagen.');
|
||||
renderPlanCheckerErgebnis(datei.name, provider, d.ergebnisse || []);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Plan-Prüfung fehlgeschlagen:', err);
|
||||
zeigeFehler(err.message || String(err));
|
||||
})
|
||||
.finally(() => planCheckerLadezustand(false));
|
||||
}
|
||||
|
||||
// planCheckerLetzteErgebnisse: die zuletzt gerenderten Ergebniszeilen,
|
||||
// gemerkt für planCheckerZeigeScreenshot() unten — ein Klick auf eine Zeile
|
||||
// übergibt nur den Index, nicht die (potenziell sehr lange) Data-URL selbst
|
||||
// als Inline-onclick-Parameter.
|
||||
let planCheckerLetzteErgebnisse = [];
|
||||
|
||||
// renderPlanCheckerErgebnis: zeigt das Prüfergebnis in der rechten Spalte —
|
||||
// Dateiname/Anbieter/Zeitpunkt als Kopfzeile, danach je Merkmal eine Zeile
|
||||
// mit grünem/rotem Kreis, der Begründung der KI und (falls vorhanden) einem
|
||||
// Screenshot — demjenigen der an die KI gesendeten Bilder (Übersicht oder
|
||||
// eine Kachel), das die KI über bildIndex als besten Beleg für dieses
|
||||
// Merkmal benannt hat (server.js, pruefePlanMitKI()). Parameter: dateiname
|
||||
// (String), provider ('chatgpt'|'claude'), ergebnisse (Array {merkmal,
|
||||
// erfuellt, begruendung, screenshot}, screenshot eine Data-URL oder null).
|
||||
// Keine Rückgabe.
|
||||
function renderPlanCheckerErgebnis(dateiname, provider, ergebnisse){
|
||||
const leerEl = document.getElementById('planCheckerLeer');
|
||||
const zielEl = document.getElementById('planCheckerErgebnis');
|
||||
if(!zielEl) return;
|
||||
if(leerEl) leerEl.hidden = true;
|
||||
planCheckerLetzteErgebnisse = ergebnisse;
|
||||
|
||||
const anbieterLabel = provider === 'claude' ? 'Claude' : 'ChatGPT';
|
||||
const zeit = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
// Der Screenshot öffnet NICHT als <a target="_blank"> auf die data:-URL —
|
||||
// Chrome blockiert das Öffnen einer data:-URL als Top-Level-Navigation in
|
||||
// einem neuen Tab, der Klick blieb dadurch wirkungslos (Eriks Rückmeldung:
|
||||
// "wenn ich auf das Bild im Protokoll öffne, wird es nicht in groß
|
||||
// angezeigt"). Stattdessen ein Knopf, der #pcShotModal füllt und einblendet
|
||||
// (planCheckerZeigeScreenshot(), App.html) — bleibt auf derselben Seite,
|
||||
// keine Navigation nötig.
|
||||
const zeilen = ergebnisse.map((e, i) => `<div class="pc-result-row">
|
||||
<span class="pc-verdict ${e.erfuellt ? 'ok' : 'fail'}">${e.erfuellt ? '✓' : '✕'}</span>
|
||||
<div class="pc-result-text"><div class="titel">${esc(e.merkmal)}</div><div class="begruendung">${esc(e.begruendung)}</div></div>
|
||||
${e.screenshot ? `<button type="button" class="pc-result-shot" onclick="planCheckerZeigeScreenshot(${i})" title="Planausschnitt vergrößern">
|
||||
<img src="${e.screenshot}" alt="Planausschnitt zu ${esc(e.merkmal)}">
|
||||
</button>` : ''}
|
||||
</div>`).join('');
|
||||
|
||||
zielEl.innerHTML = `
|
||||
<div class="pc-result-head">
|
||||
<span class="filename">${esc(dateiname)}</span>
|
||||
<span class="meta">geprüft mit ${esc(anbieterLabel)} · ${esc(zeit)}</span>
|
||||
</div>
|
||||
${zeilen || '<span class="hint">Keine Merkmale geprüft.</span>'}`;
|
||||
}
|
||||
|
||||
// planCheckerZeigeScreenshot: füllt #pcShotModal (App.html) mit dem
|
||||
// Screenshot der Ergebniszeile index (aus planCheckerLetzteErgebnisse) und
|
||||
// blendet es ein. Parameter: index (Number). Keine Rückgabe.
|
||||
function planCheckerZeigeScreenshot(index){
|
||||
const e = planCheckerLetzteErgebnisse[index];
|
||||
if(!e || !e.screenshot) return;
|
||||
const img = document.getElementById('pcShotModalImg');
|
||||
const titel = document.getElementById('pcShotModalTitel');
|
||||
const modal = document.getElementById('pcShotModal');
|
||||
if(img) img.src = e.screenshot;
|
||||
if(titel) titel.textContent = e.merkmal;
|
||||
if(modal) modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
// planCheckerSchliesseScreenshot: blendet #pcShotModal wieder aus. Leert
|
||||
// zusätzlich img.src (statt die Data-URL im DOM zu belassen) - unnötig, das
|
||||
// Bild erneut im Speicher zu halten, solange der Dialog geschlossen ist.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function planCheckerSchliesseScreenshot(){
|
||||
const modal = document.getElementById('pcShotModal');
|
||||
const img = document.getElementById('pcShotModalImg');
|
||||
if(modal) modal.style.display = 'none';
|
||||
if(img) img.src = '';
|
||||
}
|
||||
@@ -106,7 +106,7 @@ function erstellePlanungTerminplan(){
|
||||
id: uid(), projectUid: uid(),
|
||||
nr: '', name, group: '', gewerke: [], tags: [],
|
||||
status: '', order: plaene.length, lph: {}, assign: {}, plan: null,
|
||||
auftragsstatus: 'beauftragt', honorar: 0, groesse: 0,
|
||||
auftragsstatus: 'beauftragt', honorar: 0,
|
||||
beauftragteLP: [], aktuelleLP: [], besprochen: false,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
@@ -126,5 +126,8 @@ function erstellePlanungTerminplan(){
|
||||
if(typeof showPlanSection === 'function') showPlanSection('einstellungen');
|
||||
const honorarEl = document.getElementById('settingsHonorar');
|
||||
if(honorarEl) honorarEl.focus();
|
||||
} else if(typeof showPlanSection === 'function'){
|
||||
// "Leer beginnen" führt direkt in den (leeren) Terminplan.
|
||||
showPlanSection('terminplan');
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,20 @@
|
||||
|
||||
// delProjectFromList: löscht ein Projekt (per Bestätigungsdialog) in der
|
||||
// Listen-Ansicht. Parameter `id`: Projekt-ID. Keine Rückgabe; bei gesperrter
|
||||
// Sitzung abgebrochen.
|
||||
function delProjectFromList(id){
|
||||
// Sitzung abgebrochen. Nur für Projektleiter und Administratoren
|
||||
// (darfProjektlisteAendern(), js/auth.js; serverseitig in /save-week
|
||||
// durchgesetzt). Der Server legt das Projekt beim Speichern in den
|
||||
// Papierkorb, aus dem es ein Administrator 30 Tage lang zurückholen kann
|
||||
// (Admin → Papierkorb, js/admin/admin.js).
|
||||
function delProjectFromList(id, ohneRueckfrage){
|
||||
if(guardWeekLocked()) return;
|
||||
if(!confirm("Projekt aus dieser Sitzung löschen?")) return;
|
||||
if(!darfProjektlisteAendern()){
|
||||
alert('Projekte löschen dürfen nur Projektleiter und Administratoren.');
|
||||
return;
|
||||
}
|
||||
const p = currentWeek().projects.find(x => x.id === id);
|
||||
const bez = p ? ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() : '';
|
||||
if(!ohneRueckfrage && !confirm('Projekt ' + (bez ? '„' + bez + '" ' : '') + 'löschen?\n\nEin Administrator kann es 30 Tage lang wiederherstellen.')) return;
|
||||
const w = currentWeek();
|
||||
w.projects = w.projects.filter(p=>p.id!==id);
|
||||
save(); renderProjects(); renderBoard();
|
||||
@@ -159,8 +169,7 @@ function renderProjects(){
|
||||
${locked ? '' : `<input type="text" class="tag-input" placeholder="+ Tag" onkeydown="handleProjectTagKeydown(event,'${p.id}')">`}
|
||||
</div>
|
||||
<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 ? 'disabled' : ''}>Löschen</button>
|
||||
<button class="danger small" onclick="delProjectFromList('${p.id}')" ${locked || !darfProjektlisteAendern() ? 'disabled' : ''}>Löschen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
+47
-93
@@ -9,7 +9,7 @@
|
||||
// Die Schritte:
|
||||
//
|
||||
// 1. PROJEKT Nummer, Bezeichnung, GEWERKE, Gruppe, Schlagwörter
|
||||
// 2. AUFTRAG Auftragsstatus, Honorar ODER Projektgröße, beauftragte
|
||||
// 2. AUFTRAG Auftragsstatus, Honorar (freiwillig), beauftragte
|
||||
// Leistungsphasen
|
||||
// 3. STAND amtierende Leistungsphase
|
||||
// 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
|
||||
// Anlegen an drei weiteren Stellen nachgetragen werden — und wurden es meist
|
||||
// nicht. Genau daher rühren die Projekte, die in der Auslastung als "nicht
|
||||
// bewertbar" erscheinen.
|
||||
// nicht.
|
||||
//
|
||||
// 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
|
||||
// Projekte durchlässt, löst das Problem nicht, das er lösen soll.
|
||||
//
|
||||
@@ -51,14 +51,20 @@ const GEWERK_LABELS = { ELT: 'Elektrotechnik', HLS: 'Heizung · Lüftung · Sani
|
||||
let wizard = null;
|
||||
|
||||
// openNewProjWizard: öffnet den Assistenten mit leerem Zustand.
|
||||
// Keine Parameter, keine Rückgabe; gesperrte Sitzungen werden abgewiesen.
|
||||
// Keine Parameter, keine Rückgabe; gesperrte Sitzungen werden abgewiesen,
|
||||
// ebenso Konten ohne Rolle Projektleiter/Administrator (die Knöpfe sind für
|
||||
// sie ohnehin ausgeblendet; /save-week weist das Anlegen mit 403 ab).
|
||||
function openNewProjWizard(){
|
||||
if(guardWeekLocked()) return;
|
||||
if(!darfProjektlisteAendern()){
|
||||
alert('Projekte anlegen dürfen nur Projektleiter und Administratoren.');
|
||||
return;
|
||||
}
|
||||
wizard = {
|
||||
schritt: 0,
|
||||
fertig: null, // angelegtes Projekt, solange der Abschluss steht
|
||||
nr: '', name: '', gewerke: [], gruppe: 'Aktive Projekte', tags: [],
|
||||
status: AUFTRAGSSTATUS_VORGABE, grundlage: 'honorar', honorar: '', groesse: '',
|
||||
status: AUFTRAGSSTATUS_VORGABE, honorar: '',
|
||||
beauftragt: [], amtierend: '',
|
||||
team: {}, fremde: [],
|
||||
planErzeugen: true, planStart: heuteIso(), planPersonen: '',
|
||||
@@ -127,24 +133,12 @@ function wizardHonorar(){
|
||||
const n = parseFloat(String(wizard.honorar).replace(',', '.'));
|
||||
return (!Number.isFinite(n) || n <= 0) ? 0 : n;
|
||||
}
|
||||
function wizardGroesse(){
|
||||
const n = parseFloat(String(wizard.groesse).replace(',', '.'));
|
||||
if(!Number.isFinite(n) || n <= 0) return 0;
|
||||
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.
|
||||
// wizardBasis: der Geldbetrag für den Terminplan — allein das Honorar.
|
||||
// Es ist beim Anlegen freiwillig (Eriks Vorgabe); ohne Honorar entsteht
|
||||
// kein Terminplan, er lässt sich später im Projektbereich erzeugen.
|
||||
function wizardBasis(){
|
||||
if(wizard.grundlage === 'honorar'){
|
||||
const h = wizardHonorar();
|
||||
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 };
|
||||
const h = wizardHonorar();
|
||||
return h > 0 ? { betrag: h, quelle: 'Honorar' } : { betrag: 0, quelle: null };
|
||||
}
|
||||
|
||||
// wizardPlanPersonen: wie viele gleichzeitig an einer Phase arbeiten.
|
||||
@@ -162,7 +156,7 @@ function wizardPlanPersonen(){
|
||||
// aus dem Honorar gerechnet werden kann).
|
||||
function wizardPlanZeilen(){
|
||||
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 summe = phasen.reduce((s, l) => s + (Number(HONORAR_ANTEILE[l]) || 0), 0);
|
||||
if(summe <= 0) return [];
|
||||
@@ -196,9 +190,8 @@ function wizardPlanZeilen(){
|
||||
// Klartexten; leer = der Schritt ist vollständig.
|
||||
//
|
||||
// Der Assistent lässt niemanden weiter, solange hier etwas steht: Genau die
|
||||
// Lücken, die sonst entstehen (kein Honorar, keine Phase, niemand
|
||||
// zugeordnet), sind der Grund, warum Projekte später in der Auslastung nicht
|
||||
// bewertbar sind.
|
||||
// Lücken, die sonst entstehen (keine Phase, niemand zugeordnet), fielen
|
||||
// später im Wochenbericht und im Projektbereich auf.
|
||||
function wizardFehlt(key){
|
||||
const fehlt = [];
|
||||
if(key === 'projekt'){
|
||||
@@ -207,8 +200,6 @@ function wizardFehlt(key){
|
||||
if(!wizard.gewerke.length) fehlt.push('mindestens ein Gewerk');
|
||||
}
|
||||
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(key === 'stand'){
|
||||
@@ -218,7 +209,7 @@ function wizardFehlt(key){
|
||||
if(wizardTeamAnzahl() === 0) fehlt.push('mindestens eine beteiligte Person');
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -311,35 +302,19 @@ function wizardProjektHtml(){
|
||||
}
|
||||
|
||||
function wizardAuftragHtml(){
|
||||
const perHonorar = wizard.grundlage === 'honorar';
|
||||
return `
|
||||
<div class="form-row">
|
||||
<label>Auftragsstatus</label>
|
||||
<select onchange="wizardSetz('status', this.value)">
|
||||
${auftragsstatusOptionsHtml(wizard.status)}
|
||||
</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 class="form-row">
|
||||
<label>Grundlage der Rechnung</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"
|
||||
${perHonorar ? `oninput="wizardSetz('honorar', this.value)"` : 'disabled'}>
|
||||
<span class="hint" id="wizHinweisHonorar">${wizardHinweisHonorar()}</span>
|
||||
</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>
|
||||
<label>Honorar (netto, €)</label>
|
||||
<input type="number" id="wizHonorar" min="0" step="1000" value="${esc(wizard.honorar)}" placeholder="z. B. 480000"
|
||||
oninput="wizardSetz('honorar', this.value)">
|
||||
<span class="hint" id="wizHinweisHonorar">${wizardHinweisHonorar()}</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Beauftragte Leistungsphasen <span class="wiz-pflicht">*</span></label>
|
||||
@@ -352,18 +327,13 @@ function wizardAuftragHtml(){
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Die beiden Hinweistexte stehen als eigene Funktionen da, weil sie beim
|
||||
// Tippen mitlaufen müssen, ohne dass das Eingabefeld neu gebaut wird — sonst
|
||||
// springt der Cursor ans Feldende.
|
||||
// Der Hinweistext steht als eigene Funktion da, weil er beim Tippen
|
||||
// mitläuft, ohne dass das Eingabefeld neu gebaut wird — sonst springt der
|
||||
// Cursor ans Feldende.
|
||||
function wizardHinweisHonorar(){
|
||||
return wizard.grundlage === 'honorar'
|
||||
? 'Grundlage für Leistungsstand, Auslastung und Terminplan.'
|
||||
: 'Gesperrt — es wird mit der Projektgröße gerechnet.';
|
||||
}
|
||||
function wizardHinweisGroesse(){
|
||||
return wizard.grundlage === 'groesse'
|
||||
? 'Grober Umfang des Projekts. 0 = keine Angabe, 1 = großes Projekt.'
|
||||
: 'Gesperrt — es wird mit dem Honorar gerechnet.';
|
||||
return wizardHonorar() > 0
|
||||
? 'Grundlage für Leistungsstand und Terminplan.'
|
||||
: 'Freiwillig. Ohne Honorar entsteht kein Terminplan; es lässt sich später in den Projekteinstellungen nachtragen.';
|
||||
}
|
||||
|
||||
function wizardStandHtml(){
|
||||
@@ -376,8 +346,7 @@ function wizardStandHtml(){
|
||||
<button type="button" class="wiz-chip" aria-pressed="${wizard.amtierend === l}"
|
||||
onclick="wizardSetz('amtierend','${esc(l)}')">${esc(l)}</button>`).join('')}
|
||||
</div>
|
||||
<span class="hint">Woran gerade gearbeitet wird. Ohne diese Angabe erschiene das Projekt
|
||||
in der Auslastung als nicht bewertbar.</span>
|
||||
<span class="hint">Woran gerade gearbeitet wird.</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -437,14 +406,11 @@ function wizardTeamHtml(){
|
||||
}
|
||||
|
||||
function wizardTerminplanHtml(){
|
||||
// Aus der Projektgröße lässt sich kein Terminplan rechnen: Sie ist eine
|
||||
// Schätzung des Umfangs, kein vereinbarter Betrag. Termine, die daraus
|
||||
// entstünden, sähen im Gantt so verbindlich aus wie errechnete.
|
||||
if(wizard.grundlage !== 'honorar'){
|
||||
// Ohne Honorar kein Terminplan — er lässt sich später im Projektbereich
|
||||
// erzeugen („Terminplan aus Honorar").
|
||||
if(wizardHonorar() <= 0){
|
||||
return `<div class="wiz-sperre">
|
||||
<b>Kein Terminplan aus der Projektgröße.</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>
|
||||
<b>Kein Terminplan ohne Honorar.</b>
|
||||
<p>Sobald das Honorar feststeht, lässt sich der Terminplan im Projektbereich jederzeit
|
||||
nachträglich erzeugen („Terminplan aus Honorar").</p>
|
||||
</div>`;
|
||||
@@ -531,9 +497,7 @@ function wizardPruefenHtml(){
|
||||
${zeile('Gruppe', esc(wizard.gruppe))}
|
||||
${zeile('Schlagwörter', wizard.tags.length ? esc(wizard.tags.join(', ')) : 'keine', !wizard.tags.length)}
|
||||
${zeile('Auftragsstatus', esc(auftragsstatusLabel(wizard.status)))}
|
||||
${zeile('Grundlage', wizard.grundlage === 'honorar'
|
||||
? eurFormat(wizardHonorar()) + ' Honorar'
|
||||
: 'Projektgröße ' + faktorFormat(wizardGroesse()))}
|
||||
${zeile('Honorar', wizardHonorar() > 0 ? eurFormat(wizardHonorar()) : 'nicht angegeben', !(wizardHonorar() > 0))}
|
||||
${zeile('Beauftragte Phasen', esc(wizard.beauftragt.join(', ')))}
|
||||
${zeile('Amtierende Phase', esc(wizard.amtierend))}
|
||||
${zeile('Team', esc(team))}
|
||||
@@ -541,14 +505,12 @@ function wizardPruefenHtml(){
|
||||
const p = wizardPersonen().find(x => x.id === id);
|
||||
return p ? p.kuerzel + (p.gewerk ? ' (' + p.gewerk + ')' : '') : id;
|
||||
}).join(', '))) : ''}
|
||||
${zeile('Terminplan', wizard.grundlage !== 'honorar'
|
||||
? 'nicht möglich — Grundlage ist die Projektgröße'
|
||||
${zeile('Terminplan', !(wizardHonorar() > 0)
|
||||
? 'nicht möglich — kein Honorar angegeben'
|
||||
: (!wizard.planErzeugen ? 'wird nicht erzeugt'
|
||||
: (plan.length ? plan.length + ' Phasen bis ' + formatWeekDate(isoVon(plan[plan.length - 1].ende)) : 'nicht errechenbar')),
|
||||
wizard.grundlage !== 'honorar' || !wizard.planErzeugen || !plan.length)}
|
||||
</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>`;
|
||||
!(wizardHonorar() > 0) || !wizard.planErzeugen || !plan.length)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function wizardAbschlussHtml(){
|
||||
@@ -579,13 +541,8 @@ function wizardSetz(feld, wert){
|
||||
if(wert === 'anfrage' || wert === 'angebot') wizard.gruppe = 'In Anbahnung';
|
||||
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.
|
||||
if(feld === 'nr' || feld === 'name' || feld === 'honorar' || feld === 'groesse' || feld === 'planPersonen'){
|
||||
if(feld === 'nr' || feld === 'name' || feld === 'honorar' || feld === 'planPersonen'){
|
||||
wizardNurAbhaengiges();
|
||||
return;
|
||||
}
|
||||
@@ -618,8 +575,6 @@ function wizardNurAbhaengiges(){
|
||||
|
||||
const hh = document.getElementById('wizHinweisHonorar');
|
||||
if(hh) hh.textContent = wizardHinweisHonorar();
|
||||
const hg = document.getElementById('wizHinweisGroesse');
|
||||
if(hg) hg.textContent = wizardHinweisGroesse();
|
||||
|
||||
const tab = document.getElementById('wizPlanTabelle');
|
||||
if(tab) tab.innerHTML = wizardPlanTabelleHtml();
|
||||
@@ -719,14 +674,13 @@ function wizardAnlegen(){
|
||||
status: '', statusState: undefined, order: nextOrder,
|
||||
lph: {}, assign: {}, plan: null,
|
||||
auftragsstatus: auftragsstatusOf({ auftragsstatus: wizard.status }),
|
||||
honorar: wizard.grundlage === 'honorar' ? wizardHonorar() : 0,
|
||||
groesse: wizard.grundlage === 'groesse' ? wizardGroesse() : 0,
|
||||
honorar: wizardHonorar(),
|
||||
beauftragteLP: wizard.beauftragt.slice(),
|
||||
aktuelleLP: [wizard.amtierend],
|
||||
};
|
||||
|
||||
// Zuordnungen: Zustand 1 = Hauptverantwortung (dieselbe Bedeutung wie in
|
||||
// cycleAssign(), js/board/board.js).
|
||||
// planCycleAssign(), js/plan/plan-settings.js).
|
||||
ROLES.forEach(r => {
|
||||
wizard.team[r].forEach(id => { pr.assign[assignKey(id, r)] = 1; });
|
||||
});
|
||||
@@ -738,13 +692,13 @@ function wizardAnlegen(){
|
||||
todos: zeilen.map((z, i) => ({
|
||||
id: uid(),
|
||||
title: z.lph,
|
||||
lph: z.lph,
|
||||
start: isoVon(z.start),
|
||||
end: isoVon(z.ende),
|
||||
done: false,
|
||||
progress: 0,
|
||||
assignees: [],
|
||||
deps: [],
|
||||
order: i,
|
||||
dependsOn: [],
|
||||
})),
|
||||
milestones: [],
|
||||
};
|
||||
|
||||
+29
-13
@@ -3,7 +3,7 @@
|
||||
// -----------------------------
|
||||
// Diese Datei kapselt die HTTP-Kommunikation mit dem Express-Server (siehe
|
||||
// server.js im Projekt-Root) und wird nach js/date-picker.js von beiden
|
||||
// HTML-Einstiegspunkten geladen. Sie stellt baseUrl, blobToDataUrl,
|
||||
// HTML-Einstiegspunkten geladen. Sie stellt baseUrl,
|
||||
// sendWeekToServer und deleteWeekFromServer bereit. Genutzt wird sie von
|
||||
// js/store.js (syncAllToServer, Speichern aller Wochen), js/weeks.js
|
||||
// (Wochen-Löschung) und js/plan/plan-state.js (savePlan schreibt direkt die
|
||||
@@ -25,28 +25,22 @@ function baseUrl(){
|
||||
return 'http://localhost:3003';
|
||||
}
|
||||
|
||||
// blobToDataUrl: liest einen Blob als Data-URL ein (z. B. für Bilddaten)
|
||||
// Parameter: blob (Blob). Rückgabe: Promise, das die Data-URL auflöst.
|
||||
function blobToDataUrl(blob){
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
// sendWeekToServer: POSTet ein komplettes Wochen-Objekt (JSON) an
|
||||
// <baseUrl>/save-week. Parameter: week (Objekt). Rückgabe: Promise mit
|
||||
// Server-Antwort. `baseUpdatedAt` übermittelt die Revision, auf der die
|
||||
// lokalen Daten beruhen; meldet der Server 409 (conflict), wird ein Error
|
||||
// mit `err.conflict = true` und `err.updatedAt` geworfen, damit der Aufrufer
|
||||
// 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){
|
||||
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
|
||||
return fetch(baseUrl() + '/save-week',{
|
||||
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({
|
||||
week,
|
||||
baseUpdatedAt:(week && week.updatedAt) ? week.updatedAt : undefined
|
||||
@@ -69,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
|
||||
// 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
|
||||
|
||||
@@ -24,9 +24,82 @@
|
||||
// gerufen. Keine Parameter, keine Rueckgabe.
|
||||
function renderSettings(){
|
||||
renderSettingsAccount();
|
||||
renderSettingsKalender();
|
||||
renderSettingsAiKeys();
|
||||
}
|
||||
|
||||
// ---------- Kalender in Outlook ----------
|
||||
// Geheime Abo-Adresse je Konto (GET/POST /mein-kalender). Ohne Adresse ein
|
||||
// Knopf zum Einrichten; mit Adresse: Kopieren, „In Outlook öffnen“ (webcal:),
|
||||
// Anleitung, neue Adresse (alte wird ungültig) und Abschalten.
|
||||
let kalenderUrlAktuell = null;
|
||||
async function renderSettingsKalender(){
|
||||
const el = document.getElementById('settingsKalender');
|
||||
if(!el) return;
|
||||
if(kalenderUrlAktuell === null){
|
||||
try {
|
||||
const d = await fetch(baseUrl() + '/mein-kalender').then(r => r.json());
|
||||
kalenderUrlAktuell = (d && d.url) || '';
|
||||
} catch(e){ el.innerHTML = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
|
||||
}
|
||||
const url = kalenderUrlAktuell;
|
||||
if(!currentUser || !currentUser.personId){
|
||||
el.innerHTML = '<p class="settings-warn">Ohne Personen-Zuordnung enthält der Kalender nur Ihre Abwesenheiten.</p>';
|
||||
} else el.innerHTML = '';
|
||||
if(!url){
|
||||
el.innerHTML += '<button type="button" onclick="kalenderAdresse(false)">Kalender-Adresse erzeugen</button>';
|
||||
return;
|
||||
}
|
||||
const webcal = url.replace(/^https?:/, 'webcal:');
|
||||
el.innerHTML += `
|
||||
<div class="kalender-adresse">
|
||||
<input type="text" id="kalenderUrlFeld" readonly value="${esc(url)}" onclick="this.select()" aria-label="Kalender-Adresse">
|
||||
<button type="button" class="secondary" onclick="kalenderKopieren()">Kopieren</button>
|
||||
<a class="button-link" href="${esc(webcal)}">In Outlook öffnen</a>
|
||||
</div>
|
||||
<details class="kalender-anleitung">
|
||||
<summary>So abonnieren Sie den Kalender</summary>
|
||||
<ol>
|
||||
<li><b>Outlook am PC (klassisch):</b> „In Outlook öffnen“ klicken und bestätigen — oder
|
||||
Kalender → <i>Kalender hinzufügen</i> → <i>Aus dem Internet</i> → Adresse einfügen.</li>
|
||||
<li><b>Neues Outlook / Outlook im Browser:</b> Kalender → <i>Kalender hinzufügen</i> →
|
||||
<i>Aus dem Internet abonnieren</i> → Adresse einfügen. Das funktioniert nur, wenn der
|
||||
Server aus dem Internet erreichbar ist — Microsoft holt den Kalender von außen ab.</li>
|
||||
</ol>
|
||||
<p class="hint">Die Adresse ist Ihr persönlicher Schlüssel: Wer sie kennt, sieht Ihre Termine.
|
||||
Nicht weitergeben. Bei Verdacht „Neue Adresse“ wählen — die alte gilt dann nicht mehr.</p>
|
||||
</details>
|
||||
<div class="kalender-aktionen">
|
||||
<button type="button" class="secondary small" onclick="kalenderAdresse(false, true)">Neue Adresse</button>
|
||||
<button type="button" class="secondary small" onclick="kalenderAdresse(true)">Abschalten</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function kalenderAdresse(aus, nachfragen){
|
||||
if(aus && !confirm('Kalender-Abo abschalten? Outlook zeigt danach keine neuen Termine mehr.')) return;
|
||||
if(nachfragen && !confirm('Neue Adresse erzeugen? Die bisherige gilt dann nicht mehr — in Outlook muss der Kalender neu abonniert werden.')) return;
|
||||
try {
|
||||
const r = await fetch(baseUrl() + '/mein-kalender', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ aus: !!aus })
|
||||
});
|
||||
const d = await r.json();
|
||||
if(!r.ok || !d.ok) throw new Error(d.error || 'Fehler');
|
||||
kalenderUrlAktuell = d.url || '';
|
||||
renderSettingsKalender();
|
||||
showSettingsNote(aus ? 'Kalender-Abo abgeschaltet.' : 'Kalender-Adresse erzeugt.');
|
||||
} catch(e){ alert('Das hat nicht geklappt: ' + e.message); }
|
||||
}
|
||||
|
||||
function kalenderKopieren(){
|
||||
const feld = document.getElementById('kalenderUrlFeld');
|
||||
if(!feld) return;
|
||||
const fertig = () => showSettingsNote('Adresse kopiert.');
|
||||
if(navigator.clipboard && window.isSecureContext){
|
||||
navigator.clipboard.writeText(feld.value).then(fertig).catch(() => { feld.select(); document.execCommand('copy'); fertig(); });
|
||||
} else { feld.select(); document.execCommand('copy'); fertig(); }
|
||||
}
|
||||
|
||||
// ---------- Mein Konto ----------
|
||||
|
||||
// renderSettingsAccount: zeigt die Stammdaten des angemeldeten Kontos an.
|
||||
@@ -47,12 +120,17 @@ function renderSettingsAccount(){
|
||||
+ `<div class="settings-kv-row">
|
||||
<span class="settings-kv-key">E-Mail</span>
|
||||
<span class="settings-email-edit">
|
||||
<input type="email" id="settingsEmailInput" value="${esc(currentUser.email || '')}" placeholder="name@firma.de">
|
||||
<input type="email" id="settingsEmailInput" value="${esc(currentUser.email || '')}" placeholder="${esc(emailVorschlag(currentUser.displayName) || 'v.nachname@' + EMAIL_DOMAIN)}">
|
||||
<button class="secondary" onclick="submitEmailChange()">Speichern</button>
|
||||
<span id="settingsEmailError" class="settings-warn" hidden></span>
|
||||
</span>
|
||||
</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
|
||||
// über plan_todo_assignees.person_id zugeordnet werden (siehe
|
||||
// GET /my-dashboard). Deshalb steht der Hinweis hier und nicht nur dort.
|
||||
@@ -64,6 +142,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.
|
||||
// Anders als der Passwortwechsel braucht es kein aktuelles Passwort zur
|
||||
// Bestätigung — die Route ist ohnehin an die eigene, per Session
|
||||
@@ -139,6 +225,7 @@ function setAiMode(provider, modus){
|
||||
async function renderSettingsAiKeys(){
|
||||
const statusEl = { chatgpt: document.getElementById('aiOpenaiStatus'), claude: document.getElementById('aiClaudeStatus') };
|
||||
const urlEl = { chatgpt: document.getElementById('setAiOpenaiUrl'), claude: document.getElementById('setAiClaudeUrl') };
|
||||
const modelEl = { chatgpt: document.getElementById('setAiOpenaiModel'), claude: document.getElementById('setAiClaudeModel') };
|
||||
const clearBtn = { chatgpt: document.getElementById('aiOpenaiClearBtn'), claude: document.getElementById('aiClaudeClearBtn') };
|
||||
const reconnectBtn = { chatgpt: document.getElementById('aiOpenaiReconnectBtn'), claude: document.getElementById('aiClaudeReconnectBtn') };
|
||||
const testErrorEl = { chatgpt: document.getElementById('aiOpenaiTestError'), claude: document.getElementById('aiClaudeTestError') };
|
||||
@@ -156,6 +243,7 @@ async function renderSettingsAiKeys(){
|
||||
chip.innerHTML = '<span class="dot"></span>' + (info.configured ? 'Verbunden' : 'Nicht eingerichtet');
|
||||
}
|
||||
if(urlEl[provider] && document.activeElement !== urlEl[provider]) urlEl[provider].value = info.apiUrl || '';
|
||||
if(modelEl[provider] && document.activeElement !== modelEl[provider]) modelEl[provider].value = info.model || '';
|
||||
if(clearBtn[provider]) clearBtn[provider].disabled = !info.configured;
|
||||
if(reconnectBtn[provider]) reconnectBtn[provider].disabled = !info.configured;
|
||||
// Ein neuer Seitenaufruf ist ein neuer Anlauf — ein Fehler aus einem
|
||||
@@ -219,6 +307,8 @@ function putMyAiKey(provider, patch){
|
||||
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.');
|
||||
// "Termin aus Mail" ist nur mit eingerichteter KI aktiv — Stand neu holen.
|
||||
if(typeof mailKiStatusLaden === 'function') mailKiStatusLaden();
|
||||
return json;
|
||||
});
|
||||
}
|
||||
@@ -270,6 +360,32 @@ async function saveMyAiUrl(provider){
|
||||
}
|
||||
}
|
||||
|
||||
// saveMyAiModell: übernimmt den eingegebenen Modellnamen — leer löscht eine
|
||||
// zuvor gesetzte Abweichung und fällt auf die im Code hinterlegte Vorgabe
|
||||
// zurück (gpt-4o-mini/gpt-4o bzw. claude-haiku/claude-sonnet-5, je nach
|
||||
// Aufrufer). Nötig für einen eigenständigen Anbieter mit abweichendem
|
||||
// Modell-Katalog (z. B. Mammouth.ai), bei dem die festen OpenAI-/
|
||||
// Anthropic-Modellnamen nicht existieren. Eigenständig von saveMyAiUrl() aus
|
||||
// demselben Grund, aus dem URL und Schlüssel schon getrennte Speichern-
|
||||
// Knöpfe haben. Parameter: provider. Keine Rückgabe.
|
||||
async function saveMyAiModell(provider){
|
||||
const errEl = document.getElementById('settingsAiError');
|
||||
const show = msg => { if(errEl){ errEl.textContent = msg; errEl.hidden = false; } };
|
||||
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
|
||||
|
||||
const modelEl = document.getElementById(provider === 'chatgpt' ? 'setAiOpenaiModel' : 'setAiClaudeModel');
|
||||
const modelValue = modelEl ? modelEl.value.trim() : '';
|
||||
|
||||
try {
|
||||
await putMyAiKey(provider, { model: modelValue });
|
||||
renderSettingsAiKeys();
|
||||
showSettingsNote(modelValue ? 'Modellname gespeichert.' : 'Modellname entfernt — Vorgabe wird wieder verwendet.');
|
||||
} catch(err){
|
||||
console.error('Modellname speichern fehlgeschlagen:', err);
|
||||
show(err.message || String(err));
|
||||
}
|
||||
}
|
||||
|
||||
// clearMyAiKey: löscht nur den Schlüssel, lässt eine hinterlegte API-URL
|
||||
// unangetastet (patch enthält absichtlich kein apiUrl-Feld). Parameter:
|
||||
// provider. Keine Rückgabe.
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Show Mode
|
||||
// -----------------------------
|
||||
// Präsentationsmodus für die Hauptseite (nur von Test.html geladen, nach
|
||||
// js/gantt.js, vor js/tabs-auth.js). Er durchläuft automatisch die in
|
||||
// SHOW_MODE_SEQUENCE hinterlegten Ansichten (Board und Auslastung) und zeigt
|
||||
// sie in einem Vollbild-Overlay. Genutzt werden showTab() aus
|
||||
// js/tabs-auth.js, setHeaderCollapsed() aus js/ui.js sowie renderBoard()/
|
||||
// renderLoad() aus js/board/board.js/js/load/load.js. Der gesamte Lauf-
|
||||
// Zustand liegt in den Modulvariablen am Dateikopf.
|
||||
|
||||
// showModeActive: true, solange der Show Mode läuft
|
||||
let showModeActive = false;
|
||||
// showModeTimer: Timer-Handle für den Wechsel zur nächsten Folie
|
||||
let showModeTimer = null;
|
||||
// showModeResumeTimer: Timer-Handle für die automatische Fortsetzung nach einer Pause
|
||||
let showModeResumeTimer = null;
|
||||
// showModePaused: true, wenn der Durchlauf gerade pausiert ist
|
||||
let showModePaused = false;
|
||||
// showModeOriginal: Tab vor dem Start, wird beim Beenden wiederhergestellt
|
||||
let showModeOriginal = { tab: DEFAULT_TAB };
|
||||
const SHOW_MODE_SLIDE_DURATION = 10000; // 10 Sekunden pro Slide
|
||||
const SHOW_MODE_PAUSE_DURATION = 60000; // 1 Minute Pause
|
||||
// SHOW_MODE_SEQUENCE: Reihenfolge der Slides (Ansicht), die zyklisch
|
||||
// durchlaufen wird
|
||||
const SHOW_MODE_SEQUENCE = [
|
||||
{ view: 'board' },
|
||||
{ view: 'load' }
|
||||
];
|
||||
// showModeIndex: Position innerhalb der SHOW_MODE_SEQUENCE
|
||||
let showModeIndex = 0;
|
||||
|
||||
// startShowMode: startet den Präsentationsmodus (falls nicht schon aktiv):
|
||||
// merkt sich Abteilung/Tab, zeigt das Overlay, kollabiert das Menü und beginnt
|
||||
// den Slide-Durchlauf. Kein Rückgabewert.
|
||||
function startShowMode(){
|
||||
if(showModeActive) return;
|
||||
showModeActive = true;
|
||||
showModePaused = false;
|
||||
showModeOriginal = { tab: currentTab };
|
||||
showModeIndex = 0;
|
||||
|
||||
const overlay = document.getElementById('showModeOverlay');
|
||||
if(overlay) overlay.style.display = 'flex';
|
||||
|
||||
// Menü schließen, falls offen
|
||||
setHeaderCollapsed(true, false);
|
||||
|
||||
showModeNextSlide();
|
||||
}
|
||||
|
||||
// stopShowMode: beendet den Präsentationsmodus: stoppt alle Timer, verbirgt
|
||||
// das Overlay und stellt den Tab des ursprünglichen Zustands wieder her
|
||||
// (renderAll danach). Kein Rückgabewert.
|
||||
function stopShowMode(){
|
||||
showModeActive = false;
|
||||
showModePaused = false;
|
||||
clearTimeout(showModeTimer);
|
||||
clearTimeout(showModeResumeTimer);
|
||||
|
||||
const overlay = document.getElementById('showModeOverlay');
|
||||
if(overlay) overlay.style.display = 'none';
|
||||
|
||||
// Inhalt leeren: Die Kopie enthält dieselben Element-IDs wie das echte Board
|
||||
// bzw. der Auslastungs-Tab (#boardTable, #loadGrid, #loadScope) und steht im
|
||||
// Dokument VOR ihnen. Blieb sie stehen, trafen renderBoard()/renderLoad()
|
||||
// ab dann die tote Kopie im ausgeblendeten Overlay — die sichtbare Ansicht
|
||||
// fror auf dem Stand vor der Präsentation ein, bis die Seite neu geladen wurde.
|
||||
const content = document.getElementById('showModeContent');
|
||||
if(content) content.innerHTML = '';
|
||||
|
||||
// Ursprünglichen Zustand wiederherstellen.
|
||||
currentTab = showModeOriginal.tab;
|
||||
showTab(currentTab);
|
||||
renderAll();
|
||||
}
|
||||
|
||||
// pauseShowMode: pausiert den Durchlauf (Anzeige "Pausiert") und startet die
|
||||
// automatische Fortsetzung nach SHOW_MODE_PAUSE_DURATION; bei erneutem Aufruf
|
||||
// im pausierten Zustand wird sofort fortgesetzt. Kein Rückgabewert.
|
||||
function pauseShowMode(){
|
||||
if(!showModeActive) return;
|
||||
if(showModePaused){
|
||||
// bereits pausiert -> sofort fortsetzen
|
||||
resumeShowMode();
|
||||
return;
|
||||
}
|
||||
showModePaused = true;
|
||||
clearTimeout(showModeTimer);
|
||||
clearTimeout(showModeResumeTimer);
|
||||
const pausedEl = document.getElementById('showModePaused');
|
||||
if(pausedEl) pausedEl.style.display = 'block';
|
||||
showModeResumeTimer = setTimeout(() => {
|
||||
if(showModeActive && showModePaused) resumeShowMode();
|
||||
}, SHOW_MODE_PAUSE_DURATION);
|
||||
}
|
||||
|
||||
// resumeShowMode: setzt einen pausierten Durchlauf fort (Timer aufräumen,
|
||||
// Hinweis ausblenden) und zeigt die nächste Folie. Kein Rückgabewert.
|
||||
function resumeShowMode(){
|
||||
if(!showModeActive) return;
|
||||
showModePaused = false;
|
||||
clearTimeout(showModeResumeTimer);
|
||||
const pausedEl = document.getElementById('showModePaused');
|
||||
if(pausedEl) pausedEl.style.display = 'none';
|
||||
showModeNextSlide();
|
||||
}
|
||||
|
||||
// showModeNextSlide: stellt die nächste Folie der SHOW_MODE_SEQUENCE dar:
|
||||
// setzt Titel und Ladehinweis und kopiert danach die aktuelle Ansicht ins
|
||||
// Overlay. Kein Rückgabewert.
|
||||
function showModeNextSlide(){
|
||||
if(!showModeActive || showModePaused) return;
|
||||
|
||||
const slide = SHOW_MODE_SEQUENCE[showModeIndex % SHOW_MODE_SEQUENCE.length];
|
||||
showModeIndex++;
|
||||
|
||||
const titleEl = document.getElementById('showModeTitle');
|
||||
if(titleEl){
|
||||
titleEl.textContent = slide.view === 'load' ? 'Auslastung' : 'Projektboard';
|
||||
}
|
||||
|
||||
// Ladehinweis anzeigen, damit der alte Inhalt nicht kurz sichtbar flackert
|
||||
const content = document.getElementById('showModeContent');
|
||||
if(content){
|
||||
content.innerHTML = '<div class="show-mode-loading">Lädt…</div>';
|
||||
}
|
||||
|
||||
showTab(slide.view);
|
||||
renderAll();
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
copyCurrentViewToShowMode();
|
||||
scheduleNextShowModeSlide();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// scheduleNextShowModeSlide: plant den Wechsel zur nächsten Folie nach
|
||||
// SHOW_MODE_SLIDE_DURATION (außer im pausierten/inaktiven Zustand).
|
||||
function scheduleNextShowModeSlide(){
|
||||
if(!showModeActive || showModePaused) return;
|
||||
clearTimeout(showModeTimer);
|
||||
showModeTimer = setTimeout(() => {
|
||||
showModeNextSlide();
|
||||
}, SHOW_MODE_SLIDE_DURATION);
|
||||
}
|
||||
|
||||
// copyCurrentViewToShowMode: kopiert das gerenderte HTML des aktuellen Tabs
|
||||
// (Board oder Auslastung) in das Show-Mode-Overlay; in der Auslastungs-Ansicht
|
||||
// werden dabei Titel und Toolbar ausgeblendet. Kein Rückgabewert.
|
||||
function copyCurrentViewToShowMode(){
|
||||
const content = document.getElementById('showModeContent');
|
||||
const sourceId = currentTab === 'load' ? 'load' : 'board';
|
||||
const source = document.getElementById(sourceId);
|
||||
if(content && source){
|
||||
content.innerHTML = source.innerHTML;
|
||||
// Im Show Mode sollen in der Auslastung Überschrift und Steuerelemente ausgeblendet werden
|
||||
if(currentTab === 'load'){
|
||||
content.querySelectorAll('.section-title, .toolbar').forEach(el => el.remove());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ESC-Taste beendet den Show Mode (globaler Keydown-Listener).
|
||||
document.addEventListener('keydown', function(e){
|
||||
if(showModeActive && e.key === 'Escape'){
|
||||
stopShowMode();
|
||||
}
|
||||
});
|
||||
+618
-31
@@ -6,7 +6,7 @@
|
||||
// den kompletten Datenbestand (alle Wochen + globale Personenliste). Die Daten
|
||||
// liegen serverseitig in einer SQLite-Datenbank und werden als strukturierte
|
||||
// JSON-Objekte über die API ausgetauscht (keine CSV-Strings). Sie wird von
|
||||
// `Test.html` nach `js/date-picker.js` und `js/server.js` geladen und stellt
|
||||
// `App.html` nach `js/date-picker.js` und `js/server.js` geladen und stellt
|
||||
// die globalen Funktionen `save()`, `syncAllToServer()`, `load()`, `seed()`
|
||||
// und `demoProj()` bereit. Aufrufer sind u.a. `js/weeks.js`, `js/board/board.js`,
|
||||
// `js/people/people.js`, `js/projects/projects.js` und `js/load/load.js`; das
|
||||
@@ -112,6 +112,43 @@ function syncAllToServer(){
|
||||
// weekChangeCounter): Nur wenn er bei der Antwort unverändert ist, sind
|
||||
// wirklich alle Änderungen dieser Woche beim Server angekommen.
|
||||
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)
|
||||
.then(res => {
|
||||
if(res && res.ok){
|
||||
@@ -119,28 +156,21 @@ function syncAllToServer(){
|
||||
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
|
||||
// nicht fälschlich als Konflikt gemeldet werden.
|
||||
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);
|
||||
} else {
|
||||
problem = 'Sitzung "' + w.label + '" konnte nicht gespeichert werden.';
|
||||
console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
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.
|
||||
}
|
||||
}));
|
||||
.catch(err => { problem = speicherFehler(label, w, err) || problem; }));
|
||||
});
|
||||
|
||||
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
|
||||
// Aktionen, die Wochen AUFßER der aktuellen betreffen (z. B. Auto-Lock).
|
||||
function markWeekDirty(label){
|
||||
@@ -159,6 +241,406 @@ function markWeekDirty(label){
|
||||
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){
|
||||
// Eine ältere, hier nicht geladene Sitzung (z. B. entsperrt): nur ihren
|
||||
// Eintrag in der Sitzungsliste nachziehen, nicht alles neu laden.
|
||||
const kurz = (db.wochenListe || []).find(x => x.label === label);
|
||||
if(kurz) return wocheVomServer(label).then(f => { if(f) Object.assign(kurz, sitzungKurz(f)); ansichtNachBearbeitung(); }).catch(() => {});
|
||||
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
|
||||
// geänderte Woche meldet (409). Nur diese eine Woche wird vom Server neu
|
||||
// geladen; andere lokal geänderte Wochen (dirtyWeeks) und die Personenliste
|
||||
@@ -172,16 +654,109 @@ function handleWeekConflict(week, err){
|
||||
else load();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sitzungen nach Bedarf (Eriks Wunsch: beim Start nur die aktuelle und drei
|
||||
// davor; im Verlauf „Weitere laden“ holt je fünf ältere nach)
|
||||
// ---------------------------------------------------------------------------
|
||||
// db.weeks hält nur die GELADENEN Sitzungen, db.wochenListe ALLE schlank
|
||||
// ({id, label, date, locked, updatedAt}, älteste zuerst). Wer eine nicht
|
||||
// geladene Sitzung braucht (Admin → Sitzungen, Deep-Link auf ein Projekt
|
||||
// einer älteren Sitzung), holt sie über sitzungSicherLaden().
|
||||
const SITZUNGEN_START = 4;
|
||||
const SITZUNGEN_NACHLADEN = 5;
|
||||
|
||||
function wocheAbsichern(w){
|
||||
// Felder absichern, damit alte/kaputte Dokumente nicht crashen
|
||||
if(!Array.isArray(w.people)) w.people = [];
|
||||
if(!Array.isArray(w.projects)) w.projects = [];
|
||||
if(!w.attendance) w.attendance = {};
|
||||
return w;
|
||||
}
|
||||
function sitzungKurz(w){
|
||||
return { id: w.id, label: w.label, date: w.date || '', locked: !!w.locked, updatedAt: w.updatedAt || '' };
|
||||
}
|
||||
|
||||
// sitzungKurzAbgleichen: den schlanken Eintrag einer Sitzung in
|
||||
// db.wochenListe auf den Stand des vollen Objekts bringen (Sperre, Revision).
|
||||
function sitzungKurzAbgleichen(w){
|
||||
const k = (db.wochenListe || []).find(x => x.label === w.label);
|
||||
if(k) Object.assign(k, sitzungKurz(w));
|
||||
}
|
||||
|
||||
// wochenEinfuegen: geladene Sitzungen in db.weeks aufnehmen (ohne Doppel),
|
||||
// sortieren und als Basis fürs Zusammenführen merken.
|
||||
function wochenEinfuegen(liste){
|
||||
(liste || []).forEach(w => {
|
||||
if(!w || typeof w.label !== 'string' || db.weeks.some(x => x.label === w.label)) return;
|
||||
db.weeks.push(wocheAbsichern(w));
|
||||
basisMerken(w);
|
||||
});
|
||||
db.weeks.sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||||
}
|
||||
|
||||
// aeltereSitzungenAnzahl: wie viele Sitzungen älter als die älteste geladene
|
||||
// noch nicht geladen sind.
|
||||
function aeltereSitzungenAnzahl(){
|
||||
const liste = db.wochenListe || [];
|
||||
if(!db.weeks.length) return 0;
|
||||
const aelteste = db.weeks[0].label;
|
||||
const idx = liste.findIndex(w => w.label === aelteste);
|
||||
return idx < 0 ? 0 : idx;
|
||||
}
|
||||
|
||||
// weitereSitzungenLaden: holt die nächsten älteren Sitzungen und zeichnet
|
||||
// danach neu — die ganze Ansicht, oder nur, was nachher (Name einer globalen
|
||||
// Funktion) zeichnet. Der Wochenbericht nutzt das, damit ein angefangener
|
||||
// Kommentar nicht durch ein Neuzeichnen verloren geht. Rückgabe: Promise.
|
||||
let weitereLaufen = null;
|
||||
function weitereSitzungenLaden(anzahl, nachher){
|
||||
if(weitereLaufen) return weitereLaufen;
|
||||
if(!db.weeks.length || aeltereSitzungenAnzahl() === 0) return Promise.resolve();
|
||||
const vor = db.weeks[0].label;
|
||||
weitereLaufen = fetch(baseUrl() + '/weeks-aelter?vor=' + encodeURIComponent(vor) + '&anzahl=' + (anzahl || SITZUNGEN_NACHLADEN))
|
||||
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(d => {
|
||||
wochenEinfuegen(d.weeks);
|
||||
if(nachher && typeof window[nachher] === 'function'){ window[nachher](); return; }
|
||||
ansichtAuffrischen();
|
||||
// Kommentar-Verlauf im Projekt-Dashboard hängt an renderPlanInfo().
|
||||
if(typeof currentTab !== 'undefined' && currentTab === 'plan' && typeof renderPlanInfo === 'function') renderPlanInfo();
|
||||
})
|
||||
.catch(err => { console.error('Ältere Sitzungen laden fehlgeschlagen:', err); alert('Ältere Sitzungen konnten nicht geladen werden.'); })
|
||||
.finally(() => { weitereLaufen = null; });
|
||||
return weitereLaufen;
|
||||
}
|
||||
|
||||
// weitereLadenKnopf: Knopf „Weitere laden“ für einen Verlauf — nur, solange
|
||||
// es ältere, nicht geladene Sitzungen gibt. Parameter: nachher (optional,
|
||||
// siehe weitereSitzungenLaden()). Rückgabe: HTML-String ('' sonst).
|
||||
function weitereLadenKnopf(nachher){
|
||||
const n = aeltereSitzungenAnzahl();
|
||||
if(!n) return '';
|
||||
const k = Math.min(n, SITZUNGEN_NACHLADEN);
|
||||
return `<button type="button" class="secondary small verlauf-weitere" onmousedown="event.preventDefault()" onclick="event.stopPropagation(); weitereSitzungenLaden(0${nachher ? ", '" + nachher + "'" : ''})"
|
||||
title="Noch ${n} ältere Sitzung${n === 1 ? '' : 'en'} am Server">Weitere laden (${k} ${k === 1 ? 'Sitzung' : 'Sitzungen'})</button>`;
|
||||
}
|
||||
|
||||
// sitzungSicherLaden: liefert eine Sitzung, lädt sie bei Bedarf einzeln
|
||||
// nach. Rückgabe: Promise<Woche|null>.
|
||||
function sitzungSicherLaden(label){
|
||||
const da = db.weeks.find(w => w.label === label);
|
||||
if(da) return Promise.resolve(da);
|
||||
return wocheVomServer(label).then(w => {
|
||||
if(!w) return null;
|
||||
wochenEinfuegen([w]);
|
||||
return db.weeks.find(x => x.label === label) || null;
|
||||
});
|
||||
}
|
||||
|
||||
// reloadWeekFromServer: lädt alle Sitzungen neu und ersetzt NUR die
|
||||
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
|
||||
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
|
||||
// syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten.
|
||||
function reloadWeekFromServer(label){
|
||||
fetch(baseUrl() + '/load-all')
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
|
||||
.then(data => {
|
||||
const fresh = (Array.isArray(data.weeks) ? data.weeks : [])
|
||||
.find(w => w && w.label === label);
|
||||
wocheVomServer(label)
|
||||
.then(fresh => {
|
||||
const idx = db.weeks.findIndex(w => w.label === label);
|
||||
if(idx >= 0){
|
||||
if(fresh){
|
||||
@@ -190,6 +765,7 @@ function reloadWeekFromServer(label){
|
||||
if(!Array.isArray(fresh.projects)) fresh.projects = [];
|
||||
if(!fresh.attendance) fresh.attendance = {};
|
||||
db.weeks[idx] = fresh;
|
||||
basisMerken(fresh);
|
||||
} else {
|
||||
db.weeks.splice(idx, 1);
|
||||
}
|
||||
@@ -256,7 +832,13 @@ function loadAppSettings(){
|
||||
return Promise.all([settings, holidays, honorar]);
|
||||
}
|
||||
|
||||
function load(){
|
||||
// load: lädt die Sitzungsdaten vom Server neu. Parameter: silent (optional) —
|
||||
// true für eine stille Hintergrund-Aktualisierung (js/wand/wand.js, alle 60s):
|
||||
// ein Fehlschlag wird nur geloggt statt per alert() gemeldet, und der
|
||||
// bisherige Datenstand bleibt stehen statt geleert zu werden. Ohne diesen
|
||||
// Zweig stapelten sich auf einem unbeaufsichtigten Touch-Display bei jedem
|
||||
// Fehlschlag blockierende alert()-Dialoge übereinander.
|
||||
function load(silent){
|
||||
// Nach einem (Neu-)Load sind die lokalen Daten serverseitig aktuell:
|
||||
// laufenden Debounce abbrechen und Dirty-Zustand verwerfen.
|
||||
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
|
||||
@@ -265,7 +847,9 @@ function load(){
|
||||
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
|
||||
return;
|
||||
}
|
||||
const url = baseUrl() + '/load-all';
|
||||
// Nur die jüngsten SITZUNGEN_START Sitzungen (aktuelle + drei davor);
|
||||
// ältere holt weitereSitzungenLaden() bei Bedarf (Eriks Wunsch).
|
||||
const url = baseUrl() + '/load-all?wochen=' + SITZUNGEN_START;
|
||||
console.log('Loading from', url);
|
||||
// Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen:
|
||||
// renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala,
|
||||
@@ -295,13 +879,14 @@ function load(){
|
||||
|
||||
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
|
||||
.filter(w => w && typeof w.label === 'string')
|
||||
.map(w => {
|
||||
// Felder absichern, damit alte/kaputte Dokumente nicht crashen
|
||||
if(!Array.isArray(w.people)) w.people = [];
|
||||
if(!Array.isArray(w.projects)) w.projects = [];
|
||||
if(!w.attendance) w.attendance = {};
|
||||
return w;
|
||||
});
|
||||
.map(wocheAbsichern);
|
||||
// Alle Sitzungen schlank (Name, Datum, Sperre) — auch die nicht geladenen.
|
||||
db.wochenListe = Array.isArray(data.wochenListe) ? data.wochenListe : db.weeks.map(sitzungKurz);
|
||||
|
||||
// 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){
|
||||
console.warn('Server lieferte 0 Sitzungen. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob die SQLite-Datenbank sessions/projektuebersicht.db existiert.');
|
||||
@@ -321,9 +906,11 @@ function load(){
|
||||
// wenn der Tab per Deep-Link-URL (z. B. "/Projekte") gewählt wurde.
|
||||
renderAll();
|
||||
showTab(currentTab);
|
||||
liveVerbinden();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Laden vom Server fehlgeschlagen:', err);
|
||||
if(silent) return;
|
||||
const serverUrl = baseUrl();
|
||||
const msg = 'Server unter ' + serverUrl + ' nicht erreichbar.\n\n' +
|
||||
'Mögliche Ursachen:\n' +
|
||||
|
||||
+30
-17
@@ -1,20 +1,21 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Tabs (Navigation)
|
||||
// -----------------------------
|
||||
// Modul für die Tab-Navigation der Hauptseite (nur von Test.html geladen, nach
|
||||
// js/showmode.js, vor js/pdf.js). showTab() wechselt zwischen Board,
|
||||
// Wochenbericht, Auslastung, Projekte, Projektbeteiligte, Meine Aufgaben,
|
||||
// Einstellungen, Admin und dem Projektbereich eines einzelnen Projekts
|
||||
// ('plan', siehe TAB_PANELS).
|
||||
// Modul für die Tab-Navigation der Hauptseite (nur von App.html geladen, nach
|
||||
// js/wand/wand.js, vor js/pdf.js). showTab() wechselt zwischen Board,
|
||||
// Wochenbericht, Auslastung, Projekte, Projektbeteiligte, Planung, Plan
|
||||
// Checker, Meine Aufgaben, Wandtafel, Einstellungen, Admin und dem
|
||||
// Projektbereich eines einzelnen Projekts ('plan', siehe TAB_PANELS).
|
||||
// Einstellungen und Admin sind keine eigenen Tabs mehr, sondern aufklappbare
|
||||
// Bereiche direkt in der Seitenleiste (siehe toggleSidebarAccordion() in
|
||||
// js/ui.js). Gerendert werden beim Tab-Wechsel renderBoard()/
|
||||
// renderWochenbericht()/renderLoad()/renderProjects()/renderPeople()/
|
||||
// renderDashboard() aus js/board/board.js, js/wochenbericht/wochenbericht.js,
|
||||
// js/load/load.js, js/projects/projects.js, js/people/people.js,
|
||||
// js/dashboard/dashboard.js. Der frühere clientseitige Passwortschutz für die
|
||||
// Tabs 'projects'/'people' wurde entfernt — der Zugriffsschutz läuft jetzt
|
||||
// serverseitig über die Session (siehe js/auth.js, server.js).
|
||||
// renderDashboard()/renderWandboard() aus js/board/board.js,
|
||||
// js/wochenbericht/wochenbericht.js, js/load/load.js, js/projects/projects.js,
|
||||
// js/people/people.js, js/dashboard/dashboard.js, js/wand/wand.js. Der
|
||||
// frühere clientseitige Passwortschutz für die Tabs 'projects'/'people' wurde
|
||||
// entfernt — der Zugriffsschutz läuft jetzt serverseitig über die Session
|
||||
// (siehe js/auth.js, server.js).
|
||||
//
|
||||
// TAB_PANELS: alle Bereiche der Hauptseite. 'plan' ist der Projektbereich
|
||||
// EINES Projekts (#plan) — früher die eigenständige Seite project-plan.html,
|
||||
@@ -22,15 +23,17 @@
|
||||
// Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der
|
||||
// Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom
|
||||
// Dashboard ins Projektboard.
|
||||
const TAB_PANELS = ['board','wochenbericht','load','people','planung','dashboard','settings','admin','plan'];
|
||||
const TAB_PANELS = ['board','wochenbericht','load','abwesenheit','people','planung','planchecker','dashboard','wand','settings','admin','plan'];
|
||||
|
||||
function showTab(t){
|
||||
// Der Bereich „Admin" steht nur Administratoren offen. Ohne diese Weiche
|
||||
// zeigte ein weitergereichter Deep-Link „/Admin" einem Mitglied eine leere
|
||||
// Seite — die Karten darin rendert renderAdminArea() für Nicht-Admins gar
|
||||
// nicht erst. (Ein UI-Gate, mehr nicht: Die Routen dahinter sind
|
||||
// serverseitig mit requireRole('admin') abgesichert.)
|
||||
if(t === 'admin' && !(currentUser && currentUser.role === 'admin')) t = DEFAULT_TAB;
|
||||
// Weichen nach Rollen & Rechte (UI-Gate, die Routen dahinter prüft der
|
||||
// Server mit requireRecht()): „Admin“ nur mit einem Admin-Recht, die
|
||||
// Wandtafel nur mit „Wandtafel anzeigen“, und eine Rolle ohne „Anwendung
|
||||
// nutzen“ (früher das Kiosk-Konto) landet immer auf der Wandtafel.
|
||||
if(currentUser && !hatRecht('app') && hatRecht('wand')) t = 'wand';
|
||||
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;
|
||||
currentTab = t;
|
||||
saveUiState();
|
||||
@@ -46,13 +49,23 @@ function showTab(t){
|
||||
// Kopfzeile des Projektbereichs.
|
||||
const containerEl = document.querySelector('.app-main > .container');
|
||||
if(containerEl) containerEl.style.display = (t === 'plan') ? 'none' : '';
|
||||
// Wandtafel: Seitenleiste und Kopfzeile ausblenden (body.wandtafel-aktiv,
|
||||
// styles.css) — das Kiosk-Konto hat echte Schreibrechte, eine zufällige
|
||||
// Berührung der Navigation soll nicht in einen bearbeitbaren Bereich
|
||||
// führen. startWandboard()/stopWandboard() (js/wand/wand.js) starten bzw.
|
||||
// beenden dabei die Hintergrund-Aktualisierung und die Kachel-Rotation.
|
||||
document.body.classList.toggle('wandtafel-aktiv', t === 'wand');
|
||||
if(t === 'wand') startWandboard(); else stopWandboard();
|
||||
document.querySelectorAll('.tab').forEach(el=>el.classList.toggle('active',el.dataset.tab===t));
|
||||
if(t==='board' && currentWeek()) renderBoard();
|
||||
if(t==='wochenbericht') renderWochenbericht();
|
||||
if(t==='load') renderLoad();
|
||||
if(t==='abwesenheit'){ abwBeimOeffnen(); renderAbwesenheiten(); }
|
||||
if(t==='people') renderPeople();
|
||||
if(t==='planung') renderPlanung();
|
||||
if(t==='planchecker') renderPlanChecker();
|
||||
if(t==='dashboard') renderDashboard();
|
||||
if(t==='wand') renderWandboard();
|
||||
if(t==='settings') renderSettings();
|
||||
if(t==='admin') renderAdminArea();
|
||||
syncAdminNavState(t);
|
||||
|
||||
+3
-3
@@ -3,12 +3,12 @@
|
||||
// -----------------------------
|
||||
// Diese Datei verwaltet den reinen UI-Zustand: den kollabierbaren Header sowie
|
||||
// das Light/Dark-Theme. Sie wird nach js/globals.js von beiden HTML-
|
||||
// Einstiegspunkt Test.html geladen. Die
|
||||
// Einstiegspunkt App.html geladen. Die
|
||||
// bereitgestellten Funktionen werden von js/init.js beim Start sowie von
|
||||
// js/tabs-auth.js (Tab-Wechsel, showTab) aufgerufen; im Theme-Umschalter der
|
||||
// Seite werden toggleTheme/applyTheme direkt eingebunden.
|
||||
// Seit der Projektbereich eines Projekts kein eigenes Dokument mehr ist,
|
||||
// sondern der Bereich #plan von Test.html, kennen applyUrlRoute() und
|
||||
// sondern der Bereich #plan von App.html, kennen applyUrlRoute() und
|
||||
// updateRouteUrl() auch dessen Adresse "/Projektplan/<Projektname>".
|
||||
|
||||
// saveUiState: speichert den aktiven Tab in den LocalStorage
|
||||
@@ -258,7 +258,7 @@ function toggleSidebarCollapse(){
|
||||
// toggleSidebarAccordion: klappt einen der beiden Aktions-Bereiche in der
|
||||
// Seitenleiste (Einstellungen/Admin) auf oder zu. Erwartet die zugehörigen
|
||||
// Elemente unter den IDs "<key>AccordionToggle" (Kopf-Button) und
|
||||
// "<key>Subnav" (Liste der Aktionen), siehe Test.html. Kein Persistieren des
|
||||
// "<key>Subnav" (Liste der Aktionen), siehe App.html. Kein Persistieren des
|
||||
// Zustands — jeder Bereich startet beim Neuladen zugeklappt.
|
||||
// Parameter: key ('settings'|'admin'). Rückgabe: keine.
|
||||
function toggleSidebarAccordion(key){
|
||||
|
||||
+692
-42
@@ -87,6 +87,54 @@ function weekSortKey(label){
|
||||
// Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und
|
||||
// Zuordnungen auch nach einem Neuladen stabil bleiben.
|
||||
// Parameter: name (String). Rückgabe: String wie 'p_ab'.
|
||||
// beteiligteJePerson: fasst die Zuordnungen eines Projekts ({person, role,
|
||||
// state}; state 1 = Haupt, 2 = Unterstützung) zu EINEM Eintrag je Person
|
||||
// zusammen — wer Planung und Projektleitung macht, erscheint im
|
||||
// Avatar-Stapel nur einmal (Eriks Wunsch). Die Farbe folgt der wichtigsten
|
||||
// Rolle (Haupt vor Unterstützung, dann Reihenfolge ROLES), der Tooltip nennt
|
||||
// alle. Rückgabe: [{person, role, state, title}] in der Reihenfolge des
|
||||
// ersten Auftretens.
|
||||
function beteiligteJePerson(eintraege){
|
||||
const jePerson = new Map();
|
||||
(eintraege || []).forEach(e => {
|
||||
if(!e || !e.person || !(e.state > 0)) return;
|
||||
const key = e.person.id;
|
||||
if(!jePerson.has(key)) jePerson.set(key, { person: e.person, rollen: [] });
|
||||
jePerson.get(key).rollen.push({ role: e.role, state: Number(e.state) });
|
||||
});
|
||||
const rang = r => (r.state === 1 ? 0 : 10) + Math.max(0, ROLES.indexOf(r.role));
|
||||
return Array.from(jePerson.values()).map(({ person, rollen }) => {
|
||||
const haupt = rollen.slice().sort((a, b) => rang(a) - rang(b))[0];
|
||||
const liste = rollen.slice().sort((a, b) => ROLES.indexOf(a.role) - ROLES.indexOf(b.role))
|
||||
.map(r => r.role + (r.state === 2 ? ' (Unterstützung)' : '')).join(', ');
|
||||
const sortiert = rollen.slice().sort((a, b) => ROLES.indexOf(a.role) - ROLES.indexOf(b.role));
|
||||
return { person, role: haupt.role, state: haupt.state, rollen: sortiert, title: (person.fullName || person.name) + ' · ' + liste };
|
||||
});
|
||||
}
|
||||
|
||||
// ROLLEN_FARBE: dieselben Farben wie .board-avatar.role-* in styles.css.
|
||||
const ROLLEN_FARBE = { 'Planung': 'var(--green)', 'Projektleitung': 'var(--red)', 'Bauüberwachung': 'var(--accent)' };
|
||||
|
||||
// beteiligtenAvatarHtml: Kürzel-Kreis eines Eintrags aus beteiligteJePerson().
|
||||
// Bei mehreren Rollen (Eriks Wunsch) ist der Kreis in Segmente geteilt — je
|
||||
// Rolle eines in ihrer Farbe. Parameter: b, klasse
|
||||
// (Rollen-Klasse, z. B. roleClass(b.role)). Rückgabe: HTML-String.
|
||||
function beteiligtenAvatarHtml(b, klasse){
|
||||
const rollen = (b.rollen || []).filter(r => ROLLEN_FARBE[r.role]);
|
||||
if(rollen.length < 2){
|
||||
return `<span class="board-avatar ${klasse} state-${b.state}" title="${esc(b.title)}">${esc(b.person.name)}</span>`;
|
||||
}
|
||||
const teil = 100 / rollen.length;
|
||||
const stopps = rollen.map((r, i) => {
|
||||
// Immer die volle Rollenfarbe — eine blassere Abstufung für
|
||||
// Unterstützung wurde im Dunkelmodus zu einem eigenen, dunklen Ton.
|
||||
// Ob Haupt oder Unterstützung, steht im Tooltip.
|
||||
const farbe = ROLLEN_FARBE[r.role];
|
||||
return `${farbe} ${(i * teil).toFixed(2)}% ${((i + 1) * teil).toFixed(2)}%`;
|
||||
}).join(', ');
|
||||
return `<span class="board-avatar ist-mehrfach" style="background-image: linear-gradient(to right, ${stopps})" title="${esc(b.title)}">${esc(b.person.name)}</span>`;
|
||||
}
|
||||
|
||||
function personIdForName(name){
|
||||
return 'p_' + String(name).trim().toLowerCase().replace(/[^a-z0-9]/g,'_');
|
||||
}
|
||||
@@ -104,6 +152,50 @@ function initialsForName(name){
|
||||
return words.slice(0, 4).map(w => w[0]).join('').toUpperCase();
|
||||
}
|
||||
|
||||
// EMAIL_DOMAIN: Domain der Firmen-Adressen, Grundlage von emailVorschlag().
|
||||
const EMAIL_DOMAIN = 'rauschenberg-ing.de';
|
||||
|
||||
// emailVorschlag: schlägt aus einem Namen die Firmen-Adresse nach dem Schema
|
||||
// „Anfangsbuchstabe Vorname . Nachname @rauschenberg-ing.de" vor, klein
|
||||
// geschrieben, Umlaute umschrieben (ä→ae, ß→ss), übrige Sonderzeichen
|
||||
// entfernt: "Miriam Körner" → "m.koerner@rauschenberg-ing.de". Vorname ist
|
||||
// das erste, Nachname das letzte Wort. Nur ein Vorschlag — bleibt
|
||||
// überschreibbar. Parameter: name (String) — oder, wenn `nachname` gegeben
|
||||
// ist, Vorname und Nachname getrennt (dann zählt der ganze Nachname, z. B.
|
||||
// "von Plum" → "vonplum"). Rückgabe: Adresse oder ''.
|
||||
function emailVorschlag(name, nachname){
|
||||
if(nachname !== undefined){
|
||||
const v = String(name || '').trim(), n = String(nachname || '').trim();
|
||||
if(!v || !n) return emailVorschlag((v + ' ' + n).trim());
|
||||
const l = emailVorschlag(v.split(/\s+/)[0] + ' ' + n.replace(/\s+/g, ''));
|
||||
return l;
|
||||
}
|
||||
const teil = s => String(s).toLowerCase()
|
||||
.replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss')
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g,'')
|
||||
.replace(/[^a-z0-9-]/g,'');
|
||||
const woerter = String(name || '').trim().split(/\s+/).map(teil).filter(Boolean);
|
||||
if(woerter.length === 0) return '';
|
||||
const lokal = woerter.length === 1 ? woerter[0] : woerter[0][0] + '.' + woerter[woerter.length - 1];
|
||||
return lokal + '@' + EMAIL_DOMAIN;
|
||||
}
|
||||
|
||||
// benutzernameVorschlag: Benutzername eines neuen Kontos = Anfang der
|
||||
// E-Mail-Adresse, also "Anfangsbuchstabe Vorname . Nachname" in der
|
||||
// Schreibweise von emailVorschlag() ("Miriam Körner" → "m.koerner").
|
||||
// Angemeldet wird mit genau diesem Namen. Rückgabe: String oder ''.
|
||||
function benutzernameVorschlag(vorname, nachname){
|
||||
const adresse = emailVorschlag(vorname, nachname);
|
||||
return adresse ? adresse.slice(0, adresse.indexOf('@')) : '';
|
||||
}
|
||||
|
||||
// grossAnfang: erster Buchstabe groß, Leerraum zusammengefasst — für Vor-
|
||||
// und Nachnamen beim Speichern ("miriam" → "Miriam").
|
||||
function grossAnfang(s){
|
||||
s = String(s || '').trim().replace(/\s+/g, ' ');
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
|
||||
}
|
||||
|
||||
// formatWeekDate: wandelt ein ISO-Datum (YYYY-MM-DD) in deutsches Format DD.MM.YYYY um
|
||||
// Parameter: dateString (String). Rückgabe: formatierter String oder leer.
|
||||
function formatWeekDate(dateString){
|
||||
@@ -246,7 +338,7 @@ function getWeekNumber(d){
|
||||
// Fett, Farben, Absätze) wird sowohl im Projektboard (js/board/board.js,
|
||||
// Detailpanel) als auch auf der Projektplan-Seite (js/plan/plan-info.js)
|
||||
// verwendet — deshalb liegen die reinen Hilfsfunktionen hier im gemeinsamen
|
||||
// Modul. Nur statusBlur() (board.js, ruft editProj() auf) bzw. der
|
||||
// Modul. Nur der
|
||||
// Plan-Gegenpart (planStatusBlur() in plan-info.js) bleiben modul-eigen, da
|
||||
// sie unterschiedliche Speicherpfade ansteuern.
|
||||
|
||||
@@ -265,6 +357,104 @@ function statusFocus(el, 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.
|
||||
// Liefert bei reinem Leerraum '' zurück, sonst den unveränderten HTML-String
|
||||
// (Formatierung bleibt erhalten). Parameter: `html`, Rückgabe: String.
|
||||
@@ -289,10 +479,38 @@ function formatStatus(command, value){
|
||||
// eingeschleust werden können; im style-Attribut sind nur color/font-weight
|
||||
// erlaubt. Parameter: `html` (String). Rückgabe: bereinigter HTML-String ('' bei
|
||||
// leerem Eingabewert).
|
||||
//
|
||||
// DOMParser statt `div.innerHTML = html` — das ist der GANZE Sicherheitsgewinn
|
||||
// dieser Funktion, nicht bloß Geschmackssache: innerHTML lädt eingebettete
|
||||
// Ressourcen und führt deren Fehler-Handler aus, AUCH wenn das Element gar
|
||||
// nicht im Dokument hängt. Ein `<img src=x onerror=…>` lief damit schon
|
||||
// WÄHREND des Bereinigens — also auf jedem Anzeigepfad, der hier
|
||||
// durchkommt, obwohl das Bild danach korrekt entfernt wurde. Der Server
|
||||
// speichert Kommentare und Protokolltexte unverändert, ein angemeldetes
|
||||
// Konto konnte solches HTML also über die API hinterlegen und damit Code in
|
||||
// der Sitzung aller anderen ausführen. parseFromString() erzeugt dagegen ein
|
||||
// inertes Dokument: kein Laden, kein Handler. Nachgemessen — mit innerHTML
|
||||
// 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){
|
||||
if(!html) return '';
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
// Die Bereinigung läuft vollständig IM inerten Dokument ab: Würden die
|
||||
// Knoten vorher in ein Element des Hauptdokuments übernommen, zöge
|
||||
// appendChild() ein adoptNode() nach sich — und ein dabei übernommenes
|
||||
// <img src=…> begänne wieder zu laden, also genau das, was hier
|
||||
// verhindert werden soll.
|
||||
const inert = new DOMParser().parseFromString(String(html), 'text/html');
|
||||
const div = inert.body;
|
||||
const allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']);
|
||||
const allowedAttrs = new Set(['style']);
|
||||
// Elemente, deren INHALT mit verschwindet, statt ausgepackt zu werden:
|
||||
@@ -325,6 +543,10 @@ function sanitizeStatusHtml(html){
|
||||
}
|
||||
for(let j = child.attributes.length - 1; j >= 0; j--){
|
||||
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(child.hasAttribute('style')){
|
||||
@@ -345,26 +567,27 @@ function sanitizeStatusHtml(html){
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// statusHtmlToEditText: wandelt einen gespeicherten Kommentar (HTML) in den
|
||||
// Klartext, der im Textfeld des Kommentar-Editors steht. Fettschrift wird zu
|
||||
// **Sternchen**, Absätze und <br> werden zu Zeilenumbrüchen, alles andere
|
||||
// fällt weg. Gegenstück zu statusEditTextToHtml(). Nötig, weil der Editor im
|
||||
// Board seit der Kommentar-Überarbeitung ein normales <textarea> ist und kein
|
||||
// contenteditable mehr (document.execCommand ist abgekündigt) — gespeichert
|
||||
// wird aber weiterhin HTML, damit alle vorhandenen Anzeigepfade (Board-
|
||||
// Vorschau, Wochenbericht, PDF-Export, Show-Mode, Verlauf) unverändert
|
||||
// funktionieren und Altbestände lesbar bleiben.
|
||||
// statusTextMitZeilen: wandelt Kommentar-HTML in reinen Text und erhält dabei
|
||||
// die Zeilenumbrüche (<br> und Blockgrenzen werden zu \n). Gegenstück zu
|
||||
// statusPlainText() weiter unten, das Umbrüche bewusst zu Leerzeichen
|
||||
// einebnet — hier werden sie gebraucht, weil der einzige Aufrufer die ERSTE
|
||||
// ZEILE will: openTodoAusKommentar() (js/wochenbericht/wochenbericht.js)
|
||||
// schlägt daraus den Titel einer neuen Aufgabe vor.
|
||||
//
|
||||
// Hieß bis zur Umstellung auf contenteditable statusHtmlToEditText() und gab
|
||||
// Fett als **Sternchen**, Kursiv als *Sternchen* und Farbe als
|
||||
// [[c:…]]…[[/c]] zurück — die Rückrichtung zum damaligen Markup-Editor.
|
||||
// Seit der Editor echtes HTML führt (richtextWickeln() unten), hat dieses
|
||||
// Markup keinen Leser mehr; übrig blieben Sternchen, die unvermittelt im
|
||||
// vorgeschlagenen Aufgabentitel standen, obwohl sie nie jemand getippt hat.
|
||||
// Deshalb liefert die Funktion jetzt schlichten Text.
|
||||
// Parameter: html (String). Rückgabe: String.
|
||||
function statusHtmlToEditText(html){
|
||||
function statusTextMitZeilen(html){
|
||||
if(!html) return '';
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = sanitizeStatusHtml(html);
|
||||
// Fett-Auszeichnungen vor dem Auflösen in Sternchen überführen
|
||||
div.querySelectorAll('b, strong').forEach(el => {
|
||||
const inner = (el.textContent || '').trim();
|
||||
el.replaceWith(document.createTextNode(inner ? '**' + inner + '**' : ''));
|
||||
});
|
||||
// Block-Grenzen zu Zeilenumbrüchen
|
||||
// Block-Grenzen zu Zeilenumbrüchen; alle Auszeichnungen fallen durch
|
||||
// textContent ohnehin weg.
|
||||
div.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode('\n')));
|
||||
div.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode('\n')));
|
||||
return (div.textContent || '')
|
||||
@@ -373,19 +596,425 @@ function statusHtmlToEditText(html){
|
||||
.trim();
|
||||
}
|
||||
|
||||
// statusEditTextToHtml: wandelt den Klartext aus dem Kommentar-Editor in das
|
||||
// gespeicherte HTML. Escapt zuerst vollständig (esc()), sodass eingetippte
|
||||
// spitze Klammern niemals als Markup ankommen, und setzt danach die einzige
|
||||
// erlaubte Auszeichnung **fett** sowie Zeilenumbrüche. Gegenstück zu
|
||||
// statusHtmlToEditText(). Leerer oder reiner Leerraum-Text ergibt '' — die
|
||||
// bisherige Bedeutung von "kein Kommentar" bleibt damit erhalten.
|
||||
// Parameter: text (String). Rückgabe: String (HTML).
|
||||
function statusEditTextToHtml(text){
|
||||
const clean = String(text || '').trim();
|
||||
if(clean === '') return '';
|
||||
return esc(clean)
|
||||
.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
|
||||
.replace(/\n/g, '<br>');
|
||||
// ---------- Formatierbare Textfelder: contenteditable statt Markup-Zeichen ----------
|
||||
//
|
||||
// Erster Anlauf (Markup-Zeichen **fett**/*kursiv*/[[c:…]] in einem normalen
|
||||
// <textarea>, inzwischen vollständig entfernt) zeigte während des
|
||||
// Tippens sichtbare Stern-/Klammer-Zeichen (**fett**, [[c:…]]) statt echter
|
||||
// Formatierung — Eriks Rückmeldung: "statt den zwei Sternchen... den Text
|
||||
// richtig fett machen". Diese Felder sind jetzt contenteditable statt
|
||||
// <textarea>, und richtextWickeln()/richtextFett()/richtextKursiv()/
|
||||
// richtextFarbe() wenden die Auszeichnung direkt auf die Markierung an — über
|
||||
// die Selection/Range-API (range.extractContents()/insertNode()), bewusst
|
||||
// KEIN document.execCommand (abgekündigt, derselbe Grund, der die vorige
|
||||
// contenteditable-Fassung dieser Felder schon einmal verworfen hatte — nur
|
||||
// diesmal ohne execCommand). Der Nutzer sieht dadurch sofort echten fetten/
|
||||
// kursiven/farbigen Text, keine Markup-Zeichen mehr.
|
||||
// richtextToolbarHtml() liefert die dazugehörige, überall wiederverwendbare
|
||||
// Werkzeugleiste; sie erwartet, dass das Zielfeld im selben .richtext-wrap
|
||||
// steht (per closest()+querySelector() gefunden), damit mehrere Instanzen
|
||||
// auf einer Seite (z. B. je Protokoll-Punkt) einander nicht in die Quere
|
||||
// kommen. onmousedown mit preventDefault() verhindert, dass der Knopf-Klick
|
||||
// dem Feld vorher den Fokus (und damit die Markierung) stiehlt.
|
||||
|
||||
function richtextWickeln(feld, tagName, styleAttr){
|
||||
if(!feld) return;
|
||||
const sel = window.getSelection();
|
||||
if(!sel || sel.rangeCount === 0) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
if(range.collapsed || !feld.contains(range.commonAncestorContainer)) return;
|
||||
const wrapper = document.createElement(tagName);
|
||||
if(styleAttr) wrapper.setAttribute('style', styleAttr);
|
||||
wrapper.appendChild(range.extractContents());
|
||||
range.insertNode(wrapper);
|
||||
// Markierung auf den neu eingefügten Inhalt setzen — ein zweiter Klick auf
|
||||
// eine andere Auszeichnung wirkt dadurch sofort auf denselben Text weiter.
|
||||
sel.removeAllRanges();
|
||||
const neueRange = document.createRange();
|
||||
neueRange.selectNodeContents(wrapper);
|
||||
sel.addRange(neueRange);
|
||||
feld.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
// ---------- Auszeichnung wieder entfernen (Umschalten) ----------
|
||||
//
|
||||
// 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
|
||||
// (:empty:before + data-placeholder, siehe styles.css) als Ersatz für ein
|
||||
// <textarea>. html MUSS bereits sanitizeStatusHtml()-bereinigt sein (niemals
|
||||
// escapter Klartext — der Inhalt soll als Markup interpretiert werden, nicht
|
||||
// als Zeichenkette). extraAttrs hängt aufruferspezifische Event-Handler an.
|
||||
// editable=false (gesperrte Sitzung) setzt contenteditable="false" statt das
|
||||
// Feld ganz durch reinen Text zu ersetzen — Auswahl/Kopieren bleibt möglich.
|
||||
// Parameter: cssClass, html, placeholder, extraAttrs (Strings), editable
|
||||
// (Boolean, optional, Default true). Rückgabe: HTML-String.
|
||||
function richtextFeldHtml(cssClass, html, placeholder, extraAttrs, editable){
|
||||
const bearbeitbar = editable === false ? 'false' : 'true';
|
||||
return `<div class="${cssClass}" contenteditable="${bearbeitbar}" data-placeholder="${esc(placeholder || '')}" ${extraAttrs || ''}>${html || ''}</div>`;
|
||||
}
|
||||
|
||||
// richtextCursorAnsEnde: setzt den Fokus in ein contenteditable-Feld und den
|
||||
// Cursor ans Textende — das Gegenstück zu textarea.setSelectionRange(), das
|
||||
// es für contenteditable nicht gibt. Parameter: feld (Element). Keine
|
||||
// Rückgabe.
|
||||
function richtextCursorAnsEnde(feld){
|
||||
if(!feld) return;
|
||||
feld.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(feld);
|
||||
range.collapse(false);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
// Einfügen bereinigen: Ohne diesen Lauscher übernimmt ein contenteditable-Feld
|
||||
// beim Einfügen aus Word, einer Webseite oder einer Mail deren vollständige
|
||||
// Formatierung — Schriftarten, Größen, Tabellen, Bilder. Sichtbar blieb das
|
||||
// bis zum Speichern; erst dort lief sanitizeStatusHtml() und der Text sprang
|
||||
// unvermittelt in eine andere Gestalt. Jetzt wird schon beim Einfügen auf
|
||||
// dieselbe Positivliste reduziert, die auch gespeichert wird — was man sieht,
|
||||
// ist, was bleibt.
|
||||
//
|
||||
// EIN Lauscher am Dokument für alle Felder (gleiche Begründung wie beim
|
||||
// selectionchange-Lauscher in js/plan/plan-protokoll.js): Die Felder werden
|
||||
// beim Rendern ständig neu erzeugt, ein Lauscher je Feld müsste bei jedem
|
||||
// Neuaufbau erneut gesetzt werden.
|
||||
document.addEventListener('paste', function(e){
|
||||
const feld = e.target && e.target.closest ? e.target.closest('[contenteditable="true"]') : null;
|
||||
if(!feld || !e.clipboardData) return;
|
||||
e.preventDefault();
|
||||
// text/html nur, wenn vorhanden — reiner Text wird escaped eingesetzt,
|
||||
// damit darin enthaltene spitze Klammern Zeichen bleiben und nicht als
|
||||
// Markup ankommen.
|
||||
const html = e.clipboardData.getData('text/html');
|
||||
const sauber = html
|
||||
? sanitizeStatusHtml(html)
|
||||
: esc(e.clipboardData.getData('text/plain')).replace(/\r\n|\r|\n/g, '<br>');
|
||||
if(!sauber) return;
|
||||
const sel = window.getSelection();
|
||||
if(!sel || sel.rangeCount === 0) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
// Über ein <template> in Knoten verwandeln: dessen Inhalt liegt in einem
|
||||
// eigenen, inerten Dokumentfragment. `sauber` ist zwar bereits geprüft,
|
||||
// aber so bleibt auch dieser Schritt frei von Ressourcen-Ladevorgängen.
|
||||
const vorlage = document.createElement('template');
|
||||
vorlage.innerHTML = sauber;
|
||||
const fragment = vorlage.content;
|
||||
const letzter = fragment.lastChild;
|
||||
range.insertNode(fragment);
|
||||
// Schreibmarke hinter das Eingefügte setzen, sonst stünde sie davor.
|
||||
if(letzter){
|
||||
const nach = document.createRange();
|
||||
nach.setStartAfter(letzter);
|
||||
nach.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(nach);
|
||||
}
|
||||
feld.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
|
||||
// Rückmeldung der Formatierungsleiste: Fett/Kursiv/Farbe wirken nur auf
|
||||
// MARKIERTEN Text — ohne Markierung passiert beim Klick nichts, und zwar ohne
|
||||
// jeden Hinweis warum. Die Leiste hebt sich deshalb sichtbar hervor, sobald im
|
||||
// zugehörigen Feld etwas markiert ist, und trägt daneben einen Hinweis, der
|
||||
// von "Erst Text markieren" auf die Bestätigung umspringt.
|
||||
//
|
||||
// EIN dokumentweiter Lauscher für JEDES Vorkommen von .richtext-wrap, nicht
|
||||
// nur für den Protokoll-Schreibbereich (wo diese Funktion ursprünglich nur
|
||||
// dort lebte): der Board-Kommentar nutzt dieselbe Leiste
|
||||
// (richtextToolbarHtml(), renderCommentEditor() in js/board/board.js), zeigte
|
||||
// die Markierung aber nie an — die Leiste blieb dort dauerhaft blass, egal ob
|
||||
// Text markiert war. Anders als im Protokoll (immer höchstens EIN
|
||||
// Schreibbereich) können im Projektboard mehrere Zeilen gleichzeitig
|
||||
// aufgeklappt sein, jede mit eigenem Kommentarfeld — der Lauscher geht
|
||||
// deshalb jedes .richtext-wrap einzeln durch statt nur eines anzunehmen.
|
||||
document.addEventListener('selectionchange', function(){
|
||||
const sel = window.getSelection();
|
||||
const bereich = (sel && sel.rangeCount > 0 && !sel.isCollapsed) ? sel.getRangeAt(0) : null;
|
||||
document.querySelectorAll('.richtext-wrap').forEach(wrap => {
|
||||
const leiste = wrap.querySelector('.richtext-toolbar');
|
||||
const feld = wrap.querySelector('[contenteditable="true"]');
|
||||
if(!leiste || !feld) return;
|
||||
const markiert = !!(bereich && feld.contains(bereich.commonAncestorContainer));
|
||||
leiste.classList.toggle('hat-auswahl', markiert);
|
||||
const hinweis = leiste.querySelector('.richtext-hinweis');
|
||||
if(hinweis) hinweis.textContent = markiert ? '✓ Markiert — jetzt formatieren' : 'Erst Text markieren · erneut klicken hebt auf';
|
||||
});
|
||||
});
|
||||
|
||||
// RICHTEXT_FARBEN: die Palette der Farbknöpfe — bewusst dieselben Werte wie
|
||||
// --red/--green/--accent/--amber (styles.css), damit eine im Text gewählte
|
||||
// Farbe dieselbe Bedeutung trägt wie anderswo in der Anwendung (Rot = Achtung,
|
||||
// Grün = erledigt/positiv, Blau = Hinweis, Orange = offen/Warnung).
|
||||
const RICHTEXT_FARBEN = [
|
||||
{ farbe: '#d70015', titel: 'Rot' },
|
||||
{ farbe: '#248a3d', titel: 'Grün' },
|
||||
{ farbe: '#0071e3', titel: 'Blau' },
|
||||
{ farbe: '#b25000', titel: 'Orange' }
|
||||
];
|
||||
|
||||
// richtextToolbarHtml: liefert die Werkzeugleiste (Fett/Kursiv/Farben) für ein
|
||||
// contenteditable-Feld (richtextFeldHtml()), das im selben .richtext-wrap-
|
||||
// Container steht. targetSelector wählt das Zielfeld innerhalb dieses
|
||||
// Containers aus — jeder Aufrufer übergibt seine eigene Feldklasse (z. B.
|
||||
// '.comment-field'), der Default greift nur, falls das je vergessen wird.
|
||||
// Parameter: targetSelector (String, optional). Rückgabe: HTML-String.
|
||||
function richtextToolbarHtml(targetSelector){
|
||||
const sel = targetSelector || '[contenteditable]';
|
||||
const ziel = `this.closest('.richtext-wrap').querySelector('${sel}')`;
|
||||
const farbButtons = RICHTEXT_FARBEN.map(f =>
|
||||
`<button type="button" class="richtext-farbe-btn" style="background:${f.farbe}" title="${f.titel}"
|
||||
onmousedown="event.preventDefault()" onclick="richtextFarbe(${ziel}, '${f.farbe}')"></button>`).join('');
|
||||
// .richtext-hinweis: Fett/Kursiv/Farbe wirken nur auf MARKIERTEN Text —
|
||||
// ohne Markierung tut ein Klick nichts, und das war bisher nirgends
|
||||
// ablesbar. Der Hinweis steht deshalb fest in der Leiste; wer ihn
|
||||
// aktualisieren will, wenn tatsächlich etwas markiert ist, hängt sich an
|
||||
// selectionchange (so macht es der Protokoll-Schreibbereich,
|
||||
// js/plan/plan-protokoll.js).
|
||||
return `<div class="richtext-toolbar">
|
||||
<span class="richtext-btn-group">
|
||||
<button type="button" class="richtext-btn" title="Fett" onmousedown="event.preventDefault()" onclick="richtextFett(${ziel})"><b>F</b></button>
|
||||
<button type="button" class="richtext-btn" title="Kursiv" onmousedown="event.preventDefault()" onclick="richtextKursiv(${ziel})"><i>K</i></button>
|
||||
</span>
|
||||
<span class="richtext-divider"></span>
|
||||
<span class="richtext-farben">${farbButtons}</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>`;
|
||||
}
|
||||
|
||||
// -------- Projekt-Verlauf über mehrere Sitzungen --------
|
||||
@@ -437,18 +1066,22 @@ function statusPlainText(html){
|
||||
// wurden, tragen den Text der Vorwoche unverändert mit. Parameter: uid
|
||||
// (String). Rückgabe: Array von { week, text, html, author, at, isFirst },
|
||||
// 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 entries = [];
|
||||
let lastText = null;
|
||||
versions.forEach((v, idx) => {
|
||||
const text = statusPlainText(v.project.status);
|
||||
const k = kommentarVon(v.project, gewerk);
|
||||
const text = statusPlainText(k.status);
|
||||
const entry = {
|
||||
week: v.week,
|
||||
text,
|
||||
html: sanitizeStatusHtml(v.project.status || ''),
|
||||
author: v.project.statusAuthor || '',
|
||||
at: v.project.statusAt || '',
|
||||
html: sanitizeStatusHtml(k.status || ''),
|
||||
author: k.statusAuthor || '',
|
||||
at: k.statusAt || '',
|
||||
isFirst: idx === 0
|
||||
};
|
||||
if(idx === 0){
|
||||
@@ -554,8 +1187,19 @@ function verlaufBeteiligte(version){
|
||||
// die eine geänderte Zahl sonst nicht.
|
||||
// Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell"
|
||||
// gilt und deshalb NICHT als Kachel erscheint), locked. Rückgabe: HTML.
|
||||
// verlaufOffen: project_uids, deren Verlauf aufgeklappt ist — übersteht das
|
||||
// Neuzeichnen nach „Weitere laden“ (sonst klappte er dabei wieder zu).
|
||||
const verlaufOffen = new Set();
|
||||
function verlaufUmschalten(uid, offen){ if(offen) verlaufOffen.add(uid); else verlaufOffen.delete(uid); }
|
||||
|
||||
function renderProjektVerlauf(p, weekLabel, locked){
|
||||
const versionen = projectVersionsForUid(p.projectUid);
|
||||
// Gezeigt wird, was geladen ist (anfangs die drei Sitzungen vor der
|
||||
// aktuellen); „Weitere laden“ holt je fünf ältere (js/store.js).
|
||||
const weitere = typeof weitereLadenKnopf === 'function' ? weitereLadenKnopf() : '';
|
||||
const uidAttr = esc(String(p.projectUid || '')).replace(/'/g, ''');
|
||||
const offen = verlaufOffen.has(p.projectUid) ? ' open' : '';
|
||||
const umschalten = ` ontoggle="verlaufUmschalten('${uidAttr}', this.open)"`;
|
||||
// Neueste zuerst, damit links die jüngste Vergangenheit steht.
|
||||
//
|
||||
// Ohne weekLabel werden ALLE Sitzungen gezeigt: Der Verlaufs-Dialog im
|
||||
@@ -566,13 +1210,15 @@ function renderProjektVerlauf(p, weekLabel, locked){
|
||||
const frueher = (weekLabel ? (idx > 0 ? versionen.slice(0, idx) : []) : versionen.slice()).reverse();
|
||||
|
||||
if(frueher.length === 0){
|
||||
return `<details class="timeline-block">
|
||||
return `<details class="timeline-block"${offen}${umschalten}>
|
||||
<summary onmousedown="event.preventDefault()">
|
||||
<span class="comment-history-caret">▶</span>
|
||||
<span>Verlauf</span>
|
||||
<span class="comment-history-count zero">0</span>
|
||||
</summary>
|
||||
<div class="timeline-body"><span class="hint" style="margin:0">Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.</span></div>
|
||||
<div class="timeline-body"><span class="hint" style="margin:0">${weitere
|
||||
? 'In den geladenen Sitzungen steht dieses Projekt noch nicht.'
|
||||
: 'Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.'}</span>${weitere}</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
@@ -594,7 +1240,10 @@ function renderProjektVerlauf(p, weekLabel, locked){
|
||||
</div>`;
|
||||
}).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 termineDavor = davor ? verlaufTermine(davor.project) : { ende:'', meilenstein:'' };
|
||||
const beteiligte = verlaufBeteiligte(v);
|
||||
@@ -628,7 +1277,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
|
||||
<span class="timeline-kw">${esc(v.week.label)}</span>
|
||||
<span class="timeline-datum">${esc(v.week.date ? formatWeekDate(v.week.date) : '')}</span>
|
||||
</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}
|
||||
<div class="timeline-lph-list">${lphHtml}</div>
|
||||
${zeilen.length ? `<div class="timeline-termine">${zeilen.join('')}</div>` : ''}
|
||||
@@ -640,7 +1289,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
|
||||
</article>`;
|
||||
}).join('');
|
||||
|
||||
return `<details class="timeline-block">
|
||||
return `<details class="timeline-block"${offen}${umschalten}>
|
||||
<summary onmousedown="event.preventDefault()">
|
||||
<span class="comment-history-caret">▶</span>
|
||||
<span>Verlauf</span>
|
||||
@@ -648,6 +1297,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
|
||||
</summary>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-rail">${kacheln}</div>
|
||||
${weitere ? `<div class="verlauf-weitere-zeile">${weitere}</div>` : ''}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
@@ -0,0 +1,891 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Wandtafel (Touch-Dashboard)
|
||||
// -----------------------------
|
||||
// Modul für den Tab "Wandtafel" (App.html, Ladereihenfolge: ... → load →
|
||||
// dashboard → wand → settings → ...). Ersetzt den früheren Show Mode: der
|
||||
// klonte nur die gerade gerenderte Board- bzw. Auslastungs-Ansicht der EINEN
|
||||
// angemeldeten Sitzung (kein eigener Datenpfad, keine Aggregation über
|
||||
// Gewerke oder Nutzer hinweg). Gedacht für ein dauerhaft laufendes
|
||||
// Touch-Display mit einem eigens dafür angelegten Kiosk-Konto — siehe
|
||||
// CLAUDE.md "Wandtafel".
|
||||
//
|
||||
// Aufbau: aus einem Vergleich mehrerer komplett unterschiedlicher
|
||||
// Gesamtentwürfe wählte Erik die "Leitstand"-Struktur (Kopf → eine
|
||||
// Kennzahlen-Reihe aus vier Kacheln → eine Reihe aus drei Panels), dann in
|
||||
// einem zweiten Durchgang die konkrete Gestaltung ("Pastell Freundlich"),
|
||||
// zuletzt umgefärbt in die zwei Farben des Firmenlogos (aus public/logo.ico
|
||||
// ausgelesen) statt einer generischen Pastellmischung — siehe die
|
||||
// --dash-*-Eigenschaften in styles.css. Kopf: Marke "Dashboard" + Sitzung/
|
||||
// Uhrzeit + wer heute abwesend ist (renderWandDashKopfHtml()). Kennzahlen-
|
||||
// Reihe: Auslastung gesamt, Besprochen gesamt, offene Aufgaben, überfällig
|
||||
// (wandKpiReiheHtml()/wandGesamtKennzahlen()). Panel-Reihe: Auslastung je
|
||||
// Gewerk, Besprochen je Gewerk, Aktuell — ein chronologisch sortierter
|
||||
// Ereignis-Feed (wandAuslastungInhalt()/wandBesprochenInhalt()/
|
||||
// wandAktuellInhalt(), gemeinsam gerahmt von wandPanel()). Die Kacheln/
|
||||
// Panels selbst sind NICHT antippbar (kein Auf-/Zuklappen, auf Eriks
|
||||
// Wunsch) — stattdessen ist jedes einzelne darin dargestellte Element für
|
||||
// sich antippbar (eine Gewerk-Zeile, eine Kennzahl, ein Kürzel, ein
|
||||
// Feed-Eintrag) und führt in eine Vollbild-Übersicht bzw. direkt ins
|
||||
// betroffene Projekt. Jede Kachel/jedes Ereignis ist eine reine Projektion
|
||||
// bereits geladener Daten (dasselbe db aus GET /load-all, das jeder andere
|
||||
// Tab auch nutzt) — keine eigene Server-Route, keine schreibenden Aktionen.
|
||||
// Wiederverwendet werden aufgabenVerlaufJePerson()/loadAmpelColor()/
|
||||
// kapazitaetsFaktor()/stundenFormat()/lpActiveList()/schwelleGelb()/
|
||||
// schwelleRot() aus js/load/load.js,
|
||||
// classifyProjectChange()/findPreviousWeek()/springZumWochenbericht() aus
|
||||
// js/wochenbericht/wochenbericht.js bzw. js/plan/plan-info.js,
|
||||
// statusPreviewText()/renderBoardRow()/renderBoardDrawer()/BOARD_OPTS aus
|
||||
// js/board/board.js, relativerTag()/tageBis()/initialsFor()/
|
||||
// formatWeekDate() aus js/dashboard/dashboard.js bzw. js/utils.js,
|
||||
// GROUP_ORDER/ROLES aus js/globals.js, sortGroups()/peopleByRole() aus
|
||||
// js/utils.js, esc()/heuteIso() aus js/utils.js, currentWeek() aus
|
||||
// js/weeks.js, load()/baseUrl() aus js/store.js/js/server.js,
|
||||
// showLoginOverlay() aus js/auth.js, openProjectPlan() aus js/gantt.js
|
||||
// (jeder Sprung ins Projekt verlässt die Wandtafel bewusst in den normalen
|
||||
// Projektbereich, Seitenleiste und Kopfzeile kommen dabei zurück — wie bei
|
||||
// jedem anderen Tab-Wechsel auch).
|
||||
//
|
||||
// Die Normalansicht ist höher als ein Bildschirm: unterhalb von Kopf,
|
||||
// Kennzahlen- und Panel-Reihe folgt die vollständige Projektliste
|
||||
// (wandProjektlisteHtml()) — auf Eriks ausdrücklichen Wunsch optisch
|
||||
// identisch mit der echten Projektliste (Tab "Projekte"), nicht eine eigene,
|
||||
// vereinfachte Zeilenform: Jede Gruppe/Zeile entsteht über denselben
|
||||
// renderBoardRow()/BOARD_OPTS wie dort, nur mit locked FEST auf true (siehe
|
||||
// wandProjektlisteHtml()) — die Wandtafel schreibt nie, also gibt es hier
|
||||
// nie Ziehgriffe oder einen Löschen-Knopf, unabhängig vom echten Sperrstatus
|
||||
// der Sitzung. Das Display ist ein echter Touch-Monitor — Erik kann
|
||||
// davortreten und darin scrollen; ohne Berührung kehrt die Ansicht nach
|
||||
// WAND_IDLE_MS von selbst an den Anfang zurück (wandAktivitaetErkannt()/
|
||||
// wandBeiUntaetigkeitZuruecksetzen()), damit ein unbeaufsichtigtes Display
|
||||
// nicht dauerhaft im Zustand der letzten vorbeigehenden Person hängen
|
||||
// bleibt. Erik wollte diesen Rücksprung nicht nur erleben, sondern vorher
|
||||
// sehen können: #wandIdleCountdown (neben "← Zur Übersicht", App.html) zeigt
|
||||
// laufend die verbleibende Zeit (wandCountdownTick(), ein eigenes
|
||||
// 1-Sekunden-Intervall, siehe startWandboard()/stopWandboard()).
|
||||
// Dasselbe gilt auch AUSSERHALB der Wandtafel: Ein Klick auf eine
|
||||
// Projekt-/Gewerk-Fläche verlässt sie ja absichtlich (Projektbereich bzw.
|
||||
// Wochenbericht) — ein document-weiter, capturing Klick-Lauscher auf
|
||||
// #wandContent setzt dabei wandUnterwegs, worüber dieselbe globale
|
||||
// Aktivitätserkennung (wandAktivitaetErkannt()) statt der Untätigkeits- die
|
||||
// Rückkehr-Uhr bedient; nach WAND_IDLE_MS ohne Berührung dort kehrt
|
||||
// wandKehreZurueck() von selbst auf die Wandtafel zurück. Der bewusste
|
||||
// Ausstieg über "← Zur Übersicht" (außerhalb von #wandContent) setzt
|
||||
// wandUnterwegs NICHT — eine Vorschau durch Personal soll nicht nach einer
|
||||
// Minute unterbrochen werden.
|
||||
|
||||
// WAND_GEWERKE: die drei Fachrichtungen, in dieser Reihenfolge überall auf
|
||||
// der Wandtafel. Dieselben Werte wie VALID_DEPARTMENTS/project_gewerke.
|
||||
const WAND_GEWERKE = ['ELT', 'HLS', 'ARCH'];
|
||||
|
||||
const WAND_REFRESH_MS = 60000; // Hintergrund-Aktualisierung
|
||||
// WAND_IDLE_MS: Erik hat einen echten Touch-Monitor — er darf davortreten und
|
||||
// in der Projektliste unterhalb der Kacheln scrollen ("ich kann mich vor die
|
||||
// Tafel stellen und scrollen"). Nach dieser Zeit ohne jede Berührung kehrt
|
||||
// die Ansicht von selbst in den Normalzustand zurück (oben, keine offene
|
||||
// Übersicht) — ein unbeaufsichtigtes Display darf nicht dauerhaft in einem
|
||||
// Zustand hängen bleiben, den zufällig die letzte vorbeigehende Person
|
||||
// hinterlassen hat.
|
||||
const WAND_IDLE_MS = 60000;
|
||||
|
||||
let wandRefreshTimer = null;
|
||||
let wandIdleTimer = null;
|
||||
let wandCountdownTimer = null;
|
||||
let wandIdleDeadline = 0; // Date.now()-Zeitpunkt, zu dem die Untätigkeits-Uhr auslöst
|
||||
let wandRueckkehrTimer = null;
|
||||
// wandUnterwegs: true zwischen einem Klick auf eine Projekt-/Gewerk-Fläche
|
||||
// DER WANDTAFEL (gesetzt vom Klick-Lauscher auf #wandContent weiter unten)
|
||||
// und der Rückkehr dorthin — nicht beim Verlassen über "← Zur Übersicht"
|
||||
// (ein bewusster Ausstieg für eine Vorschau, kein versehentliches Antippen,
|
||||
// und außerhalb von #wandContent). Nur während dieser Zeit treibt
|
||||
// wandAktivitaetErkannt() die Rückkehr-Uhr statt der Untätigkeits-Uhr.
|
||||
let wandUnterwegs = false;
|
||||
let wandAktuelleWoche = null; // für Übersicht-Klicks gemerkt (re-render ohne erneutes Laden)
|
||||
// wandUebersicht: null (zweispaltige Normalansicht) oder {typ, gewerk?} —
|
||||
// welche Vollbild-Übersicht gerade anstelle der Normalansicht steht. gewerk
|
||||
// ist nur bei typ 'auslastung'/'besprochen' gesetzt (eine Übersicht je
|
||||
// Gewerk).
|
||||
let wandUebersicht = null;
|
||||
|
||||
// startWandboard: startet die Hintergrund-Aktualisierung, falls nicht schon
|
||||
// aktiv. Der Schon-aktiv-Schutz ist kein Komfort, sondern notwendig: load()
|
||||
// ruft an seinem Ende selbst showTab(currentTab) auf — showTab('wand') riefe
|
||||
// ohne diesen Schutz bei JEDEM eigenen 60s-Takt erneut startWandboard() auf
|
||||
// und setzte damit sofort einen weiteren, ungeduldigen Takt in Gang (eine
|
||||
// Rückkopplungsschleife, die praktisch ohne Pause neu lädt). Nach einer
|
||||
// erneuten Anmeldung (stopWandboard() setzt wandRefreshTimer zuvor auf null,
|
||||
// siehe wandRefreshTick()) greift der Schutz dagegen nicht und die
|
||||
// Aktualisierung startet korrekt neu. Keine Parameter, keine Rückgabe.
|
||||
function startWandboard(){
|
||||
if(wandRefreshTimer) return;
|
||||
// Ein frisches Betreten beginnt immer bei der normalen Ansicht, nie in
|
||||
// einer Übersicht hängen — die läuft nur innerhalb EINES Aufenthalts auf
|
||||
// der Wandtafel weiter (der 60s-Hintergrund-Takt ruft startWandboard()
|
||||
// zwar erneut auf, der Schutz oben greift dann aber schon vorher, sodass
|
||||
// dieser Reset NICHT bei jedem Takt läuft). Eine evtl. noch laufende
|
||||
// Rückkehr-Uhr endet hier ebenfalls — sie ist erfüllt, sobald die
|
||||
// Wandtafel wieder aktiv ist, egal ob durch sie selbst ausgelöst oder
|
||||
// durch eine andere Navigation dorthin.
|
||||
wandUebersicht = null;
|
||||
wandUnterwegs = false;
|
||||
clearTimeout(wandRueckkehrTimer); wandRueckkehrTimer = null;
|
||||
wandRefreshTick();
|
||||
wandRefreshTimer = setInterval(wandRefreshTick, WAND_REFRESH_MS);
|
||||
wandAktivitaetErkannt(); // startet die Untätigkeits-Uhr für diesen Aufenthalt
|
||||
wandCountdownTick();
|
||||
wandCountdownTimer = setInterval(wandCountdownTick, 1000);
|
||||
}
|
||||
|
||||
// stopWandboard: beendet den Hintergrund-Takt der Wandtafel. Wird von
|
||||
// showTab() beim Verlassen des Tabs aufgerufen (NACH dem Entfernen von
|
||||
// body.wandtafel-aktiv, siehe tabs-auth.js). Wenn das Verlassen durch einen
|
||||
// Klick INNERHALB von #wandContent ausgelöst wurde (wandUnterwegs, siehe der
|
||||
// document-weite Klick-Lauscher unten), wird hier sofort die Rückkehr-Uhr
|
||||
// scharf gestellt — wandAktivitaetErkannt() nimmt wegen des bereits
|
||||
// entfernten wandtafel-aktiv automatisch den passenden Zweig. Ein Verlassen
|
||||
// OHNE vorherigen Klick (etwa stopWandboard() aus wandRefreshTick() bei
|
||||
// abgelaufener Sitzung) lässt wandUnterwegs unberührt — dort gibt es nichts,
|
||||
// wohin zurückzukehren wäre. Keine Parameter, keine Rückgabe.
|
||||
function stopWandboard(){
|
||||
clearInterval(wandRefreshTimer); wandRefreshTimer = null;
|
||||
clearTimeout(wandIdleTimer); wandIdleTimer = null;
|
||||
clearInterval(wandCountdownTimer); wandCountdownTimer = null;
|
||||
const el = document.getElementById('wandIdleCountdown');
|
||||
if(el) el.textContent = '';
|
||||
if(wandUnterwegs) wandAktivitaetErkannt();
|
||||
}
|
||||
|
||||
// wandAktivitaetErkannt: setzt je nach Lage entweder die Untätigkeits-Uhr
|
||||
// (auf der Wandtafel selbst) oder die Rückkehr-Uhr (unterwegs, siehe
|
||||
// wandUnterwegs) zurück. Horcht global auf Berührung/Maus/Tastatur/Scroll —
|
||||
// so kann der Listener einmalig beim Laden des Skripts registriert werden,
|
||||
// statt bei jedem Betreten/Verlassen an- und abgemeldet zu werden, und ist
|
||||
// überall sonst (kein wandtafel-aktiv, nicht wandUnterwegs) ein reines
|
||||
// No-op. Keine Parameter, keine Rückgabe.
|
||||
function wandAktivitaetErkannt(){
|
||||
if(document.body.classList.contains('wandtafel-aktiv')){
|
||||
clearTimeout(wandIdleTimer);
|
||||
wandIdleTimer = setTimeout(wandBeiUntaetigkeitZuruecksetzen, WAND_IDLE_MS);
|
||||
wandIdleDeadline = Date.now() + WAND_IDLE_MS;
|
||||
wandCountdownTick();
|
||||
} else if(wandUnterwegs){
|
||||
clearTimeout(wandRueckkehrTimer);
|
||||
wandRueckkehrTimer = setTimeout(wandKehreZurueck, WAND_IDLE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// wandKehreZurueck: nach WAND_IDLE_MS ohne Berührung AUSSERHALB der
|
||||
// Wandtafel — ausgelöst durch einen Klick auf eine ihrer eigenen
|
||||
// Projekt-/Gewerk-Flächen (wandUnterwegs, siehe dort) — zurück zur
|
||||
// Wandtafel. Erik: "auch wenn ich eine der Flächen/Projekte auf der Tafel
|
||||
// anklicke soll nach 1 min das Bild wieder zurückspringen" — ein kurzer
|
||||
// Blick ins Projekt darf das unbeaufsichtigte Display nicht dauerhaft
|
||||
// woanders hinterlassen. Keine Parameter, keine Rückgabe.
|
||||
function wandKehreZurueck(){
|
||||
wandRueckkehrTimer = null;
|
||||
showTab('wand');
|
||||
}
|
||||
|
||||
// wandCountdownTick: schreibt die verbleibende Zeit bis zum automatischen
|
||||
// Rücksprung in #wandIdleCountdown (Kopfzeile neben "← Zur Übersicht", siehe
|
||||
// App.html) — Erik wollte sehen können, wann das passiert, statt vom Sprung
|
||||
// überrascht zu werden. Läuft über ein eigenes 1-Sekunden-Intervall statt
|
||||
// über wandIdleTimer selbst, da der nur EIN MAL (beim Auslösen) feuert.
|
||||
// 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(){
|
||||
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');
|
||||
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));
|
||||
el.textContent = rest > 0 ? ('Rücksprung in ' + rest + ' s') : '';
|
||||
}
|
||||
|
||||
// wandBeiUntaetigkeitZuruecksetzen: nach WAND_IDLE_MS ohne Berührung zurück in
|
||||
// den Normalzustand — eine offene Übersicht schließt sich, und die
|
||||
// Projektliste scrollt an den Anfang zurück. Unbedingt (nicht nur wenn gerade
|
||||
// eine Übersicht offen war): Auch ein bloßes Herunterscrollen in der
|
||||
// Projektliste soll sich nach einer Minute von selbst zurücksetzen.
|
||||
// Startet die Uhr am Ende selbst neu (wandAktivitaetErkannt()), statt sie
|
||||
// auslaufen zu lassen — die Anzeige in #wandIdleCountdown soll durchgehend
|
||||
// laufen, nicht nach dem ersten Rücksprung stehen bleiben, solange niemand
|
||||
// die Tafel berührt. Keine Parameter, keine Rückgabe.
|
||||
function wandBeiUntaetigkeitZuruecksetzen(){
|
||||
if(wandImGrundzustand()){ wandAktivitaetErkannt(); return; }
|
||||
wandUebersicht = null;
|
||||
renderWandboard({ resetScroll: true });
|
||||
wandAktivitaetErkannt();
|
||||
}
|
||||
|
||||
['pointerdown', 'touchstart', 'wheel', 'keydown'].forEach(evt =>
|
||||
document.addEventListener(evt, wandAktivitaetErkannt, { passive: true }));
|
||||
// 'scroll' bubbelt bei Elementen (anders als bei window/document) nicht —
|
||||
// ein Listener in der CAPTURING-Phase an document bekommt ihn trotzdem mit,
|
||||
// da die Capturing-Phase unabhängig vom Bubbling jedes Element auf dem Weg
|
||||
// zum Ziel durchläuft.
|
||||
document.addEventListener('scroll', wandAktivitaetErkannt, { passive: true, capture: true });
|
||||
|
||||
// Jeder Klick INNERHALB von #wandContent markiert "unterwegs" — unabhängig
|
||||
// davon, über welche konkrete Funktion er die Wandtafel verlässt
|
||||
// (openProjectPlan() direkt aus renderBoardRow() in der Projektliste,
|
||||
// springZumWochenbericht() aus der Besprochen-Übersicht, wandSpringeZuProjekt()
|
||||
// aus den übrigen Übersichten/dem Feed). Ein einzelner, allgemeiner Lauscher
|
||||
// statt eigener Wrapper um jede dieser Navigationsfunktionen: Mehrere davon
|
||||
// werden auch von ganz anderen, nicht-Wandtafel-Stellen der App aufgerufen
|
||||
// (board.js, plan-info.js) und dürfen dort keine Rückkehr auslösen — hier
|
||||
// entscheidet allein, WO der Klick stattfand, nicht WELCHE Funktion er
|
||||
// aufruft. Läuft in der CAPTURING-Phase: Mehrere Klick-Handler in der
|
||||
// Projektliste rufen event.stopPropagation() auf (z. B. der Projektname in
|
||||
// renderBoardRow()), was einen Lauscher in der normalen Bubbling-Phase an
|
||||
// #wandContent gar nicht erst erreichen würde — die Capturing-Phase läuft
|
||||
// davon unabhängig schon auf dem Weg zum eigentlichen Ziel. Das bewusste
|
||||
// Verlassen über "← Zur Übersicht" (außerhalb von #wandContent, siehe
|
||||
// App.html) setzt wandUnterwegs ABSICHTLICH NICHT — eine Vorschau durch
|
||||
// Personal soll nicht nach einer Minute unterbrochen werden. Die eigentliche
|
||||
// Uhr wird erst in stopWandboard() scharf gestellt, sobald die Navigation
|
||||
// tatsächlich stattgefunden hat.
|
||||
document.addEventListener('click', function(ev){
|
||||
const wandContent = document.getElementById('wandContent');
|
||||
if(wandContent && wandContent.contains(ev.target)) wandUnterwegs = true;
|
||||
}, { capture: true });
|
||||
|
||||
// wandRefreshTick: der äußere Hintergrund-Takt. Prüft zuerst GET /whoami statt
|
||||
// blind load() aufzurufen: Ist die Sitzung abgelaufen, zeigt er das
|
||||
// bestehende Login-Overlay an Ort und Stelle (dasselbe Formular ist seit dem
|
||||
// ersten Seitenaufruf schon verdrahtet, siehe checkAuthAndInit(),
|
||||
// js/auth.js) — eine erneute Anmeldung setzt die Wandtafel über denselben
|
||||
// onAuthenticated()-Weg fort, ohne dass die Seite neu geladen werden muss.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
async function wandRefreshTick(){
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch(baseUrl() + '/whoami');
|
||||
data = await res.json();
|
||||
} catch(err){
|
||||
console.error('Wandtafel: Anmeldestatus konnte nicht geprüft werden:', err);
|
||||
return; // nächster Takt versucht es erneut
|
||||
}
|
||||
if(!data || !data.user){
|
||||
stopWandboard();
|
||||
showLoginOverlay();
|
||||
return;
|
||||
}
|
||||
load(true); // silent — ruft am Ende renderAll()+showTab(currentTab) auf
|
||||
}
|
||||
|
||||
// renderWandboard: baut entweder die Normalansicht (Kopf + Kennzahlen-Reihe +
|
||||
// Panel-Reihe, mit der vollständigen Projektliste darunter) oder eine offene
|
||||
// Vollbild-Übersicht auf. Wird von showTab('wand') sowie nach jedem load()
|
||||
// (über showTab(currentTab)) aufgerufen.
|
||||
//
|
||||
// Die Normalansicht ist höher als ein Bildschirm — Erik hat einen echten
|
||||
// Touch-Monitor und wollte "unterhalb der Wandtafel... weitere Informationen
|
||||
// verstauen, z. B. die gesamte Projektliste". Nur dieser Teil scrollt
|
||||
// (`.wand-scrollable` auf #wandContent); eine offene Übersicht bleibt wie
|
||||
// zuvor genau einen Bildschirm hoch mit ihrer eigenen inneren Liste. Der
|
||||
// Scroll-Stand bleibt über den 60-Sekunden-Hintergrund-Takt hinweg erhalten
|
||||
// (sonst risse ein laufender Refresh jemanden, der gerade in der Liste
|
||||
// scrollt, zurück an den Anfang) — nur wandBeiUntaetigkeitZuruecksetzen()
|
||||
// verlangt ausdrücklich resetScroll:true. Parameter: opts.resetScroll
|
||||
// (optional). Keine Rückgabe.
|
||||
function renderWandboard(opts){
|
||||
const resetScroll = !!(opts && opts.resetScroll);
|
||||
const container = document.getElementById('wandContent');
|
||||
if(!container) return;
|
||||
const w = currentWeek();
|
||||
if(!w){
|
||||
container.innerHTML = '<div class="hint">Keine Sitzung vorhanden.</div>';
|
||||
return;
|
||||
}
|
||||
wandAktuelleWoche = w;
|
||||
|
||||
const vorherigerScroll = resetScroll ? 0 : container.scrollTop;
|
||||
|
||||
if(wandUebersicht){
|
||||
container.classList.remove('wand-scrollable');
|
||||
container.innerHTML = wandUebersichtHtml(w);
|
||||
return;
|
||||
}
|
||||
|
||||
container.classList.add('wand-scrollable');
|
||||
// Entwurf "Wandtafel in Glas" (Eriks Wahl): Kopf → vier Kennzahlen →
|
||||
// links die Gewerke (Auslastung + Besprochen je Zeile), rechts die
|
||||
// nächsten fünf Arbeitstage → Hinweis auf die Projektliste darunter.
|
||||
container.innerHTML = '<div class="wand-ambient wt">'
|
||||
+ wtKopfHtml(w)
|
||||
+ wtKpiHtml(w)
|
||||
+ '<div class="wt-felder">' + wtGewerkeHtml(w) + wtTageHtml(w) + '</div>'
|
||||
+ wtFussHtml(w)
|
||||
+ '</div>'
|
||||
+ wandProjektlisteHtml(w);
|
||||
container.scrollTop = vorherigerScroll;
|
||||
wtSpaltenEinpassen();
|
||||
}
|
||||
|
||||
// wandGesamtKennzahlen: zwei über alle Gewerke hinweg zusammengefasste
|
||||
// Kennzahlen für die Kennzahlen-Reihe oben — dieselbe Rechnung wie die
|
||||
// Gewerk-Aufschlüsselung darunter (wandAuslastungJeGewerk()/
|
||||
// wandBesprochenDaten()), nur aufsummiert statt aufgeschlüsselt. Die
|
||||
// bewusste Mehrfach-Gewerke-Dopplung (ein Projekt mit mehreren Gewerken
|
||||
// zählt seinen vollen Beitrag bei jedem Gewerk) gilt hier automatisch mit,
|
||||
// da beide Quellfunktionen sie bereits je Gewerk anwenden. Parameter: w.
|
||||
// Rückgabe: {auslastung: Zahl|null, besprochen: Zahl|null} — null, wenn sich
|
||||
// die Quote mangels Kapazität bzw. Projekten nicht bilden lässt.
|
||||
function wandGesamtKennzahlen(w){
|
||||
const ausl = wandAuslastungJeGewerk(w);
|
||||
let stunden = 0, kapazitaet = 0;
|
||||
WAND_GEWERKE.forEach(g => { stunden += ausl[g].stunden; kapazitaet += ausl[g].kapazitaet; });
|
||||
|
||||
const bespr = wandBesprochenDaten(w);
|
||||
let besprochen = 0, gesamt = 0;
|
||||
WAND_GEWERKE.forEach(g => { besprochen += bespr[g].besprochen; gesamt += bespr[g].gesamt; });
|
||||
|
||||
return {
|
||||
auslastung: kapazitaet > 0 ? stunden / kapazitaet : null,
|
||||
besprochen: gesamt > 0 ? besprochen / gesamt : null,
|
||||
};
|
||||
}
|
||||
|
||||
// wandProjektlisteHtml: die vollständige Projektliste unterhalb der
|
||||
// Kennzahl-Kacheln — auf Eriks ausdrücklichen Wunsch ("Die Projektliste soll
|
||||
// so aussehen wie die Originale") keine eigene, vereinfachte Darstellung,
|
||||
// sondern dieselbe Zeile wie im echten Tab "Projekte": gleiche Gruppen
|
||||
// (GROUP_ORDER/sortGroups()), gleiche Sortierung (p.order) und jede Zeile
|
||||
// über denselben renderBoardRow()+BOARD_OPTS wie renderBoard() selbst
|
||||
// (js/board/board.js) — Nr., Name, Auftragsstatus-Kennzeichen, Status-
|
||||
// Vorschau, Avatar-Stapel und Phasen-Badge, aufklappbar zum selben
|
||||
// Detailpanel (renderBoardDrawer()). Anders als die Auslastung/Besprochen-
|
||||
// Kacheln zählt hier bewusst auch "In Anbahnung" mit, da es um
|
||||
// Vollständigkeit geht, nicht um bewertbare Arbeitslast.
|
||||
//
|
||||
// `locked` wird FEST auf true übergeben, unabhängig vom tatsächlichen
|
||||
// Sperrstatus der Sitzung: Die Wandtafel schreibt nie (siehe Dateikopf), und
|
||||
// renderBoardRow()/renderBoardDrawer() blenden bei locked=true genau die
|
||||
// Bedienelemente aus, die einen Schreibzugriff auslösen würden (Ziehgriff,
|
||||
// "Löschen", editierbarer Kommentar) — ein Projekt antippen bleibt möglich
|
||||
// (öffnet/schließt die Zeile, "Öffnen" springt ins Projekt), ein Kiosk-Konto
|
||||
// bekäme auf jeden Schreibversuch ohnehin ein serverseitiges 403.
|
||||
// toggleBoardRow() (board.js) ruft nach jedem Auf-/Zuklappen zusätzlich
|
||||
// renderWandboard() auf, damit eine hier aufgeklappte Zeile sofort sichtbar
|
||||
// wird, genau wie es das für renderBoard()/renderWochenbericht() schon tut.
|
||||
// Parameter: w. Rückgabe: HTML-String.
|
||||
function wandProjektlisteHtml(w){
|
||||
const locked = true;
|
||||
const grouped = peopleByRole(w);
|
||||
const allPeople = [];
|
||||
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
|
||||
|
||||
const groups = {};
|
||||
GROUP_ORDER.forEach(g => { groups[g] = []; });
|
||||
(Array.isArray(w.projects) ? w.projects : []).forEach(p => {
|
||||
(groups[p.group] || (groups[p.group] = [])).push(p);
|
||||
});
|
||||
Object.keys(groups).forEach(g => groups[g].sort((a, b) => (a.order || 0) - (b.order || 0)));
|
||||
|
||||
const abschnitte = sortGroups(Object.keys(groups)).map(g => {
|
||||
if(groups[g].length === 0) return '';
|
||||
const zeilen = groups[g].map(p => renderBoardRow(p, allPeople, locked, w.label, BOARD_OPTS)).join('');
|
||||
return '<div class="section-title">' + esc(g) + '</div><div class="board-list">' + zeilen + '</div>';
|
||||
}).join('');
|
||||
|
||||
return '<div class="wand-projektliste">'
|
||||
+ '<h3 class="dash-panel-titel">Alle Projekte</h3>'
|
||||
+ abschnitte
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// wandSpringeZuProjekt: verlässt die Wandtafel und öffnet den Projektbereich
|
||||
// eines Projekts, optional direkt in einem bestimmten Abschnitt — derselbe
|
||||
// Weg (Hash VOR dem Tab-Wechsel setzen, showTab('plan') übernimmt ihn über
|
||||
// updateRouteUrl()) wie die Termin-Links im Dashboard
|
||||
// (js/dashboard/dashboard.js), nur als unmittelbarer Bereichswechsel statt
|
||||
// eines neuen Tabs — die Wandtafel hat nur die eine Ansicht. Der Hash wird
|
||||
// IMMER gesetzt (auch leer->'dashboard'), damit ein von einem früheren Sprung
|
||||
// übrig gebliebener Hash nicht fälschlich übernommen wird. (Die Rückkehr zur
|
||||
// Wandtafel nach einer Minute Untätigkeit — wandUnterwegs — wird nicht hier,
|
||||
// sondern generisch über den Klick-Lauscher auf #wandContent ausgelöst, da
|
||||
// die Projektliste über renderBoardRow() auch ganz ohne diese Funktion direkt
|
||||
// openProjectPlan() aufruft; siehe dort.) Parameter: pid (Projekt-ID),
|
||||
// abschnitt (Bereichs-Schlüssel, z. B. 'aufgaben'). Keine Rückgabe.
|
||||
function wandSpringeZuProjekt(pid, abschnitt){
|
||||
location.hash = abschnitt || 'dashboard';
|
||||
openProjectPlan(pid);
|
||||
}
|
||||
|
||||
// ---------- Vollbild-Übersichten (gemeinsamer Rahmen) ----------
|
||||
|
||||
// wandUebersichtRahmen: Kopfzeile (Titel + Schließen) plus Inhalt — von
|
||||
// jeder Übersicht genutzt. Parameter: titel, inhalt (HTML). Rückgabe:
|
||||
// HTML-String.
|
||||
function wandUebersichtRahmen(titel, inhalt){
|
||||
return '<div class="wand-uebersicht">'
|
||||
+ '<div class="wand-uebersicht-kopf"><h2>' + esc(titel) + '</h2>'
|
||||
+ '<button type="button" class="secondary small" onclick="wandSchliesseUebersicht()">← Zurück zur Wandtafel</button></div>'
|
||||
+ inhalt
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// wandUebersichtZeile: eine Zeile im gemeinsamen Dreispalten-Format
|
||||
// (links/mitte/rechts). onclick ist ein fertiger JS-Ausdruck als String oder
|
||||
// null für eine nicht anklickbare Zeile (z. B. eine Abwesenheit, die auf kein
|
||||
// Projekt verweist). Parameter: links, mitte, rechts (bereits escapte
|
||||
// HTML-Strings), onclick, rechtsKlasse (optional, z. B. 'ist-rot').
|
||||
// Rückgabe: HTML-String.
|
||||
function wandUebersichtZeile(links, mitte, rechts, onclick, rechtsKlasse){
|
||||
return '<div class="wand-uebersicht-zeile' + (onclick ? ' ist-klickbar' : '') + '"'
|
||||
+ (onclick ? ' onclick="' + onclick + '"' : '') + '>'
|
||||
+ '<span class="wand-uebersicht-projekt">' + links + '</span>'
|
||||
+ '<span class="wand-uebersicht-titel">' + mitte + '</span>'
|
||||
+ '<span class="wand-uebersicht-seit' + (rechtsKlasse ? ' ' + rechtsKlasse : '') + '">' + rechts + '</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// wandZeigeUebersicht: öffnet eine Vollbild-Übersicht anstelle der
|
||||
// zweispaltigen Ansicht. Parameter: typ (String), gewerk (optional, nur für
|
||||
// 'auslastung'/'besprochen'). Keine Rückgabe.
|
||||
function wandZeigeUebersicht(typ, gewerk){
|
||||
wandUebersicht = { typ, gewerk: gewerk || null };
|
||||
renderWandboard();
|
||||
}
|
||||
|
||||
// wandSchliesseUebersicht: zurück zur zweispaltigen Ansicht. Keine
|
||||
// Parameter, keine Rückgabe.
|
||||
function wandSchliesseUebersicht(){
|
||||
wandUebersicht = null;
|
||||
renderWandboard();
|
||||
}
|
||||
|
||||
// wandUebersichtHtml: baut die gerade angeforderte Übersicht. Parameter: w.
|
||||
// Rückgabe: HTML-String.
|
||||
function wandUebersichtHtml(w){
|
||||
const u = wandUebersicht;
|
||||
if(u.typ === 'ueberfaellig') return wandUebersichtUeberfaellig(w);
|
||||
if(u.typ === 'offen') return wandUebersichtOffeneAufgaben(w);
|
||||
if(u.typ === 'abwesend') return wandUebersichtAbwesend(w);
|
||||
if(u.typ === 'auslastung') return wandUebersichtAuslastung(w, u.gewerk);
|
||||
if(u.typ === 'besprochen') return wandUebersichtBesprochen(w, u.gewerk);
|
||||
return '';
|
||||
}
|
||||
|
||||
// ---------- Kachel 1: Auslastung je Gewerk ----------
|
||||
|
||||
// wandAuslastungJeGewerk: Wochenstunden, Kapazität UND die beitragenden
|
||||
// Projekte je Gewerk der aktuellen Sitzung. Rechnet mit denselben Funktionen
|
||||
// wie renderLoad() (js/load/load.js), nur nach Gewerk statt nach Person
|
||||
// summiert — dieselbe Stelle im Projekt-Durchlauf, an der auch renderLoad()
|
||||
// aggregiert. Die Projektliste je Gewerk ist der Rohstoff für die
|
||||
// Gewerk-Übersicht (wandUebersichtAuslastung()).
|
||||
//
|
||||
// Ein Projekt mit MEHREREN Gewerken zählt seinen vollen Beitrag bei JEDEM
|
||||
// seiner Gewerke — eine bewusste Dopplung, keine Aufteilung: Es gibt keine
|
||||
// Regel, wie die Stunden zwischen den Gewerken aufzuteilen wären, und eine
|
||||
// erfundene Aufteilung wäre irreführender als eine offen sichtbare Dopplung.
|
||||
// Parameter: w (Wochen-Objekt). Rückgabe: { GEWERK: {stunden, kapazitaet,
|
||||
// bewertbar, gesamt, projekte:[{id,name,nr,stunden}]} }.
|
||||
function wandAuslastungJeGewerk(w){
|
||||
const ergebnis = {};
|
||||
WAND_GEWERKE.forEach(g => { ergebnis[g] = { stunden: 0, kapazitaet: 0, bewertbar: 0, gesamt: 0, projekte: [] }; });
|
||||
|
||||
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
|
||||
(Array.isArray(w.people) ? w.people : []).forEach(p => {
|
||||
const global = (db.globalPeople || []).find(x => x.id === p.id)
|
||||
|| (p.fullName ? (db.globalPeople || []).find(x => x.fullName === p.fullName) : null);
|
||||
const gewerk = global && global.gewerk;
|
||||
if(!gewerk || !ergebnis[gewerk]) return;
|
||||
ergebnis[gewerk].kapazitaet += kapazitaetsFaktor(p) * vollzeit;
|
||||
});
|
||||
|
||||
(Array.isArray(w.projects) ? w.projects : []).forEach(pr => {
|
||||
if(pr.group === 'In Anbahnung') return;
|
||||
const gewerke = (Array.isArray(pr.gewerke) ? pr.gewerke : []).filter(g => ergebnis[g]);
|
||||
if(gewerke.length === 0) return;
|
||||
gewerke.forEach(g => { ergebnis[g].gesamt++; });
|
||||
|
||||
// Stunden dieser Woche allein aus Aufgaben mit Aufwand (load.js).
|
||||
const av = aufgabenVerlaufJePerson(pr);
|
||||
if(offeneAufwandsAufgaben(pr).length) gewerke.forEach(g => { ergebnis[g].bewertbar++; });
|
||||
const projektStunden = Object.values(av).reduce((s, v) => s + (v[0] || 0), 0);
|
||||
gewerke.forEach(g => {
|
||||
ergebnis[g].stunden += projektStunden;
|
||||
if(projektStunden > 0) ergebnis[g].projekte.push({ id: pr.id, name: pr.name, nr: pr.nr, stunden: projektStunden });
|
||||
});
|
||||
});
|
||||
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
// wandUebersichtAuslastung: alle Projekte, die zu einem Gewerk beitragen,
|
||||
// nach Stunden absteigend — Antippen springt ins Projekt (Abschnitt
|
||||
// "Leistung", der dort ohnehin schon errechnete Honorar/Fortschritt-Bezug
|
||||
// passt inhaltlich am ehesten zur Auslastungs-Frage). Parameter: w, gewerk.
|
||||
// Rückgabe: HTML-String.
|
||||
function wandUebersichtAuslastung(w, gewerk){
|
||||
const daten = wandAuslastungJeGewerk(w)[gewerk];
|
||||
const label = (DEPARTMENTS[gewerk] && DEPARTMENTS[gewerk].label) || gewerk;
|
||||
if(!daten || daten.projekte.length === 0){
|
||||
return wandUebersichtRahmen('Auslastung ' + label, '<div class="wand-leer">Keine Projekte mit Stunden in diesem Gewerk.</div>');
|
||||
}
|
||||
const sortiert = daten.projekte.slice().sort((a, b) => b.stunden - a.stunden);
|
||||
const zeilen = sortiert.map(p =>
|
||||
wandUebersichtZeile(esc(p.nr || ''), esc(p.name), stundenFormat(p.stunden) + ' h',
|
||||
'wandSpringeZuProjekt(\'' + esc(p.id) + '\',\'leistung\')')
|
||||
).join('');
|
||||
return wandUebersichtRahmen('Auslastung ' + label, '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
|
||||
}
|
||||
|
||||
// ---------- Kachel 2: Wochenbericht besprochen ----------
|
||||
|
||||
// wandBesprochenDaten: "X von Y besprochen" je Gewerk plus die noch NICHT
|
||||
// besprochenen Projekte (Rohstoff der Gewerk-Übersicht). "In Anbahnung"
|
||||
// zählt nicht mit (dieselbe Ausnahme wie in renderLoad()). Parameter: w.
|
||||
// Rückgabe: { GEWERK: {besprochen, gesamt, offeneProjekte:
|
||||
// [{id,uid,name,nr}]} }.
|
||||
function wandBesprochenDaten(w){
|
||||
const ergebnis = {};
|
||||
WAND_GEWERKE.forEach(g => {
|
||||
const relevante = (w.projects || []).filter(p =>
|
||||
Array.isArray(p.gewerke) && p.gewerke.includes(g) && p.group !== 'In Anbahnung');
|
||||
// Je Gewerk sein eigener Haken (kommentarVon(), js/utils.js).
|
||||
const offeneProjekte = relevante.filter(p => !kommentarVon(p, g).besprochen);
|
||||
ergebnis[g] = {
|
||||
besprochen: relevante.length - offeneProjekte.length,
|
||||
gesamt: relevante.length,
|
||||
offeneProjekte: offeneProjekte.map(p => ({ id: p.id, uid: p.projectUid, name: p.name, nr: p.nr })),
|
||||
};
|
||||
});
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
// wandUebersichtBesprochen: die noch nicht besprochenen Projekte eines
|
||||
// Gewerks — Antippen springt NICHT in den Projektbereich, sondern in den
|
||||
// Wochenbericht (springZumWochenbericht(), js/plan/plan-info.js): Der
|
||||
// Kommentar/besprochen-Haken wird ausschließlich dort gepflegt ("Ein Ort je
|
||||
// Angabe", CLAUDE.md), ein Sprung ins Projekt selbst würde nur auf den
|
||||
// Verweis-Hinweis dorthin treffen. Parameter: w, gewerk. Rückgabe:
|
||||
// HTML-String.
|
||||
function wandUebersichtBesprochen(w, gewerk){
|
||||
const daten = wandBesprochenDaten(w)[gewerk];
|
||||
const label = (DEPARTMENTS[gewerk] && DEPARTMENTS[gewerk].label) || gewerk;
|
||||
if(!daten || daten.offeneProjekte.length === 0){
|
||||
return wandUebersichtRahmen('Noch offen — ' + label, '<div class="wand-leer">Alle Projekte dieses Gewerks sind besprochen.</div>');
|
||||
}
|
||||
const zeilen = daten.offeneProjekte.map(p =>
|
||||
wandUebersichtZeile(esc(p.nr || ''), esc(p.name), 'noch offen',
|
||||
p.uid ? 'springZumWochenbericht(\'' + esc(p.uid) + '\')' : null)
|
||||
).join('');
|
||||
return wandUebersichtRahmen('Noch offen — ' + label, '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
|
||||
}
|
||||
|
||||
// ---------- Kachel 3: Heute (Aufgaben + Abwesenheiten) ----------
|
||||
|
||||
// wandHeuteDaten: offene/überfällige Aufgaben UND die heute abwesenden
|
||||
// Personen in einer Kachel — beides beantwortet dieselbe Frage ("was ist
|
||||
// heute los"), getrennte Kacheln dafür wären zwei dünne Kacheln nebeneinander
|
||||
// gewesen. Unternehmensweit über plan.todos ALLER Projekte, anders als GET
|
||||
// /my-dashboard (nur die eigenen Aufgaben); alle Personen, nicht "außer mir"
|
||||
// wie bei /my-dashboard — auf der Wandtafel gibt es kein "mich".
|
||||
// Parameter: w. Rückgabe: {offen, offeneListe:[{titel,projekt,pid,datum}],
|
||||
// ueberfaelligeListe:[{titel,projekt,pid,datum}],
|
||||
// abwesend:[{name,typ,bis}]}.
|
||||
function wandHeuteDaten(w){
|
||||
const heute = heuteIso();
|
||||
const offeneListe = [];
|
||||
const ueberfaelligeListe = [];
|
||||
(w.projects || []).forEach(pr => {
|
||||
const todos = (pr.plan && Array.isArray(pr.plan.todos)) ? pr.plan.todos : [];
|
||||
todos.forEach(t => {
|
||||
if(t.done) return;
|
||||
const eintrag = {
|
||||
titel: t.title || 'Aufgabe',
|
||||
projekt: (pr.nr ? pr.nr + ' ' : '') + pr.name,
|
||||
datum: t.end || '',
|
||||
pid: pr.id,
|
||||
};
|
||||
offeneListe.push(eintrag);
|
||||
if(t.end && t.end < heute) ueberfaelligeListe.push(eintrag);
|
||||
});
|
||||
});
|
||||
// Überfällig: am längsten überfällig zuerst. Offen: nächste Frist zuerst,
|
||||
// Aufgaben ohne Termin ans Ende (dieselbe Reihenfolge wie "Meine Aufgaben"
|
||||
// im Dashboard).
|
||||
ueberfaelligeListe.sort((a, b) => a.datum.localeCompare(b.datum));
|
||||
offeneListe.sort((a, b) => (a.datum || '9999') < (b.datum || '9999') ? -1 : (a.datum || '9999') > (b.datum || '9999') ? 1 : 0);
|
||||
|
||||
const abwesend = (db.globalPeople || []).filter(p =>
|
||||
Array.isArray(p.absences) && p.absences.some(a => a.start <= heute && heute <= a.end)
|
||||
).map(p => {
|
||||
const a = p.absences.find(a => a.start <= heute && heute <= a.end);
|
||||
return { name: p.fullName || p.name, typ: (a && a.type) || 'Abwesend', bis: a && a.end };
|
||||
});
|
||||
|
||||
return { offen: offeneListe.length, offeneListe, ueberfaelligeListe, abwesend };
|
||||
}
|
||||
|
||||
// wandTileHeute ist entfallen — "offene Aufgaben"/"überfällig" stehen jetzt
|
||||
// in der Kennzahlen-Reihe (wandKpiReiheHtml()), das Abwesenheits-Kürzel im
|
||||
// Kopf (wandDashKopfHtml()); beide lesen weiterhin aus wandHeuteDaten().
|
||||
|
||||
// wandUebersichtOffeneAufgaben: ALLE offenen Aufgaben (nicht nur
|
||||
// überfällige), nächste Frist zuerst. Parameter: w. Rückgabe: HTML-String.
|
||||
function wandUebersichtOffeneAufgaben(w){
|
||||
const d = wandHeuteDaten(w);
|
||||
if(d.offeneListe.length === 0){
|
||||
return wandUebersichtRahmen('Offene Aufgaben', '<div class="wand-leer">Keine offenen Aufgaben.</div>');
|
||||
}
|
||||
const zeilen = d.offeneListe.map(t =>
|
||||
wandUebersichtZeile(esc(t.projekt), '„' + esc(t.titel) + '"', t.datum ? esc(relativerTag(t.datum)) : 'ohne Termin',
|
||||
'wandSpringeZuProjekt(\'' + esc(t.pid) + '\',\'aufgaben\')')
|
||||
).join('');
|
||||
return wandUebersichtRahmen('Offene Aufgaben (' + d.offeneListe.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
|
||||
}
|
||||
|
||||
// wandUebersichtUeberfaellig: alle überfälligen Aufgaben, am längsten
|
||||
// überfällig zuerst — jede Zeile springt ins Projekt, Abschnitt "Aufgaben".
|
||||
// Parameter: w. Rückgabe: HTML-String.
|
||||
function wandUebersichtUeberfaellig(w){
|
||||
const d = wandHeuteDaten(w);
|
||||
if(d.ueberfaelligeListe.length === 0){
|
||||
return wandUebersichtRahmen('Überfällige Aufgaben', '<div class="wand-leer">Keine überfälligen Aufgaben.</div>');
|
||||
}
|
||||
const zeilen = d.ueberfaelligeListe.map(t =>
|
||||
wandUebersichtZeile(esc(t.projekt), '„' + esc(t.titel) + '"', esc(relativerTag(t.datum)),
|
||||
'wandSpringeZuProjekt(\'' + esc(t.pid) + '\',\'aufgaben\')', 'ist-rot')
|
||||
).join('');
|
||||
return wandUebersichtRahmen('Überfällige Aufgaben (' + d.ueberfaelligeListe.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
|
||||
}
|
||||
|
||||
// wandUebersichtAbwesend: alle heute abwesenden Personen mit Art und bis
|
||||
// wann — nicht anklickbar (eine Abwesenheit gehört zu keinem Projekt, in das
|
||||
// man springen könnte). Parameter: w. Rückgabe: HTML-String.
|
||||
function wandUebersichtAbwesend(w){
|
||||
const d = wandHeuteDaten(w);
|
||||
if(d.abwesend.length === 0){
|
||||
return wandUebersichtRahmen('Abwesend heute', '<div class="wand-leer">Niemand abwesend.</div>');
|
||||
}
|
||||
const zeilen = d.abwesend.map(p =>
|
||||
wandUebersichtZeile(esc(p.name), esc(p.typ), p.bis ? 'bis ' + esc(formatWeekDate(p.bis)) : '', null)
|
||||
).join('');
|
||||
return wandUebersichtRahmen('Abwesend heute (' + d.abwesend.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
|
||||
}
|
||||
|
||||
// ---------- Ereignis-Feed (Panel "Aktuell") ----------
|
||||
|
||||
// ===========================================================================
|
||||
// Normalansicht "Wandtafel in Glas" (Eriks Wahl aus dem Entwurf): gleicher
|
||||
// Hintergrund wie die App (Lichtflächen + Logo, styles.css, Block
|
||||
// "WANDTAFEL IN GLAS"), Schrift Outfit wie die Kalenderwoche der Login-Seite.
|
||||
// Jedes Element ist für sich antippbar und führt in dieselben Übersichten
|
||||
// bzw. Projekte wie zuvor.
|
||||
// ===========================================================================
|
||||
|
||||
// wtKopfHtml: Logo + "Dashboard", Sitzung, heute Abwesende (antippbar →
|
||||
// Übersicht "abwesend"), große Uhr mit Datum (#wtUhr, sekündlich von
|
||||
// wandCountdownTick() nachgeführt). Parameter: w. Rückgabe: HTML.
|
||||
function wtKopfHtml(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 tage = [];
|
||||
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 => {
|
||||
const projekt = (pr.nr ? pr.nr + ' ' : '') + (pr.name || '');
|
||||
const plan = pr.plan || {};
|
||||
// Eriks Wunsch: In den Tagesspalten stehen NUR Meilensteine. Aufgaben
|
||||
// (offen/überfällig) sind über die Kennzahlen oben erreichbar, die heute
|
||||
// Abwesenden über die Kopfleiste.
|
||||
(plan.milestones || []).forEach(m => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
tage.forEach(t => t.eintraege.sort((a, b) => String(a.sort).localeCompare(String(b.sort))));
|
||||
return tage;
|
||||
}
|
||||
|
||||
// wtTageHtml: die Spalten der nächsten Tage, nur mit Meilensteinen (Eriks
|
||||
// Wunsch). Ein Eintrag springt in den Terminplan seines Projekts.
|
||||
// Parameter: w. Rückgabe: HTML.
|
||||
function wtTageHtml(w){
|
||||
const tage = wtTageDaten(w);
|
||||
const WT = ['So','Mo','Di','Mi','Do','Fr','Sa'];
|
||||
const gruppe = {};
|
||||
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 eintraege = sichtbar.map(e => {
|
||||
let kopf = '';
|
||||
if(t.heute && gruppe[e.art] && e.art !== letzteGruppe){ kopf = '<div class="wt-gruppe wt-' + e.art + '">' + gruppe[e.art] + '</div>'; letzteGruppe = e.art; }
|
||||
const klick = e.pid ? ' onclick="wandSpringeZuProjekt(\'' + esc(e.pid) + '\',\'' + e.abschnitt + '\')"' : '';
|
||||
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>';
|
||||
}).join('');
|
||||
// "+ n weitere" steht fest am unteren Rand; wtSpaltenEinpassen() zählt
|
||||
// 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>'
|
||||
+ '<div class="wt-tag-liste">' + (eintraege || '<div class="wt-leer">Kein Meilenstein.</div>') + '</div>' + mehr + '</div>';
|
||||
}).join('');
|
||||
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>';
|
||||
}
|
||||
|
||||
// wtSpaltenEinpassen: blendet in jeder Tagesspalte die Einträge aus, die
|
||||
// nicht mehr in die Höhe passen (die Spaltenhöhe hängt vom Bildschirm ab,
|
||||
// zweizeilige Titel brauchen mehr Platz), und zählt sie zu "+ n weitere".
|
||||
// Läuft nach jedem Zeichnen der Normalansicht. Keine Parameter.
|
||||
function wtSpaltenEinpassen(){
|
||||
document.querySelectorAll('#wandContent .wt-tag').forEach(tag => {
|
||||
const liste = tag.querySelector('.wt-tag-liste');
|
||||
const mehr = tag.querySelector('.wt-mehr');
|
||||
if(!liste || !mehr) return;
|
||||
let versteckt = 0;
|
||||
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;
|
||||
}
|
||||
const rest = (Number(mehr.dataset.rest) || 0) + versteckt;
|
||||
mehr.textContent = '+ ' + rest + ' weitere';
|
||||
mehr.hidden = rest === 0;
|
||||
});
|
||||
}
|
||||
|
||||
// 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>';
|
||||
}
|
||||
|
||||
+5
-2
@@ -2,7 +2,7 @@
|
||||
// Projektübersicht — Sitzungs-/Wochen-Verwaltung
|
||||
// -----------------------------
|
||||
// Diese Datei verwaltet die Sitzungen (Wochen): Auswahl, Anzeige des Datums,
|
||||
// Sperren (Auto-Lock) und Löschen. Sie wird von `Test.html` nach `js/store.js`
|
||||
// Sperren (Auto-Lock) und Löschen. Sie wird von `App.html` nach `js/store.js`
|
||||
// geladen und stellt globale Funktionen wie `currentWeek()`, `switchWeek()`,
|
||||
// `applyAutoLock()`, `guardWeekLocked()`, `unlockWeek()` und `deleteWeekById()`
|
||||
// bereit. Genutzt wird
|
||||
@@ -138,7 +138,9 @@ function unlockCurrentWeek(){ unlockWeek(currentWeek()); }
|
||||
// üblichen renderAll() zusätzlich läuft — die Admin-Liste zeichnet sich
|
||||
// darüber selbst neu, weil renderAll() sie nicht kennt). Keine Rückgabe.
|
||||
function deleteWeekById(weekId, nachDelete){
|
||||
if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
|
||||
// Gezählt werden ALLE Sitzungen, nicht nur die geladenen (db.wochenListe).
|
||||
const gesamt = (db.wochenListe && db.wochenListe.length) || db.weeks.length;
|
||||
if(gesamt<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
|
||||
const w = db.weeks.find(x => x.id === weekId);
|
||||
if(!w) return;
|
||||
if(!confirm('Sitzung "' + w.label + '" wirklich löschen?')) return;
|
||||
@@ -151,6 +153,7 @@ function deleteWeekById(weekId, nachDelete){
|
||||
console.log('Woche gelöscht:', w.label);
|
||||
const warAktuell = (weekId === db.currentWeek);
|
||||
db.weeks = db.weeks.filter(x=>x.id!==weekId);
|
||||
if(Array.isArray(db.wochenListe)) db.wochenListe = db.wochenListe.filter(x => x.label !== w.label);
|
||||
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||||
// Zeigte die Oberfläche gerade die gelöschte Sitzung (oder zeigt sie aus
|
||||
// irgendeinem Grund gar keine mehr existierende), auf die jüngste
|
||||
|
||||
@@ -16,17 +16,19 @@
|
||||
// Der Vergleich zur Vorwoche läuft über project_uid (siehe
|
||||
// migrateAddProjectUid() in server.js) — die dauerhafte, wochenunabhängige
|
||||
// Projekt-Identität, die project_id (wird bei jedem cloneWeek() neu
|
||||
// vergeben) nicht bieten konnte. Die Bearbeitung der Projekte selbst
|
||||
// (Kommentar-Editor, LPH, Beteiligte) läuft vollständig über die bestehenden
|
||||
// Funktionen aus js/board/board.js (editProj/editLph/cycleAssign/
|
||||
// renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses Modul
|
||||
// ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die
|
||||
// Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten).
|
||||
// vergeben) nicht bieten konnte. Die Bearbeitung von Kommentar und LPH läuft
|
||||
// vollständig über die bestehenden Funktionen aus js/board/board.js
|
||||
// (editLph/renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses
|
||||
// Modul ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die
|
||||
// Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten). Die
|
||||
// Beteiligten-Zuordnung zeigt dieser Tab seit ihrer Verlagerung in die
|
||||
// Projekteinstellungen nur noch zum Lesen, mit Verweis dorthin
|
||||
// (springZuEinstellungen(), js/gantt.js).
|
||||
// Die Anwesenheit lag früher im Projektboard; sie gehört hierher, weil in
|
||||
// diesem Tab die Sitzung abgearbeitet wird.
|
||||
//
|
||||
// Läuft wie board.js als klassisches globales Skript (kein Modul/IIFE), nur
|
||||
// von Test.html geladen (nach js/board/board.js).
|
||||
// von App.html geladen (nach js/board/board.js).
|
||||
|
||||
// findPreviousWeek: liefert die chronologisch vorherige Sitzung zu `week`
|
||||
// (nach weekSortKey sortiert) oder null, wenn `week` die früheste ist.
|
||||
@@ -54,7 +56,11 @@ function lphSignature(p){
|
||||
// ihr (z. B. gerade erst angelegtes Projekt) gilt es als neu. Parameter: p
|
||||
// (Projekt der aktuellen Sitzung), prevWeek (Vorwoche-Objekt oder null).
|
||||
// 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)
|
||||
? prevWeek.projects.find(x => x.projectUid === p.projectUid)
|
||||
: null;
|
||||
@@ -62,8 +68,10 @@ function classifyProjectChange(p, prevWeek){
|
||||
if(!prev){
|
||||
return { bucket: 'new', reason: 'Neues Projekt' };
|
||||
}
|
||||
const statusNow = (p.status || '').trim();
|
||||
const statusPrev = (prev.status || '').trim();
|
||||
const kNow = kommentarVon(p, g);
|
||||
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
|
||||
// Kommentar. Seit cloneWeek() (js/weeks.js) den Text der Vorwoche nicht
|
||||
// mehr mitkopiert, startet jede duplizierte Sitzung mit leerem Kommentar —
|
||||
@@ -79,7 +87,7 @@ function classifyProjectChange(p, prevWeek){
|
||||
// wortgleich den Text der Vorwoche, setzt dabei aber einen neuen
|
||||
// Zeitstempel. Ohne diese Prüfung landete das Projekt unter "Unverändert",
|
||||
// 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' };
|
||||
}
|
||||
if(lphSignature(p) !== lphSignature(prev)){
|
||||
@@ -89,11 +97,12 @@ function classifyProjectChange(p, prevWeek){
|
||||
}
|
||||
|
||||
// BERICHT_OPTS: Darstellungs-Optionen, die dieser Tab an die gemeinsamen
|
||||
// Render-Funktionen aus js/board/board.js übergibt. Im Wochenbericht werden
|
||||
// nur die beauftragten Leistungsphasen gezeigt (p.beauftragteLP) — im
|
||||
// Projektboard bleibt es bei allen. Den Verlauf der Vorwochen hängt
|
||||
// renderBoardDrawer() inzwischen überall an (renderProjektVerlauf() in
|
||||
// js/utils.js), er braucht deshalb kein eigenes Kennzeichen mehr.
|
||||
// Render-Funktionen aus js/board/board.js übergibt. dicht ist das einzige,
|
||||
// was den Wochenbericht noch vom Projektboard unterscheidet (zwei statt drei
|
||||
// Spalten) — nurBeauftragteLph gilt inzwischen für beide (BOARD_OPTS,
|
||||
// js/board/board.js). Den Verlauf der Vorwochen hängt renderBoardDrawer()
|
||||
// überall an (renderProjektVerlauf() in js/utils.js), er braucht deshalb kein
|
||||
// eigenes Kennzeichen mehr.
|
||||
const BERICHT_OPTS = { nurBeauftragteLph: true, dicht: true };
|
||||
|
||||
// renderReportCard: baut die immer aufgeklappte Karte eines Projekts im
|
||||
@@ -120,27 +129,6 @@ function renderReportCard(p, reason, allPeople, locked, weekLabel, variant){
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderReportSection: baut einen Abschnitt des Berichts (Überschrift mit
|
||||
// Anzahl, Liste der zugehörigen Projekte). `expanded` steuert, ob die volle
|
||||
// Karte (renderReportCard) oder die normale einklappbare Board-Zeile
|
||||
// (renderBoardRow) genutzt wird. Parameter: title, entries (Array
|
||||
// {p, reason}), allPeople, locked, expanded (Boolean), emptyText. Rückgabe:
|
||||
// HTML-String.
|
||||
function renderReportSection(title, entries, allPeople, locked, expanded, emptyText, weekLabel, variant){
|
||||
let html = `<div class="section-title">${esc(title)} (${entries.length})</div>`;
|
||||
if(entries.length === 0){
|
||||
html += `<div class="hint">${esc(emptyText)}</div>`;
|
||||
return html;
|
||||
}
|
||||
html += `<div class="${expanded ? 'report-list' : 'board-list'}">`;
|
||||
html += entries.map(({p, reason}) => expanded
|
||||
? renderReportCard(p, reason, allPeople, locked, weekLabel, variant)
|
||||
: renderBoardRow(p, allPeople, locked, weekLabel, BERICHT_OPTS)
|
||||
).join('');
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---------- Anwesenheit der Sitzung ----------
|
||||
// Die Anwesenheit hing ursprünglich als Zweitfunktion an der Kürzel-Legende
|
||||
// unter dem Projektboard ("klick für Anwesenheit") — Namenserklärung und
|
||||
@@ -174,21 +162,32 @@ function absenceNoteFor(personId, weekDate){
|
||||
}
|
||||
|
||||
// renderAttendance: baut den Anwesenheitsblock der aktuellen Sitzung.
|
||||
// Aufgerufen aus renderBoard(). Keine Parameter, keine Rückgabe.
|
||||
// Aufgerufen innerhalb dieser Datei (toggleAttendance(), setAllAttendance()
|
||||
// sowie innerhalb/rund um renderWochenbericht()) — NICHT aus renderBoard(),
|
||||
// die Anwesenheit ist allein eine Wochenbericht-Angelegenheit. Keine
|
||||
// Parameter, keine Rückgabe.
|
||||
function renderAttendance(){
|
||||
const container = document.getElementById('weekAttendance');
|
||||
if(!container) return;
|
||||
const w = currentWeek();
|
||||
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 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
|
||||
// Abschnitt wie Leistungsphasen/Beteiligte, kein dauerhaft sichtbarer Block
|
||||
// mehr — Zustand in berichtDisclosureState (js/board/board.js).
|
||||
const offen = berichtDisclosureState.anwesenheit ? 'open' : '';
|
||||
|
||||
if(people.length === 0){
|
||||
container.innerHTML = `<div class="attendance-block"><div class="attendance-bar">
|
||||
<span class="attendance-title">Anwesenheit</span></div>
|
||||
<div class="attendance-grid"><span class="hint">Für diese Sitzung sind keine Projektbeteiligten hinterlegt.</span></div></div>`;
|
||||
container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}>
|
||||
<summary><span>Anwesenheit</span><span class="chev">${icon('chevronRight')}</span></summary>
|
||||
<div class="bericht-disclosure-body"><span class="hint" style="margin:0">Für dieses Gewerk sind in dieser Sitzung keine Projektbeteiligten hinterlegt.</span></div>
|
||||
</details>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -197,9 +196,10 @@ function renderAttendance(){
|
||||
// ein offener Kommentar-Editor beim Klick den Fokus, sein Blur-Netz baut den
|
||||
// ganzen Bericht neu auf, die Schaltfläche verschwindet dabei — und der
|
||||
// Klick kommt nie an (erst der zweite Versuch wirkte).
|
||||
const actions = locked ? '' : `
|
||||
const actions = locked ? '' : `<div class="attendance-actions">
|
||||
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(true)">Alle anwesend</button>
|
||||
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(false)">Zurücksetzen</button>`;
|
||||
<button type="button" class="secondary small" onmousedown="event.preventDefault()" onclick="setAllAttendance(false)">Zurücksetzen</button>
|
||||
</div>`;
|
||||
|
||||
const tiles = people.map(p => {
|
||||
const present = !!att[p.id];
|
||||
@@ -221,15 +221,10 @@ function renderAttendance(){
|
||||
</button>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `<div class="attendance-block">
|
||||
<div class="attendance-bar">
|
||||
<span class="attendance-title">Anwesenheit</span>
|
||||
<span class="attendance-count">${anwesend} von ${people.length} anwesend</span>
|
||||
<span class="attendance-spacer"></span>
|
||||
${actions}
|
||||
</div>
|
||||
<div class="attendance-grid">${tiles}</div>
|
||||
</div>`;
|
||||
container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}>
|
||||
<summary><span>Anwesenheit ${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>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
// toggleAttendance: schaltet die Anwesenheit einer Person für die aktuelle
|
||||
@@ -257,7 +252,7 @@ function setAllAttendance(present){
|
||||
const w = currentWeek();
|
||||
if(!w) return;
|
||||
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();
|
||||
renderAttendance();
|
||||
}
|
||||
@@ -265,9 +260,9 @@ function setAllAttendance(present){
|
||||
// renderWochenbericht: baut den kompletten Wochenbericht-Tab für die eine
|
||||
// offene Sitzung auf (currentWeek(), js/weeks.js) — Hinweis auf die Vorwoche,
|
||||
// dann die drei Gruppen. Wird nach jedem Laden sowie nach jeder Änderung
|
||||
// über editLph()/cycleAssign() (js/board/board.js) neu aufgerufen, damit ein
|
||||
// Projekt sofort in die passende Gruppe wandert. Keine Parameter, keine
|
||||
// Rückgabe.
|
||||
// über editLph() (js/board/board.js) bzw. planCycleAssign()
|
||||
// (js/plan/plan-settings.js) neu aufgerufen, damit ein Projekt sofort in die
|
||||
// passende Gruppe wandert. Keine Parameter, keine Rückgabe.
|
||||
// ---------- Tagesordnung: Zustand ----------
|
||||
|
||||
// berichtAuswahl: project_uid des Projekts, das gerade rechts im Detail
|
||||
@@ -373,46 +368,66 @@ function waehleBerichtProjekt(uid){
|
||||
|
||||
// ---------- Besprochen-Haken ----------
|
||||
|
||||
// toggleBesprochen: hakt ein Projekt als besprochen ab bzw. nimmt den Haken
|
||||
// zurück. Der Haken gehört zur Sitzung (Spalte projects.besprochen) und geht
|
||||
// deshalb durch den normalen Speicherweg. Parameter: pid. Keine Rückgabe.
|
||||
function toggleBesprochen(pid){
|
||||
if(guardWeekLocked()) return;
|
||||
const w = currentWeek();
|
||||
const p = (w.projects || []).find(x => x.id === pid);
|
||||
if(!p) return;
|
||||
p.besprochen = !p.besprochen;
|
||||
save();
|
||||
renderWochenbericht();
|
||||
}
|
||||
// HINWEIS: toggleBesprochen() ist entfallen — die schmale Nummern-Leiste des
|
||||
// Fokus-Modus (Eriks Wahl "C") hat keinen Platz mehr für einen Haken je
|
||||
// Zeile, und die Detail-Kopfzeile zeigt bewusst keinen eigenen mehr (siehe
|
||||
// freigegebener Entwurf). Einzelne Projekte abhaken läuft über
|
||||
// besprochenUndWeiter() (einzeln, beim Weitergehen), alle zurücknehmen über
|
||||
// setzeAlleBesprochen(false) ("Zurücksetzen" in der Kopfleiste) — ein
|
||||
// bewusstes Zurücknehmen nur EINES bereits abgehakten Projekts ohne die
|
||||
// übrigen anzutasten ist damit nicht mehr vorgesehen.
|
||||
|
||||
// besprochenUndWeiter: hakt das aktuelle Projekt ab und springt zum nächsten,
|
||||
// das noch offen ist. Der Motor des Meetings — ohne ihn sucht man nach jedem
|
||||
// Projekt von Hand die nächste Zeile. Parameter: pid. Keine Rückgabe.
|
||||
function besprochenUndWeiter(pid){
|
||||
if(guardWeekLocked()) return;
|
||||
if(guardWeekLocked() || !hatRecht('wb.besprochen')) return;
|
||||
const w = currentWeek();
|
||||
const p = (w.projects || []).find(x => x.id === pid);
|
||||
if(!p) return;
|
||||
p.besprochen = true;
|
||||
kommentarSetzen(p, wochenberichtGewerk, { besprochen: true });
|
||||
save();
|
||||
|
||||
const liste = berichtProjekte(w);
|
||||
const idx = liste.findIndex(x => x.id === pid);
|
||||
// Erst hinter der aktuellen Stelle suchen, dann von vorn — so landet man
|
||||
// auch dann beim nächsten offenen Punkt, wenn man mittendrin eingestiegen ist.
|
||||
const offen = liste.slice(idx + 1).find(x => !x.besprochen)
|
||||
|| liste.find(x => !x.besprochen);
|
||||
const istAb = x => kommentarVon(x, wochenberichtGewerk).besprochen;
|
||||
const offen = liste.slice(idx + 1).find(x => !istAb(x))
|
||||
|| liste.find(x => !istAb(x));
|
||||
berichtAuswahl = offen ? offen.projectUid : p.projectUid;
|
||||
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.
|
||||
// Parameter: wert (Boolean). Keine Rückgabe.
|
||||
function setzeAlleBesprochen(wert){
|
||||
if(guardWeekLocked()) return;
|
||||
const w = currentWeek();
|
||||
(w.projects || []).forEach(p => { p.besprochen = !!wert; });
|
||||
// berichtProjekte(w) statt w.projects direkt: Der Knopf wirkt auf die
|
||||
// gerade sichtbare, nach wochenberichtGewerk gefilterte Liste - sonst
|
||||
// markierte "Alle abhaken" bei gewähltem Gewerk auch die Projekte der
|
||||
// ANDEREN Gewerke als besprochen, die in dieser Besprechung nie zu sehen
|
||||
// waren (widerspricht "Wochenbericht — Gewerke-Trennung": Vollständigkeit
|
||||
// gilt innerhalb des gewählten Gewerks, nicht über alle hinweg).
|
||||
// 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();
|
||||
renderWochenbericht();
|
||||
}
|
||||
@@ -420,20 +435,34 @@ function setzeAlleBesprochen(wert){
|
||||
// ---------- Abwesenheit ----------
|
||||
|
||||
// abwesendeBeteiligte: die einem Projekt zugeordneten Personen, die heute
|
||||
// fehlen. "Heute" ist der Anwesenheitsstand dieser Sitzung (w.attendance) —
|
||||
// er wird am Fuß des Berichts ohnehin erfasst. Punkte, deren Zuständige
|
||||
// fehlen, vertagt man im Meeting besser.
|
||||
// Parameter: p (Projekt), w (Woche). Rückgabe: Array von Personennamen.
|
||||
// laut ihrer EINGETRAGENEN Abwesenheit fehlen (Urlaub, Krank, Fortbildung …
|
||||
// aus der Benutzerverwaltung, db.globalPeople[].absences) — Eriks Wunsch.
|
||||
// Früher wurde stattdessen die Anwesenheitsliste am Fuß des Berichts
|
||||
// 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){
|
||||
const att = w.attendance || {};
|
||||
// Solange niemand als anwesend markiert ist, ist die Anwesenheit schlicht
|
||||
// noch nicht erfasst — dann wäre "alle fehlen" eine Falschmeldung.
|
||||
if(!Object.values(att).some(v => v)) return [];
|
||||
const heute = heuteIso();
|
||||
const konten = Array.isArray(db.globalPeople) ? db.globalPeople : [];
|
||||
const namen = [];
|
||||
(w.people || []).forEach(person => {
|
||||
const rollen = Array.isArray(person.role) ? person.role : [person.role];
|
||||
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;
|
||||
}
|
||||
@@ -455,12 +484,15 @@ function passtZurSuche(p){
|
||||
|
||||
// ---------- Rendern ----------
|
||||
|
||||
// renderWochenbericht: die Tagesordnung des Teammeetings — links die
|
||||
// vollständige Projektliste, rechts das gewählte Projekt in voller Größe.
|
||||
// renderWochenbericht: die Tagesordnung des Teammeetings — links eine
|
||||
// schmale Nummern-Leiste über die vollständige Projektliste (Fokus-Modus,
|
||||
// Eriks Wahl "C" aus dem Darstellungs-Vergleich: Nummer und Änderungs-Punkt
|
||||
// sichtbar, Nummer und Name im Tooltip), rechts das gewählte Projekt in
|
||||
// voller Größe.
|
||||
//
|
||||
// Diese Aufteilung hat die frühere Abfolge gestapelter Karten abgelöst: Bei
|
||||
// 29 Projekten war der Bericht 18 Bildschirmseiten lang, und man verlor beim
|
||||
// Durchgehen die Orientierung. Jetzt bleibt die Liste stehen, man sieht
|
||||
// Durchgehen die Orientierung. Jetzt bleibt die Leiste stehen, man sieht
|
||||
// jederzeit, wo man steht und was noch kommt, und scrollt nicht mehr.
|
||||
//
|
||||
// Die Liste ist nach PROJEKTGRUPPE geordnet (wie im Projektboard), nicht mehr
|
||||
@@ -494,29 +526,29 @@ function renderWochenbericht(){
|
||||
if(locked){
|
||||
html += `<div class="lock-notice">
|
||||
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>`;
|
||||
}
|
||||
|
||||
html += renderBerichtKopf(w, liste, prevWeek, locked);
|
||||
|
||||
if(liste.length === 0){
|
||||
html += `<div class="hint">In dieser Sitzung ist noch kein Projekt angelegt.</div>`;
|
||||
html += renderBerichtKopf(w, liste, prevWeek, locked);
|
||||
html += `<div class="hint">Für ${esc(wochenberichtGewerk)} steht in dieser Sitzung kein Projekt auf der Tagesordnung.</div>`;
|
||||
container.innerHTML = html;
|
||||
renderAttendance();
|
||||
return;
|
||||
}
|
||||
|
||||
html += `<div class="bericht-split">
|
||||
<div class="bericht-liste-spalte">
|
||||
<div class="bericht-suche">
|
||||
<input type="search" id="berichtSuche" placeholder="Nummer oder Name…"
|
||||
value="${esc(berichtSuche)}" oninput="setzeBerichtSuche(this.value)"
|
||||
aria-label="Zu einem Projekt springen">
|
||||
</div>
|
||||
<div class="bericht-liste" id="berichtListe"></div>
|
||||
// Eine Fläche (.bericht-panel), nicht zwei lose nebeneinanderstehende Karten
|
||||
// — Kopfleiste und Leiste|Hauptspalte gehören sichtbar zusammen (Entwurf
|
||||
// "Fokus-Modus"). Die Kopfleiste bekommt einen eigenen Container, damit das
|
||||
// Öffnen/Schließen des Sprungfelds (oeffneBerichtSuche()) nur sie neu
|
||||
// zeichnen muss, nicht die ganze Tagesordnung samt offenem Kommentarfeld.
|
||||
html += `<div class="bericht-panel">
|
||||
<div id="berichtKopf">${renderBerichtKopf(w, liste, prevWeek, locked)}</div>
|
||||
<div class="bericht-layout">
|
||||
<div class="bericht-rail" id="berichtListe"></div>
|
||||
<div class="bericht-main" id="berichtDetail">${renderBerichtDetail(aktiv, w, prevWeek, locked)}</div>
|
||||
</div>
|
||||
<div class="bericht-detail" id="berichtDetail">${renderBerichtDetail(aktiv, w, prevWeek, locked)}</div>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = html;
|
||||
@@ -524,72 +556,128 @@ function renderWochenbericht(){
|
||||
renderAttendance();
|
||||
}
|
||||
|
||||
// renderBerichtKopf: Vergleichs-Hinweis und Fortschrittsanzeige.
|
||||
// berichtSucheOffen: ob das Sprungfeld gerade als Eingabefeld statt als
|
||||
// Knopf dargestellt wird (Fokus-Modus, Eriks Wahl "C") — der Entwurf zeigt
|
||||
// die Suche standardmäßig nur als schmalen Knopf, nicht als dauerhaft
|
||||
// sichtbares Eingabefeld. Bleibt auch offen, solange tatsächlich gesucht
|
||||
// wird (berichtSuche nicht leer), sonst verschwände der eigene Suchtext beim
|
||||
// nächsten Neu-Rendern wieder hinter dem Knopf.
|
||||
let berichtSucheOffen = false;
|
||||
|
||||
// oeffneBerichtSuche: blendet das Eingabefeld ein und fokussiert es.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function oeffneBerichtSuche(){
|
||||
berichtSucheOffen = true;
|
||||
renderBerichtKopfNeu();
|
||||
const feld = document.getElementById('berichtSuche');
|
||||
if(feld) feld.focus();
|
||||
}
|
||||
|
||||
// schliesseBerichtSucheFallsLeer: fällt beim Verlassen des leeren Feldes
|
||||
// zurück auf den Knopf — ein leeres Feld brächte sonst dauerhaft unnötige
|
||||
// Breite in die Kopfleiste. Keine Parameter, keine Rückgabe.
|
||||
function schliesseBerichtSucheFallsLeer(){
|
||||
if(berichtSuche) return;
|
||||
berichtSucheOffen = false;
|
||||
renderBerichtKopfNeu();
|
||||
}
|
||||
|
||||
// renderBerichtKopfNeu: zeichnet nur die Kopfleiste neu (Gewerk, Fortschritt,
|
||||
// Sprungfeld) — eigene Funktion aus demselben Grund wie renderBerichtListe():
|
||||
// ein Klick auf den Such-Knopf darf weder die Leiste noch einen gerade
|
||||
// offenen Kommentar im Detail antasten. Keine Parameter, keine Rückgabe.
|
||||
function renderBerichtKopfNeu(){
|
||||
const w = currentWeek();
|
||||
const el = document.getElementById('berichtKopf');
|
||||
if(!w || !el) return;
|
||||
const prevWeek = findPreviousWeek(w);
|
||||
const liste = berichtProjekte(w);
|
||||
const locked = isWeekLocked(w);
|
||||
el.innerHTML = renderBerichtKopf(w, liste, prevWeek, locked);
|
||||
}
|
||||
|
||||
// renderBerichtKopf: Gewerke-Wahl, Fortschrittsring und das Sprungfeld.
|
||||
// Parameter: w, liste, prevWeek, locked. Rückgabe: HTML.
|
||||
function renderBerichtKopf(w, liste, prevWeek, locked){
|
||||
const ab = liste.filter(p => p.besprochen).length;
|
||||
const ab = liste.filter(p => kommentarVon(p, wochenberichtGewerk).besprochen).length;
|
||||
const pct = liste.length ? Math.round(ab / liste.length * 100) : 0;
|
||||
// Der Vergleichs-Hinweis ("Vergleich mit KW …") steht nicht mehr als eigene
|
||||
// Zeile da, sondern als Titel-Tooltip am Fortschritt — dieselbe Angabe,
|
||||
// ohne eine weitere, dauerhaft sichtbare Textzeile im Entwurf zu ergänzen.
|
||||
const vergleich = prevWeek
|
||||
? `Vergleich mit ${esc(prevWeek.label)}${prevWeek.date ? ' (' + esc(formatWeekDate(prevWeek.date)) + ')' : ''}.`
|
||||
: 'Dies ist die früheste vorhandene Sitzung — kein Vergleich zur Vorwoche möglich.';
|
||||
|
||||
return `<div class="bericht-kopf">
|
||||
const resetBtn = (locked || !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(true)">Alle abhaken</button>`);
|
||||
|
||||
const sucheAktiv = berichtSucheOffen || !!berichtSuche;
|
||||
const sucheHtml = sucheAktiv
|
||||
? `<input type="search" id="berichtSuche" class="bericht-suche-input" placeholder="Nummer oder Name…"
|
||||
value="${esc(berichtSuche)}" oninput="setzeBerichtSuche(this.value)"
|
||||
onblur="schliesseBerichtSucheFallsLeer()" aria-label="Zu einem Projekt springen">`
|
||||
: `<button type="button" class="bericht-search-btn" onclick="oeffneBerichtSuche()">Projekt suchen…</button>`;
|
||||
|
||||
return `<div class="bericht-topbar">
|
||||
${renderGewerkDropdown()}
|
||||
<span class="hint" style="margin:0">${vergleich}</span>
|
||||
<span class="bericht-fortschritt">
|
||||
<span class="bericht-fortschritt-text">${ab} von ${liste.length} ${esc(wochenberichtGewerk)}-Projekten besprochen</span>
|
||||
<span class="bericht-fortschritt-bar"><i style="width:${pct}%"></i></span>
|
||||
${locked ? '' : (ab > 0
|
||||
? `<button type="button" class="secondary small" onclick="setzeAlleBesprochen(false)">Haken zurücksetzen</button>`
|
||||
: `<button type="button" class="secondary small" onclick="setzeAlleBesprochen(true)">Alle abhaken</button>`)}
|
||||
</span>
|
||||
<span class="bericht-ring" style="background:conic-gradient(var(--green) 0% ${pct}%, var(--line-soft) ${pct}% 100%)"><span></span></span>
|
||||
<span class="bericht-fortschritt-text" title="${esc(vergleich)}">${ab} / ${liste.length} besprochen</span>
|
||||
${resetBtn}
|
||||
${sucheHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderBerichtListe: die Tagesordnung links. Eigene Funktion, weil das
|
||||
// Sprung-Feld sie allein neu zeichnet — das Detail rechts und vor allem ein
|
||||
// gerade getippter Kommentar dürfen dabei nicht verlorengehen.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
// renderBerichtListe: die Tagesordnung als Leiste mit Nummer, Gruppe UND
|
||||
// Name direkt sichtbar (Entwurf "Aktenregal", Eriks Wahl aus dem zweiten
|
||||
// Darstellungs-Vergleich — löst die vorherige schmale Icon-Leiste ab, die
|
||||
// Nummer und Namen nur im Hover-Tooltip zeigte: "so sehe ich die ganze
|
||||
// Tagesordnung auf einen Blick"). Gruppiert wie die Projektliste
|
||||
// (GROUP_ORDER, js/globals.js), mit einer Zwischenüberschrift bei jedem
|
||||
// Gruppenwechsel — die Positionsnummer zählt dabei über alle Gruppen hinweg
|
||||
// weiter, sie ist die Position in der VOLLSTÄNDIGEN Tagesordnung. Eigene
|
||||
// Funktion, weil das Sprung-Feld sie allein neu zeichnet — das Detail und
|
||||
// vor allem ein gerade getippter Kommentar dürfen dabei nicht
|
||||
// verlorengehen. Keine Parameter, keine Rückgabe.
|
||||
function renderBerichtListe(){
|
||||
const el = document.getElementById('berichtListe');
|
||||
const w = currentWeek();
|
||||
if(!el || !w) return;
|
||||
const locked = isWeekLocked(w);
|
||||
const prevWeek = findPreviousWeek(w);
|
||||
const aktiv = aktuellesBerichtProjekt(w);
|
||||
const liste = berichtProjekte(w).filter(passtZurSuche);
|
||||
// voll bleibt UNGEFILTERT: die Kachel-Nummer ist die Position in der
|
||||
// vollständigen Tagesordnung, damit sie beim Tippen im Sprung-Feld nicht
|
||||
// ständig neu durchnummeriert wird und mit der Zählung im Detail
|
||||
// übereinstimmt.
|
||||
const voll = berichtProjekte(w);
|
||||
const liste = voll.filter(passtZurSuche);
|
||||
|
||||
if(liste.length === 0){
|
||||
el.innerHTML = `<div class="hint" style="padding:10px">Kein Projekt passt zu „${esc(berichtSuche)}".</div>`;
|
||||
el.innerHTML = `<div class="hint" style="padding:10px 6px">Kein Projekt passt zu „${esc(berichtSuche)}".</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let letzteGruppe = null;
|
||||
let gruppeVorher = null;
|
||||
el.innerHTML = liste.map(p => {
|
||||
const info = classifyProjectChange(p, prevWeek);
|
||||
const fehlen = abwesendeBeteiligte(p, w);
|
||||
const kopf = p.group !== letzteGruppe
|
||||
? (letzteGruppe = p.group, `<div class="bericht-gruppe">${esc(p.group || 'Ohne Gruppe')}</div>`)
|
||||
: '';
|
||||
const istAktiv = aktiv && aktiv.id === p.id;
|
||||
return kopf + `<div class="bericht-zeile${istAktiv ? ' is-aktiv' : ''}${p.besprochen ? ' is-ab' : ''}"
|
||||
role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}" data-pid="${esc(p.id)}"
|
||||
const position = voll.findIndex(x => x.id === p.id) + 1;
|
||||
const gruppe = p.group || 'Ohne Gruppe';
|
||||
const gruppenkopf = gruppe !== gruppeVorher ? `<div class="bericht-rail-gruppe">${esc(gruppe)}</div>` : '';
|
||||
gruppeVorher = gruppe;
|
||||
const name = (p.nr ? p.nr + ' · ' : '') + p.name;
|
||||
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 || '')}"
|
||||
aria-current="${istAktiv ? 'true' : 'false'}">
|
||||
<button type="button" class="bericht-haken${p.besprochen ? ' ab' : ''}"
|
||||
${locked ? 'disabled' : `onclick="event.stopPropagation();toggleBesprochen('${esc(p.id)}')"`}
|
||||
title="${p.besprochen ? 'Haken zurücknehmen' : 'Als besprochen markieren'}"
|
||||
aria-label="${esc(p.name)} als besprochen markieren">✓</button>
|
||||
<span class="bericht-zeile-nr">${esc(p.nr || '—')}</span>
|
||||
<span class="bericht-zeile-name">${esc(p.name)}</span>
|
||||
${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''}
|
||||
${fehlen.length ? `<span class="bericht-abwesend" title="Fehlt heute: ${esc(fehlen.join(', '))}">${esc(fehlen.length)}✗</span>` : ''}
|
||||
${info.bucket === 'unchanged' ? '' : `<span class="bericht-punkt is-${esc(info.bucket)}" title="${esc(info.reason)}"></span>`}
|
||||
<span class="bericht-rail-num">${String(position).padStart(2, '0')}</span>
|
||||
${info.bucket === 'unchanged' ? '' : `<span class="bericht-rail-dot is-${esc(info.bucket)}" aria-hidden="true"></span>`}
|
||||
<span class="bericht-rail-name">${esc(name)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
el.querySelectorAll('.bericht-zeile').forEach(z => {
|
||||
el.querySelectorAll('.bericht-rail-item').forEach(z => {
|
||||
const waehlen = () => waehleBerichtProjekt(z.dataset.uid || null);
|
||||
z.addEventListener('click', e => { if(!e.target.closest('.bericht-haken')) waehlen(); });
|
||||
z.addEventListener('click', waehlen);
|
||||
z.addEventListener('keydown', e => {
|
||||
if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); waehlen(); }
|
||||
});
|
||||
@@ -602,6 +690,9 @@ function renderBerichtListe(){
|
||||
// "Besprochen & weiter". Parameter: p, w, prevWeek, locked. Rückgabe: HTML.
|
||||
function renderBerichtDetail(p, w, prevWeek, locked){
|
||||
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 allPeople = [];
|
||||
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
|
||||
@@ -609,46 +700,189 @@ function renderBerichtDetail(p, w, prevWeek, locked){
|
||||
const info = classifyProjectChange(p, prevWeek);
|
||||
const tags = Array.isArray(p.tags) ? p.tags : [];
|
||||
const fehlen = abwesendeBeteiligte(p, w);
|
||||
const liste = berichtProjekte(w);
|
||||
const nr = liste.findIndex(x => x.id === p.id) + 1;
|
||||
// Reiner Text wie im freigegebenen Entwurf — kein zusätzlicher
|
||||
// Auftragsstatus-Chip mehr an dieser Stelle (der Status bleibt über die
|
||||
// Projektliste/-einstellungen erreichbar, "Ein Ort je Angabe").
|
||||
const subText = esc(p.group || 'Ohne Gruppe') + (tags.length ? ' · ' + tags.map(t => esc(t)).join(' · ') : '');
|
||||
|
||||
return `<div class="bericht-detail-kopf">
|
||||
<span class="bericht-detail-zaehler">${nr} / ${liste.length}</span>
|
||||
<span class="bericht-zeile-nr">${esc(p.nr || '—')}</span>
|
||||
<h3>${esc(p.name)}</h3>
|
||||
${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''}
|
||||
${tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('')}
|
||||
${info.bucket === 'unchanged' ? '' : `<span class="bericht-kennz is-${esc(info.bucket)}">${esc(info.reason)}</span>`}
|
||||
return `<div class="bericht-head-line">
|
||||
<span class="bericht-head-nr">${esc(p.nr || '—')}</span>
|
||||
${info.bucket === 'unchanged' ? '' : `<span class="bericht-change-pill is-${esc(info.bucket)}">${esc(info.reason)}</span>`}
|
||||
</div>
|
||||
<div class="bericht-detail-meta">${esc(p.group || 'Ohne Gruppe')}</div>
|
||||
${fehlen.length ? `<div class="bericht-abwesend-hinweis">Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.</div>` : ''}
|
||||
${renderVorwochenzeile(p, prevWeek)}
|
||||
${renderBoardDrawer(p, allPeople, locked, w.label, BERICHT_OPTS)}
|
||||
${locked ? '' : `<div class="bericht-detail-fuss">
|
||||
<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
|
||||
<h1 class="bericht-h1">${esc(p.name)}</h1>
|
||||
<div class="bericht-sub">${subText}</div>
|
||||
${fehlen.length ? `<span class="bericht-abwesend-hinweis">Heute abwesend: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''}
|
||||
${renderBoardDrawer(p, allPeople, kLocked, w.label, Object.assign({}, BERICHT_OPTS, { ohneVerlauf: true }))}
|
||||
<div class="bericht-detail-fuss">
|
||||
${!locked && hatRecht('proj.plan') ? `<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
|
||||
Aufgabe aus Kommentar…
|
||||
</button>
|
||||
</button>` : ''}
|
||||
<span class="bericht-fuss-spacer"></span>
|
||||
<button type="button" onclick="besprochenUndWeiter('${esc(p.id)}')">Besprochen & weiter →</button>
|
||||
</div>`}`;
|
||||
<button type="button" class="secondary" onclick="berichtWeiter('${esc(p.id)}')"${berichtNaechstes(w, p.id) ? '' : ' disabled title="Letztes Projekt der Liste"'}>Weiter →</button>
|
||||
${!locked && hatRecht('wb.besprochen') ? `<button type="button" onclick="besprochenUndWeiter('${esc(p.id)}')">Besprochen & weiter →</button>` : ''}
|
||||
</div>
|
||||
${renderBerichtVerlauf(p, w, kLocked)}`;
|
||||
}
|
||||
|
||||
// renderVorwochenzeile: der Kommentar der Vorwoche als eine Zeile ÜBER dem
|
||||
// Eingabefeld. Die häufigste Frage im Meeting ist "was hatten wir letzte
|
||||
// Woche vereinbart?" — der Verlauf beantwortet sie zwar, aber erst nach dem
|
||||
// Aufklappen. Parameter: p, prevWeek. Rückgabe: HTML (leer ohne Vorwoche).
|
||||
function renderVorwochenzeile(p, prevWeek){
|
||||
if(!prevWeek || !p.projectUid) return '';
|
||||
const vor = (prevWeek.projects || []).find(x => x.projectUid === p.projectUid);
|
||||
if(!vor) return '';
|
||||
const text = statusPlainText(vor.status);
|
||||
if(!text) return '';
|
||||
return `<div class="bericht-vorwoche">
|
||||
<span class="bericht-vorwoche-label">${esc(prevWeek.label)}</span>
|
||||
<span class="bericht-vorwoche-text">${esc(text)}</span>
|
||||
// ---------- Verlauf der Vorwochen (Eriks Wahl "B") ----------
|
||||
//
|
||||
// Löst das Scrollrad über dem Kommentarfeld (renderVorwocheRad()) und den
|
||||
// aufklappbaren Kachel-Streifen (renderProjektVerlauf(), bleibt für
|
||||
// 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()).
|
||||
|
||||
// Gezeigt wird, was geladen ist — beim Start die drei Sitzungen vor der
|
||||
// aktuellen; „Weitere laden“ holt je fünf ältere (weitereLadenKnopf(),
|
||||
// js/store.js) und zeichnet nur diesen Abschnitt neu (berichtVerlaufNeu()).
|
||||
|
||||
// berichtKurzKw: "KW 43 / 2026" -> "KW 43" (Jahr nur, wenn nicht das laufende)
|
||||
function berichtKurzKw(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];
|
||||
}
|
||||
|
||||
// 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 '';
|
||||
const e = berichtVerlaufDaten(p, w, wochenberichtGewerk).eintraege[0];
|
||||
if(!e) return '';
|
||||
// Mit Formatierung (fett, kursiv, Farbe, Zeilen) wie im Verlauf darunter —
|
||||
// e.html ist bereits über sanitizeStatusHtml() bereinigt.
|
||||
return `<div class="bericht-zuletzt">
|
||||
<span class="bericht-zuletzt-kw">Zuletzt ${esc(berichtKurzKw(e.week.label))}</span>
|
||||
<div class="bericht-zuletzt-text">${e.html}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderBerichtVerlauf: der Abschnitt "Verlauf der Vorwochen" unter der
|
||||
// Bearbeitungsmaske. Parameter: p, w, locked. Rückgabe: HTML.
|
||||
function renderBerichtVerlauf(p, w, locked){
|
||||
if(!p || !p.projectUid) return '';
|
||||
const g = wochenberichtGewerk;
|
||||
const { sitzungen, eintraege } = berichtVerlaufDaten(p, w, g);
|
||||
const gText = g || 'alle Gewerke';
|
||||
let inhalt;
|
||||
if(eintraege.length === 0){
|
||||
inhalt = `<div class="hint" style="margin:0">${sitzungen === 0 && !aeltereSitzungenAnzahl()
|
||||
? 'Dieses Projekt erscheint zum ersten Mal, es gibt noch keine frühere Sitzung.'
|
||||
: sitzungen === 0 ? 'In den geladenen Sitzungen steht dieses Projekt noch nicht.'
|
||||
: 'In den früheren Sitzungen gibt es noch keinen Kommentar für ' + esc(gText) + '.'}</div>`;
|
||||
} else {
|
||||
const zeigen = eintraege;
|
||||
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 = weitereLadenKnopf('berichtVerlaufNeu');
|
||||
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>`;
|
||||
}
|
||||
|
||||
// berichtVerlaufNeu: zeichnet nur den Verlauf neu (nach „Weitere laden“),
|
||||
// damit ein angefangener Kommentar unberührt bleibt.
|
||||
function berichtVerlaufNeu(){
|
||||
const w = currentWeek();
|
||||
const p = w && aktuellesBerichtProjekt(w);
|
||||
const el = document.getElementById('berichtVerlauf');
|
||||
if(!p || !el) return;
|
||||
el.outerHTML = renderBerichtVerlauf(p, w, isWeekLocked(w));
|
||||
}
|
||||
|
||||
// berichtVerlaufUebernehmen: schreibt den Kommentar jener Sitzung in das
|
||||
// Kommentarfeld der aktuellen (takeCommentFromHistory(), js/board/board.js).
|
||||
function berichtVerlaufUebernehmen(weekLabel){
|
||||
const feld = document.querySelector('#berichtDetail .comment-block .comment-field');
|
||||
if(feld) takeCommentFromHistory(feld, weekLabel);
|
||||
}
|
||||
|
||||
// ---------- Aufgabe aus dem Kommentar ----------
|
||||
//
|
||||
// Beschlüsse fallen im Meeting und landen bisher als Fließtext im Kommentar —
|
||||
@@ -672,8 +906,13 @@ function openTodoAusKommentar(pid){
|
||||
// Vorschlag aus dem gerade im Feld stehenden Text, nicht aus dem
|
||||
// gespeicherten: Im Meeting tippt man den Beschluss und legt sofort die
|
||||
// Aufgabe an, ohne vorher zu speichern.
|
||||
// feld ist seit der Umstellung auf contenteditable (Eriks Wunsch nach
|
||||
// echter Fett-/Kursiv-/Farbdarstellung statt sichtbarer Markup-Zeichen,
|
||||
// js/utils.js) ein <div> ohne .value — statusTextMitZeilen() liefert
|
||||
// daraus reinen Text mit erhaltenen Zeilenumbrüchen, aus dem unten die
|
||||
// erste Zeile als Titelvorschlag gezogen wird.
|
||||
const feld = document.querySelector('#wochenbericht .comment-field');
|
||||
const text = feld ? feld.value : 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) || '';
|
||||
|
||||
document.getElementById('todoKommentarTitel').value = ersteZeile.slice(0, 120);
|
||||
@@ -722,6 +961,13 @@ function saveTodoAusKommentar(){
|
||||
return;
|
||||
}
|
||||
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);
|
||||
|
||||
if(!p.plan) p.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 };
|
||||
@@ -760,8 +1006,9 @@ function openBerichtProtokoll(){
|
||||
if(prevWeek) zeilen.push('Vergleich mit ' + prevWeek.label);
|
||||
zeilen.push('');
|
||||
|
||||
const anwesend = (w.people || []).filter(x => (w.attendance || {})[x.id]).map(x => x.fullName || x.name);
|
||||
const fehlend = (w.people || []).filter(x => !(w.attendance || {})[x.id]).map(x => x.fullName || x.name);
|
||||
const leute = personenDesGewerks(w, wochenberichtGewerk);
|
||||
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){
|
||||
zeilen.push('Anwesend: ' + anwesend.join(', '));
|
||||
if(fehlend.length) zeilen.push('Entschuldigt: ' + fehlend.join(', '));
|
||||
@@ -774,7 +1021,7 @@ function openBerichtProtokoll(){
|
||||
if(info.bucket === 'unchanged') return; // nur, was sich geändert hat
|
||||
anzahl++;
|
||||
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);
|
||||
// Aufgaben, die in dieser Sitzung entstanden sind, gehören ins Protokoll:
|
||||
// Sie sind das Ergebnis der Besprechung.
|
||||
@@ -836,24 +1083,37 @@ function protokollAlsMail(){
|
||||
// ---------- Tastatur ----------
|
||||
//
|
||||
// Wer im Meeting mitschreibt, hat keine Hand für die Maus frei. ↓/↑ wechseln
|
||||
// das Projekt, Strg+↵ speichert den Kommentar und geht weiter. Die Tasten
|
||||
// greifen nur im Wochenbericht und nicht, während in einem Eingabefeld
|
||||
// getippt wird — mit einer Ausnahme: Strg+↵ im Kommentarfeld ist genau der
|
||||
// Fall, für den es gedacht ist.
|
||||
// das Projekt, Strg+↵ im Kommentarfeld speichert den Kommentar und geht
|
||||
// weiter. Die Tasten greifen nur im Wochenbericht und nicht, während in
|
||||
// einem Eingabefeld getippt wird.
|
||||
document.addEventListener('keydown', function(e){
|
||||
if(typeof currentTab === 'undefined' || currentTab !== 'wochenbericht') return;
|
||||
const w = currentWeek();
|
||||
if(!w) return;
|
||||
const imFeld = e.target && e.target.matches && e.target.matches('input, textarea, select');
|
||||
// isContentEditable erfasst das Kommentarfeld (seit der Umstellung auf
|
||||
// contenteditable ein <div>, kein <textarea> mehr, siehe js/utils.js) —
|
||||
// ohne diese Ergänzung griffen ↓/↑ dort als Projekt-Wechsel statt als
|
||||
// normale Cursorbewegung im Text.
|
||||
const imFeld = e.target && ((e.target.matches && e.target.matches('input, textarea, select')) || e.target.isContentEditable);
|
||||
|
||||
if((e.key === 'Enter') && (e.ctrlKey || e.metaKey)){
|
||||
const feld = e.target && e.target.closest ? e.target.closest('.comment-field') : null;
|
||||
// Strg+Enter nur abfangen, wenn der Fokus tatsächlich im Kommentarfeld
|
||||
// liegt — sonst OHNE feld-Prüfung, z. B. in einem Leistungsphasen-
|
||||
// Eingabefeld (.bericht-lph-input) oder im Titelfeld des "Aufgabe aus
|
||||
// Kommentar"-Dialogs getippt, sprang die App sofort zum nächsten Projekt
|
||||
// und baute die Liste neu auf — ein gerade eingetippter, noch nicht per
|
||||
// onchange/blur gespeicherter Wert ging dabei kommentarlos verloren, und
|
||||
// ein offen stehender Dialog bezog sich danach lautlos auf das falsche
|
||||
// (inzwischen gewechselte) Projekt. Ohne feld bricht diese Weiche jetzt
|
||||
// einfach ab und überlässt Strg+Enter dem normalen Feldverhalten.
|
||||
if(!feld) return;
|
||||
const aktiv = aktuellesBerichtProjekt(w);
|
||||
if(!aktiv) return;
|
||||
e.preventDefault();
|
||||
// Der Kommentar speichert sich über den bestehenden Weg selbst; hier
|
||||
// folgt nur der Sprung zum nächsten offenen Punkt.
|
||||
if(feld && typeof saveBoardComment === 'function'){
|
||||
if(typeof saveBoardComment === 'function'){
|
||||
const knopf = feld.closest('.comment-block').querySelector('.comment-editor-foot button');
|
||||
if(knopf) saveBoardComment(knopf);
|
||||
}
|
||||
|
||||
File renamed without changes.
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 223 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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 |
+1108
-77
File diff suppressed because it is too large.
Load diff
+1888
-418
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user