Author SHA1 Message Date
erikandClaude Sonnet 5 11eabe42ae Protokoll-Import: KI-Erkennung (Gruppe, Teilnehmer, Status, Mehrfachpunkte), Ladeanzeige, Erstellungsdatum je Punkt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 10:54:43 +02:00
GitButler 6f18848f86 GitButler Workspace Commit
This is placeholder commit and will be replaced by a merge of your virtual branches.

Due to GitButler managing multiple virtual branches, you cannot switch back and
forth between git branches and virtual branches easily. 

If you switch to another branch, GitButler will need to be reinitialized.
If you commit on this branch, GitButler will throw it away.

For more information about what we're doing here, check out our docs:
https://docs.gitbutler.com/features/branch-management/integration-branch
2026-10-01 06:47:37 +02:00
145 changed files with 4079 additions and 25084 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
omit=optional
+101 -1992
View File
File diff suppressed because it is too large. Load diff
+5 -9
View File
@@ -7,10 +7,10 @@ WORKDIR /app
# package.json und package-lock.json zuerst kopieren, damit npm-Installation gecacht wird # package.json und package-lock.json zuerst kopieren, damit npm-Installation gecacht wird
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm config set fetch-retries 6 && npm config set fetch-retry-maxtimeout 180000 && npm config set fetch-timeout 600000 && npm ci --omit=dev RUN npm ci --production
# Nur die Laufzeit-Dateien kopieren # Nur die Laufzeit-Dateien kopieren
COPY server.js pdf-arbeit.js ./ COPY server.js ./
COPY public ./public COPY public ./public
# Sessions-Ordner fuer die SQLite-Datenbank sicherstellen # Sessions-Ordner fuer die SQLite-Datenbank sicherstellen
@@ -20,11 +20,7 @@ USER node
EXPOSE 3003 EXPOSE 3003
# wget (busybox) statt eines eigenen Node-Prozesses je Prüfung: auf dem HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
# Pi Zero kostete jeder Healthcheck sonst ~40 MB und eine Sekunde CPU. 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))"
HEALTHCHECK --interval=60s --timeout=10s --start-period=60s --retries=3 \
CMD wget -qO- http://127.0.0.1:3003/health >/dev/null || exit 1
# --max-old-space-size: Node räumt früher auf, statt bis an die CMD ["node", "server.js"]
# Container-Grenze zu wachsen (Pi Zero 2, 512 MB). Siehe docker-compose.yml.
CMD ["node", "--max-old-space-size=160", "server.js"]
-236
View File
@@ -1,236 +0,0 @@
#!/usr/bin/env bash
# pi-einrichten.sh — macht Ubuntu Server auf dem Raspberry Pi Zero 2 (512 MB)
# stabil für die Projektübersicht. Mehrfach ausführbar (prüft jeden Schritt).
#
# sudo bash Docker/pi-einrichten.sh # Standard
# sudo bash Docker/pi-einrichten.sh --snapd-aus # zusätzlich snapd abschalten
#
# Was es tut (Einzelheiten in CLAUDE.md, "Betrieb auf dem Pi"):
# 1. Hardware-Watchdog: hängt das System, startet der Pi selbst neu
# 2. zram: komprimierter Auslagerungsspeicher im RAM (statt SD-Karte)
# 3. Speichergrenzen für Container erlauben (cgroup memory)
# 4. gpu_mem=16 — kein Bildschirm, mehr RAM für Linux
# 5. Unnötige Dienste aus (multipathd, ModemManager, udisks2 …)
# 6. earlyoom: greift ein, BEVOR das System im Speichermangel festfriert
# 7. Systemprotokoll auf 50 MB begrenzen
# 8. Nächtlicher Neustart des Containers (3:30 Uhr)
# 9. Prüfung der Stromversorgung (Unterspannung)
# 10. WLAN: Energiesparen aus, Wächter verbindet neu und startet den Pi
# neu, wenn das Netz 10 Minuten lang weg bleibt
set -u
if [ "$(id -u)" -ne 0 ]; then echo "Bitte mit sudo ausführen."; exit 1; fi
SNAPD_AUS=0
[ "${1:-}" = "--snapd-aus" ] && SNAPD_AUS=1
NEUSTART_NOETIG=0
FIRMWARE=/boot/firmware
[ -d "$FIRMWARE" ] || FIRMWARE=/boot
ok() { echo " ✓ $*"; }
info() { echo " • $*"; }
warn() { echo " ! $*"; }
echo "== Projektübersicht: Pi einrichten =="
echo "System: $(. /etc/os-release; echo "$PRETTY_NAME"), $(uname -m), $(free -m | awk '/Mem:/{print $2}') MB RAM"
# 1. Hardware-Watchdog ------------------------------------------------------
echo "[1] Hardware-Watchdog"
mkdir -p /etc/systemd/system.conf.d
cat > /etc/systemd/system.conf.d/10-watchdog.conf <<'EOF'
# Hängt das System, löst der Watchdog des Pi nach 14 s einen Neustart aus
# (der bcm2835-Watchdog kann höchstens ~15 s).
[Manager]
RuntimeWatchdogSec=14s
RebootWatchdogSec=2min
EOF
if [ -e /dev/watchdog ]; then ok "Watchdog aktiv nach Neustart (/dev/watchdog vorhanden)"
else warn "/dev/watchdog fehlt — Kernelmodul bcm2835_wdt wird beim Start geladen"; echo bcm2835_wdt > /etc/modules-load.d/watchdog.conf; NEUSTART_NOETIG=1; fi
systemctl daemon-reexec
# 2. zram -------------------------------------------------------------------
echo "[2] zram (komprimierter Auslagerungsspeicher)"
apt-get update -qq
if apt-cache show systemd-zram-generator >/dev/null 2>&1; then
apt-get install -y -qq systemd-zram-generator >/dev/null
cat > /etc/systemd/zram-generator.conf <<'EOF'
[zram0]
zram-size = ram / 2
compression-algorithm = zstd
swap-priority = 100
EOF
systemctl daemon-reload
systemctl restart systemd-zram-setup@zram0.service 2>/dev/null || true
ok "systemd-zram-generator eingerichtet (halber RAM, zstd)"
else
apt-get install -y -qq zram-tools >/dev/null
printf 'ALGO=zstd\nPERCENT=50\nPRIORITY=100\n' > /etc/default/zramswap
systemctl restart zramswap 2>/dev/null || true
ok "zram-tools eingerichtet (halber RAM, zstd)"
fi
cat > /etc/sysctl.d/90-zram.conf <<'EOF'
# zram ist schnell: früh auslagern, Seiten einzeln holen
vm.swappiness=100
vm.page-cluster=0
vm.vfs_cache_pressure=200
EOF
sysctl -q --system
swapon --show | sed 's/^/ /'
# 3. Speichergrenzen für Container -------------------------------------------
echo "[3] Speichergrenzen für Container (cgroup memory)"
if grep -qw memory /sys/fs/cgroup/cgroup.controllers 2>/dev/null; then
ok "bereits verfügbar"
else
CMDLINE="$FIRMWARE/cmdline.txt"
if [ -f "$CMDLINE" ] && ! grep -q "cgroup_enable=memory" "$CMDLINE"; then
cp "$CMDLINE" "$CMDLINE.vor-projektuebersicht"
sed -i '1 s/$/ cgroup_enable=memory cgroup_memory=1/' "$CMDLINE"
ok "in $CMDLINE ergänzt (Sicherung: cmdline.txt.vor-projektuebersicht)"
NEUSTART_NOETIG=1
else
warn "nicht verfügbar — $CMDLINE prüfen"
fi
fi
# 4. GPU-Speicher -----------------------------------------------------------
echo "[4] GPU-Speicher"
CONFIG="$FIRMWARE/config.txt"
if [ -f "$CONFIG" ]; then
if grep -q "^gpu_mem=" "$CONFIG"; then sed -i 's/^gpu_mem=.*/gpu_mem=16/' "$CONFIG"
else printf '\n# Projektübersicht: kein Bildschirm, mehr RAM für Linux\ngpu_mem=16\n' >> "$CONFIG"; fi
ok "gpu_mem=16"
NEUSTART_NOETIG=1
else warn "$CONFIG nicht gefunden"; fi
# 5. Unnötige Dienste -------------------------------------------------------
echo "[5] Unnötige Dienste abschalten"
for d in multipathd multipathd.socket ModemManager udisks2 packagekit bluetooth avahi-daemon avahi-daemon.socket; do
if systemctl list-unit-files "$d.service" "$d" 2>/dev/null | grep -q "$d"; then
systemctl disable --now "$d" >/dev/null 2>&1 && ok "$d aus"
fi
done
if [ "$SNAPD_AUS" = 1 ]; then
if command -v snap >/dev/null && snap list docker >/dev/null 2>&1; then
warn "Docker ist als Snap installiert — snapd bleibt an"
else
systemctl disable --now snapd.service snapd.socket snapd.seeded.service >/dev/null 2>&1 && ok "snapd aus"
fi
else
info "snapd bleibt an (spart ~40 MB mit --snapd-aus)"
fi
# 6. earlyoom ---------------------------------------------------------------
echo "[6] earlyoom"
apt-get install -y -qq earlyoom >/dev/null
cat > /etc/default/earlyoom <<'EOF'
# Bei unter 5 % freiem RAM UND unter 10 % freiem Swap den größten Prozess
# beenden — nie SSH, Docker oder systemd. Der App-Container startet dann neu.
# Ohne innere Anführungszeichen: systemd reicht sie sonst wörtlich weiter.
EARLYOOM_ARGS="-m 5 -s 10 -r 3600 --avoid ^(sshd|dockerd|containerd|systemd|systemd-journal)$ --prefer ^node$"
EOF
systemctl enable --now earlyoom >/dev/null 2>&1
systemctl restart earlyoom
ok "aktiv"
# 7. Systemprotokoll --------------------------------------------------------
echo "[7] Systemprotokoll begrenzen"
mkdir -p /etc/systemd/journald.conf.d
printf '[Journal]\nSystemMaxUse=50M\n' > /etc/systemd/journald.conf.d/10-groesse.conf
systemctl restart systemd-journald
ok "höchstens 50 MB"
# 8. Nächtlicher Neustart ---------------------------------------------------
echo "[8] Nächtlicher Neustart des Containers"
cat > /etc/cron.d/projektuebersicht <<'EOF'
# Projektübersicht: frischer Speicher jede Nacht (gegen schleichende Lecks)
30 3 * * * root docker restart projektuebersicht >/dev/null 2>&1
EOF
systemctl enable docker >/dev/null 2>&1
ok "täglich 3:30 Uhr; Docker startet beim Booten"
# 9. Stromversorgung --------------------------------------------------------
echo "[9] Stromversorgung"
T=""
[ -r /sys/devices/platform/soc/soc:firmware/get_throttled ] && T=$(cat /sys/devices/platform/soc/soc:firmware/get_throttled)
command -v vcgencmd >/dev/null && T=$(vcgencmd get_throttled | cut -d= -f2)
if [ -z "$T" ]; then info "nicht auslesbar (Paket libraspberrypi-bin installieren)"
elif [ "$((T))" -eq 0 ]; then ok "keine Unterspannung seit dem Start ($T)"
else warn "Unterspannung/Drosselung erkannt ($T) — besseres Netzteil (5 V / 2,5 A) verwenden!"; fi
# 10. WLAN ------------------------------------------------------------------
# Befund 29.10.: Der Pi "stürzte" nicht ab, sondern verlor das WLAN
# (wlan0: Lost carrier) und kam danach nie wieder in das Netz
# (ASSOC-REJECT in Schleife) — erreichbar war er erst nach Stecker ziehen.
echo "[10] WLAN stabil halten"
apt-get install -y -qq iw >/dev/null 2>&1
cat > /etc/systemd/system/wlan-energiesparen-aus.service <<'EOF'
[Unit]
Description=WLAN-Energiesparen aus (Pi Zero 2 verliert sonst die Verbindung)
After=sys-subsystem-net-devices-wlan0.device
Wants=sys-subsystem-net-devices-wlan0.device
[Service]
Type=oneshot
ExecStart=/usr/sbin/iw dev wlan0 set power_save off
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
cat > /usr/local/sbin/wlan-waechter <<'EOF'
#!/bin/sh
# Prüft jede Minute, ob das Gateway erreichbar ist. Ohne Netz: alle 2
# Minuten WLAN neu verbinden, nach 10 Minuten den Pi neu starten.
ZAEHLER=/run/wlan-waechter.zaehler
GW=$(ip route | awk '/^default/{print $3; exit}')
if [ -n "$GW" ] && ping -c 2 -W 3 "$GW" >/dev/null 2>&1; then
rm -f "$ZAEHLER"; exit 0
fi
N=$(( $(cat "$ZAEHLER" 2>/dev/null || echo 0) + 1 ))
echo "$N" > "$ZAEHLER"
logger -t wlan-waechter "kein Netz seit $N Minute(n)"
if [ "$N" -ge 10 ]; then
logger -t wlan-waechter "Neustart des Pi"; systemctl reboot
elif [ $((N % 2)) -eq 0 ]; then
logger -t wlan-waechter "WLAN neu verbinden"
wpa_cli -i wlan0 reassociate >/dev/null 2>&1
if [ "$N" -ge 4 ]; then
ip link set wlan0 down; sleep 2; ip link set wlan0 up
networkctl reconfigure wlan0 >/dev/null 2>&1
fi
fi
EOF
chmod 755 /usr/local/sbin/wlan-waechter
cat > /etc/systemd/system/wlan-waechter.service <<'EOF'
[Unit]
Description=WLAN-Wächter der Projektübersicht
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/wlan-waechter
EOF
cat > /etc/systemd/system/wlan-waechter.timer <<'EOF'
[Unit]
Description=WLAN-Wächter jede Minute
[Timer]
OnBootSec=3min
OnUnitActiveSec=1min
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now wlan-energiesparen-aus.service >/dev/null 2>&1
systemctl enable --now wlan-waechter.timer >/dev/null 2>&1
if iw dev wlan0 get power_save 2>/dev/null | grep -q off; then ok "Energiesparen aus"; else warn "Energiesparen ließ sich nicht abschalten"; fi
ok "Wächter aktiv (neu verbinden ab 2 min, Neustart nach 10 min ohne Netz)"
echo
if [ "$NEUSTART_NOETIG" = 1 ]; then
echo "Fertig. Bitte den Pi jetzt neu starten: sudo reboot"
else
echo "Fertig."
fi
echo "Danach prüfen: bash Docker/pi-pruefen.sh"
-44
View File
@@ -1,44 +0,0 @@
#!/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
+137 -109
View File
@@ -1,129 +1,157 @@
# Projektübersicht # Projektübersicht — README
Werkzeug für ein Ingenieurbüro der technischen Gebäudeausrüstung (TGA), um ## Übersicht
Projekte über wöchentliche Sitzungen hinweg zu verfolgen: Leistungsphasen nach Dieses Projekt ist eine lokale Weboberfläche zur Verwaltung von TGA-Projekten (Elektro, HLS, Architektur) über wöchentliche Sitzungen. Die Anwendung besteht aus:
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.
## Technologie - 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)
- **Frontend**: klassische, unmodulare `<script>`-Dateien unter `public/js/` ## Datei- und Komponentenübersicht
— 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.
## Starten ### 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
### Lokal (Node.js ≥ 22.13) - `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)
```bash ```bash
npm install npm install
npm start npm start
``` ```
Erreichbar unter `http://localhost:3003`. Anderer Port: Danach ist die Anwendung erreichbar unter `http://localhost:3003`.
Falls der Port belegt ist:
```bash ```bash
PORT=3004 npm start PORT=3004 npm start
``` ```
### Mit Docker ### Mit Docker
Im Projektstamm ausführen:
```bash ```bash
docker compose up --build 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.
### Erste Anmeldung Danach ist die Anwendung erreichbar unter:
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.
## Bereiche der Anwendung ```
http://localhost:3003
```
Seitenleiste, in dieser Reihenfolge: ## Wichtige Hinweise
- Der Server benötigt Lese-/Schreibzugriff auf `sessions/` (für die SQLite-Datenbank und das Docker-Bind-Mount).
- **Meine Aufgaben** — Dashboard und Startansicht nach dem Login: offene - 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.
persönliche Aufgaben, Termine der eigenen Projekte, ausstehende - `js/store.js` und `js/server.js` arbeiten mit Abteilungsparametern und speichern Daten abteilungsweise getrennt.
Datei-Freigaben, Abwesenheiten von Kollegen. - Die Passwortfunktion (Tabs „Projekte“/„Projektbeteiligte“, Sitzungslöschung, Entsperren) ist nur ein einfacher Client-Mechanismus im Frontend (SHA256-Vergleich) und kein echter Sicherheitsmechanismus.
- **Projekte** — die eine Projektliste (Board), gruppiert, per Drag-and-Drop - Globale Personen werden pro Abteilung in der Datenbank verwaltet. Wird eine Person global gelöscht, bleibt sie in bereits existierenden Sitzungen sichtbar (Wochen-Personenkopien).
sortierbar. - CryptoJS und pdfmake werden von `cdnjs.cloudflare.com` geladen — PDF-Export und Passwortprüfung funktionieren ohne Internet nicht.
- **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.
+5 -26
View File
@@ -14,31 +14,10 @@ services:
# Sommerzeit gälte damit zwischen 00:00 und 02:00 Ortszeit noch der # Sommerzeit gälte damit zwischen 00:00 und 02:00 Ortszeit noch der
# Vortag, und Termine von heute erschienen als überfällig. # Vortag, und Termine von heute erschienen als überfällig.
TZ: Europe/Berlin TZ: Europe/Berlin
# Speicher-Wächter: darüber startet der Server sich selbst sauber neu, restart: unless-stopped
# bevor die Grenze von 320 MB (mem_limit) erreicht ist.
SPEICHER_MAX_MB: "220"
# PDF-Kindprozess (Protokoll-Import, Plan Checker): bricht darüber ab.
PDF_KIND_MAX_MB: "220"
PDF_KIND_HEAP_MB: "160"
# Hänger-Wächter: reagiert der Server so lange nicht, Neustart.
HAENGER_MAX_S: "90"
restart: always
# Betrieb auf dem Pi Zero 2 (512 MB), siehe CLAUDE.md "Betrieb auf dem Pi":
# tini als PID 1 — Signale kommen sauber bei Node an (DB wird geschlossen).
init: true
# Feste Speichergrenze: Läuft die App über, trifft es nur diesen
# Container (restart: always holt ihn zurück), nicht SSH oder Docker.
mem_limit: 320m
memswap_limit: 512m
# Die Protokolle wuchsen sonst unbegrenzt auf der SD-Karte.
logging:
driver: json-file
options:
max-size: "5m"
max-file: "3"
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/health >/dev/null || exit 1"] 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: 60s interval: 30s
timeout: 10s timeout: 5s
start_period: 60s start_period: 10s
retries: 3 retries: 3
-49
View File
@@ -4,55 +4,6 @@
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "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": { "node_modules/@xmldom/xmldom": {
"version": "0.8.15", "version": "0.8.15",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
-3
View File
@@ -1,3 +0,0 @@
# `@napi-rs/canvas-darwin-arm64`
This is the **aarch64-apple-darwin** binary for `@napi-rs/canvas`
-44
View File
@@ -1,44 +0,0 @@
{
"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"
}
}
Binary file not shown.
-21
View File
@@ -1,21 +0,0 @@
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
View File
@@ -1,445 +0,0 @@
# `skr canvas`
[![CI](https://github.com/Brooooooklyn/canvas/actions/workflows/CI.yaml/badge.svg)](https://github.com/Brooooooklyn/canvas/actions/workflows/CI.yaml)
![Skia Version](https://img.shields.io/badge/Skia-chrome%2Fm156-hotpink)
[![install size](https://packagephobia.com/badge?p=@napi-rs/canvas)](https://packagephobia.com/result?p=@napi-rs/canvas)
[![Downloads](https://img.shields.io/npm/dm/@napi-rs/canvas.svg?sanitize=true)](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()
```
![](./example/simple.png)
## 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)
```
![](./example/draw-emoji.png)
# 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
![boolean-operations](./docs/imgs/boolean-operations.svg)
### 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.
![SVG fill-rule](./docs/imgs/asWinding@2x.png)
```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
View File
@@ -1,873 +0,0 @@
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
View File
File diff suppressed because it is too large. Load diff
-196
View File
@@ -1,196 +0,0 @@
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
View File
@@ -1,464 +0,0 @@
// 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
View File
@@ -1,160 +0,0 @@
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
View File
@@ -1,216 +0,0 @@
/**
* 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
View File
@@ -1,379 +0,0 @@
'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
View File
@@ -1,180 +0,0 @@
{
"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"
}
}
+48 -48
View File
@@ -8,7 +8,6 @@
"name": "projektuebersicht", "name": "projektuebersicht",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^1.0.10",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"mammoth": "^1.13.0", "mammoth": "^1.13.0",
@@ -17,10 +16,11 @@
} }
}, },
"node_modules/@napi-rs/canvas": { "node_modules/@napi-rs/canvas": {
"version": "1.0.10", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.9.tgz",
"integrity": "sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==", "integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"workspaces": [ "workspaces": [
"e2e/*" "e2e/*"
], ],
@@ -32,23 +32,23 @@
"url": "https://github.com/sponsors/Brooooooklyn" "url": "https://github.com/sponsors/Brooooooklyn"
}, },
"optionalDependencies": { "optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.10", "@napi-rs/canvas-android-arm64": "1.0.9",
"@napi-rs/canvas-darwin-arm64": "1.0.10", "@napi-rs/canvas-darwin-arm64": "1.0.9",
"@napi-rs/canvas-darwin-x64": "1.0.10", "@napi-rs/canvas-darwin-x64": "1.0.9",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.10", "@napi-rs/canvas-linux-arm64-gnu": "1.0.9",
"@napi-rs/canvas-linux-arm64-musl": "1.0.10", "@napi-rs/canvas-linux-arm64-musl": "1.0.9",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.10", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.9",
"@napi-rs/canvas-linux-x64-gnu": "1.0.10", "@napi-rs/canvas-linux-x64-gnu": "1.0.9",
"@napi-rs/canvas-linux-x64-musl": "1.0.10", "@napi-rs/canvas-linux-x64-musl": "1.0.9",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.10", "@napi-rs/canvas-win32-arm64-msvc": "1.0.9",
"@napi-rs/canvas-win32-x64-msvc": "1.0.10" "@napi-rs/canvas-win32-x64-msvc": "1.0.9"
} }
}, },
"node_modules/@napi-rs/canvas-android-arm64": { "node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.10", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz",
"integrity": "sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==", "integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -66,9 +66,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-darwin-arm64": { "node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.10", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz",
"integrity": "sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==", "integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -86,9 +86,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-darwin-x64": { "node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.10", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz",
"integrity": "sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==", "integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -106,9 +106,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.10", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz",
"integrity": "sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==", "integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -126,9 +126,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-arm64-gnu": { "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.10", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz",
"integrity": "sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==", "integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -149,9 +149,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-arm64-musl": { "node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.10", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz",
"integrity": "sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==", "integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -172,9 +172,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": { "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.10", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz",
"integrity": "sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==", "integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -195,9 +195,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-x64-gnu": { "node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.10", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz",
"integrity": "sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==", "integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -218,9 +218,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-x64-musl": { "node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.10", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz",
"integrity": "sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==", "integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -241,9 +241,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-win32-arm64-msvc": { "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.10", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz",
"integrity": "sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==", "integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -261,9 +261,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-win32-x64-msvc": { "node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.10", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz",
"integrity": "sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==", "integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
-1
View File
@@ -7,7 +7,6 @@
"start": "node server.js" "start": "node server.js"
}, },
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^1.0.10",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"mammoth": "^1.13.0", "mammoth": "^1.13.0",
-239
View File
@@ -1,239 +0,0 @@
// 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));
}
});
}
File diff suppressed because it is too large. Load diff
File renamed without changes.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

-400
View File
@@ -1,400 +0,0 @@
/* ===========================================================================
GLAS-DARSTELLUNG (Eriks Wahl: Gestaltungsvorschlag "Projektübersicht in
Glas", Annäherung an Apple Liquid Glass)
---------------------------------------------------------------------------
Eine eigene Schicht über styles.css und plan-styles.css, geladen als LETZTES
Stylesheet. Alles hängt an der Klasse html.glas, die ein kleines Skript im
<head> von App.html setzt — ohne sie sieht die App aus wie zuvor. Abschalten
lässt sie sich je Browser unter Einstellungen → Mein Konto ("Glas-
Darstellung", localStorage-Schlüssel projuebersicht-glas).
Prinzip:
- Tokens statt Einzelregeln: --surface & Co. werden durchscheinend, damit
wirken die ~70 Stellen, die var(--surface) als Fläche nutzen, ohne dass
jede einzeln angefasst wird.
- Echtes Glas (Unschärfe des Hintergrunds) nur auf den großen Flächen —
Menüband, Seitenleiste, Karten, Dialoge. Kleine Elemente bleiben ohne
backdrop-filter: verschachtelte Unschärfe kostet Rechenzeit und bringt
auf einer bereits gläsernen Karte nichts.
- Drei Dichten: Rahmen (Menüband/Seitenleiste) am dichtesten, Inhalt
mittel, Dialoge und Menüs fast deckend (sie liegen über Inhalt, der
sonst durchscheinen und den Text stören würde).
- Hintergrund: Lichtflächen in den Logo-Blautönen und das Logo als großes
Wasserzeichen (logo.svg, vektorisiert aus logo.png), beides fixiert.
- Die Wandtafel (body.wandtafel-aktiv) bleibt außen vor: eigenes Design,
und auf dem Kiosk-Rechner wäre Unschärfe zu teuer.
=========================================================================== */
/* ---------- Tokens: hell ---------- */
html.glas {
--bg: #e8eef3;
--surface: rgba(255,255,255,.58);
--surface-alt: rgba(255,255,255,.40);
--surface-sunk: rgba(15,90,133,.07);
--line: rgba(20,45,65,.16);
--line-soft: rgba(20,45,65,.09);
--accent: #0f5a85;
--accent-hover: #0d5079;
--accent-active: #0a4565;
--accent-soft: rgba(15,90,133,.11);
--band-neu: #0f5a85;
--text: #14212b;
--text-muted: #4b5d6b;
--text-subtle: #6f8291;
--r-md: 10px;
--r-lg: 18px;
--r-xl: 22px;
--sh-1: 0 8px 28px rgba(21,58,86,.10), 0 1px 4px rgba(21,58,86,.06);
--sh-2: 0 12px 34px rgba(21,58,86,.13), 0 2px 6px rgba(21,58,86,.07);
--sh-3: 0 20px 60px rgba(21,58,86,.20), 0 4px 14px rgba(21,58,86,.10);
--glas-rahmen: rgba(255,255,255,.62);
--glas-dicht: rgba(255,255,255,.86);
--glas-deckend: #f7fafc;
--glas-kante: rgba(255,255,255,.80);
--glas-kante-2: rgba(255,255,255,.35);
--glas-blur: 24px;
--glas-hell: #94b0cc;
--glas-tief: #116896;
--glas-logo: .16;
}
/* ---------- Tokens: dunkel (System und manuell) ---------- */
@media (prefers-color-scheme: dark) {
html.glas:not([data-theme="light"]) {
--bg: #0b141b;
--surface: rgba(30,46,60,.50);
--surface-alt: rgba(30,46,60,.34);
--surface-sunk: rgba(255,255,255,.06);
--line: rgba(255,255,255,.14);
--line-soft: rgba(255,255,255,.08);
--accent: #8cc9e6;
--accent-hover: #a3d4ec;
--accent-active: #6fb3d3;
--accent-soft: rgba(140,201,230,.15);
--band-neu: #116896;
--text: #e8f0f5;
--text-muted: #a9bccb;
--text-subtle: #7790a2;
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30);
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30);
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35);
--glas-rahmen: rgba(28,42,54,.58);
--glas-dicht: rgba(22,34,44,.90);
--glas-deckend: #15212b;
--glas-kante: rgba(255,255,255,.18);
--glas-kante-2: rgba(255,255,255,.07);
--glas-hell: #2c5675;
--glas-tief: #1d6f99;
--glas-logo: .10;
}
}
html.glas[data-theme="dark"] {
--bg: #0b141b;
--surface: rgba(30,46,60,.50);
--surface-alt: rgba(30,46,60,.34);
--surface-sunk: rgba(255,255,255,.06);
--line: rgba(255,255,255,.14);
--line-soft: rgba(255,255,255,.08);
--accent: #8cc9e6;
--accent-hover: #a3d4ec;
--accent-active: #6fb3d3;
--accent-soft: rgba(140,201,230,.15);
--band-neu: #116896;
--text: #e8f0f5;
--text-muted: #a9bccb;
--text-subtle: #7790a2;
--sh-1: 0 10px 30px rgba(0,0,0,.40), 0 1px 4px rgba(0,0,0,.30);
--sh-2: 0 14px 38px rgba(0,0,0,.45), 0 2px 8px rgba(0,0,0,.30);
--sh-3: 0 22px 64px rgba(0,0,0,.55), 0 4px 16px rgba(0,0,0,.35);
--glas-rahmen: rgba(28,42,54,.58);
--glas-dicht: rgba(22,34,44,.90);
--glas-deckend: #15212b;
--glas-kante: rgba(255,255,255,.18);
--glas-kante-2: rgba(255,255,255,.07);
--glas-hell: #2c5675;
--glas-tief: #1d6f99;
--glas-logo: .10;
}
/* ---------- Hintergrund: Lichtflächen und Logo ---------- */
html.glas body {
background:
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%),
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%),
radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%),
radial-gradient(40% 40% at 28% 82%, color-mix(in srgb, var(--glas-hell) 42%, transparent), transparent 70%),
var(--bg);
background-attachment: fixed;
}
/* Wasserzeichen: fixiert, links unten, teils unter Seitenleiste und Karten —
dort schimmert es durch das Glas, daneben ist es klar zu sehen. */
html.glas body::before {
content: "";
position: fixed;
left: -3vw; bottom: -14vh;
width: min(1100px, 68vw); aspect-ratio: 256 / 216;
background: url("/logo.svg") no-repeat center / contain;
opacity: var(--glas-logo);
pointer-events: none;
z-index: 0;
}
html.glas .app-shell { position: relative; z-index: 1; }
html.glas body.wandtafel-aktiv::before { display: none; }
/* ---------- Das Material ---------- */
html.glas .app-topbar,
html.glas #headerMenu.side-menu,
html.glas .modal,
html.glas .user-menu-dropdown,
html.glas .user-menu,
html.glas .board-list,
html.glas .abw-team-kopf,
html.glas .report-card,
html.glas .load-card,
html.glas .settings-card,
html.glas .dash-karte,
html.glas .dash-band,
html.glas .bericht-panel,
html.glas .pc-panel,
html.glas .table-wrap,
html.glas .gantt-container,
html.glas .people-readonly,
html.glas .bubble-card,
html.glas .range-picker,
html.glas .sync-problem-bar,
html.glas .plan-header,
html.glas .plan-nav,
html.glas .plan-panel,
html.glas .plan-einst-nav,
html.glas .load-karte,
html.glas .plan-kpi-tile,
html.glas .plan-lage-band,
html.glas .plan-mini-card,
html.glas .protokoll-liste,
html.glas .protokoll-schreiben,
html.glas .plan-toast,
html.glas .glocke-menue,
html.glas .wiz {
background: var(--surface);
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%);
backdrop-filter: blur(var(--glas-blur)) saturate(170%);
border: 1px solid var(--glas-kante-2);
box-shadow: var(--sh-1), inset 0 1px 0 var(--glas-kante), inset 0 -1px 0 var(--glas-kante-2);
}
/* Rahmen: Menüband und Seitenleiste dichter als der Inhalt. */
html.glas .app-topbar {
background: var(--glas-rahmen);
border-width: 0 0 1px 0;
box-shadow: 0 1px 0 var(--glas-kante-2), 0 6px 24px rgba(21,58,86,.06);
}
html.glas .topbar-logo { height: 26px; }
/* Dialoge, Menüs, Hinweise: fast deckend — sie liegen über Inhalt, der sonst
durchscheint und den Text unruhig macht. */
html.glas .modal,
html.glas .user-menu-dropdown,
html.glas .user-menu,
html.glas .range-picker,
html.glas .plan-toast,
html.glas .glocke-menue,
html.glas .sync-problem-bar {
background: var(--glas-dicht);
box-shadow: var(--sh-3), inset 0 1px 0 var(--glas-kante);
}
html.glas .modal-bg {
background: rgba(15,35,50,.28);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
/* ---------- Seitenleiste: schwebende Glasfläche (Desktop) ---------- */
@media (min-width: 769px) {
html.glas #headerMenu.side-menu {
background: var(--glas-rahmen);
top: calc(52px + 12px);
height: calc(100vh - 52px - 24px);
margin: 12px 0 12px 12px;
border-radius: var(--r-xl);
border: 1px solid var(--glas-kante-2);
box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante);
overflow: hidden;
}
}
/* ---------- "Alles schwebt" (Eriks Wahl C, Desktop) ----------
Vorher stieß der eckige Projektkopf (.plan-header + .plan-nav, ein
durchgehender Streifen ohne Abstand) an die runde, schwebende
Seitenleiste. Jetzt schweben Menüband, Seitenleiste und Projektkopf mit
demselben Abstand (12px) und denselben Rundungen; der aktive Reiter ist
eine Kapsel wie der aktive Eintrag der Seitenleiste. */
@media (min-width: 769px) {
html.glas .app-topbar {
top: 12px;
margin: 12px 12px 0;
border-radius: var(--r-lg);
border: 1px solid var(--glas-kante-2);
box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante);
}
html.glas #headerMenu.side-menu {
top: calc(52px + 24px);
height: calc(100vh - 52px - 36px);
}
html.glas .plan-kopf {
margin: 12px 12px 0;
border-radius: var(--r-xl);
background: var(--glas-rahmen);
-webkit-backdrop-filter: blur(var(--glas-blur)) saturate(170%);
backdrop-filter: blur(var(--glas-blur)) saturate(170%);
border: 1px solid var(--glas-kante-2);
box-shadow: var(--sh-2), inset 0 1px 0 var(--glas-kante);
padding: 4px 12px 10px;
}
/* Kopf und Reiter sind innerhalb der Karte keine eigenen Flächen mehr. */
html.glas .plan-kopf .plan-header,
html.glas .plan-kopf .plan-nav {
background: transparent;
-webkit-backdrop-filter: none;
backdrop-filter: none;
border: 0;
box-shadow: none;
}
/* Inhalt bündig unter der Kopfkarte und im selben 12px-Abstand. */
html.glas .plan-page .plan-main-content { padding-left: 12px; padding-right: 12px; padding-top: 12px; }
html.glas .plan-kopf .plan-header { padding: 10px 12px 6px; }
html.glas .plan-kopf .plan-nav { padding: 0; gap: 2px; }
html.glas .plan-kopf .plan-nav-item {
border-bottom: 0;
border-radius: 999px;
padding: 8px 16px;
font-size: 13.5px;
}
html.glas .plan-kopf .plan-nav-item:hover { background: var(--surface-sunk); }
html.glas .plan-kopf .plan-nav-item.is-active {
background: var(--glas-dicht);
color: var(--accent);
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10);
}
}
@media (min-width: 769px) and (prefers-color-scheme: dark) {
html.glas:not([data-theme="light"]) .plan-kopf .plan-nav-item.is-active { background: rgba(255,255,255,.10); }
}
@media (min-width: 769px) {
html.glas[data-theme="dark"] .plan-kopf .plan-nav-item.is-active { background: rgba(255,255,255,.10); }
}
/* Projektbereich: .plan-main hat in plan-styles.css einen deckenden
Grund (var(--bg)). Unter Glas verdeckte er die Lichtflächen und das
Logo-Wasserzeichen — im Projekt sah man nur flaches Dunkelblau bzw.
Grau, auf allen anderen Seiten den Glas-Hintergrund. */
html.glas .plan-main { background: transparent; }
html.glas .nav-item { border-radius: 999px; }
html.glas .nav-item:hover,
html.glas .nav-item:visited:hover { background: var(--surface-sunk); }
html.glas .nav-item.active {
background: var(--glas-dicht);
color: var(--text);
box-shadow: inset 0 1px 0 var(--glas-kante), 0 2px 10px rgba(21,58,86,.10);
}
html.glas .nav-item.active .nav-icon { color: var(--accent); }
/* Die bestehenden Dunkel-Regeln für die Navigation setzen feste Werte —
hier auf die Glas-Tokens zurückführen. */
@media (prefers-color-scheme: dark) {
html.glas:not([data-theme="light"]) .nav-item:hover { background: var(--surface-sunk); }
html.glas:not([data-theme="light"]) .nav-item.active { background: rgba(255,255,255,.10); }
html.glas:not([data-theme="light"]) .app-topbar { background: var(--glas-rahmen); }
}
html.glas[data-theme="dark"] .nav-item:hover { background: var(--surface-sunk); }
html.glas[data-theme="dark"] .nav-item.active { background: rgba(255,255,255,.10); }
html.glas[data-theme="dark"] .app-topbar { background: var(--glas-rahmen); }
/* ---------- Bedienelemente ---------- */
/* Zweitknöpfe als klare Glaskapsel statt grauer Fläche. */
html.glas button.secondary {
background: var(--surface);
border: 1px solid var(--glas-kante-2);
box-shadow: inset 0 1px 0 var(--glas-kante);
color: var(--text);
}
html.glas button.secondary:hover { background: var(--glas-dicht); }
html.glas button:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent); }
/* Hauptknöpfe (ohne eigene Klasse bzw. .small) mit leichtem Glanz oben —
bewusst nicht jeder <button>: Kalendertage, Projektköpfe und Kacheln sind
ebenfalls Knöpfe und bekämen sonst eine helle Kante. */
html.glas button:not([class]),
html.glas button.small:not(.secondary):not(.danger) {
box-shadow: inset 0 1px 0 rgba(255,255,255,.28);
}
/* Text auf Akzentfläche: bisher var(--surface) (deckend weiß), jetzt
durchscheinend — feste Farbe je Thema statt Flächen-Token. */
html.glas .dash-kal-tag.ist-gewaehlt,
html.glas .pt-badge-person { color: #fff; }
@media (prefers-color-scheme: dark) {
html.glas:not([data-theme="light"]) .dash-kal-tag.ist-gewaehlt,
html.glas:not([data-theme="light"]) .pt-badge-person { color: #0b141b; }
}
html.glas[data-theme="dark"] .dash-kal-tag.ist-gewaehlt,
html.glas[data-theme="dark"] .pt-badge-person { color: #0b141b; }
html.glas input[type=text],
html.glas input[type=email],
html.glas input[type=password],
html.glas input[type=number],
html.glas input[type=date],
html.glas input[type=search],
html.glas select,
html.glas textarea {
background-color: var(--glas-dicht);
border-color: var(--line);
border-radius: 12px;
}
/* ---------- Kleine Korrekturen an durchscheinenden Flächen ---------- */
/* Sticky-Leisten bekamen bisher eine deckende Fläche; durchscheinend würde
der darunter wegscrollende Inhalt den Text überlagern. */
html.glas .bericht-rail { background: var(--glas-rahmen); }
/* GANTT: die feststehende Namensspalte muss deckend sein — beim seitlichen
Scrollen laufen die Balken darunter durch. */
/* Die feste Namensspalte des GANTT muss DECKEND sein: beim waagerechten
Scrollen laufen die Balken unter ihr durch, und mit dem halbdurchsichtigen
--glas-deckend (58 %) schienen sie durch die Namen hindurch (Eriks
Meldung). --gantt-label-grund ist derselbe Farbton, nur ohne Transparenz. */
html.glas { --gantt-label-grund: #f6f9fb; }
@media (prefers-color-scheme: dark) { html.glas:not([data-theme="light"]) { --gantt-label-grund: #15212b; } }
html.glas[data-theme="dark"] { --gantt-label-grund: #15212b; }
html.glas .gantt-label,
html.glas .gantt-row.gantt-header .gantt-label,
html.glas .gantt-row.gantt-days .gantt-label { background: var(--gantt-label-grund); }
/* Login: derselbe Hintergrund wie die App, aber deckend — darunter liegt
die (noch leere) App-Oberfläche, die nicht durchscheinen darf. */
html.glas .login-seite {
background:
radial-gradient(55% 50% at 8% 4%, color-mix(in srgb, var(--glas-hell) 70%, transparent), transparent 70%),
radial-gradient(45% 45% at 92% 12%, color-mix(in srgb, var(--glas-tief) 26%, transparent), transparent 70%),
radial-gradient(50% 55% at 72% 96%, color-mix(in srgb, #7fc3b6 26%, transparent), transparent 70%),
var(--bg);
}
/* ---------- Ohne Glas: Wandtafel, reduzierte Transparenz ---------- */
html.glas body.wandtafel-aktiv * {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
@media (prefers-reduced-transparency: reduce) {
html.glas body::before { display: none; }
html.glas * { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
html.glas {
--surface: #ffffff; --surface-alt: #fafcfd; --glas-rahmen: #f7f9fb; --glas-dicht: #ffffff;
}
}
/* Schalter in Einstellungen → Mein Konto (gilt auch ohne html.glas). */
.settings-glas-schalter { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
/* Mit Id-Gewicht, weil eine spätere Regel die Aufgabenzeilen wieder auf das
halbdurchsichtige --surface setzte. Getönte Zeilen (Phasen, Projekt)
legen ihre Tönung als background-image darüber (plan-styles.css). */
html.glas #planGanttContainer .gantt-row .gantt-label { background-color: var(--gantt-label-grund); }
-430
View File
@@ -1,430 +0,0 @@
// 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(); });
}
+36 -323
View File
@@ -37,10 +37,6 @@
// initialsManuallyEdited: gleiches Muster für das Kürzel-Feld. // initialsManuallyEdited: gleiches Muster für das Kürzel-Feld.
let initialsManuallyEdited = false; 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 // editUserAbsencesCache: zuletzt geladene Abwesenheiten des im
// Bearbeiten-Modal geöffneten Benutzers (für Rendern/Löschen ohne erneuten // Bearbeiten-Modal geöffneten Benutzers (für Rendern/Löschen ohne erneuten
// Request pro Aktion). // Request pro Aktion).
@@ -73,52 +69,6 @@
return Array.from(document.querySelectorAll('#' + containerId + ' input[type="checkbox"]:checked')).map(cb => cb.value); return Array.from(document.querySelectorAll('#' + containerId + ' input[type="checkbox"]:checked')).map(cb => cb.value);
} }
// systemRollen: die Rollen aus „Rollen & Rechte“ (GET /rollen) für die
// Auswahl „Rolle“ und die Anzeige in der Tabelle.
let systemRollen = [];
function ladeSystemRollen(){
fetch(baseUrl() + '/rollen')
.then(r => r.json())
.then(j => {
if(!j || !j.ok) return;
systemRollen = j.rollen || [];
['newUserRole', 'editUserRole'].forEach(id => {
const sel = document.getElementById(id);
if(!sel) return;
const wert = sel.value;
sel.innerHTML = systemRollen.map(r => `<option value="${esc(r.key)}">${esc(r.name)}${nurWandtafelRolle(r.key) ? ' (nur Wandtafel)' : ''}</option>`).join('');
if(wert && systemRollen.some(r => r.key === wert)) sel.value = wert;
});
loadAndRenderUsers();
})
.catch(err => console.error('Rollen laden fehlgeschlagen:', err));
}
// nurWandtafelRolle: Rolle ohne „Anwendung nutzen“ — sieht nur die
// Wandtafel und trägt keine Fachrolle (früher das Kiosk-Konto).
function nurWandtafelRolle(key){
const r = systemRollen.find(x => x.key === key);
return !!r && !r.fest && !(r.rechte || []).includes('app');
}
function rollenName(key){
const r = systemRollen.find(x => x.key === key);
return r ? r.name : systemRolleText(key);
}
// onUserRoleChange: wird beim Wechsel der Systemrolle in einem der beiden
// 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 // formulareVorbereitet: Abteilungs-Auswahl und Rollen-Ankreuzfelder muessen
// nur einmal aufgebaut werden; renderAdminUsers() laeuft aber bei jedem // nur einmal aufgebaut werden; renderAdminUsers() laeuft aber bei jedem
// Wechsel in den Abschnitt. // Wechsel in den Abschnitt.
@@ -131,7 +81,6 @@
// erreichbar (showTab() leitet sie um) und die Endpunkte sind serverseitig // erreichbar (showTab() leitet sie um) und die Endpunkte sind serverseitig
// abgesichert. Keine Parameter, keine Rueckgabe. // abgesichert. Keine Parameter, keine Rueckgabe.
window.renderAdminUsers = function(){ window.renderAdminUsers = function(){
ladeSystemRollen();
if(!formulareVorbereitet){ if(!formulareVorbereitet){
renderDepartmentOptions('newUserDepartment'); renderDepartmentOptions('newUserDepartment');
renderDepartmentOptions('editUserDepartment'); renderDepartmentOptions('editUserDepartment');
@@ -178,24 +127,27 @@
const statusParts = []; const statusParts = [];
statusParts.push(u.active ? '<span class="badge-active">Aktiv</span>' : '<span class="badge-inactive">Deaktiviert</span>'); 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>'); 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 || ''); const deptLabel = (DEPARTMENTS[u.department] && DEPARTMENTS[u.department].label) || esc(u.department || '');
return `<tr> return `<tr>
<td>${esc(u.username)}</td> <td>${esc(u.username)}</td>
<td>${esc(u.displayName)}</td> <td>${esc(u.displayName)}</td>
<td>${esc(u.email || '')}</td> <td>${esc(u.email || '')}</td>
<td>${esc(rollenName(u.role))}</td> <td>${u.role === 'admin' ? 'Administrator' : 'Mitglied'}</td>
<td>${deptLabel}</td> <td>${deptLabel}</td>
<td>${esc(u.initials || '')}</td> <td>${esc(u.initials || '')}</td>
<td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td> <td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td>
<td>${esc(u.personId || '')}</td> <td>${esc(u.personId || '')}</td>
<td>${statusParts.join('<br>')}</td> <td>${statusParts.join('<br>')}</td>
<td class="actions-cell"> <td class="actions-cell">
<span class="user-aktionen"> <button class="secondary small" onclick="openEditUserModal(${u.id})">Bearbeiten</button>
<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 type="button" class="user-kebab" aria-haspopup="menu" aria-expanded="false" <button class="secondary small" ${isSelf ? 'disabled title="Sie können sich nicht selbst deaktivieren."' : ''} onclick="toggleUserActive(${u.id}, ${u.active})">${toggleLabel}</button>
aria-label="Weitere Aktionen für ${esc(u.displayName || u.username)}" ${deleteBtn}
onclick="openUserActionMenu(event, ${u.id})">${PUNKTE_SVG}</button>
</span>
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -231,17 +183,12 @@
window.openNewUserModal = function(){ window.openNewUserModal = function(){
document.getElementById('newUserUsername').value = ''; document.getElementById('newUserUsername').value = '';
document.getElementById('newUserFirstName').value = ''; document.getElementById('newUserDisplayName').value = '';
document.getElementById('newUserLastName').value = '';
document.getElementById('newUserEmail').value = ''; document.getElementById('newUserEmail').value = '';
document.getElementById('newUserRole').value = systemRollen.some(r => r.key === 'member') ? 'member' : ((systemRollen.find(r => !r.fest) || {}).key || ''); document.getElementById('newUserRole').value = 'member';
onUserRoleChange('new');
document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT; document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT;
document.getElementById('newUserInitials').value = ''; document.getElementById('newUserInitials').value = '';
document.getElementById('newUserPersonId').value = ''; document.getElementById('newUserPersonId').value = '';
// Sonst übernahm das nächste Konto unbemerkt die Wochenstunden des
// zuvor abgebrochenen Dialogs (Teilzeit ohne Absicht).
document.getElementById('newUserCapacity').value = '';
document.querySelectorAll('#newUserRoleCheckboxes input[type="checkbox"]').forEach(cb => { cb.checked = false; }); document.querySelectorAll('#newUserRoleCheckboxes input[type="checkbox"]').forEach(cb => { cb.checked = false; });
document.getElementById('newUserPassword').value = ''; document.getElementById('newUserPassword').value = '';
document.getElementById('newUserPassword').style.display = 'none'; document.getElementById('newUserPassword').style.display = 'none';
@@ -249,66 +196,27 @@
document.getElementById('newUserError').textContent = ''; document.getElementById('newUserError').textContent = '';
personIdManuallyEdited = false; personIdManuallyEdited = false;
initialsManuallyEdited = false; initialsManuallyEdited = false;
emailManuallyEdited = false;
usernameManuallyEdited = false;
document.getElementById('newUserModal').style.display = 'flex'; document.getElementById('newUserModal').style.display = 'flex';
document.getElementById('newUserFirstName').focus(); document.getElementById('newUserUsername').focus();
}; };
window.closeNewUserModal = function(){ window.closeNewUserModal = function(){
document.getElementById('newUserModal').style.display = 'none'; document.getElementById('newUserModal').style.display = 'none';
}; };
// onNewUserNameInput: schlägt beim Tippen von Vor-/Nachname E-Mail, // onNewUserDisplayNameInput: schlägt beim Tippen des Anzeigenamens eine
// Benutzername (= Anfang der E-Mail, z. B. m.koerner), Person-ID // Person-ID über personIdForName() vor (js/utils.js) — nur solange das Feld
// (personIdForName()) und Kürzel vor (js/utils.js) — jedes Feld nur, // nicht bereits manuell bearbeitet wurde (Kollisionsvermeidung laut Plan).
// solange es nicht bereits manuell bearbeitet wurde. window.onNewUserDisplayNameInput = function(){
window.onNewUserNameInput = function(){ const name = document.getElementById('newUserDisplayName').value;
const vorname = document.getElementById('newUserFirstName').value; if(!personIdManuallyEdited){
const nachname = document.getElementById('newUserLastName').value; document.getElementById('newUserPersonId').value = name.trim() ? personIdForName(name) : '';
const name = (vorname.trim() + ' ' + nachname.trim()).trim(); }
if(!initialsManuallyEdited){ if(!initialsManuallyEdited){
document.getElementById('newUserInitials').value = name.trim() ? initialsForName(name) : ''; 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(){ window.onNewUserPwModeChange = function(){
const manual = document.querySelector('input[name="newUserPwMode"]:checked').value === 'manual'; const manual = document.querySelector('input[name="newUserPwMode"]:checked').value === 'manual';
document.getElementById('newUserPassword').style.display = manual ? '' : 'none'; document.getElementById('newUserPassword').style.display = manual ? '' : 'none';
@@ -316,9 +224,7 @@
window.saveNewUser = function(){ window.saveNewUser = function(){
const username = document.getElementById('newUserUsername').value.trim(); const username = document.getElementById('newUserUsername').value.trim();
// Mit großem Anfangsbuchstaben gespeichert. const displayName = document.getElementById('newUserDisplayName').value.trim();
const firstName = grossAnfang(document.getElementById('newUserFirstName').value);
const lastName = grossAnfang(document.getElementById('newUserLastName').value);
const email = document.getElementById('newUserEmail').value.trim(); const email = document.getElementById('newUserEmail').value.trim();
const role = document.getElementById('newUserRole').value; const role = document.getElementById('newUserRole').value;
const department = document.getElementById('newUserDepartment').value; const department = document.getElementById('newUserDepartment').value;
@@ -330,12 +236,8 @@
const errEl = document.getElementById('newUserError'); const errEl = document.getElementById('newUserError');
errEl.textContent = ''; errEl.textContent = '';
if(!firstName || !lastName){ if(!username || !displayName){
errEl.textContent = 'Vor- und Nachname sind erforderlich.'; errEl.textContent = 'Benutzername und Anzeigename sind erforderlich.';
return;
}
if(!username){
errEl.textContent = 'Benutzername ist erforderlich.';
return; return;
} }
if(!department){ if(!department){
@@ -345,10 +247,8 @@
// Ohne Fachrolle ist der Benutzer in der ganzen Anwendung unsichtbar: // Ohne Fachrolle ist der Benutzer in der ganzen Anwendung unsichtbar:
// peopleByRole() (js/utils.js) gruppiert ausschließlich über die Rollen — // peopleByRole() (js/utils.js) gruppiert ausschließlich über die Rollen —
// das Konto erschiene weder als Board-Spalte noch in der Auslastung noch // das Konto erschiene weder als Board-Spalte noch in der Auslastung noch
// im PDF-Export, ohne dass es dafür irgendeinen Hinweis gäbe. Für ein // im PDF-Export, ohne dass es dafür irgendeinen Hinweis gäbe.
// Kiosk-Konto gilt das nicht — es soll gerade NICHT in Boards/Auslastung if(!roles || roles.length === 0){
// 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.'; errEl.textContent = 'Bitte mindestens eine Rolle wählen (Planung, Projektleitung oder Bauüberwachung) — sonst erscheint der Benutzer in keinem Projektboard.';
return; return;
} }
@@ -358,7 +258,7 @@
errEl.textContent = 'Bitte eine Personen-Kennung vergeben — ohne sie kann der Benutzer keiner Sitzung und keinen Aufgaben zugeordnet werden.'; errEl.textContent = 'Bitte eine Personen-Kennung vergeben — ohne sie kann der Benutzer keiner Sitzung und keinen Aufgaben zugeordnet werden.';
return; return;
} }
const body = { username, firstName, lastName, email, role, personId, department, initials, capacityHours, roles }; const body = { username, displayName, email, role, personId, department, initials, capacityHours, roles };
if(pwMode === 'manual'){ if(pwMode === 'manual'){
const password = document.getElementById('newUserPassword').value; const password = document.getElementById('newUserPassword').value;
if(password.length < 8){ if(password.length < 8){
@@ -377,10 +277,6 @@
} }
closeNewUserModal(); closeNewUserModal();
loadAndRenderUsers(); loadAndRenderUsers();
// Der Server hat das Konto in den Teilnehmerkreis der aktuellen
// Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis
// zum nächsten Seitenaufruf in Projekten und im Assistenten.
if(res.data.sitzungGeaendert && typeof load === 'function') load(true);
if(res.data.generatedPassword){ if(res.data.generatedPassword){
showGeneratedPassword(username, res.data.generatedPassword); showGeneratedPassword(username, res.data.generatedPassword);
} }
@@ -394,13 +290,8 @@
if(!u) return; if(!u) return;
editingUserId = id; editingUserId = id;
document.getElementById('editUserUsernameLabel').textContent = 'Benutzername: ' + u.username + ' (nicht änderbar)'; document.getElementById('editUserUsernameLabel').textContent = 'Benutzername: ' + u.username + ' (nicht änderbar)';
// Konten von vor der Umstellung tragen evtl. noch keinen Vor-/Nachnamen: document.getElementById('editUserDisplayName').value = u.displayName || '';
// 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 || ''; document.getElementById('editUserEmail').value = u.email || '';
onEditUserNameInput();
document.getElementById('editUserRole').value = u.role; document.getElementById('editUserRole').value = u.role;
document.getElementById('editUserDepartment').value = u.department || DEFAULT_DEPARTMENT; document.getElementById('editUserDepartment').value = u.department || DEFAULT_DEPARTMENT;
document.getElementById('editUserInitials').value = u.initials || ''; document.getElementById('editUserInitials').value = u.initials || '';
@@ -410,7 +301,6 @@
document.querySelectorAll('#editUserRoleCheckboxes input[type="checkbox"]').forEach(cb => { document.querySelectorAll('#editUserRoleCheckboxes input[type="checkbox"]').forEach(cb => {
cb.checked = userRoles.includes(cb.value); cb.checked = userRoles.includes(cb.value);
}); });
onUserRoleChange('edit');
document.getElementById('editUserError').textContent = ''; document.getElementById('editUserError').textContent = '';
document.getElementById('editUserAbsenceError').textContent = ''; document.getElementById('editUserAbsenceError').textContent = '';
document.getElementById('editUserAbsenceStart').value = ''; document.getElementById('editUserAbsenceStart').value = '';
@@ -438,8 +328,7 @@
return; return;
} }
const body = { const body = {
firstName: grossAnfang(document.getElementById('editUserFirstName').value), displayName: document.getElementById('editUserDisplayName').value.trim(),
lastName: grossAnfang(document.getElementById('editUserLastName').value),
email: document.getElementById('editUserEmail').value.trim(), email: document.getElementById('editUserEmail').value.trim(),
role: document.getElementById('editUserRole').value, role: document.getElementById('editUserRole').value,
department, department,
@@ -455,10 +344,6 @@
} }
closeEditUserModal(); closeEditUserModal();
loadAndRenderUsers(); loadAndRenderUsers();
// Der Server hat das Konto in den Teilnehmerkreis der aktuellen
// Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis
// zum nächsten Seitenaufruf in Projekten und im Assistenten.
if(res.data.sitzungGeaendert && typeof load === 'function') load(true);
}).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; }); }).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; });
}; };
@@ -544,8 +429,8 @@
// ---------- Zeilen-Aktionen (Passwort-Reset, Aktivieren/Deaktivieren, Löschen) ---------- // ---------- Zeilen-Aktionen (Passwort-Reset, Aktivieren/Deaktivieren, Löschen) ----------
window.resetUserPassword = function(id, username, ohneRueckfrage){ window.resetUserPassword = function(id, username){
if(!ohneRueckfrage && !confirm('Passwort für "' + username + '" wirklich zurücksetzen? Bisherige Sitzungen dieses Benutzers werden beendet.')) return; if(!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 => { apiFetch('/admin/users/' + id + '/reset-password', { method: 'POST' }).then(res => {
if(!res.ok || !res.data || !res.data.ok){ if(!res.ok || !res.data || !res.data.ok){
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')')); alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
@@ -556,193 +441,29 @@
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message)); }).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
}; };
window.toggleUserActive = function(id, currentlyActive, ohneRueckfrage){ window.toggleUserActive = function(id, currentlyActive){
const goingActive = !currentlyActive; const goingActive = !currentlyActive;
if(!goingActive && !ohneRueckfrage && !confirm('Benutzer wirklich deaktivieren? Bestehende Sitzungen werden sofort beendet.')) return; if(!goingActive && !confirm('Benutzer wirklich deaktivieren? Bestehende Sitzungen werden sofort beendet.')) return;
apiFetch('/admin/users/' + id, { method: 'PATCH', body: JSON.stringify({ active: goingActive }) }).then(res => { apiFetch('/admin/users/' + id, { method: 'PATCH', body: JSON.stringify({ active: goingActive }) }).then(res => {
if(!res.ok || !res.data || !res.data.ok){ if(!res.ok || !res.data || !res.data.ok){
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')')); alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
return; return;
} }
loadAndRenderUsers(); loadAndRenderUsers();
// Der Server hat das Konto in den Teilnehmerkreis der aktuellen
// Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis
// zum nächsten Seitenaufruf in Projekten und im Assistenten.
if(res.data.sitzungGeaendert && typeof load === 'function') load(true);
}).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message)); }).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message));
}; };
window.hardDeleteUser = function(id, username, ohneRueckfrage){ window.hardDeleteUser = function(id, username){
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; if(!confirm('Benutzer "' + username + '" wirklich ENDGÜLTIG löschen? Das kann nicht rückgängig gemacht werden.')) return;
apiFetch('/admin/users/' + id + '?hard=1', { method: 'DELETE' }).then(res => { apiFetch('/admin/users/' + id + '?hard=1', { method: 'DELETE' }).then(res => {
if(!res.ok || !res.data || !res.data.ok){ if(!res.ok || !res.data || !res.data.ok){
alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')')); alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'));
return; return;
} }
loadAndRenderUsers(); 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)); }).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 // personIdManuallyEdited wird gesetzt, sobald der Nutzer das Person-ID-Feld
// selbst bearbeitet (nach dem Laden des DOM gebunden). // selbst bearbeitet (nach dem Laden des DOM gebunden).
document.addEventListener('DOMContentLoaded', function(){ document.addEventListener('DOMContentLoaded', function(){
@@ -752,15 +473,7 @@
} }
const initialsInput = document.getElementById('newUserInitials'); const initialsInput = document.getElementById('newUserInitials');
if(initialsInput){ if(initialsInput){
initialsInput.addEventListener('input', function(){ initialsManuallyEdited = true; personIdAusKuerzel(); }); initialsInput.addEventListener('input', function(){ initialsManuallyEdited = true; });
}
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; });
} }
}); });
+96 -419
View File
@@ -42,55 +42,30 @@
// Dass der Bereich für Nicht-Administratoren gar nicht erst auftaucht, ist // Dass der Bereich für Nicht-Administratoren gar nicht erst auftaucht, ist
// wie überall in dieser App ein reines UI-Gate; die eigentliche Sperre sitzt // wie überall in dieser App ein reines UI-Gate; die eigentliche Sperre sitzt
// serverseitig in requireRole('admin') auf PUT /app-settings, // serverseitig in requireRole('admin') auf PUT /app-settings,
// PUT /honorar-config, POST/PATCH/DELETE /lph-templates, POST/DELETE /holidays, // PUT /honorar-config, POST/DELETE /lph-templates, POST/DELETE /holidays,
// GET /backup, POST /import und POST /delete-week. // GET /backup, POST /import und POST /delete-week.
// ADMIN_SECTIONS: die Abschnitte in der Reihenfolge der Seitenleiste. Die // ADMIN_SECTIONS: die Abschnitte in der Reihenfolge der Seitenleiste. Die
// EINE Stelle, an der ein Abschnitt definiert wird — Schlüssel (zugleich // EINE Stelle, an der ein Abschnitt definiert wird — Schlüssel (zugleich
// URL-Fragment) und Überschrift. Die Zuordnung zum Markup läuft über das // URL-Fragment) und Überschrift. Die Zuordnung zum Markup läuft über das
// Attribut data-admin-section, sowohl an der Karte in App.html als auch am // Attribut data-admin-section, sowohl an der Karte in Test.html als auch am
// Unterpunkt in der Seitenleiste; ein neuer Abschnitt braucht damit nur einen // Unterpunkt in der Seitenleiste; ein neuer Abschnitt braucht damit nur einen
// Eintrag hier plus die Karte. `wide` hebt die Breitenbegrenzung des Rasters // Eintrag hier plus die Karte. `wide` hebt die Breitenbegrenzung des Rasters
// auf — die Benutzertabelle hat zehn Spalten und scrollte sonst quer. // auf — die Benutzertabelle hat zehn Spalten und scrollte sonst quer.
// recht: welches Recht (Rollen & Rechte) den Abschnitt öffnet; bei einer
// Liste genügt eines davon.
const ADMIN_SECTIONS = [ const ADMIN_SECTIONS = [
{ key: 'benutzer', title: 'Benutzerverwaltung', wide: true, recht: 'a.benutzer' }, { key: 'benutzer', title: 'Benutzerverwaltung', wide: true },
{ key: 'rollen', title: 'Rollen & Rechte', wide: true, recht: 'a.rollen' }, { key: 'honorar', title: 'Honorar' },
{ key: 'honorar', title: 'Honorar', recht: 'a.honorar' }, { key: 'auslastung', title: 'Auslastung' },
{ key: 'auslastung', title: 'Auslastung', recht: 'a.auslastung' }, { key: 'termine', title: 'Termine & Sitzungen' },
{ key: 'termine', title: 'Termine & Sitzungen', recht: 'a.termine' }, { key: 'feiertage', title: 'Feiertage' },
{ key: 'feiertage', title: 'Feiertage', recht: 'a.feiertage' }, { key: 'vorlagen', title: 'Aufgaben-Vorlagen' },
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen', recht: 'a.vorlagen' }, { key: 'betrieb', title: 'Betrieb & Sicherheit' },
{ key: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' }, { key: 'datenbank', title: 'Datenbank & Sitzungen' },
{ 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. // adminSection: der gerade sichtbare Abschnitt.
let adminSection = 'benutzer'; 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') // renderAdminArea: Einstiegspunkt des Bereichs, von showTab('admin')
// gerufen. Befüllt alle Masken und wählt danach den sichtbaren Abschnitt. // gerufen. Befüllt alle Masken und wählt danach den sichtbaren Abschnitt.
// Keine Parameter, keine Rückgabe. // Keine Parameter, keine Rückgabe.
@@ -99,7 +74,6 @@ function renderAdminArea(){
loadAdminHonorar(); loadAdminHonorar();
loadAdminTemplates(); loadAdminTemplates();
loadAdminPlanCheckerMerkmale();
// Die firmenweiten Zahlen und Feiertage stehen bereits in APP_SETTINGS bzw. // Die firmenweiten Zahlen und Feiertage stehen bereits in APP_SETTINGS bzw.
// HOLIDAYS (beim Seitenstart geladen, js/store.js) — die Masken füllen sich // HOLIDAYS (beim Seitenstart geladen, js/store.js) — die Masken füllen sich
// also ohne einen weiteren Serveraufruf. // also ohne einen weiteren Serveraufruf.
@@ -114,13 +88,13 @@ function renderAdminArea(){
// isAdminUser: Kurzform für die Sichtbarkeit des Bereichs. // isAdminUser: Kurzform für die Sichtbarkeit des Bereichs.
// Rückgabe: Boolean. // Rückgabe: Boolean.
function isAdminUser(){ function isAdminUser(){
return hatAdminRecht(); return !!(currentUser && currentUser.role === 'admin');
} }
// adminSectionExists: prüft, ob ein Schlüssel ein bekannter Abschnitt ist. // adminSectionExists: prüft, ob ein Schlüssel ein bekannter Abschnitt ist.
// Parameter: key. Rückgabe: Boolean. // Parameter: key. Rückgabe: Boolean.
function adminSectionExists(key){ function adminSectionExists(key){
return ADMIN_SECTIONS.some(x => x.key === key && adminAbschnittErlaubt(x)); return ADMIN_SECTIONS.some(x => x.key === key);
} }
// showAdminSection: schaltet auf einen Abschnitt um — blendet die zugehörige // showAdminSection: schaltet auf einen Abschnitt um — blendet die zugehörige
@@ -130,9 +104,8 @@ function adminSectionExists(key){
// (Klick aus einem anderen Bereich heraus), wird zuerst dorthin gewechselt. // (Klick aus einem anderen Bereich heraus), wird zuerst dorthin gewechselt.
// Parameter: key. Keine Rückgabe. // Parameter: key. Keine Rückgabe.
function showAdminSection(key){ function showAdminSection(key){
if(!adminSectionExists(key)) key = (ADMIN_SECTIONS.find(adminAbschnittErlaubt) || ADMIN_SECTIONS[0]).key; if(!adminSectionExists(key)) key = ADMIN_SECTIONS[0].key;
adminSection = key; adminSection = key;
adminNavAbgleichen();
if(currentTab !== 'admin'){ showTab('admin'); return; } if(currentTab !== 'admin'){ showTab('admin'); return; }
@@ -157,11 +130,6 @@ function showAdminSection(key){
// aendert sich auch ausserhalb dieses Browsers, und die uebrigen Masken // aendert sich auch ausserhalb dieses Browsers, und die uebrigen Masken
// rechnen mit ihr (Wochenstunden -> Auslastung). // rechnen mit ihr (Wochenstunden -> Auslastung).
if(key === 'benutzer' && typeof renderAdminUsers === 'function') renderAdminUsers(); 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 { try {
history.replaceState(null, '', location.pathname + location.search + '#' + key); history.replaceState(null, '', location.pathname + location.search + '#' + key);
@@ -208,8 +176,8 @@ const APP_SETTING_FIELDS = {
// Kapazität der Person — ein volles Band ist eine volle Woche. Die feste // Kapazität der Person — ein volles Band ist eine volle Woche. Die feste
// Vorgabe 8 hatte zur Folge, dass ein roter Balken (ab 1,0) nur zu einem // Vorgabe 8 hatte zur Folge, dass ein roter Balken (ab 1,0) nur zu einem
// Achtel gefüllt war; Balken und Ampel widersprachen einander. // Achtel gefüllt war; Balken und Ampel widersprachen einander.
// Referenzhonorar und Verteilzeitraum sind entfallen: Die Auslastung setReferenzHonorar: 'referenzHonorar',
// rechnet nur noch mit Aufgaben-Stunden (js/load/load.js). setVerteilWochen: 'verteilzeitraumWochen',
}, },
termine: { termine: {
setStundenProTag: 'arbeitsstundenProTag', setStundenProTag: 'arbeitsstundenProTag',
@@ -235,6 +203,49 @@ function renderAppSettingFields(){
}); });
const autoLock = document.getElementById('setAutoLock'); const autoLock = document.getElementById('setAutoLock');
if(autoLock) autoLock.checked = !!Number(APP_SETTINGS.autoLockAktiv); if(autoLock) autoLock.checked = !!Number(APP_SETTINGS.autoLockAktiv);
renderAufwandMatrix();
}
// renderAufwandMatrix: die firmenweite Gewichtung als Tabelle Leistungsphase ×
// Rolle. „W+M" fehlt bewusst — das Aufwandsmodell kennt nur die
// Leistungsphasen (AUFWAND_LPS in js/load/load.js). Ein leeres Feld bedeutet
// „kein Eintrag"; dafür zählt die Auslastung dann den vollen Faktor 1,0.
// Keine Parameter, keine Rückgabe.
function renderAufwandMatrix(){
const wrap = document.getElementById('setAufwandMatrix');
if(!wrap) return;
const vorlage = APP_SETTINGS.aufwandVorlage || {};
const phasen = LPHS.filter(l => l !== 'W+M');
wrap.innerHTML = `<table class="settings-matrix">
<thead><tr><th>Phase</th>${ROLES.map(r => `<th>${esc(r)}</th>`).join('')}</tr></thead>
<tbody>${phasen.map(l => `<tr>
<th>${esc(l)}</th>
${ROLES.map(r => {
const v = vorlage[l] ? vorlage[l][r] : undefined;
return `<td><input type="number" min="0" max="1" step="0.1"
data-lph="${esc(l)}" data-role="${esc(r)}"
value="${v === undefined ? '' : esc(v)}" placeholder="1,0"></td>`;
}).join('')}
</tr>`).join('')}</tbody>
</table>`;
}
// collectAufwandMatrix: liest die Matrix aus den Feldern zurück. Leere Felder
// werden weggelassen (siehe renderAufwandMatrix), 0 ist ein gültiger Wert und
// bleibt erhalten. Rückgabe: Objekt LPH -> { Rolle: Faktor }.
function collectAufwandMatrix(){
const wrap = document.getElementById('setAufwandMatrix');
const out = {};
if(!wrap) return out;
wrap.querySelectorAll('input[data-lph]').forEach(inp => {
if(inp.value.trim() === '') return;
const v = parseFloat(inp.value);
if(!Number.isFinite(v)) return;
const lph = inp.dataset.lph;
(out[lph] = out[lph] || {})[inp.dataset.role] = v;
});
return out;
} }
// putAppSettings: schickt einen Teil-Satz an PUT /app-settings und übernimmt // putAppSettings: schickt einen Teil-Satz an PUT /app-settings und übernimmt
@@ -273,9 +284,11 @@ function fieldValues(gruppe){
return patch; return patch;
} }
// saveAuslastungSettings: Schwellen und Vollzeit-Stunden in einem Zug. Keine Parameter, keine Rückgabe. // saveAuslastungSettings: Schwellen, Skala, Vollzeit-Stunden und die
// Aufwandsvorlage in einem Zug. Keine Parameter, keine Rückgabe.
function saveAuslastungSettings(){ function saveAuslastungSettings(){
const patch = fieldValues('auslastung'); const patch = fieldValues('auslastung');
patch.aufwandVorlage = collectAufwandMatrix();
putAppSettings(patch, 'Auslastungs-Einstellungen gespeichert.'); putAppSettings(patch, 'Auslastungs-Einstellungen gespeichert.');
} }
@@ -289,14 +302,6 @@ function juengsteWoche(){
return [...db.weeks].sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))).pop(); 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), // renderAdminSitzungen: listet alle Sitzungen (js/weeks.js: db.weeks),
// neueste zuerst, mit Datum, Zustand und den passenden Aktionen. // neueste zuerst, mit Datum, Zustand und den passenden Aktionen.
// //
@@ -331,7 +336,7 @@ function renderAdminSitzungen(){
if(!el || !Array.isArray(db.weeks)) return; if(!el || !Array.isArray(db.weeks)) return;
const juengste = juengsteWoche(); const juengste = juengsteWoche();
const sortiert = adminSitzungen().sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label))); const sortiert = [...db.weeks].sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label)));
el.innerHTML = sortiert.map(w => { el.innerHTML = sortiert.map(w => {
const aktuell = w.id === db.currentWeek; const aktuell = w.id === db.currentWeek;
const istJuengste = juengste && w.id === juengste.id; const istJuengste = juengste && w.id === juengste.id;
@@ -347,20 +352,20 @@ function renderAdminSitzungen(){
// jede Aktion und hätte keinen Weg zurück außer über eine andere, // jede Aktion und hätte keinen Weg zurück außer über eine andere,
// zufällige applyAutoLock()-auslösende Handlung. // zufällige applyAutoLock()-auslösende Handlung.
chip = '<span class="sitzung-chip ist-geoeffnet">Geöffnet (Korrektur)</span>'; chip = '<span class="sitzung-chip ist-geoeffnet">Geöffnet (Korrektur)</span>';
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.label)}')">Fertig – wieder sperren</button>`; aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.id)}')">Fertig – wieder sperren</button>`;
} else if(w.locked){ } else if(w.locked){
chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>'; chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>';
aktionen = hatRecht('s.entsperren') ? `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.label)}')">Entsperren</button>` : ''; aktionen = `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.id)}')">Entsperren</button>`;
} else { } else {
chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>'; chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>';
aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.label)}')">Öffnen</button>`; aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.id)}')">Öffnen</button>`;
} }
return `<div class="template-admin-row"> return `<div class="template-admin-row">
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span> <span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
${chip} ${chip}
${aktionen} ${aktionen}
${hatRecht('s.loeschen') ? `<button class="danger small" onclick="adminDeleteWeek('${esc(w.label)}')" title="Sitzung löschen">×</button>` : ''} <button class="danger small" onclick="adminDeleteWeek('${esc(w.id)}')" title="Sitzung löschen">×</button>
</div>`; </div>`;
}).join(''); }).join('');
} }
@@ -373,22 +378,15 @@ function renderAdminSitzungen(){
// nicht. "Öffnen" macht dagegen ausdrücklich sichtbar, was sich geändert // nicht. "Öffnen" macht dagegen ausdrücklich sichtbar, was sich geändert
// hat: Es wechselt zur Sitzung UND zum Board, statt sie nur im Hintergrund // hat: Es wechselt zur Sitzung UND zum Board, statt sie nur im Hintergrund
// zur aktuellen zu machen. // zur aktuellen zu machen.
// Alle vier nehmen das Label: Die Sitzung ist evtl. nicht geladen und wird function adminUnlockWeek(weekId){
// dafür erst nachgeholt (sitzungSicherLaden(), js/store.js). unlockWeek(db.weeks.find(w => w.id === weekId), renderAdminSitzungen);
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 adminUnlockWeek(label){ function adminDeleteWeek(weekId){
adminMitSitzung(label, w => unlockWeek(w, () => { sitzungKurzAbgleichen(w); renderAdminSitzungen(); })); deleteWeekById(weekId, renderAdminSitzungen);
} }
function adminDeleteWeek(label){ function adminOpenWeek(weekId){
adminMitSitzung(label, w => deleteWeekById(w.id, renderAdminSitzungen)); switchWeek(weekId);
} showTab('board');
function adminOpenWeek(label){
adminMitSitzung(label, w => { switchWeek(w.id); showTab('board'); });
} }
// adminRelockWeek: schließt eine Korrektur ab — sperrt die übergebene // adminRelockWeek: schließt eine Korrektur ab — sperrt die übergebene
@@ -400,11 +398,10 @@ function adminOpenWeek(label){
// markieren würde — und das ist zu diesem Zeitpunkt schon die jüngste, NICHT // markieren würde — und das ist zu diesem Zeitpunkt schon die jüngste, NICHT
// die gerade gesperrte Sitzung (gleiches Muster wie savePlan() in // die gerade gesperrte Sitzung (gleiches Muster wie savePlan() in
// js/plan/plan-state.js). Parameter: weekId. Keine Rückgabe. // js/plan/plan-state.js). Parameter: weekId. Keine Rückgabe.
function adminRelockWeek(label){ function adminRelockWeek(weekId){
const w = db.weeks.find(x => x.label === label); const w = db.weeks.find(x => x.id === weekId);
if(!w || w.locked) return; if(!w || w.locked) return;
w.locked = true; w.locked = true;
sitzungKurzAbgleichen(w);
markWeekDirty(w.label); markWeekDirty(w.label);
save({ week: false }); save({ week: false });
const juengste = juengsteWoche(); const juengste = juengsteWoche();
@@ -602,31 +599,17 @@ function deleteHoliday(date){
} }
// ---------- Abschnitt Aufgaben-Vorlagen ---------- // ---------- Abschnitt Aufgaben-Vorlagen ----------
//
// Vorlagen gibt es je Gewerk und Leistungsphase (Eriks Wunsch). Oben wählt
// man das Gewerk (Umschalter) und die Leistungsphase, darunter steht die
// Liste der Schritte: Bezeichnung, Start und Dauer direkt bearbeitbar
// (gespeichert beim Verlassen des Feldes, PATCH /lph-templates/:id),
// Reihenfolge über ↑/↓ (POST …/verschieben), Löschen über ×.
// Vorlagen ohne Gewerk (Altbestand) stehen unter jedem Gewerk mit dem
// Vermerk „alle Gewerke“.
let adminTemplates = [];
let adminTemplateGewerk = null;
// loadAdminTemplates: holt alle Vorlagen-Schritte (GET /lph-templates) und // loadAdminTemplates: holt alle Vorlagen-Schritte (GET /lph-templates) und
// rendert die Liste. Keine Parameter, keine Rückgabe. // rendert die Liste der gerade gewählten Leistungsphase.
// Keine Parameter, keine Rückgabe.
function loadAdminTemplates(){ function loadAdminTemplates(){
const select = document.getElementById('adminTemplateLph'); const select = document.getElementById('adminTemplateLph');
if(select && !select.options.length){ if(select && !select.options.length){
select.innerHTML = LPHS.map(l => `<option value="${esc(l)}">${esc(l)}</option>`).join(''); select.innerHTML = LPHS.map(l => `<option value="${esc(l)}">${esc(l)}</option>`).join('');
} }
if(!adminTemplateGewerk){
const eigen = currentUser && Object.keys(DEPARTMENTS).includes(currentUser.department) ? currentUser.department : '';
adminTemplateGewerk = eigen || Object.keys(DEPARTMENTS)[0];
}
const listEl = document.getElementById('templateAdminList'); const listEl = document.getElementById('templateAdminList');
if(listEl && !adminTemplates.length) listEl.innerHTML = '<span class="hint">Lade Vorlagen…</span>'; if(listEl) listEl.innerHTML = '<span class="hint">Lade Vorlagen…</span>';
fetch(baseUrl() + '/lph-templates') fetch(baseUrl() + '/lph-templates')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); }) .then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
@@ -640,74 +623,33 @@ function loadAdminTemplates(){
}); });
} }
// vorlagenAnteilText: Anzeige der Spalte „Anteil %“ — Zahl mit Komma oder // renderAdminTemplates: zeigt die Schritte der gewählten Leistungsphase in
// „in a)“ / „in a) + b)“ (Siemon: in anderen Schritten enthalten). // ihrer gepflegten Reihenfolge, je Zeile mit Versatz/Dauer und Lösch-Knopf.
function vorlagenAnteilText(t){ // Wird auch vom onchange der Leistungsphasen-Auswahl gerufen.
if(t.enthaltenIn) return 'in ' + t.enthaltenIn.split(',').map(x => x + ')').join(' + '); // Keine Parameter, keine Rückgabe.
return t.anteil ? String(t.anteil).replace('.', ',') : '';
}
// 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(){ 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'); const listEl = document.getElementById('templateAdminList');
if(!listEl) return; if(!listEl) return;
const eigene = adminTemplates.filter(t => t.lph === lph && t.gewerk === g); const select = document.getElementById('adminTemplateLph');
const alle = adminTemplates.filter(t => t.lph === lph && !t.gewerk); const lph = select ? select.value : '';
const entries = adminTemplates.filter(t => t.lph === lph);
const zeile = (t, i, n) => `<div class="template-admin-row vorlagen-zeile"> listEl.innerHTML = entries.length === 0
<input type="text" value="${esc(t.title)}" aria-label="Bezeichnung" onchange="aendereTemplateEntry('${esc(t.id)}', 'title', this.value)"> ? '<span class="hint">Für diese Leistungsphase ist noch kein Schritt hinterlegt.</span>'
<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)"> : entries.map(t => `<div class="template-admin-row">
<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)"> <span class="template-admin-title">${esc(t.title)}</span>
<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="hint" style="margin:0">Start +${esc(t.offsetDays)} Tage · Dauer ${esc(t.durationDays)} Tage</span>
<span class="vorlagen-aktionen"> <button class="danger small" onclick="deleteTemplateEntry('${esc(t.id)}')" title="Schritt aus der Vorlage löschen">×</button>
${n !== null ? `<button class="secondary small" title="nach oben" ${i === 0 ? 'disabled' : ''} onclick="verschiebeTemplateEntry('${esc(t.id)}', -1)">↑</button> </div>`).join('');
<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 // addTemplateEntry: legt einen Schritt für die gewählte Leistungsphase an
// gewählte Leistungsphase an (POST /lph-templates). Keine Parameter. // (POST /lph-templates, Admin-only). Keine Parameter, keine Rückgabe.
function addTemplateEntry(){ function addTemplateEntry(){
const select = document.getElementById('adminTemplateLph'); const select = document.getElementById('adminTemplateLph');
const titleEl = document.getElementById('templateNewTitle'); const titleEl = document.getElementById('templateNewTitle');
const offsetEl = document.getElementById('templateNewOffset'); const offsetEl = document.getElementById('templateNewOffset');
const durationEl = document.getElementById('templateNewDuration'); const durationEl = document.getElementById('templateNewDuration');
const anteilEl = document.getElementById('templateNewAnteil');
const title = titleEl ? titleEl.value.trim() : ''; const title = titleEl ? titleEl.value.trim() : '';
if(!title){ alert('Bitte eine Bezeichnung für den Schritt eingeben.'); return; } if(!title){ alert('Bitte eine Bezeichnung für den Schritt eingeben.'); return; }
@@ -716,19 +658,15 @@ function addTemplateEntry(){
headers: { 'Content-Type': 'application/json; charset=utf-8' }, headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ body: JSON.stringify({
lph: select ? select.value : '', lph: select ? select.value : '',
gewerk: adminTemplateGewerk,
title, title,
offsetDays: offsetEl ? parseInt(offsetEl.value, 10) || 0 : 0, offsetDays: offsetEl ? parseInt(offsetEl.value, 10) || 0 : 0,
// leer oder 0 = ohne Termin durationDays: durationEl ? parseInt(durationEl.value, 10) || 1 : 1,
durationDays: durationEl ? parseInt(durationEl.value, 10) || 0 : 0,
anteil: anteilEl ? anteilEl.value : '',
}) })
}) })
.then(r => r.json().then(json => ({ ok: r.ok, json }))) .then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => { .then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.'); if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.');
if(titleEl){ titleEl.value = ''; titleEl.focus(); } if(titleEl) titleEl.value = '';
if(anteilEl) anteilEl.value = '';
loadAdminTemplates(); loadAdminTemplates();
showAdminNote('Schritt angelegt.'); showAdminNote('Schritt angelegt.');
}) })
@@ -738,50 +676,6 @@ function addTemplateEntry(){
}); });
} }
// aendereTemplateEntry: speichert ein geändertes Feld eines Schritts
// (PATCH /lph-templates/:id). Parameter: id, feld, wert. Keine Rückgabe.
function aendereTemplateEntry(id, feld, wert){
const body = {};
body[feld] = (feld === 'title' || feld === 'anteil') ? wert : parseInt(wert, 10);
fetch(baseUrl() + '/lph-templates/' + encodeURIComponent(id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(body)
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.');
const t = adminTemplates.find(x => x.id === id);
if(t) Object.assign(t, json.template);
renderAdminTemplates();
showAdminNote('Gespeichert.');
})
.catch(err => {
console.error('Vorlagen-Schritt ändern fehlgeschlagen:', err);
alert('Änderung konnte nicht gespeichert werden: ' + (err.message || err));
loadAdminTemplates();
});
}
// verschiebeTemplateEntry: Schritt eine Stelle nach oben/unten.
// Parameter: id, richtung (-1/1). Keine Rückgabe.
function verschiebeTemplateEntry(id, richtung){
fetch(baseUrl() + '/lph-templates/' + encodeURIComponent(id) + '/verschieben', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ richtung })
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Verschieben fehlgeschlagen.');
loadAdminTemplates();
})
.catch(err => {
console.error('Vorlagen-Schritt verschieben fehlgeschlagen:', err);
alert('Schritt konnte nicht verschoben werden: ' + (err.message || err));
});
}
// deleteTemplateEntry: löscht einen Vorlagen-Schritt (DELETE, Admin-only). // deleteTemplateEntry: löscht einen Vorlagen-Schritt (DELETE, Admin-only).
// Bereits in Projekte eingefügte Aufgaben bleiben unberührt — sie sind nach // Bereits in Projekte eingefügte Aufgaben bleiben unberührt — sie sind nach
// dem Einfügen ganz normale plan_todos ohne Verbindung zur Vorlage. // dem Einfügen ganz normale plan_todos ohne Verbindung zur Vorlage.
@@ -800,91 +694,6 @@ 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 ---------- // ---------- Rückmeldung ----------
@@ -1031,135 +840,3 @@ function loadHolidaysIntoState(){
}) })
.catch(err => console.error('Feiertage neu laden fehlgeschlagen:', err)); .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; }
el.innerHTML = `
<div class="settings-kv-row"><span class="settings-kv-key">Status</span>
<span>${d.konfiguriert ? '<span class="sitzung-chip ist-geoeffnet">Verbunden</span> <span class="hint" style="margin:0">' + esc(d.host) + '</span>'
: '<span class="sitzung-chip ist-gesperrt">Nicht eingerichtet</span>'}</span></div>
<label class="settings-field"><span>${d.konfiguriert ? 'Neue Adresse (ersetzt die bisherige)' : 'Adresse aus dem Teams-Workflow'}</span>
<input type="password" id="teamsWebhook" placeholder="https://…" autocomplete="off"></label>
<label class="settings-glas-schalter"><input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''}
onchange="teamsSpeichern({ neueAufgaben: this.checked })"> Neue Aufgaben melden</label>
<div class="kalender-aktionen" style="margin-top:10px">
<button type="button" onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Adresse speichern</button>
<button type="button" class="secondary" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht senden</button>
${d.konfiguriert ? '<button type="button" class="secondary" onclick="teamsEntfernen()">Entfernen</button>' : ''}
</div>
<p class="field-error" id="teamsFehler" hidden></p>`;
}
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 — bitte im Teams-Kanal 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'; }
}
}
-209
View File
@@ -1,209 +0,0 @@
// -----------------------------
// 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(); }
}
+4 -2
View File
@@ -11,7 +11,7 @@
// (p.beauftragteLP). Deshalb erfasst der Dialog "Beauftragung" beides in EINEM // (p.beauftragteLP). Deshalb erfasst der Dialog "Beauftragung" beides in EINEM
// Schritt — direkt danach ist der Terminplan rechenbar. // Schritt — direkt danach ist der Terminplan rechenbar.
// //
// Geladen von App.html als gemeinsames Modul (klassisches Skript, alle // Geladen von Test.html als gemeinsames Modul (klassisches Skript, alle
// Funktionen liegen dadurch auf window). Genutzt werden LPHS aus // Funktionen liegen dadurch auf window). Genutzt werden LPHS aus
// js/globals.js, esc() aus js/utils.js, currentWeek()/save()/markWeekDirty() // js/globals.js, esc() aus js/utils.js, currentWeek()/save()/markWeekDirty()
// aus js/store.js, isWeekLocked() aus js/weeks.js und openProjectPlan() aus // aus js/store.js, isWeekLocked() aus js/weeks.js und openProjectPlan() aus
@@ -202,7 +202,9 @@ function openBeauftragungModal(pid){
} }
// Gruppenwechsel nur anbieten, wenn das Projekt noch in "In Anbahnung" // Gruppenwechsel nur anbieten, wenn das Projekt noch in "In Anbahnung"
// steht: Ein beauftragtes Projekt gehört nicht mehr in die 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.
const gruppeWrap = document.getElementById('beauftragungGruppeWrap'); const gruppeWrap = document.getElementById('beauftragungGruppeWrap');
const gruppeCheck = document.getElementById('beauftragungGruppeCheck'); const gruppeCheck = document.getElementById('beauftragungGruppeCheck');
if(gruppeWrap) gruppeWrap.style.display = (p.group === 'In Anbahnung') ? '' : 'none'; if(gruppeWrap) gruppeWrap.style.display = (p.group === 'In Anbahnung') ? '' : 'none';
+16 -144
View File
@@ -1,12 +1,12 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Authentifizierung (Login-Overlay, Session, Logout) // Projektübersicht — Authentifizierung (Login-Overlay, Session, Logout)
// ----------------------------- // -----------------------------
// Wird von App.html direkt nach // Wird von Test.html direkt nach
// js/server.js geladen, da checkAuthAndInit() baseUrl() benötigt. Ersetzt den // js/server.js geladen, da checkAuthAndInit() baseUrl() benötigt. Ersetzt den
// früheren rein clientseitigen Passwortschutz (js/tabs-auth.js) durch eine // früheren rein clientseitigen Passwortschutz (js/tabs-auth.js) durch eine
// echte serverseitige Session (server.js: /login, /logout, /whoami). Ohne // echte serverseitige Session (server.js: /login, /logout, /whoami). Ohne
// gültige Session zeigt checkAuthAndInit() das blockierende Login-Overlay // gültige Session zeigt checkAuthAndInit() das blockierende Login-Overlay
// (#loginOverlay/#loginForm, siehe App.html); nach // (#loginOverlay/#loginForm, siehe Test.html); nach
// erfolgreichem Login läuft der eigentliche Seiteneinstieg (per Callback // erfolgreichem Login läuft der eigentliche Seiteneinstieg (per Callback
// übergeben) wie gewohnt weiter. // übergeben) wie gewohnt weiter.
// //
@@ -19,62 +19,25 @@
// zeigte „Passwortwechsel erforderlich" an, erfüllen konnte ihn niemand. // zeigte „Passwortwechsel erforderlich" an, erfüllen konnte ihn niemand.
// currentUser: der aktuell angemeldete Benutzer ({id, username, displayName, // currentUser: der aktuell angemeldete Benutzer ({id, username, displayName,
// email, role, rolleName, rechte, personId, mustChangePassword}) oder null // email, role, personId, mustChangePassword}) oder null vor dem Login.
// vor dem Login.
let currentUser = null; let currentUser = null;
// renderUserBar: zeigt Namen + Rolle des angemeldeten Benutzers im Menueband // renderUserBar: zeigt Namen + Rolle des angemeldeten Benutzers im Menueband
// an (Element #userBarInfo) und blendet den gesamten Admin-Bereich der // an (Element #userBarInfo) und blendet den gesamten Admin-Bereich der
// Seitenleiste (#navAdminSection) nur bei mindestens einem Admin-Recht ein // Seitenleiste (#navAdminSection) nur fuer Administratoren ein. Dieser
// (Rollen & Rechte). Es ist ein reines UI-Gate; die eigentliche Sperre sitzt // Bereich fuehrt zu allen Admin-Aufgaben — Benutzerverwaltung, firmenweite
// serverseitig in requireRecht() auf den zugehoerigen Routen, und showTab() // Vorgaben, Datenbank und Sitzungen. Es ist ein reines UI-Gate; die
// leitet ohne Recht zusaetzlich vom Bereich „Admin" weg (js/tabs-auth.js). // 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).
function renderUserBar(){ function renderUserBar(){
const info = document.getElementById('userBarInfo'); const info = document.getElementById('userBarInfo');
if(info && currentUser){ if(info && currentUser){
const roleLabel = currentUser.rolleName || systemRolleText(currentUser.role); const roleLabel = currentUser.role === 'admin' ? 'Administrator' : 'Mitglied';
info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')'; info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')';
} }
const navAdminSection = document.getElementById('navAdminSection'); const navAdminSection = document.getElementById('navAdminSection');
if(navAdminSection) navAdminSection.style.display = hatAdminRecht() ? '' : 'none'; if(navAdminSection) navAdminSection.style.display = (currentUser && currentUser.role === 'admin') ? '' : '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 // logout: beendet die Server-Session und lädt die Seite neu (zeigt danach
@@ -84,63 +47,9 @@ async function logout(){
window.location.reload(); window.location.reload();
} }
// showLoginOverlay / hideLoginOverlay: blenden die blockierende Login-Seite ein/aus. // showLoginOverlay / hideLoginOverlay: blenden das blockierende Login-Overlay ein/aus.
function showLoginOverlay(){ function showLoginOverlay(){
renderLoginWoche();
document.getElementById('loginOverlay').style.display = 'flex'; 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(){ function hideLoginOverlay(){
document.getElementById('loginOverlay').style.display = 'none'; document.getElementById('loginOverlay').style.display = 'none';
@@ -155,13 +64,7 @@ function afterAuthenticated(onAuthenticated){
hideLoginOverlay(); hideLoginOverlay();
renderUserBar(); renderUserBar();
onAuthenticated(); 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); if(currentUser && currentUser.mustChangePassword) openPasswordModal(true);
// Rundgang (js/einfuehrung.js): wartet selbst, bis die Daten geladen sind
// und kein Dialog offen ist — nach einem Pflicht-Passwortwechsel also erst
// danach.
else if(typeof einfuehrungBeiBedarf === 'function') einfuehrungBeiBedarf();
} }
// ---------- Eigenes Passwort ändern (#passwordModal) ---------- // ---------- Eigenes Passwort ändern (#passwordModal) ----------
@@ -242,7 +145,6 @@ async function submitPasswordChange(ev){
// Der Bereich „Einstellungen" zeigt die Kennzeichnung mit an. // Der Bereich „Einstellungen" zeigt die Kennzeichnung mit an.
if(typeof renderSettings === 'function' && typeof currentTab !== 'undefined' && currentTab === 'settings') renderSettings(); if(typeof renderSettings === 'function' && typeof currentTab !== 'undefined' && currentTab === 'settings') renderSettings();
alert('Das Passwort wurde geändert.'); alert('Das Passwort wurde geändert.');
if(typeof einfuehrungBeiBedarf === 'function') einfuehrungBeiBedarf();
} }
// checkAuthAndInit: prüft per GET /whoami, ob eine gültige Session besteht. // checkAuthAndInit: prüft per GET /whoami, ob eine gültige Session besteht.
@@ -271,54 +173,24 @@ async function checkAuthAndInit(onAuthenticated){
return; return;
} }
showLoginOverlay(); showLoginOverlay();
verdrahteLoginHilfen();
const form = document.getElementById('loginForm'); const form = document.getElementById('loginForm');
const errEl = document.getElementById('loginError'); 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){ form.onsubmit = async function(ev){
ev.preventDefault(); ev.preventDefault();
errEl.hidden = true; errEl.textContent = '';
const username = document.getElementById('loginUsername').value.trim(); const username = document.getElementById('loginUsername').value;
const password = document.getElementById('loginPassword').value; 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 { try {
const r = await fetch(baseUrl() + '/login', { const r = await fetch(baseUrl() + '/login', {
method:'POST', headers:{'Content-Type':'application/json'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password })
}); });
const d = await r.json(); const d = await r.json();
if(!r.ok || !d.ok){ if(!r.ok || !d.ok){ errEl.textContent = d.error || 'Anmeldung fehlgeschlagen.'; return; }
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; 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); afterAuthenticated(onAuthenticated);
} catch(err){ } catch(err){
zeigeFehler('Der Server ist nicht erreichbar. Bitte später erneut versuchen.'); errEl.textContent = 'Verbindung zum Server fehlgeschlagen.';
} finally {
btn.disabled = false; btn.classList.remove('laedt');
} }
}; };
} }
+3 -5
View File
@@ -3,7 +3,7 @@
// ----------------------------- // -----------------------------
// Stellt downloadBackup() bereit: lädt einen konsistenten Snapshot der // Stellt downloadBackup() bereit: lädt einen konsistenten Snapshot der
// SQLite-Datenbank über GET /backup als .db-Datei herunter (VACUUM INTO, // SQLite-Datenbank über GET /backup als .db-Datei herunter (VACUUM INTO,
// WAL-sicher). Hängt im Seitenmenü von App.html am Button "DB Backup" und // WAL-sicher). Hängt im Seitenmenü von Test.html am Button "DB Backup" und
// nutzt baseUrl() aus js/server.js. Ergänzt das automatische Backup, das der // nutzt baseUrl() aus js/server.js. Ergänzt das automatische Backup, das der
// Server beim Start in sessions/backups/ ablegt. // Server beim Start in sessions/backups/ ablegt.
@@ -21,10 +21,8 @@ async function downloadBackup(){
throw new Error(msg); throw new Error(msg);
} }
const blob = await res.blob(); const blob = await res.blob();
// heuteIso() (js/utils.js) statt toISOString(): Letzteres rechnet nach const filename = 'projektuebersicht_backup_'
// UTC um und datierte das Backup zwischen 0 und 2 Uhr Ortszeit auf den + new Date().toISOString().slice(0, 10) + '.db';
// Vortag.
const filename = 'projektuebersicht_backup_' + heuteIso() + '.db';
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
-209
View File
@@ -1,209 +0,0 @@
// -----------------------------
// Projektübersicht — Benachrichtigungen (Glocke im Menüband + Dashboard)
// -----------------------------
// Eriks Wunsch: Wer eine neue Aufgabe zugewiesen bekommt, soll das in der
// Übersicht sehen. Der Server legt die Einträge beim Speichern einer Sitzung
// an (neueZuweisungenMelden(), server.js); dieses Modul zeigt sie:
//
// - die GLOCKE im Menüband (#glockeBtn) mit der Zahl der ungelesenen, ein
// Klick öffnet die Liste (#glockeMenue). Ein Eintrag führt in den
// Bereich "Aufgaben" seines Projekts und gilt damit als gelesen.
// - auf "Meine Aufgaben" (#dashNeu, js/dashboard/dashboard.js) die
// ungelesenen als eigener Block "Neu zugewiesen".
//
// Abgefragt wird beim Laden der Daten (renderAll()) und danach jede Minute —
// eine Zuweisung durch einen Kollegen erscheint also spätestens nach einer
// Minute, ohne neu zu laden. Kiosk-Konten fragen nicht (der Server würde sie
// ohnehin mit 403 abweisen).
//
// Nutzt baseUrl() (js/server.js), currentUser (js/auth.js), esc()/
// formatWeekDate() (js/utils.js), currentWeek(), openProjectPlan() und
// openSandboxPlan().
let benachrichtigungen = { ungelesen: 0, eintraege: [] };
let benachrichtigungenZuletzt = 0;
const BENACHRICHTIGUNG_INTERVALL_MS = 60 * 1000;
// ladeBenachrichtigungen: holt GET /benachrichtigungen und zeichnet Glocke,
// offene Liste und Dashboard-Block neu. Mehrfachaufrufe innerhalb von fünf
// Sekunden werden zusammengefasst (renderAll() läuft oft mehrmals hintereinander).
// Parameter: erzwingen (Boolean, optional). Keine Rückgabe.
async function ladeBenachrichtigungen(erzwingen){
if(!currentUser || !hatRecht('app')) return;
if(!erzwingen && Date.now() - benachrichtigungenZuletzt < 5000) return;
benachrichtigungenZuletzt = Date.now();
// Die Glocke erscheint für jedes angemeldete Konto, auch wenn der Abruf
// scheitert (etwa ein noch nicht neu gestarteter Server ohne die Route) —
// sie dann still auszublenden, sah aus, als fehle die Funktion ganz.
try {
const res = await fetch(baseUrl() + '/benachrichtigungen');
const data = await res.json().catch(() => null);
if(res.ok && data && data.ok){
benachrichtigungen = { ungelesen: data.ungelesen || 0, eintraege: Array.isArray(data.eintraege) ? data.eintraege : [] };
}
} catch(e){ /* still — die Glocke ist kein Grund für eine Fehlermeldung */ }
renderGlocke();
if(glockeOffen()) renderGlockeMenue();
renderDashNeu();
}
setInterval(() => ladeBenachrichtigungen(true), BENACHRICHTIGUNG_INTERVALL_MS);
// renderGlocke: Zahl am Glockensymbol (ausgeblendet bei 0).
function renderGlocke(){
const btn = document.getElementById('glockeBtn');
const zahl = document.getElementById('glockeZahl');
if(!btn || !zahl) return;
btn.hidden = !currentUser || !hatRecht('app');
const n = benachrichtigungen.ungelesen;
zahl.textContent = n > 9 ? '9+' : String(n);
zahl.hidden = n === 0;
btn.title = n ? n + (n === 1 ? ' neue Benachrichtigung' : ' neue Benachrichtigungen') : 'Benachrichtigungen';
btn.classList.toggle('hat-neue', n > 0);
}
function glockeOffen(){
const m = document.getElementById('glockeMenue');
return !!(m && !m.hidden);
}
// toggleGlocke: Liste öffnen/schließen. Beim Öffnen frisch laden.
function toggleGlocke(){
const m = document.getElementById('glockeMenue');
const btn = document.getElementById('glockeBtn');
if(!m) return;
if(glockeOffen()){ schliesseGlocke(); return; }
m.hidden = false;
if(btn) btn.setAttribute('aria-expanded', 'true');
renderGlockeMenue();
ladeBenachrichtigungen(true);
setTimeout(() => {
document.addEventListener('click', glockeAussenKlick);
document.addEventListener('keydown', glockeEscape);
}, 0);
}
function schliesseGlocke(){
const m = document.getElementById('glockeMenue');
const btn = document.getElementById('glockeBtn');
if(m) m.hidden = true;
if(btn) btn.setAttribute('aria-expanded', 'false');
document.removeEventListener('click', glockeAussenKlick);
document.removeEventListener('keydown', glockeEscape);
}
function glockeAussenKlick(e){
// composedPath() statt e.target.closest(): Ein Klick auf „ד oder „Alle
// löschen“ zeichnet die Liste neu, bevor dieser Lauscher läuft — das
// angeklickte Element hängt dann nicht mehr im Menü, closest() fände es
// nicht, und das Fenster ginge zu. Der Pfad wird beim Auslösen festgehalten.
const pfad = e.composedPath ? e.composedPath() : [];
const drin = el => el && (pfad.includes(el) || e.target.closest && e.target.closest('#' + el.id));
if(drin(document.getElementById('glockeMenue')) || drin(document.getElementById('glockeBtn'))) return;
schliesseGlocke();
}
function glockeEscape(e){ if(e.key === 'Escape') schliesseGlocke(); }
// zeitVor: "gerade eben", "vor 5 Min.", "vor 3 Std.", sonst das Datum.
function zeitVor(iso){
const t = Date.parse(iso);
if(isNaN(t)) return '';
const min = Math.round((Date.now() - t) / 60000);
if(min < 1) return 'gerade eben';
if(min < 60) return 'vor ' + min + ' Min.';
const std = Math.round(min / 60);
if(std < 24) return 'vor ' + std + ' Std.';
return new Date(t).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
// benachrichtigungZeile: eine Zeile für Glocke und Dashboard.
function benachrichtigungZeile(b){
const projekt = (b.projectNr ? b.projectNr + ' ' : '') + (b.projectName || '');
const faellig = b.faellig ? ' · fällig ' + esc(formatWeekDate(b.faellig)) : '';
// Zeile = Öffnen-Knopf + Löschen-Knopf daneben (Knöpfe dürfen nicht
// ineinander stecken).
return '<div class="glocke-zeile' + (b.gelesen ? '' : ' ist-neu') + '">'
+ '<button type="button" class="glocke-eintrag' + (b.gelesen ? '' : ' ist-neu') + '" onclick="oeffneBenachrichtigung(\'' + esc(b.id) + '\')">'
+ '<span class="glocke-punkt" aria-hidden="true"></span>'
+ '<span class="glocke-text">'
+ '<span class="glocke-titel">Neue Aufgabe: ' + esc(b.titel) + '</span>'
+ '<span class="glocke-meta">' + esc(projekt) + faellig + '</span>'
+ '<span class="glocke-meta">' + (b.von ? 'von ' + esc(b.von) + ', ' : '') + esc(zeitVor(b.erstellt)) + '</span>'
+ '</span></button>'
+ '<button type="button" class="glocke-loeschen" title="Benachrichtigung löschen" aria-label="Benachrichtigung löschen" onclick="loescheBenachrichtigungen([\'' + esc(b.id) + '\'])">&times;</button>'
+ '</div>';
}
// renderGlockeMenue: die Liste in der Glocke.
function renderGlockeMenue(){
const m = document.getElementById('glockeMenue');
if(!m) return;
const e = benachrichtigungen.eintraege;
m.innerHTML = '<div class="glocke-kopf"><b>Benachrichtigungen</b><span class="glocke-aktionen">'
+ (benachrichtigungen.ungelesen ? '<button type="button" class="glocke-alle" onclick="alleBenachrichtigungenGelesen()">Alle gelesen</button>' : '')
+ (e.length ? '<button type="button" class="glocke-alle" onclick="loescheBenachrichtigungen(null)">Alle löschen</button>' : '')
+ '</span></div>'
+ (e.length ? '<div class="glocke-liste">' + e.map(benachrichtigungZeile).join('') + '</div>'
: '<div class="glocke-leer">Keine Benachrichtigungen. Neue Aufgaben, die Ihnen jemand zuweist, erscheinen hier.</div>');
}
// renderDashNeu: Block "Neu zugewiesen" auf dem Dashboard — nur, solange es
// Ungelesenes gibt; sonst bleibt der Platz leer.
function renderDashNeu(){
const el = document.getElementById('dashNeu');
if(!el) return;
const neu = benachrichtigungen.eintraege.filter(b => !b.gelesen);
if(!neu.length){ el.innerHTML = ''; return; }
el.innerHTML = '<section class="dash-abschnitt"><div class="dash-abschnitt-kopf">'
+ '<h3 class="dash-abschnitt-titel">Neu zugewiesen</h3><span class="dash-abschnitt-zahl">' + neu.length + '</span>'
+ '<button type="button" class="dash-proj-alle" onclick="alleBenachrichtigungenGelesen()">Alle als gelesen markieren</button></div>'
+ '<div class="dash-karte dash-neu">' + neu.slice(0, 6).map(benachrichtigungZeile).join('') + '</div></section>';
}
// markiereGelesen: lokal und am Server. Parameter: ids (Array) oder null = alle.
async function markiereGelesen(ids){
benachrichtigungen.eintraege.forEach(b => { if(!ids || ids.includes(b.id)) b.gelesen = true; });
benachrichtigungen.ungelesen = benachrichtigungen.eintraege.filter(b => !b.gelesen).length;
renderGlocke(); if(glockeOffen()) renderGlockeMenue(); renderDashNeu();
try {
await fetch(baseUrl() + '/benachrichtigungen/gelesen', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ids ? { ids } : { alle: true }),
});
} catch(e){ /* beim nächsten Abruf stimmt der Stand wieder */ }
ladeBenachrichtigungen(true);
}
function alleBenachrichtigungenGelesen(){ markiereGelesen(null); }
// loescheBenachrichtigungen: entfernt Einträge lokal sofort und am Server
// endgültig. Parameter: ids (Array) oder null = alle. Keine Rückgabe.
async function loescheBenachrichtigungen(ids){
benachrichtigungen.eintraege = ids ? benachrichtigungen.eintraege.filter(b => !ids.includes(b.id)) : [];
benachrichtigungen.ungelesen = benachrichtigungen.eintraege.filter(b => !b.gelesen).length;
renderGlocke(); if(glockeOffen()) renderGlockeMenue(); renderDashNeu();
try {
await fetch(baseUrl() + '/benachrichtigungen/loeschen', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ids ? { ids } : { alle: true }),
});
} catch(e){ /* beim nächsten Abruf stimmt der Stand wieder */ }
ladeBenachrichtigungen(true);
}
// oeffneBenachrichtigung: als gelesen markieren und in die Aufgaben des
// Projekts springen. Gesucht wird das Projekt in der AKTUELLEN Sitzung über
// die Projektnummer (die Projekt-ID wechselt mit jeder Sitzung); Terminpläne
// der Planung haben keine Nummer und werden über ihre ID geöffnet.
function oeffneBenachrichtigung(id){
const b = benachrichtigungen.eintraege.find(x => x.id === id);
if(!b) return;
markiereGelesen([id]);
schliesseGlocke();
if(b.weekLabel === SANDBOX_WEEK_LABEL){
if(typeof openSandboxPlan === 'function'){ location.hash = 'aufgaben'; openSandboxPlan(b.projectId); }
return;
}
const w = typeof currentWeek === 'function' ? currentWeek() : null;
const p = w && (w.projects || []).find(x => (b.projectNr && x.nr === b.projectNr) || x.id === b.projectId)
|| (w && (w.projects || []).find(x => x.name === b.projectName));
if(!p) return;
location.hash = 'aufgaben';
openProjectPlan(p.id);
}
+150 -312
View File
@@ -1,28 +1,25 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Board (Listenansicht) // Projektübersicht — Board (Listenansicht)
// ----------------------------- // -----------------------------
// Diese Datei rendert das Projektboard (Tab "Projektboard" in `App.html`) als // Diese Datei rendert das Projektboard (Tab "Projektboard" in `Test.html`) als
// schlanke, aufklappbare Liste statt der früheren breiten Matrix (eine Spalte // schlanke, aufklappbare Liste statt der früheren breiten Matrix (eine Spalte
// je Person/Leistungsphase): jede Zeile zeigt auf einen Blick Kürzel-Avatare // je Person/Leistungsphase): jede Zeile zeigt auf einen Blick Kürzel-Avatare
// der zugeordneten Personen, die aktuelle Leistungsphase samt Prozentwert und // der zugeordneten Personen, die aktuelle Leistungsphase samt Prozentwert und
// eine einzeilige Status-Vorschau; ein Klick auf die Zeile klappt darunter das // eine einzeilige Status-Vorschau; ein Klick auf die Zeile klappt darunter das
// Detailpanel auf (voller Status-Editor, vollständige Rollen-Zuordnung, alle // Detailpanel auf (voller Status-Editor, vollständige Rollen-Zuordnung, alle
// LPH-Balken). Sie wird von `App.html` nach `js/weeks.js` (vor // LPH-Balken). Sie wird von `Test.html` nach `js/weeks.js` (vor
// `js/people/people.js`) geladen und stellt globale Funktionen wie // `js/people/people.js`) geladen und stellt globale Funktionen wie
// `renderBoard()`, `toggleBoardRow()`, // `renderBoard()`, `toggleBoardRow()`, `cycleAssign()`,
// und `editLph()` bereit. Sie nutzt `currentWeek()`/ // und `editLph()` bereit. Sie nutzt `currentWeek()`/
// `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load/load.js`, // `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load/load.js`,
// `assignKey()`, `statusFocus()`, `sanitizeStatusHtml()` und // `assignKey()`, `statusFocus()`, `sanitizeStatusHtml()` und
// `getProgressColor()` aus `js/utils.js` sowie `openProjectPlan()` aus // `getProgressColor()` aus `js/utils.js` sowie `openProjectPlan()` aus
// `js/gantt.js` — navigiert bewusst im selben Tab (kein window.open() mehr), // `js/gantt.js` — navigiert bewusst im selben Tab (kein window.open() mehr),
// ein Klick auf den Rest der Zeile klappt stattdessen nur das Detailpanel // ein Klick auf den Rest der Zeile klappt stattdessen nur das Detailpanel
// auf/zu (toggleBoardRow()). editLph() ruft zusätzlich // auf/zu (toggleBoardRow()). editLph()/cycleAssign() rufen zusätzlich
// renderWochenbericht() (js/wochenbericht/wochenbericht.js) auf, da dessen // renderWochenbericht() (js/wochenbericht/wochenbericht.js) auf, da dessen
// Karten dieselben renderBoardDrawer()-Bedienelemente nutzen, aber in einem // Karten dieselben renderBoardDrawer()-Bedienelemente nutzen, aber in einem
// eigenen DOM-Container (#wochenberichtContent) liegen. Die Zuordnung der // eigenen DOM-Container (#wochenberichtContent) liegen.
// 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 // boardRowOpen: UI-Zustand, welche Projekt-Zeile ihr Detailpanel aufgeklappt
// darstellt (Key = Projekt-ID, Wert = boolean). Reine Anzeige-Steuerung, lebt // darstellt (Key = Projekt-ID, Wert = boolean). Reine Anzeige-Steuerung, lebt
@@ -31,36 +28,15 @@
// persistiert — jede Sitzung startet mit eingeklappten Zeilen. // persistiert — jede Sitzung startet mit eingeklappten Zeilen.
let boardRowOpen = {}; 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. // toggleBoardRow: klappt das Detailpanel einer Projekt-Zeile auf bzw. zu.
// Parameter: pid (Projekt-ID). Keine Rückgabe. // Parameter: pid (Projekt-ID). Keine Rückgabe.
function toggleBoardRow(pid){ function toggleBoardRow(pid){
boardRowOpen[pid] = !boardRowOpen[pid]; boardRowOpen[pid] = !boardRowOpen[pid];
renderBoard(); renderBoard();
// Der Wochenbericht und die Wandtafel rendern dieselben Projektzeilen in // Der Wochenbericht rendert dieselben Projektzeilen in einem eigenen
// einem eigenen Container. Ohne diesen Aufruf merkten sie sich den Klick // Container. Ohne diesen Aufruf merkte er sich den Klick zwar, zeigte die
// zwar, zeigten die Zeile aber erst nach einem Tab-Wechsel auf- bzw. // Zeile aber erst nach einem Tab-Wechsel auf- bzw. zugeklappt.
// zugeklappt.
if(typeof renderWochenbericht === 'function') renderWochenbericht(); if(typeof renderWochenbericht === 'function') renderWochenbericht();
if(typeof renderWandboard === 'function') renderWandboard();
} }
// statusPreviewText: liefert eine einzeilige Klartext-Vorschau eines // statusPreviewText: liefert eine einzeilige Klartext-Vorschau eines
@@ -106,117 +82,27 @@ function roleClass(role){
// bei Bedarf das aufgeklappte Detailpanel darunter. Parameter: p (Projekt), // bei Bedarf das aufgeklappte Detailpanel darunter. Parameter: p (Projekt),
// allPeople (Array `{person, role}`, alle Rollen), locked (Boolean). // allPeople (Array `{person, role}`, alle Rollen), locked (Boolean).
// Rückgabe: HTML-String. // Rückgabe: HTML-String.
// BOARD_OPTS: die Einstellungen der Projektliste. Sie zeigt wie der // BOARD_OPTS: die Einstellungen der Projektliste. Sie zeigt alle
// Wochenbericht nur die beauftragten Leistungsphasen (Rückfall auf alle, wenn // Leistungsphasen (nicht nur die beauftragten) in der ausführlichen Fassung,
// keine hinterlegt sind — p.beauftragteLP leer, siehe renderBoardDrawer()), // aber nichts davon ist hier bearbeitbar — siehe nurAnsicht in
// nur in der ausführlichen statt der dichten Fassung, und nichts davon ist // renderBoardDrawer().
// hier bearbeitbar (nurAnsicht). const BOARD_OPTS = { nurAnsicht: true };
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){ function renderBoardRow(p, allPeople, locked, weekLabel, opts){
const isOpen = !!boardRowOpen[p.id]; const isOpen = !!boardRowOpen[p.id];
const tags = Array.isArray(p.tags) ? p.tags : []; const tags = Array.isArray(p.tags) ? p.tags : [];
const tagsHtml = tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join(''); const tagsHtml = tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
const previewText = statusPreviewText(kommentarVon(p, kommentarGewerk()).status); const previewText = statusPreviewText(p.status);
const isNew = p.statusState === 'new'; const isNew = p.statusState === 'new';
// Avatar-Stapel der eingeklappten Zeile: nur tatsächlich zugeordnete // Avatar-Stapel der eingeklappten Zeile: nur tatsächlich zugeordnete
// Personen (Zustand 1 = Haupt oder 2 = optional), volle Rollen-Matrix nur // Personen (Zustand 1 = Haupt oder 2 = optional), volle Rollen-Matrix nur
// im aufgeklappten Detailpanel (siehe renderBoardDrawer). // im aufgeklappten Detailpanel (siehe renderBoardDrawer).
// Eine Person mit mehreren Rollen erscheint nur einmal (beteiligteJePerson()). const stackHtml = allPeople.map(entry=>{
const stackHtml = beteiligteJePerson(allPeople.map(entry => ({ const state = p.assign[assignKey(entry.person.id, entry.role)] || 0;
person: entry.person, role: entry.role, state: p.assign[assignKey(entry.person.id, entry.role)] || 0 if(state === 0) return '';
}))).map(b => beteiligtenAvatarHtml(b, roleClass(b.role))).join(''); 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('');
const aktLp = lpActiveList(p)[0]; const aktLp = lpActiveList(p)[0];
const aktPct = aktLp && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? p.lph[aktLp] : null; const aktPct = aktLp && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? p.lph[aktLp] : null;
@@ -244,12 +130,9 @@ function renderBoardRow(p, allPeople, locked, weekLabel, opts){
// Listenoperationen am Zeilenende (aus dem früheren Projekte-Tab). Das // Listenoperationen am Zeilenende (aus dem früheren Projekte-Tab). Das
// Bearbeiten der Projektangaben führt in den Projektbereich, wo sie ihren // Bearbeiten der Projektangaben führt in den Projektbereich, wo sie ihren
// einen Ort haben — hier steht nur, was die LISTE betrifft. // 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()"> html += `<div class="board-row-actions" onclick="event.stopPropagation()">
<button type="button" class="user-kebab" aria-haspopup="menu" aria-expanded="false" <button type="button" class="secondary small" onclick="openProjectPlan('${p.id}')" title="Projektbereich öffnen">Öffnen</button>
aria-label="Aktionen für ${esc(p.name || 'Projekt')}" title="Aktionen" ${locked ? '' : `<button type="button" class="danger small" onclick="delProjectFromList('${p.id}')" title="Projekt aus dieser Sitzung löschen">Löschen</button>`}
onclick="oeffneProjektMenue(event, '${p.id}', ${locked ? 'true' : 'false'})">${PROJEKT_PUNKTE_SVG}</button>
</div>`; </div>`;
html += `</div>`; // board-row-main html += `</div>`; // board-row-main
@@ -278,47 +161,21 @@ function renderBoardRow(p, allPeople, locked, weekLabel, opts){
// Wird bei jedem Aufbau der Schublade neu gesetzt. // Wird bei jedem Aufbau der Schublade neu gesetzt.
let boardCommentBaseline = {}; let boardCommentBaseline = {};
// boardCommentUebernommen: je Projekt-ID, ob der gespeicherte Kommentar die
// unveränderte Übernahme aus der Vorwoche ist (Sitzungswechsel kopiert ihn,
// ensureCurrentWeekRollover() in server.js). Dann leert "Verwerfen" das Feld.
let boardCommentUebernommen = {};
// kommentarAusVorwocheUebernommen: true, wenn der Kommentar des gewählten
// Gewerks wortgleich und mit demselben Zeitstempel schon in einer früheren
// Sitzung steht — dort der jüngste nicht leere (die einmalige Übernahme der
// Einführung kam nicht immer aus der direkten Vorwoche).
// Parameter: p (Projekt), weekLabel (String). Rückgabe: Boolean.
function kommentarAusVorwocheUebernommen(p, weekLabel){
const g = kommentarGewerk();
if(!g || !p.projectUid) return false;
const jetzt = kommentarVon(p, g);
if(!(jetzt.status || '').trim()) return false;
const versionen = projectVersionsForUid(p.projectUid);
const idx = versionen.findIndex(v => v.week.label === weekLabel);
for(let i = (idx < 0 ? versionen.length : idx) - 1; i >= 0; i--){
const k = kommentarVon(versionen[i].project, g);
if(!(k.status || '').trim()) continue;
return k.status === jetzt.status && (k.statusAt || '') === (jetzt.statusAt || '');
}
return false;
}
// commentAuthorLine: Kopfzeile des Editors — wer den gespeicherten Kommentar // commentAuthorLine: Kopfzeile des Editors — wer den gespeicherten Kommentar
// verfasst hat und wann. Bestandskommentare ohne Verfasser (vor der // verfasst hat und wann. Bestandskommentare ohne Verfasser (vor der
// Einführung von status_author) werden als "unbekannt" ausgewiesen, statt // Einführung von status_author) werden als "unbekannt" ausgewiesen, statt
// stillschweigend die aktuell angemeldete Person zu behaupten. // stillschweigend die aktuell angemeldete Person zu behaupten.
// Parameter: p (Projekt). Rückgabe: HTML-String. // Parameter: p (Projekt). Rückgabe: HTML-String.
function commentAuthorLine(p){ function commentAuthorLine(p){
const k = kommentarVon(p, kommentarGewerk()); const hasText = statusPreviewText(p.status) !== '';
const hasText = statusPreviewText(k.status) !== '';
if(!hasText){ if(!hasText){
const me = (currentUser && currentUser.displayName) || ''; const me = (currentUser && currentUser.displayName) || '';
return me return me
? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(me))}</span>${esc(me)}</span><span class="comment-sep">·</span><span>neuer Eintrag</span>` ? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(me))}</span>${esc(me)}</span><span class="comment-sep">·</span><span>neuer Eintrag</span>`
: `<span>neuer Eintrag</span>`; : `<span>neuer Eintrag</span>`;
} }
const author = k.statusAuthor || ''; const author = p.statusAuthor || '';
const at = k.statusAt ? formatCommentStamp(k.statusAt) : ''; const at = p.statusAt ? formatCommentStamp(p.statusAt) : '';
const who = author const who = author
? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(author))}</span>${esc(author)}</span>` ? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(author))}</span>${esc(author)}</span>`
: `<span class="comment-who comment-unknown">Verfasser unbekannt</span>`; : `<span class="comment-who comment-unknown">Verfasser unbekannt</span>`;
@@ -350,15 +207,13 @@ function formatCommentStamp(iso){
// Parameter: p (Projekt), weekLabel (String), locked (Boolean), // Parameter: p (Projekt), weekLabel (String), locked (Boolean),
// dicht (Boolean). Rückgabe: HTML-String. // dicht (Boolean). Rückgabe: HTML-String.
function renderCommentEditor(p, weekLabel, locked, dicht){ function renderCommentEditor(p, weekLabel, locked, dicht){
// Kommentar des gewählten Gewerks (Wochenbericht), siehe kommentarVon(). const text = statusHtmlToEditText(p.status || '');
const html = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || ''); boardCommentBaseline[p.id] = text;
boardCommentBaseline[p.id] = html;
boardCommentUebernommen[p.id] = kommentarAusVorwocheUebernommen(p, weekLabel);
if(locked){ if(locked){
return `<div class="comment-block" data-pid="${esc(p.id)}"> return `<div class="comment-block" data-pid="${esc(p.id)}">
<div class="comment-head-line">${commentAuthorLine(p)}</div> <div class="comment-head-line">${commentAuthorLine(p)}</div>
<div class="comment-readonly">${html || '<span class="hint">Kein Kommentar erfasst.</span>'}</div> <div class="comment-readonly">${text ? sanitizeStatusHtml(p.status) : '<span class="hint">Kein Kommentar erfasst.</span>'}</div>
</div>`; </div>`;
} }
@@ -374,22 +229,14 @@ function renderCommentEditor(p, weekLabel, locked, dicht){
// jetzt renderProjektVerlauf() (js/utils.js) und zeigen damit dasselbe. // jetzt renderProjektVerlauf() (js/utils.js) und zeigen damit dasselbe.
const verlaufAufklapper = ''; 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)}"> return `<div class="comment-block" data-pid="${esc(p.id)}">
<div class="comment-editor"> <div class="comment-editor">
${kopf} ${kopf}
<div class="richtext-wrap"> <textarea class="comment-field" rows="3" placeholder="Was gibt es diese Woche zu diesem Projekt?"
${formatLeiste} ${dicht ? 'title="Strg+↵ speichern · Esc verwerfen"' : ''}
${richtextFeldHtml('comment-field', html, 'Was gibt es diese Woche zu diesem Projekt?', feldAttrs)} oninput="markCommentDirty(this)"
</div> onkeydown="boardCommentKey(event, this)"
onblur="boardCommentBlur(this)">${esc(text)}</textarea>
<div class="comment-editor-foot"> <div class="comment-editor-foot">
<button type="button" class="small" onmousedown="event.preventDefault()" onclick="saveBoardComment(this)">Speichern</button> <button type="button" class="small" onmousedown="event.preventDefault()" onclick="saveBoardComment(this)">Speichern</button>
<button type="button" class="secondary small" <button type="button" class="secondary small"
@@ -432,7 +279,9 @@ function restorePendingCommentFocus(){
const kandidaten = Array.from(document.querySelectorAll(sel)); const kandidaten = Array.from(document.querySelectorAll(sel));
const field = kandidaten.find(f => f.offsetParent !== null) || kandidaten[0]; const field = kandidaten.find(f => f.offsetParent !== null) || kandidaten[0];
if(!field) return; if(!field) return;
richtextCursorAnsEnde(field); field.focus();
const ende = field.value.length;
try { field.setSelectionRange(ende, ende); } catch(e){ /* unterstützt nicht jedes Feld */ }
} }
// commentParts: löst von einem beliebigen Bedienelement aus dessen // commentParts: löst von einem beliebigen Bedienelement aus dessen
@@ -469,7 +318,7 @@ function commentParts(el){
function markCommentDirty(el){ function markCommentDirty(el){
const c = commentParts(el); const c = commentParts(el);
if(!c || !c.field || !c.dirty) return; if(!c || !c.field || !c.dirty) return;
c.dirty.hidden = c.field.innerHTML === (boardCommentBaseline[c.pid] || ''); c.dirty.hidden = c.field.value === (boardCommentBaseline[c.pid] || '');
} }
// saveBoardComment: schreibt den Feldinhalt als neuen Kommentar des Projekts, // saveBoardComment: schreibt den Feldinhalt als neuen Kommentar des Projekts,
@@ -481,24 +330,26 @@ function saveBoardComment(el){
if(guardWeekLocked()) return; if(guardWeekLocked()) return;
const c = commentParts(el); const c = commentParts(el);
if(!c || !c.field) return; if(!c || !c.field) return;
const html = sanitizeStatusHtml(c.field.innerHTML); const text = c.field.value;
if(html === (boardCommentBaseline[c.pid] || '')) return; if(text === (boardCommentBaseline[c.pid] || '')) return;
const p = currentWeek().projects.find(x => x.id === c.pid); const p = currentWeek().projects.find(x => x.id === c.pid);
if(!p) return; if(!p) return;
// Geschrieben wird immer der Kommentar EINES Gewerks — der Editor steht const html = statusEditTextToHtml(text);
// nur im Wochenbericht, und der hat stets ein gewähltes Gewerk. const before = (p.status || '').trim();
const gewerk = kommentarGewerk();
if(!gewerk) return;
const before = (kommentarVon(p, gewerk).status || '').trim();
if(before === '' && html !== '') p.statusState = 'new'; if(before === '' && html !== '') p.statusState = 'new';
else if(html === '') delete p.statusState; else if(html === '') delete p.statusState;
kommentarSetzen(p, gewerk, html === '' p.status = html;
? { status: '', statusAuthor: '', statusAt: '' } if(html === ''){
: { status: html, statusAuthor: (currentUser && currentUser.displayName) || '', statusAt: new Date().toISOString() }); p.statusAuthor = '';
boardCommentBaseline[c.pid] = html; p.statusAt = '';
} else {
p.statusAuthor = (currentUser && currentUser.displayName) || '';
p.statusAt = new Date().toISOString();
}
boardCommentBaseline[c.pid] = text;
save(); save();
renderBoard(); renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht(); if(typeof renderWochenbericht === 'function') renderWochenbericht();
@@ -512,11 +363,9 @@ function saveBoardComment(el){
function discardBoardComment(el){ function discardBoardComment(el){
const c = commentParts(el); const c = commentParts(el);
if(!c || !c.field) return; if(!c || !c.field) return;
// Übernommener Vorwochen-Kommentar: Verwerfen leert das ganze Feld, damit c.field.value = boardCommentBaseline[c.pid] || '';
// der Kommentar dieser Woche neu geschrieben werden kann (Eriks Wunsch).
c.field.innerHTML = boardCommentUebernommen[c.pid] ? '' : (boardCommentBaseline[c.pid] || '');
markCommentDirty(c.field); markCommentDirty(c.field);
richtextCursorAnsEnde(c.field); c.field.focus();
} }
// boardCommentKey: Tastaturbedienung des Kommentarfelds — Strg/Cmd+Enter // boardCommentKey: Tastaturbedienung des Kommentarfelds — Strg/Cmd+Enter
@@ -529,9 +378,6 @@ function boardCommentKey(e, el){
} else if(e.key === 'Escape'){ } else if(e.key === 'Escape'){
discardBoardComment(el); discardBoardComment(el);
e.preventDefault(); e.preventDefault();
} else {
// Enter: neue Zeile beginnt mit "- " (js/utils.js)
richtextNeueZeileMitStrich(e, el);
} }
} }
@@ -565,29 +411,13 @@ function takeCommentFromHistory(el, weekLabel){
const entry = projectStatusHistory(p.projectUid).find(e => e.week.label === weekLabel); const entry = projectStatusHistory(p.projectUid).find(e => e.week.label === weekLabel);
const html = entry const html = entry
? entry.html ? entry.html
: (v => v ? sanitizeStatusHtml(kommentarVon(v.project, kommentarGewerk()).status || '') : null)( : (v => v ? sanitizeStatusHtml(v.project.status || '') : null)(
projectVersionsForUid(p.projectUid).find(v => v.week.label === weekLabel)); projectVersionsForUid(p.projectUid).find(v => v.week.label === weekLabel));
if(html === null || html === undefined) return; if(html === null || html === undefined) return;
c.field.innerHTML = sanitizeStatusHtml(html); c.field.value = statusHtmlToEditText(html);
kommentarVorsatzEntfernen(c.field);
markCommentDirty(c.field); markCommentDirty(c.field);
richtextCursorAnsEnde(c.field); c.field.focus();
}
// kommentarVorsatzEntfernen: nimmt beim Übernehmen den Gewerke-Vorsatz am
// Anfang weg ("[ELT] ", "[ELT+HLS] " — Altbestand aus der Zeit vor den
// Kommentaren je Gewerk); im Kommentar eines Gewerks ist er überflüssig.
// Arbeitet auf dem ersten Textknoten, damit Formatierung erhalten bleibt.
// Parameter: feld (Element). Keine Rückgabe.
function kommentarVorsatzEntfernen(feld){
const walker = document.createTreeWalker(feld, NodeFilter.SHOW_TEXT);
let knoten;
while((knoten = walker.nextNode())){
if(!knoten.textContent.trim()) continue;
knoten.textContent = knoten.textContent.replace(/^\s*\[[^\]]{1,20}\]\s*/, '');
break;
}
} }
// renderBoardDrawer: baut das aufgeklappte Detailpanel einer Projekt-Zeile — // renderBoardDrawer: baut das aufgeklappte Detailpanel einer Projekt-Zeile —
@@ -606,51 +436,49 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
// musste. Im Projektboard, wo die Karte nur beim Aufklappen einer Zeile // musste. Im Projektboard, wo die Karte nur beim Aufklappen einer Zeile
// erscheint, bleibt es bei der ausführlichen Fassung. // erscheint, bleibt es bei der ausführlichen Fassung.
// //
// nurBeauftragteLph: sowohl im Wochenbericht als auch im Projektboard // nurBeauftragteLph: im Wochenbericht werden nur die Leistungsphasen
// werden nur die Leistungsphasen gezeigt, für die das Projekt beauftragt // gezeigt, für die das Projekt beauftragt ist (p.beauftragteLP, gesetzt
// ist (p.beauftragteLP, gesetzt beim Anlegen oder im Honorar-Dialog). Ist // beim Anlegen oder im Honorar-Dialog). Ist nichts hinterlegt — was auf
// nichts hinterlegt — was auf ältere Bestandsprojekte zutrifft —, bleibt es // alle Bestandsprojekte zutrifft —, bleibt es bei der vollständigen Liste,
// bei der vollständigen Liste, sonst stünde dort gar nichts. // sonst stünde dort gar nichts. Im Projektboard sind immer alle Phasen zu
// sehen.
// opts.nurAnsicht: die Karte zeigt, sie erfasst nicht. // opts.nurAnsicht: die Karte zeigt, sie erfasst nicht.
// //
// Kommentar und Fortschritt je Leistungsphase entstehen in der // Kommentar, Fortschritt je Leistungsphase und die Zuordnung der
// wöchentlichen Besprechung und werden deshalb allein im Wochenbericht // Beteiligten entstehen in der wöchentlichen Besprechung und werden deshalb
// gepflegt. Zuvor waren dieselben Angaben an drei Stellen bearbeitbar // allein im Wochenbericht gepflegt. Zuvor waren dieselben drei Angaben an
// (Projektboard, Wochenbericht, Projektbereich) — niemand wusste, welche // drei Stellen bearbeitbar (Projektboard, Wochenbericht, Projektbereich) —
// die richtige ist, und zwei Personen konnten dasselbe Feld an // niemand wusste, welche die richtige ist, und zwei Personen konnten
// verschiedenen Stellen ändern. Dieselbe Karte, dieselben Werte, nur ohne // dasselbe Feld an verschiedenen Stellen ändern. Dieselbe Karte, dieselben
// Eingabefelder und mit einem Verweis dorthin, wo sie gepflegt werden. // Werte, nur ohne Eingabefelder und mit einem Verweis dorthin, wo sie
// Eine gesperrte Sitzung wirkt weiterhin zusätzlich. // 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 ansicht = !!(opts && opts.nurAnsicht);
const starr = ansicht || locked; const starr = ansicht || locked;
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []; const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
const nurBeauftragte = !!(opts && opts.nurBeauftragteLph) && beauftragt.length > 0; const nurBeauftragte = !!(opts && opts.nurBeauftragteLph) && beauftragt.length > 0;
const sichtbareLphs = nurBeauftragte ? LPHS.filter(l => beauftragt.includes(l)) : LPHS;
const aktLp = lpActiveList(p)[0]; const aktLp = lpActiveList(p)[0];
// Die amtierende Phase (aktLp) IMMER mit anzeigen, auch wenn sie nicht in const lphRows = sichtbareLphs.map(l=>{
// beauftragteLP steht: aktLp kommt unabhängig von beauftragteLP aus const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
// lpActiveList() (js/load/load.js) - beide Felder können auseinanderlaufen const aktiv = l === aktLp;
// (z. B. ein Projekt auf LP 8, aber nur LP 1/2/3/6 als beauftragt const inputAttr = starr ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
// gepflegt). Ohne diese Ergänzung behauptete die Kopfzeile dieses const pct = v !== "" ? Math.min(100, parseFloat(v)||0) : 0;
// Abschnitts ("LP 8 aktiv"), während LP 8 in der aufgeklappten Liste const feld = ansicht
// darunter gar nicht auftauchte und ihr Fortschritt dort nicht pflegbar ? `<span class="board-lph-wert">${v === "" ? '–' : esc(v) + ' %'}</span>`
// war - gefunden beim Live-Test mit echten Projektdaten. : `<input type="text" value="${esc(v)}" ${inputAttr} placeholder="-">`;
const sichtbareLphs = nurBeauftragte ? LPHS.filter(l => beauftragt.includes(l) || l === aktLp) : LPHS; 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('');
// lphLeiste: die Leistungsphasen nebeneinander statt gestapelt — seit // lphLeiste: dieselben Werte wie lphRows, nur nebeneinander. Jede Phase ist
// Variante A (Eriks Wahl aus dem Layout-Vergleich) die EINE Darstellung für // ein schmales Feld mit der Phasennummer und ihrem Eingabefeld; der
// Leistungsphasen in dieser Schublade, nicht mehr nur die dichte // Füllstand steckt als senkrechter Verlauf im Feld-Hintergrund, damit der
// Wochenbericht-Karte. Jede Phase ist ein schmales Feld mit der // Fortschrittsbalken nicht verlorengeht. Die amtierende Phase bleibt
// Phasennummer und ihrem Eingabefeld; der Füllstand steckt als senkrechter // hervorgehoben.
// Verlauf im Feld-Hintergrund. Die amtierende Phase bleibt hervorgehoben.
const lphLeiste = sichtbareLphs.map(l=>{ const lphLeiste = sichtbareLphs.map(l=>{
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : ""; const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
const aktiv = l === aktLp; const aktiv = l === aktLp;
@@ -662,7 +490,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
const fuellung = pct > 0 const fuellung = pct > 0
? ` style="background:linear-gradient(to top, ${getProgressColor(pct)}4d ${pct}%, transparent ${pct}%)"` ? ` style="background:linear-gradient(to top, ${getProgressColor(pct)}4d ${pct}%, transparent ${pct}%)"`
: ''; : '';
return `<div class="board-lph-cell${aktiv ? ' lp-aktiv' : ''}${ansicht ? ' is-ansicht' : ''}${locked ? ' locked-cell' : ''}"${fuellung} title="${esc(l)}${aktiv ? ' · amtierende Leistungsphase' : ''}"> return `<div class="board-lph-cell${aktiv ? ' lp-aktiv' : ''}${locked ? ' locked-cell' : ''}"${fuellung} title="${esc(l)}${aktiv ? ' · amtierende Leistungsphase' : ''}">
<span class="board-lph-cell-label">${esc(l.replace('LPH ', ''))}</span> <span class="board-lph-cell-label">${esc(l.replace('LPH ', ''))}</span>
<input type="text" value="${esc(v)}" ${inputAttr} placeholder="-"> <input type="text" value="${esc(v)}" ${inputAttr} placeholder="-">
</div>`; </div>`;
@@ -672,49 +500,41 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
const entries = allPeople.filter(e=>e.role===r); const entries = allPeople.filter(e=>e.role===r);
const chips = entries.length ? entries.map(entry=>{ const chips = entries.length ? entries.map(entry=>{
const state = p.assign[assignKey(entry.person.id, entry.role)] || 0; const state = p.assign[assignKey(entry.person.id, entry.role)] || 0;
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>`; 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>`;
}).join('') : `<span class="hint" style="margin:0">Niemand hinterlegt</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>`; 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(''); }).join('');
// Dichte Fassung (Wochenbericht), zweite Fassung (Eriks Rückmeldung zum // Dichte Fassung: zwei Spalten — links der Kommentar, rechts die
// Entwurfsvergleich "Aktenregal"): Leistungsphasen und Beteiligte stehen // Leistungsphasen-Leiste und darunter die Beteiligten.
// jetzt dauerhaft sichtbar nebeneinander statt hinter Aufklappern verborgen //
// — "die Beteiligten sollen mit angezeigt werden". Die erste Fokus-Modus- // Zwischenzeitlich waren es drei Spalten, weil die gestapelten Beteiligten
// Fassung hatte beide hinter <details> versteckt (Reaktion auf "zu // die Karte höher machten und damals 29 Karten untereinander standen. Seit
// überladen" bei allem gleichzeitig sichtbar); diesmal bleiben beide // der Wochenbericht eine Tagesordnung ist, ist immer nur EIN Projekt
// bewusst KOMPAKT, damit sie nebeneinander Platz finden, ohne den Kommentar // geöffnet — die Höhe der Karte kostet nichts mehr, während die Breite
// ("im Vergleich zu groß") zu überragen: Leistungsphasen nutzen dieselbe // knapp ist: Neben der Projektliste blieben für drei Spalten nur noch
// Kästchen-Leiste wie das Projektboard (lphLeiste, oben in dieser Funktion // 137 px für die Beteiligten, zu wenig für Rollenname und Kürzel.
// 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 // Die Beteiligten behalten ihre Zeile je Rolle — anders als im Entwurf, wo
// sie zu einer flachen Reihe zusammengefasst waren: Ein Kürzel kann in // sie zu einer flachen Reihe zusammengefasst waren: Ein Kürzel kann in
// mehreren Rollen vorkommen, und ohne die Zeile wäre nicht erkennbar, in // mehreren Rollen vorkommen, und cycleAssign() braucht die Rolle. Ohne sie
// welcher Rolle genau jemand zugeordnet ist (dieselbe Zeilenform nutzt die // wäre beim Klicken nicht erkennbar, welche Zuordnung man durchschaltet.
// jetzt editierbare Matrix der Projekteinstellungen, planCycleAssign(),
// js/plan/plan-settings.js).
if(opts && opts.dicht){ 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"> return `<div class="board-drawer board-drawer-dicht">
${renderCommentEditor(p, weekLabel, locked, true)} <div class="board-drawer-grid">
<div class="bericht-meta-grid"> <div class="drawer-col drawer-col-comment">
<div> <h4>Kommentar${weekLabel ? ' zur Sitzung ' + esc(weekLabel) : ''}</h4>
<div class="bericht-meta-label">${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'} · ${lphInfo}</div> ${renderCommentEditor(p, weekLabel, locked, true)}
${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>
<div> <div class="drawer-col drawer-col-kompakt">
<div class="bericht-meta-label">Beteiligte</div> <h4>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</h4>
<div class="board-lph-leiste">${lphLeiste}</div>
<h4 class="drawer-h4-zweit">Beteiligte</h4>
<div class="board-role-mini">${roleRows}</div> <div 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>
</div> </div>
${(opts.ohneVerlauf || typeof renderProjektVerlauf !== 'function') ? '' : renderProjektVerlauf(p, weekLabel, locked)} ${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''}
</div>`; </div>`;
} }
@@ -732,12 +552,11 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
</div> </div>
<div class="drawer-col drawer-col-lph"> <div class="drawer-col drawer-col-lph">
<h4>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</h4> <h4>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</h4>
<div class="board-lph-leiste">${lphLeiste}</div> <div class="board-lph-list">${lphRows}</div>
</div> </div>
<div class="drawer-col drawer-col-people"> <div class="drawer-col drawer-col-people">
<h4>Beteiligte</h4> <h4>Beteiligte</h4>
<div class="board-role-mini">${roleRows}</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>
</div> </div>
${ansicht ? renderBerichtVerweis(p, locked) : ''} ${ansicht ? renderBerichtVerweis(p, locked) : ''}
@@ -748,7 +567,7 @@ function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
// renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen. // renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen.
// Parameter: p (Projekt), locked. Rückgabe: HTML-String. // Parameter: p (Projekt), locked. Rückgabe: HTML-String.
function renderKommentarAnsicht(p, locked){ function renderKommentarAnsicht(p, locked){
const text = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || ''); const text = sanitizeStatusHtml(p.status || '');
return `<div class="status-edit is-ansicht locked-cell">${text || return `<div class="status-edit is-ansicht locked-cell">${text ||
'<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`; '<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`;
} }
@@ -761,7 +580,7 @@ function renderBerichtVerweis(p, locked){
if(locked) return '<div class="drawer-verweis"><span class="hint" style="margin:0">Die Sitzung ist gesperrt.</span></div>'; if(locked) return '<div class="drawer-verweis"><span class="hint" style="margin:0">Die Sitzung ist gesperrt.</span></div>';
const uid = esc(p.projectUid || ''); const uid = esc(p.projectUid || '');
return `<div class="drawer-verweis"> return `<div class="drawer-verweis">
<span class="hint" style="margin:0">Kommentar und Fortschritt werden in der Besprechung erfasst.</span> <span class="hint" style="margin:0">Kommentar, Fortschritt und Zuordnung werden in der Besprechung erfasst.</span>
<button type="button" class="plan-verweis" onclick="springZumWochenbericht('${uid}')">Im Wochenbericht bearbeiten →</button> <button type="button" class="plan-verweis" onclick="springZumWochenbericht('${uid}')">Im Wochenbericht bearbeiten →</button>
</div>`; </div>`;
} }
@@ -784,8 +603,7 @@ function renderBoard(){
if(container) container.innerHTML = '<div class="hint">Keine Sitzung geladen.</div>'; if(container) container.innerHTML = '<div class="hint">Keine Sitzung geladen.</div>';
return; return;
} }
// Ziehen zwischen Gruppen ändert Stammdaten (Rollen & Rechte). const locked = isWeekLocked(w);
const locked = isWeekLocked(w) || !hatRecht('proj.stamm');
const grouped = peopleByRole(w); const grouped = peopleByRole(w);
const allPeople = []; const allPeople = [];
ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry))); ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry)));
@@ -830,30 +648,50 @@ function renderBoard(){
container.innerHTML = html; 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 // editLph: setzt oder entfernt den Prozentwert einer Leistungsphase für ein
// Projekt. Parameter: `pid` (Projekt-ID), `lph` (Leistungsphasen-Schlüssel), // Projekt. Parameter: `pid` (Projekt-ID), `lph` (Leistungsphasen-Schlüssel),
// `val` (Eingabewert). Leere/`%`-Werte werden bereinigt; keine Rückgabe. // `val` (Eingabewert). Leere/`%`-Werte werden bereinigt; keine Rückgabe.
function editLph(pid,lph,val){ function editLph(pid,lph,val){
if(guardWeekLocked()) return; if(guardWeekLocked()) return;
const p = currentWeek().projects.find(x=>x.id===pid); const p = currentWeek().projects.find(x=>x.id===pid);
if(!p) return; val = val.trim();
if(!p.lph) p.lph = {};
val = String(val || '').trim();
if(val==="") delete p.lph[lph]; if(val==="") delete p.lph[lph];
else { else p.lph[lph]=val.replace('%','');
// Nur Zahlen von 0 bis 100 — "abc" oder "250" hätten Auslastung und save(); renderBoard(); renderWochenbericht();
// 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()); // editProj: aktualisiert ein Feld eines Projekts; für Status wird zusätzlich
if(!Number.isFinite(n)){ // ein flag `statusState` gesetzt (='new' beim Anlegen eines neuen Kommentars),
renderBoard(); // wenn zuvor noch kein Status existierte. Parameter: `pid` (Projekt-ID),
if(typeof renderWochenbericht === 'function') renderWochenbericht(); // `field` (Feldname), `val` (neuer Wert). Keine Rückgabe.
return; 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;
} }
p.lph[lph] = String(Math.max(0, Math.min(100, Math.round(n))));
} }
save(); renderBoard(); p[field]=val; save();
if(typeof renderWochenbericht === 'function') renderWochenbericht();
} }
// HINWEIS: delProj() ist entfallen — seit der neuen Zeilendarstellung des // HINWEIS: delProj() ist entfallen — seit der neuen Zeilendarstellung des
+125 -461
View File
@@ -1,7 +1,7 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Dashboard ("Meine Aufgaben"-Tab) // Projektübersicht — Dashboard ("Meine Aufgaben"-Tab)
// ----------------------------- // -----------------------------
// Modul für den ersten Tab der Hauptseite (App.html, Ladereihenfolge: // Modul für den ersten Tab der Hauptseite (Test.html, Ladereihenfolge:
// ... → load → dashboard → gantt → ...). Das Dashboard ist die Startübersicht // ... → load → dashboard → gantt → ...). Das Dashboard ist die Startübersicht
// nach dem Login; alles darin stammt aus einem einzigen GET /my-dashboard. // nach dem Login; alles darin stammt aus einem einzigen GET /my-dashboard.
// //
@@ -13,22 +13,14 @@
// 2. BAND Der Befund des Tages als SATZ, daneben vier Kennzahlen // 2. BAND Der Befund des Tages als SATZ, daneben vier Kennzahlen
// (renderDashboardBand()). Ist nichts überfällig und nichts // (renderDashboardBand()). Ist nichts überfällig und nichts
// heute fällig, bleibt das Band farblos. // heute fällig, bleibt das Band farblos.
// 3. LINKS "Meine Projekte": je Projekt, dem man zugeordnet ist, ein // 3. LINKS Der eigene Arbeitsstrom: "Meine Aufgaben" (persönlich
// Block mit der eigenen Rolle, der laufenden Leistungsphase und // zugewiesene offene TODOs, als Zeilen —
// den offenen Terminen des Projekts — eigene Aufgaben // renderDashboardAufgabe()) und "Termine meiner Projekte"
// hervorgehoben (renderDashProjekte()). Projekte, in denen man // (Aufgaben und Meilensteine der Projekte, denen man zugeordnet
// nur eine einzelne Aufgabe hat, stehen ebenfalls dort. // ist, nach Dringlichkeit gegliedert — renderDashboardFristen()).
// 4. RECHTS Ein Monatskalender mit allen Terminen der eigenen Projekte, // 4. RECHTS Was man wissen muss, ohne selbst tätig zu werden: ausstehende
// den eigenen Aufgaben, Feiertagen und den Abwesenheiten der // Freigaben und Abwesenheiten der Kollegen, beide in derselben
// Projektbeteiligten; ein Klick auf einen Tag listet ihn // schmalen Zeilenform.
// (renderDashKalender()). Darunter ausstehende Freigaben und
// Abwesenheiten.
//
// Projekte und Kalender rechnen mit der bereits geladenen Sitzung
// (currentWeek(), /load-all) statt mit GET /my-dashboard: Der Kalender soll
// beliebig weit blättern können, der Server liefert Projekttermine aber nur
// 21 Tage voraus. Kopf und Band bleiben bei den Zahlen des Servers.
// Entwurf "B — Monatsblatt" aus Eriks Vergleich dreier Varianten.
// //
// Das war zuvor anders und schlechter: Die Uhrzeit war die größte Zahl auf // Das war zuvor anders und schlechter: Die Uhrzeit war die größte Zahl auf
// dem Bildschirm, vier gleich große Kennzahlkacheln zeigten meist drei Nullen // dem Bildschirm, vier gleich große Kennzahlkacheln zeigten meist drei Nullen
@@ -117,29 +109,20 @@ async function renderDashboard(){
html += '<div class="hint">Ihrem Benutzerkonto ist keine Person zugeordnet — es können daher keine Aufgaben angezeigt werden. Ein Administrator kann die Zuordnung in der Benutzerverwaltung ergänzen.</div>'; html += '<div class="hint">Ihrem Benutzerkonto ist keine Person zugeordnet — es können daher keine Aufgaben angezeigt werden. Ein Administrator kann die Zuordnung in der Benutzerverwaltung ergänzen.</div>';
} }
// Neu zugewiesene Aufgaben (js/benachrichtigungen.js) — vor allem anderen,
// weil es das ist, was sich seit dem letzten Blick geändert hat.
html += '<div id="dashNeu"></div>';
html += '<div class="dash-zonen">'; html += '<div class="dash-zonen">';
// Linke Zone: die eigenen Projekte mit ihren Terminen // Linke Zone: der eigene Arbeitsstrom
const daten = dashDaten();
html += '<div>'; html += '<div>';
const zuAlle = dashZuLesen(); html += dashAbschnitt('Meine Aufgaben', tasks.length, 'mir persönlich zugewiesen',
const alleOffen = daten.projekte.some(x => !zuAlle.has(dashProjKey(x.p))); !weekLabel ? dashLeer('Keine Sitzung vorhanden.')
html += dashAbschnitt('Meine Projekte', daten.projekte.length, null, : (tasks.length === 0 ? dashLeer('Keine offenen Aufgaben — alles abgearbeitet.')
renderDashProjekte(daten), : '<div class="dash-karte">' + tasks.map(t => renderDashboardAufgabe(weekLabel, t)).join('') + '</div>'));
daten.projekte.length > 1 html += dashAbschnitt('Termine meiner Projekte', projectDates.length, 'nächste drei Wochen',
? '<button type="button" class="dash-proj-alle" id="dashReihenfolgeReset" onclick="dashReihenfolgeZuruecksetzen()"' renderDashboardFristen(weekLabel, projectDates));
+ (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>'; html += '</div>';
// Rechte Zone: Kalender, dann was man wissen muss, ohne es selbst zu tun // Rechte Zone: was man wissen muss, ohne es selbst zu tun
html += '<div>'; html += '<div>';
html += dashAbschnitt('Kalender', null, null, '<div class="dash-karte" id="dashKalender"></div>');
html += dashAbschnitt('Ausstehende Freigaben', pending.length, null, html += dashAbschnitt('Ausstehende Freigaben', pending.length, null,
pending.length === 0 ? dashLeer('Keine ausstehenden Freigaben.') pending.length === 0 ? dashLeer('Keine ausstehenden Freigaben.')
: '<div class="dash-karte">' + pending.map(renderDashboardFreigabe).join('') + '</div>'); : '<div class="dash-karte">' + pending.map(renderDashboardFreigabe).join('') + '</div>');
@@ -150,432 +133,9 @@ async function renderDashboard(){
html += '</div>'; html += '</div>';
container.innerHTML = html; container.innerHTML = html;
if(typeof renderDashNeu === 'function'){ renderDashNeu(); ladeBenachrichtigungen(true); }
dashKalDaten = daten;
renderDashKalender();
startDashboardClock(); startDashboardClock();
} }
// ---------- Meine Projekte + Kalender (Entwurf B "Monatsblatt") ----------
// dashKalDaten / dashKalMonat / dashKalTag: Zustand des Kalenders. Der Monat
// und der gewählte Tag überleben ein Neuladen des Dashboards nicht absichtlich
// nicht — wer wiederkommt, will "heute" sehen.
let dashKalDaten = null;
let dashKalMonat = null; // { jahr, monat } (monat 0–11)
let dashKalTag = null; // ISO-Datum
// dashDaten: sammelt aus der aktuellen Sitzung (currentWeek()) die Projekte
// des angemeldeten Kontos und alle Kalendereinträge. Ein Projekt gehört dazu,
// wenn die Person ihm zugeordnet ist (assign, Haupt 1 / Unterstützung 2) oder
// dort eine eigene offene Aufgabe hat.
// Rückgabe: { projekte:[{p, rollen:[{rolle,state}], termine:[…]}], eintraege:[…] }.
function dashDaten(){
// geladen: false, solange /load-all noch nicht da ist — das Dashboard wird
// beim Anmelden oft schon vorher aufgebaut; load() zeichnet es danach neu.
const geladen = Array.isArray(db.weeks) && db.weeks.length > 0;
const leer = { projekte: [], eintraege: [], abwesenheiten: [], geladen };
const w = (geladen && typeof currentWeek === 'function') ? currentWeek() : null;
const ich = currentUser && currentUser.personId;
if(!w || !ich) return leer;
const heute = heuteIso();
const namen = {};
(w.people || []).forEach(x => { namen[x.id] = x.fullName || x.name || x.id; });
const projekte = [];
(w.projects || []).forEach(p => {
const rollen = [];
Object.keys(p.assign || {}).forEach(key => {
const [pid, rolle] = key.split('|');
const state = Number(p.assign[key]) || 0;
if(pid === ich && state > 0) rollen.push({ rolle: rolle || '', state });
});
const plan = p.plan || {};
const termine = [];
(plan.todos || []).forEach(t => {
if(t.done) return;
const meins = Array.isArray(t.assignees) && t.assignees.includes(ich);
// Fremde Aufgaben ohne Datum gehören in keinen Kalender und keine Terminliste.
if(!t.end && !meins) return;
termine.push({
art: 'aufgabe', titel: t.title || '(ohne Titel)', datum: t.end || '',
meins, progress: Number(t.progress) || 0,
wer: (t.assignees || []).map(id => namen[id] || id),
});
});
(plan.milestones || []).forEach(m => {
if(!m.date) return;
termine.push({ art: 'meilenstein', titel: m.name || 'Meilenstein', datum: m.date, meins: false, wer: [] });
});
const hatEigene = termine.some(t => t.meins);
if(!rollen.length && !hatEigene) return;
// Erledigte Vergangenheit interessiert hier nicht: Meilensteine vor heute
// fallen weg (sie haben kein Erledigt-Kennzeichen), offene Aufgaben vor
// heute bleiben als überfällig stehen.
const relevant = termine.filter(t => !(t.art === 'meilenstein' && t.datum < heute));
relevant.sort((a, b) => (a.datum || '9999').localeCompare(b.datum || '9999'));
projekte.push({ p, rollen, termine: relevant, alleTermine: termine });
});
// 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 // renderDashboardKopf: Begrüßung und Datum links, Sitzung und Uhr klein
// rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm — sie ist // rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm — sie ist
// die Angabe, nach der man am wenigsten handelt, und steht deshalb am Rand. // die Angabe, nach der man am wenigsten handelt, und steht deshalb am Rand.
@@ -634,15 +194,12 @@ function renderDashboardBand(z){
// dashAbschnitt / dashLeer: die immer gleiche Umrahmung eines Blocks — // dashAbschnitt / dashLeer: die immer gleiche Umrahmung eines Blocks —
// Überschrift, Anzahl, ein erklärender Zusatz am rechten Rand. // Überschrift, Anzahl, ein erklärender Zusatz am rechten Rand.
// Parameter: titel, zahl (oder null), zusatz (oder null), inhalt. // Parameter: titel, zahl (oder null), zusatz (oder null), inhalt.
// kopfHtml (optional): fertiges HTML am rechten Rand der Überschrift, etwa function dashAbschnitt(titel, zahl, zusatz, inhalt){
// ein Bedienknopf.
function dashAbschnitt(titel, zahl, zusatz, inhalt, kopfHtml){
return '<section class="dash-abschnitt">' return '<section class="dash-abschnitt">'
+ '<div class="dash-abschnitt-kopf">' + '<div class="dash-abschnitt-kopf">'
+ '<h3 class="dash-abschnitt-titel">' + esc(titel) + '</h3>' + '<h3 class="dash-abschnitt-titel">' + esc(titel) + '</h3>'
+ (zahl != null ? '<span class="dash-abschnitt-zahl">' + zahl + '</span>' : '') + (zahl != null ? '<span class="dash-abschnitt-zahl">' + zahl + '</span>' : '')
+ (zusatz ? '<span class="dash-abschnitt-zusatz">' + esc(zusatz) + '</span>' : '') + (zusatz ? '<span class="dash-abschnitt-zusatz">' + esc(zusatz) + '</span>' : '')
+ (kopfHtml || '')
+ '</div>' + inhalt + '</section>'; + '</div>' + inhalt + '</section>';
} }
function dashLeer(text){ function dashLeer(text){
@@ -686,6 +243,46 @@ function startDashboardClock(){
dashboardClockTimer = setInterval(tick, 30000); dashboardClockTimer = setInterval(tick, 30000);
} }
// renderDashboardAufgabe: eine eigene Aufgabe als ZEILE — Titel und Projekt
// links, Fortschritt in der Mitte, Fälligkeit rechts.
//
// Zuvor war das eine Karte, die eine halbe Bildschirmbreite für vier Zeilen
// Inhalt belegte; zwei davon füllten den sichtbaren Bereich. Dieselben
// Angaben stehen jetzt in einer Zeile, acht davon auf derselben Fläche.
// Verlinkt wie bisher auf den Projektbereich (gleiches URL-Schema wie
// openProjectPlan() in js/gantt.js).
// Parameter: weekLabel, task. Rückgabe: HTML-String.
function renderDashboardAufgabe(weekLabel, task){
const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(task.projectName || ''), window.location.origin);
url.searchParams.set('weekLabel', weekLabel);
url.searchParams.set('projNr', task.projectNr || '');
url.hash = 'aufgaben';
const heute = heuteIso();
const bald = isoPlusTage(2);
// Dieselbe Kennzeichnung wie in der Terminliste darunter, damit die Kachel
// "überfällig" und die beiden Listen zusammenpassen.
const klasse = !task.end ? '' : (task.end < heute ? ' ist-ueberfaellig' : (task.end <= bald ? ' ist-bald' : ''));
const datum = task.end ? formatWeekDate(task.end) : '—';
const relativ = task.end ? relativerTag(task.end) : 'ohne Frist';
const progress = Number(task.progress || 0);
const tags = (Array.isArray(task.tags) ? task.tags : [])
.map(t => '<span class="dash-auf-tag">' + esc(t) + '</span>').join('');
const projekt = (task.projectNr ? task.projectNr + ' ' : '') + (task.projectName || '');
return '<a class="dash-auf" href="' + url.toString() + '" target="_blank" rel="noopener">'
+ '<span class="dash-auf-text">'
+ '<span class="dash-auf-titel">' + esc(task.title || '(ohne Titel)') + '</span>'
+ '<span class="dash-auf-meta">' + esc(projekt) + tags + '</span>'
+ '</span>'
+ '<span class="dash-auf-fortschritt">'
+ '<span class="dash-auf-bar"><i style="width:' + Math.min(100, Math.max(0, progress)) + '%"></i></span>'
+ '<span class="dash-auf-pct">' + progress + '%</span>'
+ '</span>'
+ '<span class="dash-auf-frist' + klasse + '">' + esc(datum) + '<em>' + esc(relativ) + '</em></span>'
+ '</a>';
}
// relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem // relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem
// ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht // ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht
// sagt, ob das morgen oder in drei Wochen ist. // sagt, ob das morgen oder in drei Wochen ist.
@@ -699,6 +296,73 @@ function relativerTag(iso){
return 'in ' + d + ' Tagen'; return 'in ' + d + ' Tagen';
} }
// renderDashboardFristen:// renderDashboardFristen: die Termine der Projekte, denen man zugeordnet ist
// (GET /my-dashboard, Feld projectDates — bereits nach Datum sortiert, auf
// 21 Tage begrenzt und um die eigenen Aufgaben bereinigt).
//
// Bewusst eine LISTE und keine Kartenwand wie "Meine Aufgaben": Hier geht es
// um Reihenfolge in der Zeit, und Termine, die untereinander stehen, lassen
// sich der Reihe nach abarbeiten. Gegliedert wird nach Dringlichkeit statt
// nach Projekt — in der Woche, in der etwas fällig wird, ist das Datum die
// Frage, nicht das Projekt.
// Parameter: weekLabel, eintraege (Array). Rückgabe: HTML-String.
function renderDashboardFristen(weekLabel, eintraege){
if(!weekLabel) return dashLeer('Keine Sitzung vorhanden.');
if(!eintraege.length){
return dashLeer('In Ihren Projekten steht in den nächsten drei Wochen kein Termin an.');
}
const wocheEnde = isoPlusTage(7);
const gruppen = [
{ titel: 'Überfällig', klasse: 'ist-ueberfaellig', treffer: d => d.ueberfaellig },
{ titel: 'Diese Woche', klasse: 'ist-bald', treffer: d => !d.ueberfaellig && d.datum <= wocheEnde },
{ titel: 'Danach', klasse: '', treffer: d => !d.ueberfaellig && d.datum > wocheEnde },
];
// In derselben Karte wie "Meine Aufgaben" darüber: Beides ist der eigene
// Arbeitsstrom und soll gleich aussehen.
let html = '<div class="dash-karte">';
gruppen.forEach(g => {
const teil = eintraege.filter(g.treffer);
if(!teil.length) return;
html += '<div class="fristen-gruppe ' + g.klasse + '">'
+ '<div class="fristen-gruppe-titel">' + esc(g.titel) + '<span>' + teil.length + '</span></div>';
teil.forEach(d => { html += renderFristenZeile(weekLabel, d); });
html += '</div>';
});
return html + '</div>';
}
// renderFristenZeile: eine Zeile der Terminliste. Der ganze Eintrag führt in
// den Terminplan des Projekts — dorthin, wo sich der Termin ändern lässt.
// Parameter: weekLabel, d (Eintrag). Rückgabe: HTML-String.
function renderFristenZeile(weekLabel, d){
const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(d.projectName || ''), window.location.origin);
url.searchParams.set('weekLabel', weekLabel);
url.searchParams.set('projNr', d.projectNr || '');
url.hash = 'aufgaben';
const datum = d.datum ? formatWeekDate(d.datum) : '';
const tage = tageBis(d.datum);
// Die Angabe in Tagen steht neben dem Datum, weil "12.08." allein nicht
// sagt, ob das morgen oder in drei Wochen ist.
const relativ = d.heute ? 'heute'
: (tage < 0 ? (Math.abs(tage) === 1 ? 'seit 1 Tag' : 'seit ' + Math.abs(tage) + ' Tagen')
: (tage === 1 ? 'morgen' : 'in ' + tage + ' Tagen'));
const wer = Array.isArray(d.zustaendig) && d.zustaendig.length
? esc(d.zustaendig.join(', '))
: '<span class="hint" style="margin:0">niemand zugewiesen</span>';
return '<a class="fristen-zeile" href="' + url.toString() + '" target="_blank" rel="noopener">'
+ '<span class="fristen-datum"><b>' + esc(datum) + '</b><em>' + esc(relativ) + '</em></span>'
+ '<span class="fristen-art' + (d.art === 'meilenstein' ? ' ist-meilenstein' : '') + '">'
+ (d.art === 'meilenstein' ? 'Meilenstein' : 'Aufgabe') + '</span>'
+ '<span class="fristen-titel">' + esc(d.titel || '(ohne Titel)') + '</span>'
+ '<span class="fristen-projekt">' + esc(d.projectNr ? d.projectNr + ' ' + d.projectName : d.projectName) + '</span>'
+ '<span class="fristen-wer">' + wer + '</span>'
+ '</a>';
}
// tageBis: Kalendertage von heute bis zu einem ISO-Datum (negativ = liegt // tageBis: Kalendertage von heute bis zu einem ISO-Datum (negativ = liegt
// zurück). Parameter: iso. Rückgabe: ganze Zahl. // zurück). Parameter: iso. Rückgabe: ganze Zahl.
function tageBis(iso){ function tageBis(iso){
+3 -27
View File
@@ -40,7 +40,7 @@ function openDatePicker(opts){
document.body.appendChild(pop); document.body.appendChild(pop);
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 }; 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 - 332)) + 'px'; pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px';
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px'; pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
renderRangePicker(); renderRangePicker();
@@ -80,7 +80,7 @@ function openRangePicker(opts){
// Position: unterhalb des Ankers, innerhalb des Viewports halten // 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 }; 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 - 332)) + 'px'; pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px';
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px'; pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
renderRangePicker(); renderRangePicker();
@@ -160,14 +160,6 @@ function applyRangePicker(){
const selected = start || end; const selected = start || end;
if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (start ? formatDateISO(start) : ''); if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (start ? formatDateISO(start) : '');
if(endInput) endInput.value = end ? formatDateISO(end) : (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){ if(opts.displayId){
const disp = document.getElementById(opts.displayId); const disp = document.getElementById(opts.displayId);
if(disp){ if(disp){
@@ -207,15 +199,10 @@ function renderRangePicker(){
</div> </div>
<div class="range-picker-hint">${hint}</div> <div class="range-picker-hint">${hint}</div>
<div class="range-picker-grid">`; <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>`); ['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 i = 0; i < firstWeekday; i++) html += '<div></div>';
for(let day = 1; day <= daysInMonth; day++){ for(let day = 1; day <= daysInMonth; day++){
const d = new Date(st.viewYear, st.viewMonth, day); const d = new Date(st.viewYear, st.viewMonth, day);
if(d.getDay() === 1) html += kwZelle(d);
const iso = formatDateISO(d); const iso = formatDateISO(d);
const classes = ['range-picker-day']; const classes = ['range-picker-day'];
if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend'); if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend');
@@ -231,15 +218,4 @@ function renderRangePicker(){
${st.mode !== 'single' && st.start && !st.end ? '<button type="button" class="small" onclick="rangePickerState.end = rangePickerState.start; applyRangePicker()">Nur dieser Tag</button>' : ''} ${st.mode !== 'single' && st.start && !st.end ? '<button type="button" class="small" onclick="rangePickerState.end = rangePickerState.start; applyRangePicker()">Nur dieser Tag</button>' : ''}
</div>`; </div>`;
pop.innerHTML = html; 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);
-213
View File
@@ -1,213 +0,0 @@
// -----------------------------
// Einführung — geführter Rundgang
// -----------------------------
// Eriks Wunsch: Jeder Nutzer, auch bestehende, macht einmal einen geführten
// Rundgang durch die Anwendung; unter Einstellungen → Mein Konto lässt er
// sich jederzeit neu starten. Der Rundgang hebt den jeweiligen Eintrag der
// Seitenleiste hervor (Aussparung in einer abgedunkelten Ebene), wechselt in
// den Bereich und erklärt ihn in einer Sprechblase.
//
// Gezeigt wird nur, was die Rolle darf (hatRecht(), Rollen & Rechte) — ein
// Konto ohne „Anwendung nutzen“ (Wandtafel) bekommt keinen Rundgang.
// Abgeschlossen oder übersprungen → POST /einfuehrung (users.einfuehrung_am),
// danach startet er nicht mehr von selbst. Esc bzw. „Später“ schließt nur
// für diesen Seitenaufruf (sessionStorage) — beim nächsten Laden kommt er
// wieder.
//
// Ein Schritt: { id, recht?, wenn?(), tab?, ziel? (CSS-Selektor), titel,
// text (HTML, aus festen Texten) }. Ohne ziel steht die Blase in der Mitte.
const EINFUEHRUNG_SCHRITTE = [
{ id: 'start', titel: 'Willkommen in der Projektübersicht',
text: () => `Hallo ${esc((currentUser && (currentUser.displayName || '').split(' ')[0]) || '')}! In gut zwei Minuten zeigen wir dir, wo du was findest. Mit den Pfeiltasten oder „Weiter“ geht es durch, „Später“ schließt den Rundgang für jetzt.` },
{ id: 'dashboard', recht: 'app', tab: 'dashboard', ziel: '.sidebar-nav [data-tab="dashboard"]', titel: 'Dashboard',
text: () => 'Deine Startseite: die Aufgaben, die dir zugewiesen sind, die nächsten Termine deiner Projekte und ein Kalender mit Meilensteinen und Abwesenheiten. Überfälliges steht oben.' },
{ id: 'glocke', recht: 'app', ziel: '#glockeBtn', titel: 'Benachrichtigungen',
text: () => 'Die Glocke zeigt, wenn dir jemand eine neue Aufgabe zugewiesen hat. Ein Klick springt direkt in die Aufgaben des Projekts.' },
{ id: 'projekte', recht: 'app', tab: 'board', ziel: '.sidebar-nav [data-tab="board"]', titel: 'Projekte',
text: () => 'Alle Projekte der laufenden Sitzung, nach Gruppen.' + (hatRecht('proj.anlegen') ? ' Über „+ Projekt“ legst du ein neues an — ein Assistent fragt Gewerke, Auftrag, Team und Terminplan ab.' : '') },
{ id: 'projektbereich', recht: 'app', tab: 'board', ziel: '#board .board-name', titel: 'Der Projektbereich',
text: () => 'Ein Klick auf den Projektnamen öffnet den Bereich des Projekts. Oben stehen die Reiter: <b>Dashboard</b>, <b>Terminplan</b>, <b>Leistungsphasen</b>, <b>Aufgaben</b>, <b>Leistung</b>, <b>Dateien</b>, <b>Protokolle</b> und <b>Einstellungen</b>.' },
{ id: 'terminplan', recht: 'proj.plan', tab: 'board', titel: 'Terminplan und Vorlagen',
text: () => 'Im Reiter <b>Terminplan</b> legst du über „+ Neu“ Leistungsphasen, Aufgaben und Meilensteine an. „+ Vorlage“ fügt die Grundleistungen der HOAI für dein Gewerk ein — die Dauer verteilt sich nach den Siemon-Anteilen über den Phasenbalken. Gib Aufgaben einen <b>Aufwand in Stunden</b>, dann zählen sie in der Auslastung.' },
{ id: 'wochenbericht', recht: 'app', tab: 'wochenbericht', ziel: '.sidebar-nav [data-tab="wochenbericht"]', titel: 'Wochenbericht',
text: () => 'Die Tagesordnung der wöchentlichen Besprechung, je Gewerk. Links die Projekte, rechts das gewählte.' + (hatRecht('wb.kommentar') ? ' Hier schreibst du den Kommentar der Woche und den Fortschritt der Leistungsphasen; „Besprochen &amp; weiter“ führt zum nächsten Projekt.' : '') + ' „Als PDF ablegen“ speichert den Bericht.' },
{ id: 'auslastung', recht: 'app', tab: 'load', ziel: '.sidebar-nav [data-tab="load"]', titel: 'Auslastung',
text: () => 'Wie voll die nächsten zwölf Wochen für jede Person sind. Die Stunden kommen aus den Aufgaben mit Aufwand — eine Aufgabe für zwei Personen zählt für jede zur Hälfte.' },
{ id: 'abwesenheiten', wenn: () => typeof darfIrgendeineAbwesenheit === 'function' && darfIrgendeineAbwesenheit(), tab: 'abwesenheit', ziel: '.sidebar-nav [data-tab="abwesenheit"]', titel: 'Abwesenheiten',
text: () => 'Hier trägst du Urlaub, Krankheit und Fortbildung ein. Unten siehst du das Team im Monat — wer außerhalb deines Bereichs liegt, erscheint nur als „abwesend“.' + (hatRecht('team.abwAlle') || hatRecht('team.abwGewerk') || hatRecht('team.abwProjekte') ? ' Ein Klick auf einen Tag in der Zeile einer Person trägt für sie ein.' : '') },
{ id: 'planung', recht: 'wz.planung', tab: 'planung', ziel: '.sidebar-nav [data-tab="planung"]', titel: 'Planung → Terminplan',
text: () => 'Terminpläne ohne echtes Projekt — zum Ausprobieren, für Angebote oder um Kapazität abzuschätzen. Aus einem Honorar errechnet oder frei angelegt.' },
{ id: 'planchecker', recht: 'wz.checker', tab: 'planchecker', ziel: '.sidebar-nav [data-tab="planchecker"]', titel: 'Plan Checker',
text: () => 'Lädt einen Plan als PDF hoch und lässt ihn von einer KI gegen eine Liste von Merkmalen prüfen. Dafür brauchst du einen eigenen KI-Schlüssel unter Einstellungen.' },
{ id: 'einstellungen', tab: 'settings', ziel: '.sidebar-nav [data-tab="settings"]', titel: 'Einstellungen',
text: () => 'Dein Konto: Passwort, E-Mail-Adresse, deine KI-Schlüssel und die Darstellung. Hier kannst du diesen <b>Rundgang jederzeit neu starten</b>.' },
{ id: 'admin', wenn: () => typeof hatAdminRecht === 'function' && hatAdminRecht(), ziel: '#adminAccordionToggle', titel: 'Admin',
text: () => 'Du darfst Teile der Verwaltung pflegen: ' + esc(typeof ADMIN_SECTIONS !== 'undefined' ? ADMIN_SECTIONS.filter(adminAbschnittErlaubt).map(s => s.title).join(', ') : '') + '.' },
{ id: 'ende', tab: 'dashboard', titel: 'Fertig!',
text: () => {
const hinweise = [];
if(currentUser && !currentUser.personId){
hinweise.push('Dein Konto ist noch keiner Person zugeordnet — „Meine Aufgaben“ bleibt deshalb leer. Bitte einen Administrator, das in der Benutzerverwaltung nachzutragen.');
}
return 'Das war der Überblick. Bei Fragen hilft dir ein Kollege — oder du startest den Rundgang unter Einstellungen noch einmal.'
+ (hinweise.length ? '<ul class="tour-hinweise">' + hinweise.map(h => '<li>' + h + '</li>').join('') + '</ul>' : '');
} },
];
let tourSchritte = [];
let tourIndex = 0;
let tourAktiv = false;
let tourVorTab = null;
// einfuehrungBeiBedarf: startet den Rundgang, wenn er für dieses Konto noch
// aussteht — sobald die Daten geladen sind und kein Dialog (Passwort) offen
// ist. Von afterAuthenticated() und nach dem Pflicht-Passwortwechsel gerufen.
function einfuehrungBeiBedarf(){
if(!currentUser || !currentUser.einfuehrungFaellig || !hatRecht('app')) return;
try { if(sessionStorage.getItem('projuebersicht-tour-spaeter')) return; } catch(e){}
let versuche = 0;
const pruefen = () => {
if(tourAktiv) return;
const geladen = typeof currentWeek === 'function' && currentWeek();
const dialogOffen = [...document.querySelectorAll('.modal-bg')].some(m => m.style.display && m.style.display !== 'none');
const login = document.getElementById('loginOverlay');
const loginOffen = login && login.style.display !== 'none' && !login.hidden && login.offsetParent !== null;
if(geladen && !dialogOffen && !loginOffen) return einfuehrungStarten();
if(++versuche < 120) setTimeout(pruefen, 500);
};
setTimeout(pruefen, 600);
}
// einfuehrungStarten: Rundgang von vorn (auch aus Einstellungen).
function einfuehrungStarten(){
tourSchritte = EINFUEHRUNG_SCHRITTE.filter(s => (!s.recht || hatRecht(s.recht)) && (!s.wenn || s.wenn()));
tourIndex = 0;
tourAktiv = true;
tourVorTab = typeof currentTab !== 'undefined' ? currentTab : null;
let el = document.getElementById('tourEbene');
if(!el){
el = document.createElement('div');
el.id = 'tourEbene';
el.className = 'tour';
el.innerHTML = '<div class="tour-loch"></div><div class="tour-blase" role="dialog" aria-modal="true" aria-labelledby="tourTitel" tabindex="-1"></div>';
document.body.appendChild(el);
window.addEventListener('resize', () => { if(tourAktiv) tourPositionieren(); });
document.addEventListener('keydown', tourTaste, true);
}
el.hidden = false;
tourZeigen();
}
function tourTaste(e){
if(!tourAktiv) return;
if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); tourSpaeter(); }
else if(e.key === 'ArrowRight' || (e.key === 'Enter' && !e.target.closest('button'))){ e.preventDefault(); tourWeiter(); }
else if(e.key === 'ArrowLeft'){ e.preventDefault(); tourZurueck(); }
}
// tourZeigen: aktuellen Schritt aufbauen (Bereich wechseln, Blase füllen,
// danach Aussparung und Blase an das Ziel legen).
function tourZeigen(){
const s = tourSchritte[tourIndex];
if(!s) return tourBeenden(true);
if(s.tab && typeof showTab === 'function' && currentTab !== s.tab) showTab(s.tab);
const blase = document.querySelector('#tourEbene .tour-blase');
const letzter = tourIndex === tourSchritte.length - 1;
blase.innerHTML = `
<div class="tour-kopf"><span class="tour-schritt">${tourIndex + 1} von ${tourSchritte.length}</span>
<button type="button" class="tour-x" onclick="tourSpaeter()" title="Später (Esc)" aria-label="Rundgang schließen">×</button></div>
<h3 id="tourTitel">${esc(s.titel)}</h3>
<div class="tour-text">${s.text()}</div>
<div class="tour-punkte" aria-hidden="true">${tourSchritte.map((_, i) => `<i class="${i === tourIndex ? 'ist-aktiv' : i < tourIndex ? 'ist-erledigt' : ''}"></i>`).join('')}</div>
<div class="tour-knoepfe">
${letzter ? '' : '<button type="button" class="tour-skip" onclick="tourBeenden(true)">Überspringen</button>'}
<span class="tour-luecke"></span>
${tourIndex > 0 ? '<button type="button" class="secondary small" onclick="tourZurueck()">Zurück</button>' : '<button type="button" class="secondary small" onclick="tourSpaeter()">Später</button>'}
<button type="button" class="small" id="tourWeiter" onclick="tourWeiter()">${letzter ? 'Fertig' : (tourIndex === 0 ? 'Los geht\'s' : 'Weiter')}</button>
</div>`;
// Nach dem Bereichswechsel kurz warten, bis die Seite steht.
requestAnimationFrame(() => { tourPositionieren(); setTimeout(tourPositionieren, 260); });
const knopf = document.getElementById('tourWeiter');
if(knopf) knopf.focus({ preventScroll: true });
}
// tourZiel: sichtbares Ziel-Element des Schritts oder null (dann Mitte).
function tourZiel(s){
if(!s || !s.ziel) return null;
const el = document.querySelector(s.ziel);
if(!el || el.hidden || el.offsetParent === null) return null;
const r = el.getBoundingClientRect();
if(r.width < 2 || r.height < 2 || r.right < 0 || r.left > innerWidth) return null;
return el;
}
function tourPositionieren(){
const ebene = document.getElementById('tourEbene');
if(!ebene || !tourAktiv) return;
const s = tourSchritte[tourIndex];
const loch = ebene.querySelector('.tour-loch');
const blase = ebene.querySelector('.tour-blase');
const ziel = tourZiel(s);
const schmal = innerWidth < 640;
if(!ziel){
ebene.classList.add('ohne-ziel');
blase.style.left = Math.max(12, (innerWidth - blase.offsetWidth) / 2) + 'px';
blase.style.top = schmal ? '' : Math.max(12, (innerHeight - blase.offsetHeight) / 2) + 'px';
blase.classList.toggle('ist-unten', schmal);
return;
}
ebene.classList.remove('ohne-ziel');
ziel.scrollIntoView({ block: 'nearest', inline: 'nearest' });
const r = ziel.getBoundingClientRect();
const pad = 6;
Object.assign(loch.style, { left: (r.left - pad) + 'px', top: (r.top - pad) + 'px', width: (r.width + 2 * pad) + 'px', height: (r.height + 2 * pad) + 'px' });
blase.classList.toggle('ist-unten', schmal);
if(schmal){ blase.style.left = '12px'; blase.style.top = ''; return; }
const bw = blase.offsetWidth, bh = blase.offsetHeight, abstand = 16;
// Bevorzugt rechts vom Ziel (Seitenleiste links), sonst darunter/darüber.
let left, top;
if(r.right + abstand + bw < innerWidth - 12){
left = r.right + abstand; top = r.top + r.height / 2 - bh / 2;
} else if(r.bottom + abstand + bh < innerHeight - 12){
left = r.left + r.width / 2 - bw / 2; top = r.bottom + abstand;
} else {
left = r.left + r.width / 2 - bw / 2; top = r.top - abstand - bh;
}
blase.style.left = Math.min(Math.max(12, left), innerWidth - bw - 12) + 'px';
blase.style.top = Math.min(Math.max(12, top), innerHeight - bh - 12) + 'px';
}
function tourWeiter(){
if(tourIndex >= tourSchritte.length - 1) return tourBeenden(true);
tourIndex++; tourZeigen();
}
function tourZurueck(){
if(tourIndex === 0) return;
tourIndex--; tourZeigen();
}
// tourSpaeter: schließt nur für diesen Seitenaufruf (kommt beim nächsten
// Laden wieder, solange er nicht abgeschlossen ist).
function tourSpaeter(){
try { sessionStorage.setItem('projuebersicht-tour-spaeter', '1'); } catch(e){}
tourSchliessen();
if(tourVorTab && typeof showTab === 'function') showTab(tourVorTab);
}
// tourBeenden: abgeschlossen oder übersprungen — am Server vermerken.
function tourBeenden(){
tourSchliessen();
if(currentUser) currentUser.einfuehrungFaellig = false;
fetch(baseUrl() + '/einfuehrung', {
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ erledigt: true })
}).catch(err => console.error('Einführung konnte nicht vermerkt werden:', err));
if(typeof showTab === 'function') showTab('dashboard');
}
function tourSchliessen(){
tourAktiv = false;
const el = document.getElementById('tourEbene');
if(el) el.hidden = true;
}
+15 -122
View File
@@ -1,13 +1,13 @@
// ----------------------------- // -----------------------------
// Projektübersicht — GANTT-Diagramm (gemeinsam genutzt) // Projektübersicht — GANTT-Diagramm (gemeinsam genutzt)
// ----------------------------- // -----------------------------
// Gemeinsames Modul für das GANTT-Diagramm. Es wird von App.html nach // Gemeinsames Modul für das GANTT-Diagramm. Es wird von Test.html nach
// js/load/load.js geladen und ist die Grundlage sowohl der Übersichts- // js/load/load.js geladen und ist die Grundlage sowohl der Übersichts-
// Diagramme als auch des Terminplans im Projektbereich: die Module unter // Diagramme als auch des Terminplans im Projektbereich: die Module unter
// js/plan/ setzen darauf auf und müssen deshalb NACH dieser Datei stehen. // js/plan/ setzen darauf auf und müssen deshalb NACH dieser Datei stehen.
// Das Modul stellt u. a. buildGanttHtml(), projectAssignees(), // Das Modul stellt u. a. buildGanttHtml(), projectAssignees(),
// ganttRangeFromProjects() sowie openProjectPlan()/closePlanPage()/ // ganttRangeFromProjects() sowie openProjectPlan()/closePlanPage()/
// bereit. js/plan/plan-build-gantt.js umhüllt // findProjectForPlan() bereit. js/plan/plan-build-gantt.js umhüllt
// buildGanttHtml() und ergänzt sie um TODO- und Meilensteinzeilen; diese // buildGanttHtml() und ergänzt sie um TODO- und Meilensteinzeilen; diese
// Erweiterung greift nur bei gesetzter Option showTodos, die Aufrufe der // Erweiterung greift nur bei gesetzter Option showTodos, die Aufrufe der
// Übersicht bleiben also unverändert. // Übersicht bleiben also unverändert.
@@ -150,9 +150,6 @@ function buildGanttHtml(projects, start, end, options = {}){
// Kopfzeile: Monate und Tage // Kopfzeile: Monate und Tage
let headerMonths = '<div class="gantt-row gantt-header">'; let headerMonths = '<div class="gantt-row gantt-header">';
// Den Griff zum Ändern der Spaltenbreite setzt ganttGriffeEinsetzen() nach
// dem Rendern in JEDE Zeile (auch die nachträglich eingefügten Aufgaben-
// und Meilensteinzeilen aus js/plan/plan-build-gantt.js).
headerMonths += '<div class="gantt-label">Projekt</div>'; headerMonths += '<div class="gantt-label">Projekt</div>';
let monthsHtml = ''; let monthsHtml = '';
let current = new Date(start.getTime()); let current = new Date(start.getTime());
@@ -283,11 +280,10 @@ function buildGanttHtml(projects, start, end, options = {}){
} }
} }
// Meilensteine in der Projektzeile nur ohne showTodos. Im Projektplan // Meilensteine: nur die nicht positionierten (keine position) in der Projektzeile anzeigen.
// bekommt jeder Meilenstein eine eigene Zeile (plan-build-gantt.js) — // Positionierte Meilensteine werden vom Aufrufer (z.B. Projektplan) als separate Zeilen eingefügt.
// sonst lagen mehrere ohne Aufgabenbezug übereinander in einer Zeile.
milestones.forEach((m, mIdx) => { milestones.forEach((m, mIdx) => {
if(options.showTodos) return; if(options.showTodos && m.position) return;
const d = parseDateISO(m.date); const d = parseDateISO(m.date);
if(!d || d < start || d > end) return; if(!d || d < start || d > end) return;
const offset = daysBetween(start, d); const offset = daysBetween(start, d);
@@ -302,7 +298,7 @@ function buildGanttHtml(projects, start, end, options = {}){
// Zoom breiter als der Container wird und horizontal gescrollt werden kann. // Zoom breiter als der Container wird und horizontal gescrollt werden kann.
let chartStyle = ''; let chartStyle = '';
if(pxPerDay){ if(pxPerDay){
chartStyle = ` style="min-width:${(ganttSpaltenBreite() + Math.ceil(totalDays * pxPerDay)).toFixed(0)}px"`; chartStyle = ` style="min-width:${(220 + Math.ceil(totalDays * pxPerDay)).toFixed(0)}px"`;
} }
return `<div class="gantt-chart"${chartStyle}>${body}</div>`; return `<div class="gantt-chart"${chartStyle}>${body}</div>`;
} }
@@ -327,19 +323,6 @@ function openProjectPlan(pid){
window.scrollTo(0, 0); 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 // closePlanPage: Gegenstück zu openProjectPlan() für den "Schließen"-Knopf
// in der Kopfzeile des Projektbereichs. Früher ging es hier in der // in der Kopfzeile des Projektbereichs. Früher ging es hier in der
// Browser-Historie zurück, weil der Projektbereich ein eigenes Dokument war. // Browser-Historie zurück, weil der Projektbereich ein eigenes Dokument war.
@@ -351,102 +334,12 @@ function closePlanPage(){
showTab(back); showTab(back);
} }
// ---------- Breite der Spalte "Projekt" (Eriks Wunsch: "wie in Excel") ---------- // findProjectForPlan: findet Sitzung und Projekt anhand ihrer IDs im
// // geladenen Bestand.
// Die Breite steht in der CSS-Variable --gantt-label-w (styles.css, // Parameter: weekId, projectId; Rückgabe: { week, project } oder null, wenn
// .gantt-label), damit alle Zeilen beim Ziehen gleichzeitig mitgehen, ohne // die Woche nicht auflösbar ist.
// das Diagramm neu aufzubauen. Gemerkt wird sie je Browser in localStorage — function findProjectForPlan(weekId, projectId){
// reiner Darstellungszustand wie die eingeklappten Dashboard-Projekte. const w = db.weeks.find(x => x.id === weekId);
const GANTT_SPALTE_KEY = 'projuebersicht-gantt-spalte'; if(!w) return null;
const GANTT_SPALTE_MIN = 120, GANTT_SPALTE_MAX = 640, GANTT_SPALTE_STANDARD = 220; return { week: w, project: w.projects.find(x => x.id === projectId) };
}
// 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();
}
+2 -16
View File
@@ -1,7 +1,7 @@
// ----------------------------- // -----------------------------
// Projektübersicht — globale Konstanten & Zustand // Projektübersicht — globale Konstanten & Zustand
// ----------------------------- // -----------------------------
// Diese Datei wird als Erstes von App.html geladen und // Diese Datei wird als Erstes von Test.html geladen und
// legt alle zentralen Konstanten (Leistungsphasen, Rollen, Gruppen, Abteilungen, // legt alle zentralen Konstanten (Leistungsphasen, Rollen, Gruppen, Abteilungen,
// Farben, Passwort-Hash) sowie den globalen In-Memory-Zustand (db, currentTab) // Farben, Passwort-Hash) sowie den globalen In-Memory-Zustand (db, currentTab)
// fest. Sie stellt keine Funktionen, sondern nur Daten bereit, auf die alle // fest. Sie stellt keine Funktionen, sondern nur Daten bereit, auf die alle
@@ -45,14 +45,6 @@ let APP_SETTINGS = {
vollzeitStundenProWoche: 40, vollzeitStundenProWoche: 40,
auslastungSchwelleGelb: 0.75, auslastungSchwelleGelb: 0.75,
auslastungSchwelleRot: 1.0, 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: { aufwandVorlage: {
'LPH 1': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 }, 'LPH 1': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
'LPH 2': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 }, 'LPH 2': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
@@ -108,9 +100,6 @@ let saveSyncTimer = null;
// In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung. // In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung.
// globalPeople ist ein flaches Array (keine Abteilungstrennung mehr). // globalPeople ist ein flaches Array (keine Abteilungstrennung mehr).
let db = { weeks: [], currentWeek: null, globalPeople: [], 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) // sandboxWeek: Platzhalter bis zum ersten erfolgreichen load() (js/store.js)
// befüllt ihn — leer statt undefined, damit ein Zugriff vor dem Laden nicht // befüllt ihn — leer statt undefined, damit ein Zugriff vor dem Laden nicht
// crasht, sondern nur eine leere Planung-Liste zeigt. // crasht, sondern nur eine leere Planung-Liste zeigt.
@@ -146,12 +135,9 @@ const TAB_SLUGS = {
board: 'Projektübersicht', board: 'Projektübersicht',
wochenbericht: 'Wochenbericht', wochenbericht: 'Wochenbericht',
load: 'Auslastung', load: 'Auslastung',
abwesenheit: 'Abwesenheiten',
people: 'Projektbeteiligte', people: 'Projektbeteiligte',
planung: 'Planung', planung: 'Planung',
planchecker: 'PlanChecker',
dashboard:'MeineAufgaben', dashboard:'MeineAufgaben',
wand: 'Wandtafel',
settings: 'Einstellungen', settings: 'Einstellungen',
admin: 'Admin' admin: 'Admin'
}; };
@@ -162,7 +148,7 @@ const TAB_BY_SLUG = Object.fromEntries(Object.entries(TAB_SLUGS).map(([k,v]) =>
// aber nicht mehr erzeugt, weil er in TAB_SLUGS fehlt. // aber nicht mehr erzeugt, weil er in TAB_SLUGS fehlt.
TAB_BY_SLUG['Projekte'] = 'board'; TAB_BY_SLUG['Projekte'] = 'board';
// PLAN_SLUG: URL-Schlüssel des Projektbereichs (Bereich #plan von App.html), // PLAN_SLUG: URL-Schlüssel des Projektbereichs (Bereich #plan von Test.html),
// im gleichen Deep-Link-Schema wie die Tabs: "/Projektplan/<Projektname>", // im gleichen Deep-Link-Schema wie die Tabs: "/Projektplan/<Projektname>",
// dazu ?weekLabel=…&projNr=… als Parameter. Steht nicht in TAB_SLUGS, weil // dazu ?weekLabel=…&projNr=… als Parameter. Steht nicht in TAB_SLUGS, weil
// die Adresse das geöffnete Projekt mitträgt und deshalb nicht fest ist — // die Adresse das geöffnete Projekt mitträgt und deshalb nicht fest ist —
+17 -5
View File
@@ -2,10 +2,10 @@
// Projektübersicht — Init / Start // Projektübersicht — Init / Start
// ----------------------------- // -----------------------------
// Einstiegsmodul der Hauptseite: wird als letztes Skript ausschließlich von // Einstiegsmodul der Hauptseite: wird als letztes Skript ausschließlich von
// App.html geladen und startet direkt beim Laden den Initialisierungsablauf // Test.html geladen und startet direkt beim Laden den Initialisierungsablauf
// (automatische Ausführung unten in der Datei, nur wenn ein 'board'-Element // (automatische Ausführung unten in der Datei, nur wenn ein 'board'-Element
// existiert). Es stellt renderAll(), // existiert). Es stellt renderAll(),
// initUiState() bereit und nutzt loadUiState()/ // initUiState() und syncRangeDisplay() bereit und nutzt loadUiState()/
// loadTheme()/loadHeaderState()/loadSidebarCollapseState() aus js/ui.js, load() aus js/store.js, // loadTheme()/loadHeaderState()/loadSidebarCollapseState() aus js/ui.js, load() aus js/store.js,
// showTab()/TAB_PANELS aus js/tabs-auth.js sowie die Render-Funktionen // showTab()/TAB_PANELS aus js/tabs-auth.js sowie die Render-Funktionen
// renderBoard()/renderWochenbericht()/renderLoad()/renderProjects()/renderPeople() // renderBoard()/renderWochenbericht()/renderLoad()/renderProjects()/renderPeople()
@@ -18,13 +18,10 @@ function renderAll(){
const activeTab = document.querySelector('.tab.active')?.dataset.tab || currentTab; const activeTab = document.querySelector('.tab.active')?.dataset.tab || currentTab;
if(activeTab === 'wochenbericht') renderWochenbericht(); if(activeTab === 'wochenbericht') renderWochenbericht();
else if(activeTab === 'load') renderLoad(); else if(activeTab === 'load') renderLoad();
else if(activeTab === 'abwesenheit') renderAbwesenheiten();
else if(activeTab === 'people') renderPeople(); else if(activeTab === 'people') renderPeople();
else if(activeTab === 'dashboard') renderDashboard(); else if(activeTab === 'dashboard') renderDashboard();
else if(activeTab === 'settings') renderSettings(); else if(activeTab === 'settings') renderSettings();
else if(activeTab === 'admin') renderAdminArea(); else if(activeTab === 'admin') renderAdminArea();
// Glocke im Menüband auf den aktuellen Stand bringen (js/benachrichtigungen.js).
if(typeof ladeBenachrichtigungen === 'function') ladeBenachrichtigungen();
// Der Projektbereich rendert nicht hier, sondern in showTab('plan') — // Der Projektbereich rendert nicht hier, sondern in showTab('plan') —
// load() ruft direkt im Anschluss showTab(currentTab) auf, und erst dort // load() ruft direkt im Anschluss showTab(currentTab) auf, und erst dort
// ist der Bereich sichtbar (das GANTT braucht die Containerbreite). // ist der Bereich sichtbar (das GANTT braucht die Containerbreite).
@@ -61,6 +58,21 @@ function initUiState(){
showTab(tab); 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 // Automatische Initialisierung der Hauptseite — seit Projektbereich und
// Benutzerverwaltung eigene Bereiche sind, ist sie die einzige Seite. Der eigentliche // Benutzerverwaltung eigene Bereiche sind, ist sie die einzige Seite. Der eigentliche
// Start läuft erst nach erfolgreicher Anmeldung (checkAuthAndInit(), js/auth.js) — // Start läuft erst nach erfolgreicher Anmeldung (checkAuthAndInit(), js/auth.js) —
+571 -324
View File
@@ -1,10 +1,39 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Auslastung (Load) & Aufwand je Leistungsphase // Projektübersicht — Auslastung (Load) & Aufwand je Leistungsphase
// ----------------------------- // -----------------------------
// Modul für den Auslastungs-Tab (renderLoad()). Die Auslastung kommt allein // Modul für den Auslastungs-Tab der Hauptseite (Test.html, Ladereihenfolge:
// aus Aufgaben mit Aufwand, gleich geteilt auf ihre Zuständigen (siehe // globals → utils → ui → date-picker → csv → server → store → weeks → board →
// "Auslastung aus Aufgaben" unten). aufgabenVerlaufJePerson() nutzen auch // people → projects → load → gantt → …). Es stellt renderLoad(), das LP-
// die Ressourcenansicht im Terminplan (plan-gantt.js) und die Wandtafel. // 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.
// AUFWAND_LPS: Phasen des Aufwandsmodells — alle echten Leistungsphasen // AUFWAND_LPS: Phasen des Aufwandsmodells — alle echten Leistungsphasen
// (inkl. LPH 9), ohne die Sammelkategorie "W+M". Bewusst per Filter statt per // (inkl. LPH 9), ohne die Sammelkategorie "W+M". Bewusst per Filter statt per
@@ -12,6 +41,19 @@
// Raster fällt. // Raster fällt.
const AUFWAND_LPS = LPHS.filter(l => l !== 'W+M'); const AUFWAND_LPS = LPHS.filter(l => l !== 'W+M');
// aufwandVorlage: die firmenweite Gewichtung je Leistungsphase und Rolle
// (0,0–1,0). Sie greift überall dort, wo ein Projekt in seiner Aufwandsmaske
// keinen eigenen Wert trägt — also bei den meisten — und bestimmt damit still
// einen Großteil der Auslastungszahlen. Genau deshalb steht sie seit dem
// Ausbau der Einstellungen nicht mehr als Konstante hier, sondern wird
// firmenweit gepflegt (Bereich „Einstellungen", nur Administratoren) und über
// APP_SETTINGS geliefert (js/globals.js, gefüllt in js/store.js). LPH 9 steht
// in der Vorgabe bewusst auf 0 — die Faktoren sind fachlich nicht abgestimmt.
// Rückgabe: Objekt LPH -> { Rolle: Faktor }.
function aufwandVorlage(){
return (APP_SETTINGS && APP_SETTINGS.aufwandVorlage) || {};
}
// Ampel-Schwellen und Balkenskala. Die Schwellen beziehen sich auf die // Ampel-Schwellen und Balkenskala. Die Schwellen beziehen sich auf die
// KAPAZITÄT der Person (1,0 = ihre volle Stelle), nicht auf das Maximum der // KAPAZITÄT der Person (1,0 = ihre volle Stelle), nicht auf das Maximum der
// Balkenskala: Mit einer Skala bis 8 lagen sie vorher faktisch bei 6,0 (rot) // Balkenskala: Mit einer Skala bis 8 lagen sie vorher faktisch bei 6,0 (rot)
@@ -56,17 +98,128 @@ function lpActiveList(pr){
return []; return [];
} }
// ---------- Auslastung aus Aufgaben ---------- // 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 ----------
// //
// Eriks Vorgabe (10/2026): Die Auslastung entsteht ALLEIN aus Aufgaben mit // Die Auslastung wird als WOCHENSTUNDEN gerechnet und erst zum Schluss auf
// eingetragenem Aufwand (t.aufwand, Stunden). Hat eine Aufgabe 8 h und eine // die Kapazität der Person bezogen. Vorher war sie eine Summe dimensionsloser
// zuständige Person, ist diese Person mit 8 h ausgelastet; bei zwei // Gewichte: Ein Projekt in LPH 5 zählte für die Planung 1,0 — ob es 20.000 €
// Zuständigen je 4 h, bei drei je ein Drittel. Aufgaben ohne Aufwand zählen // oder 2 Mio. € umfasste. Zwei kleine Projekte wogen damit schwerer als ein
// nicht. Das frühere Phasenbudget (Honorar × Phasenanteil ÷ Stundensatz, // großes, und die Zahl sagte nichts darüber, wie viel Arbeit tatsächlich
// verteilt über eine Gewichtungstabelle Leistungsphase × Rolle) ist // anliegt.
// entfallen — samt Tabelle (firmenweit im Admin-Bereich und je Projekt). //
// Die Stunden verteilen sich auf die Arbeitstage des Aufgabenzeitraums // Der Weg von einem Projekt zu den Wochenstunden einer Person:
// (verteileAufBalken()); ohne Termine auf die nächsten UEBERFAELLIG_WOCHEN. //
// 1. BASIS Honorar des Projekts. Fehlt es, tritt die am Projekt
// gepflegte Größe (0–1) mal dem Referenzhonorar an seine
// Stelle. Fehlt beides, ist das Projekt NICHT BEWERTBAR —
// es wird als Lücke ausgewiesen, nicht als Null gezählt.
// 2. PHASE Anteil der amtierenden Leistungsphase am Honorar
// (HONORAR_ANTEILE, auf die beauftragten Phasen normiert —
// dieselbe Rechnung wie im Leistungsstand des
// Projektbereichs), abzüglich des bereits erbrachten
// Fortschritts dieser Phase. Übrig bleibt der OFFENE Wert.
// 3. STUNDEN Offener Wert geteilt durch den Stundensatz.
// 4. ROLLE Aufteilung auf die Rollen über dieselbe Gewichtung wie
// bisher (Projektmatrix, sonst firmenweite Vorlage), hier
// aber als ANTEIL: Gewicht der Rolle durch die Summe aller
// Rollengewichte dieser Phase.
// 5. KÖPFE Aufteilung auf die Personen, die dieser Rolle im Projekt
// zugeordnet sind. Ohne diesen Schritt zählte jeder von drei
// Planern die vollen Phasenstunden — der Aufwand verdreifacht
// sich nicht dadurch, dass mehr Leute daran arbeiten.
// Hauptverantwortung wiegt 1, Unterstützung 0,5.
// 6. ZEIT Verteilung der Reststunden über die Wochen bis zum Ende
// des Terminplans; ohne Terminplan über den firmenweiten
// Verteilzeitraum. Ohne Horizont wäre die Wochenlast nicht
// bestimmt: Dieselben Reststunden bedeuten über vier Wochen
// etwas anderes als über vierzig.
//
// "In Anbahnung" bleibt wie bisher ausgeklammert.
// projektBasisHonorar: die Geldgrundlage eines Projekts für die
// Auslastungsrechnung. Parameter: projekt. Rückgabe:
// { betrag, quelle: 'honorar'|'groesse'|null }.
function projektBasisHonorar(projekt){
const honorar = Number(projekt && projekt.honorar) || 0;
if(honorar > 0) return { betrag: honorar, quelle: 'honorar' };
const groesse = Number(projekt && projekt.groesse) || 0;
if(groesse > 0){
const referenz = Number(APP_SETTINGS.referenzHonorar) || 250000;
return { betrag: Math.min(1, groesse) * referenz, quelle: 'groesse' };
}
return { betrag: 0, quelle: null };
}
// phasenAnteilProzent: Anteil einer Leistungsphase am Honorar des Projekts,
// normiert auf die BEAUFTRAGTEN Phasen — ein Projekt, das nur für drei Phasen
// beauftragt ist, verteilt sein Honorar auch nur auf diese drei. Sind keine
// beauftragt, gelten die firmenweiten Anteile unnormiert (sie ergeben
// zusammen 100 %). Parameter: projekt, lph. Rückgabe: Prozent (0 = unbekannt).
function phasenAnteilProzent(projekt, lph){
const roh = Number(HONORAR_ANTEILE[lph]) || 0;
if(roh <= 0) return 0;
const beauftragt = Array.isArray(projekt.beauftragteLP) ? projekt.beauftragteLP : [];
if(beauftragt.length === 0) return roh;
if(!beauftragt.includes(lph)) return 0;
const summe = beauftragt.reduce((acc, l) => acc + (Number(HONORAR_ANTEILE[l]) || 0), 0);
return summe > 0 ? roh / summe * 100 : 0;
}
// rollenGewicht: Aufwandsgewicht einer Rolle in einer Phase — Wert der
// Projektmatrix, sonst firmenweite Vorlage, sonst 1,0. Unverändert
// gegenüber der bisherigen Rechnung; neu ist nur, wofür er dient (Anteil
// statt absoluter Last). Parameter: projekt, lph, rolle. Rückgabe: Zahl.
function rollenGewicht(projekt, lph, rolle){
const wert = aufwandMatrixWert(projekt, lph, rolle);
if(wert !== null) return wert;
const vorlage = aufwandVorlage()[lph];
const v = vorlage ? parseFloat(vorlage[rolle]) : NaN;
return isNaN(v) ? 1.0 : v;
}
// zuordnungsGewicht: Wie schwer eine Zuordnung wiegt. 1 = Hauptverantwortung,
// 2 = Unterstützung. Die Unterstützung zählt halb, weil sie sonst dieselbe
// Stundenzahl trüge wie die Hauptverantwortung und die Arbeit rechnerisch
// gleichmäßig verteilt würde, obwohl sie es nicht ist.
function zuordnungsGewicht(status){
return status === 2 ? 0.5 : status === 1 ? 1 : 0;
}
// projektVerteilWochen: über wie viele Wochen die offenen Stunden eines
// Projekts verteilt werden. Aus dem Terminplan das späteste Datum (dieselbe
// Quelle, aus der auch der Verlauf das Projektende liest), gerechnet ab
// heute; ohne Terminplan oder bei bereits verstrichenem Ende gilt der
// firmenweite Verteilzeitraum. Parameter: projekt. Rückgabe: Wochen (>= 1).
function projektVerteilWochen(projekt){
const vorgabe = Math.max(1, Number(APP_SETTINGS.verteilzeitraumWochen) || 12);
const plan = projekt && projekt.plan;
if(!plan) return vorgabe;
const daten = [];
(Array.isArray(plan.todos) ? plan.todos : []).forEach(t => { if(t && t.end) daten.push(t.end); });
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach(m => { if(m && m.date) daten.push(m.date); });
if(daten.length === 0) return vorgabe;
const ende = new Date(daten.sort().pop());
if(isNaN(ende.getTime())) return vorgabe;
const heute = new Date();
heute.setHours(0, 0, 0, 0);
const tage = daysBetween(heute, ende);
// Ein Ende in der Vergangenheit sagt nichts über die kommenden Wochen —
// dann gilt wieder die Vorgabe, statt die Restarbeit auf null Wochen zu
// verteilen und eine unendliche Wochenlast zu erzeugen.
if(!Number.isFinite(tage) || tage <= 0) return vorgabe;
return Math.max(1, tage / 7);
}
// fortschrittProzent: Fortschritt einer Leistungsphase am Projekt (0–100). // fortschrittProzent: Fortschritt einer Leistungsphase am Projekt (0–100).
// Die Werte kommen aus einem Textfeld und können "80", "80 %" oder "12,5" // Die Werte kommen aus einem Textfeld und können "80", "80 %" oder "12,5"
@@ -101,138 +254,77 @@ function arbeitsPhase(projekt, amtierend){
return folgende.find(l => fortschrittProzent(projekt, l) < 100) || null; return folgende.find(l => fortschrittProzent(projekt, l) < 100) || null;
} }
// --------------------------------------------------------------------------- // berechneAuslastung: Wochenstunden EINER Zuordnung (Person/Rolle) zu einem
// Wochenvorschau: VORSCHAU_WOCHEN Kalenderwochen ab dieser Woche; "diese // Projekt. Parameter: mitarbeiter (ungenutzt, Signatur beibehalten), projekt,
// Woche" ist Woche 0 — die Zahl, die Wandtafel und Projektbereich zeigen. // aktuelleLP, rolle, status (1 = Haupt, 2 = Unterstützung), alleZuordnungen
// --------------------------------------------------------------------------- // (optional: das assign-Objekt des Projekts, für die Aufteilung auf mehrere
const VORSCHAU_WOCHEN = 12; // Köpfe derselben Rolle).
// Eine offene Aufgabe, deren Ende schon vorbei ist (oder die keine Termine // Rückgabe: { stunden, load, anbahnung?, unbewertbar?, grund? }.
// hat): ihre Reststunden fallen in die nächsten Wochen an. // stunden = Wochenstunden dieser Person aus diesem Projekt
const UEBERFAELLIG_WOCHEN = 4; // 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' });
// vorschauMontag: Montag der aktuellen Woche, 0 Uhr Ortszeit. const basis = projektBasisHonorar(projekt);
function vorschauMontag(){ if(basis.betrag <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar und ohne Projektgröße' });
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
return d;
}
// vorschauWochen: die Kalenderwochen der Vorschau. Rückgabe: [{montag, kw}]. // Gerechnet wird mit der Phase, in der tatsächlich gearbeitet wird — der
function vorschauWochen(){ // amtierenden oder, wenn die fertig ist, der nächsten offenen.
const m = vorschauMontag(); const phase = arbeitsPhase(projekt, aktuelleLP);
return Array.from({ length: VORSCHAU_WOCHEN }, (_, i) => { if(!phase) return Object.assign({}, leer, { fertig: true });
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 — const anteil = phasenAnteilProzent(projekt, phase);
// allein über das Feld lph (Eriks Wunsch: nicht mehr über den Titel). Gesetzt if(anteil <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar-Anteil für diese Phase' });
// 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 const offenerWert = basis.betrag * anteil / 100 * (1 - fortschrittProzent(projekt, phase) / 100);
// Zeitraums (ohne Wochenenden und Feiertage) und summiert sie je Woche der if(offenerWert <= 0) return Object.assign({}, leer, { fertig: true });
// 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;
});
}
// --------------------------------------------------------------------------- const satz = Number(APP_SETTINGS.stundensatz) || 100;
// Aufgaben mit Aufwand: Der noch offene Teil (Aufwand × (1 − Fortschritt)) const gesamtStunden = offenerWert / satz;
// 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 // Rollenanteil — nach der Phase, in der gearbeitet wird
// mindestens einem Zuständigen. Rückgabe: [{t, rest}]. const gewichte = ROLES.map(r => rollenGewicht(projekt, phase, r));
function offeneAufwandsAufgaben(projekt){ const summeGewichte = gewichte.reduce((a, b) => a + b, 0);
const todos = (projekt && projekt.plan && Array.isArray(projekt.plan.todos)) ? projekt.plan.todos : []; const eigenesGewicht = rollenGewicht(projekt, phase, rolle);
return todos if(summeGewichte <= 0 || eigenesGewicht <= 0) return leer;
.filter(t => t && !t.done && !balkenPhase(t) && Number(t.aufwand) > 0 const rollenStunden = gesamtStunden * (eigenesGewicht / summeGewichte);
&& 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 // Aufteilung auf die Köpfe dieser Rolle
// UEBERFAELLIG_WOCHEN ab heute. const eigenAnteil = zuordnungsGewicht(status);
function aufgabenZeitraum(t){ if(eigenAnteil <= 0) return leer;
const a = t.start ? parseDateISO(t.start) : null; let summeKoepfe = eigenAnteil;
const e = t.end ? parseDateISO(t.end) : a; if(alleZuordnungen && typeof alleZuordnungen === 'object'){
if(a && e && e >= a) return { start: a, ende: e }; summeKoepfe = 0;
const heute = new Date(); heute.setHours(0, 0, 0, 0); Object.keys(alleZuordnungen).forEach(key => {
const bis = new Date(heute); bis.setDate(bis.getDate() + UEBERFAELLIG_WOCHEN * 7 - 1); if(key.slice(key.indexOf('|') + 1) !== rolle) return;
return { start: heute, ende: bis }; summeKoepfe += zuordnungsGewicht(alleZuordnungen[key]);
}
// 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);
}); });
}); if(summeKoepfe <= 0) summeKoepfe = eigenAnteil;
return out; }
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 };
} }
// globalePerson: der Eintrag aus db.globalPeople (Konto) zu einer Person der // groesseText: beschriftet die Projektgröße für die Eingabemasken.
// Sitzung — per id, sonst über den vollen Namen (siehe kapazitaetsFaktor()). //
function globalePerson(person){ // Nur die Zahl, KEIN Eurobetrag daneben. Intern wird die Größe zwar über das
const list = db.globalPeople || []; // Referenzhonorar in einen Betrag umgerechnet (projektBasisHonorar()) — die
return list.find(p => p.id === person.id) || (person.fullName ? list.find(p => p.fullName === person.fullName) : null); // 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.
// wochenKapazitaet: Kapazität einer Person je Woche der Vorschau in // Parameter: wert (0–1). Rückgabe: String.
// Stunden — Vertragsstunden je Arbeitstag mal verfügbare Tage (ohne function groesseText(wert){
// Wochenende, Feiertage und eingetragene Abwesenheiten). const n = Math.max(0, Math.min(1, parseFloat(wert) || 0));
// Rückgabe: [{ stunden, tage, abwesend }]. if(n <= 0) return '— keine Angabe';
function wochenKapazitaet(person){ return n.toFixed(2).replace('.', ',');
const voll = (Number(APP_SETTINGS.vollzeitStundenProWoche) || 40) * kapazitaetsFaktor(person);
const proTag = voll / 5;
const g = globalePerson(person);
const abw = (g && Array.isArray(g.absences) ? g.absences : [])
.map(a => ({ von: a.start || '', bis: a.end || a.start || '' })).filter(a => a.von);
return vorschauWochen().map(w => {
let tage = 0, abwesend = 0;
for(let i = 0; i < 5; i++){
const d = new Date(w.montag); d.setDate(d.getDate() + i);
if(!isWorkday(d)) continue;
const iso = formatDateISO(d);
if(abw.some(a => iso >= a.von && iso <= a.bis)){ abwesend++; continue; }
tage++;
}
return { stunden: tage * proTag, tage, abwesend };
});
} }
// stundenFormat: Wochenstunden in deutscher Schreibweise, eine // stundenFormat: Wochenstunden in deutscher Schreibweise, eine
@@ -267,12 +359,131 @@ function loadAmpelColor(x){
// nicht mehr nur einen Balken, sondern die große Zahl am Zeilenende. Die // 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 // früheren Festwerte (#c53030/#d69e2e/#2f855a) waren für den hellen Grund
// gewählt und standen im dunklen Thema zu dunkel; --red/--amber/--green // gewählt und standen im dunklen Thema zu dunkel; --red/--amber/--green
// sind dort hellere Töne (siehe styles.css). Ein Gegenstück mit // sind dort hellere Töne (siehe styles.css). Für den PDF-Export gilt
// druckfreundlichen Festwerten gab es für den PDF-Export (pdfLoadAmpelColor); // weiterhin pdfLoadAmpelColor() mit druckfreundlichen Festwerten — ein PDF
// es ist mit dem gewerkeübergreifenden Sitzungs-PDF entfallen — die // hat kein Thema.
// verbliebenen Exporte zeigen keine Auslastung mehr.
return x >= schwelleRot() ? 'var(--red)' : x >= schwelleGelb() ? 'var(--amber)' : 'var(--green)'; 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) ---------- // ---------- Neues Projekt (mit LP-Aufwand beim Anlegen) ----------
@@ -283,6 +494,9 @@ function loadAmpelColor(x){
// und jeden davon erst nach vollständiger Eingabe freigibt. Der Grund für den // und jeden davon erst nach vollständiger Eingabe freigibt. Der Grund für den
// Wechsel: Honorar, Team und Schlagwörter mussten zuvor NACH dem Anlegen an // Wechsel: Honorar, Team und Schlagwörter mussten zuvor NACH dem Anlegen an
// drei weiteren Stellen nachgetragen werden — und wurden es meist nicht. // drei weiteren Stellen nachgetragen werden — und wurden es meist nicht.
//
// collectLpRows()/applyLpRows()/lpRowsHtml() bleiben: Sie tragen weiterhin
// die Aufwandsmaske openLpModal(), die aus dem Projektbereich aufgerufen wird.
// PROJEKT_FARBEN: Palette der Lastband-Segmente. Bewusst gedämpfte Mitteltöne // PROJEKT_FARBEN: Palette der Lastband-Segmente. Bewusst gedämpfte Mitteltöne
// und vom Ampelspektrum (grün/gelb/rot) getrennt — ein Segment sagt aus, // und vom Ampelspektrum (grün/gelb/rot) getrennt — ein Segment sagt aus,
@@ -316,204 +530,237 @@ function projektFarbe(name){
// und Balkenskala sind firmenweit einstellbar (Bereich „Einstellungen"). // und Balkenskala sind firmenweit einstellbar (Bereich „Einstellungen").
// Mittelwert: geteilt wird durch die Zahl der Sitzungen, in denen die Person // Mittelwert: geteilt wird durch die Zahl der Sitzungen, in denen die Person
// überhaupt im Teilnehmerkreis stand — nicht durch alle vorhandenen. // überhaupt im Teilnehmerkreis stand — nicht durch alle vorhandenen.
// loadProjekteOffen: Personen (Name), deren Projektliste aufgeklappt ist.
const loadProjekteOffen = new Set();
// loadProjekteUmschalten: klappt die Projekte einer Personenkarte auf/zu,
// ohne die Seite neu zu zeichnen. Parameter: knopf (trägt data-person).
function loadProjekteUmschalten(knopf){
const name = knopf.dataset.person || '';
const box = knopf.closest('.load-chips');
if(!box) return;
const offen = !box.classList.contains('ist-offen');
box.classList.toggle('ist-offen', offen);
if(offen) loadProjekteOffen.add(name); else loadProjekteOffen.delete(name);
knopf.setAttribute('aria-expanded', String(offen));
const anzahl = box.querySelectorAll('.ist-weitere').length;
knopf.textContent = offen ? 'weniger' : '+ ' + anzahl + ' weitere';
}
function renderLoad(){ function renderLoad(){
const w = currentWeek(); const scope = document.getElementById('loadScope').value;
const grid = document.getElementById('loadGrid'); const selectedWeek = currentWeek();
if(!grid) return; const weeks = scope==='all' ? db.weeks : (selectedWeek ? [selectedWeek] : []);
if(!w){ if(weeks.length === 0){
grid.innerHTML = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>'; document.getElementById('loadGrid').innerHTML = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>';
return; return;
} }
const wochen = vorschauWochen(); const grouped = scope==='all' ? peopleByRole(db.weeks) : peopleByRole(selectedWeek);
const grouped = peopleByRole(w); const stats = {};
const personen = {}; // fullName -> { person, stunden, rollen:Set }
// Je MENSCH (über alle Rollen): Stunden je Woche, Projekte, Rollen. ROLES.forEach(r=>{
const koepfe = {}; grouped[r].forEach(entry=>{
const kopfVon = person => { stats[assignKey(entry.person.id, r)] = {stunden:0,projects:{},anbahnung:0,weeks:0};
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 ausgewiesen statt still als 0 gezählt.
const luecken = {};
let bewertbareProjekte = 0, alleProjekte = 0;
(w.projects || []).forEach(pr => {
const anbahnung = pr.group === 'In Anbahnung';
const assign = pr.assign || {};
const projektName = pr.nr ? pr.nr + ' ' + pr.name : pr.name;
// 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;
});
// 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 };
});
});
// 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';
}
}); });
// Karten vorbereiten: Kapazität je Woche (ohne Urlaub/Feiertage), Quote, // Je Person zählen, in wie vielen der ausgewerteten Sitzungen sie im
// Spitze. // Teilnehmerkreis stand. Ohne das wurde durch ALLE Sitzungen geteilt: Ein
const MAX_QUOTE = 1.4; // neuer Kollege, der seit zwei von zwanzig Sitzungen voll eingeplant ist,
const karten = Object.values(koepfe).map(k => { // erschien mit 0,30 statt 3,00 — also als praktisch unbeschäftigt.
const kap = wochenKapazitaet(k.person); weeks.forEach(w => {
const quoten = k.verlauf.map((h, i) => kap[i].stunden > 0 ? h / kap[i].stunden : null); (Array.isArray(w.people) ? w.people : []).forEach(person => {
let spitze = -1, spitzeIdx = -1; ROLES.forEach(r => {
quoten.forEach((q, i) => { if(q !== null && q > spitze){ spitze = q; spitzeIdx = i; } }); const key = assignKey(person.id, r);
const projektNamen = Object.keys(k.projekte); if(stats[key]) stats[key].weeks++;
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); });
const prozent = q => Math.round(q * 100) + ' %'; // Nicht bewertbare Projekte werden MITGEZÄHLT statt übergangen: Ein Projekt
const ueberlastet = karten.filter(c => c.spitze >= schwelleRot()); // 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
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++;
// 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;
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;
}
});
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--;
}
});
});
// 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);
// Kapazität einer Person in Stunden je Woche (aus der Benutzerverwaltung,
// sonst die firmenweite Vollzeit).
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
const kapazitaetStunden = person => kapazitaetsFaktor(person) * vollzeit;
// Summe je MENSCH über alle Rollen. Bisher fehlte sie: Wer in drei Rollen
// arbeitet, hat trotzdem nur eine Arbeitswoche — jede Rollenzeile blieb
// unter der Schwelle, die Überlastung wurde nirgends rot. Ein Mensch mit
// 0,80 Planung und 0,40 Projektleitung ist bei 1,20, und das gehört
// sichtbar an den Anfang.
const koepfe = {};
ROLES.forEach(r=>{
grouped[r].forEach(entry=>{
const schluessel = entry.person.fullName || entry.person.name;
const k = koepfe[schluessel] || (koepfe[schluessel] = { person: entry.person, stunden: 0, rollen: [] });
const std = wochenStunden(assignKey(entry.person.id, r));
k.stunden += std;
if(std > 0) k.rollen.push(r);
});
});
let html = "";
// --- Kopf: was die Zahlen abdecken ---
const luckenNamen = Object.keys(luecken); const luckenNamen = Object.keys(luecken);
const ueberlastet = Object.values(koepfe).filter(k => k.stunden / kapazitaetStunden(k.person) >= schwelleRot());
let html = `<div class="load-kopf"> html += `<div class="load-kopf">
<div class="load-kopf-zahl"><strong>${bewertbareProjekte}</strong> von ${alleProjekte} Projekten mit Aufwand in Aufgaben</div> <div class="load-kopf-zahl">
${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>` : ''} <strong>${bewertbareProjekte}</strong> von ${alleProjekte} Projekten bewertet
${luckenNamen.length ? `<button type="button" class="secondary small" onclick="toggleLoadLuecken()">${luckenNamen.length} ohne Aufwand…</button>` : ''} </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>` : ''}
</div>`; </div>`;
if(luckenNamen.length){ if(luckenNamen.length){
html += `<div class="load-luecken" id="loadLuecken" style="display:none"> html += `<div class="load-luecken" id="loadLuecken" style="display:none">
<p class="hint" style="margin:0 0 8px">Diese Projekte haben keine offene Aufgabe mit Aufwand und fließen deshalb in keine Auslastung ein.</p> <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>
${luckenNamen.sort().map(n => `<div class="load-luecke"><span>${esc(n)}</span><b>${esc(luecken[n])}</b></div>`).join('')} ${luckenNamen.sort().map(n => `<div class="load-luecke"><span>${esc(n)}</span><b>${esc(luecken[n])}</b></div>`).join('')}
</div>`; </div>`;
} }
html += `<p class="hint load-erklaerung">Auslastung der nächsten ${VORSCHAU_WOCHEN} Wochen: Gezählt werden die offenen Stunden aller Aufgaben mit Aufwand, zu gleichen Teilen auf ihre Zuständigen und auf die Arbeitstage der Aufgabe verteilt. Gestrichelt ist die volle Kapazität, schraffiert sind Wochen mit Urlaub oder anderer Abwesenheit.</p>`;
html += '<div class="load-karten">' + karten.map(c => { // --- Summe je Mensch ---
const k = c.k; const kopfListe = Object.values(koepfe)
const name = k.person.fullName || k.person.name; .filter(k => k.stunden > 0)
const anzahl = c.projektNamen.length; .sort((a,b)=> b.stunden / kapazitaetStunden(b.person) - a.stunden / kapazitaetStunden(a.person));
const jetztKap = c.kap[0].stunden; if(kopfListe.length){
const jetzt = c.quoten[0]; html += `<div class="load-rolle"><div class="load-rolle-titel">Gesamt je Person</div>`;
const jetztText = jetzt === null ? 'abwesend' : prozent(jetzt); kopfListe.forEach(k=>{
const jetztFarbe = jetzt === null ? 'var(--text-muted)' : (k.verlauf[0] > 0 ? loadAmpelColor(jetzt) : 'var(--text-subtle)'); const kap = kapazitaetStunden(k.person);
const teilzeit = Math.abs(kapazitaetsFaktor(k.person) - 1) < 0.005 ? '' const quote = kap > 0 ? k.stunden / kap : 0;
: `<span class="load-capacity" title="Vertragliche Wochenstunden aus der Benutzerverwaltung">${stundenFormat(kapazitaetsFaktor(k.person) * (Number(APP_SETTINGS.vollzeitStundenProWoche) || 40))} h/Woche</span>`; const ampel = loadAmpelColor(quote);
const saeulen = c.quoten.map((q, i) => { const pct = Math.min(100, quote * 100);
const kap = c.kap[i]; html += `<div class="load-zeile load-zeile-summe">
const h = k.verlauf[i]; <div class="load-wer">
const titel = 'KW ' + wochen[i].kw + ': ' + (kap.stunden > 0 <div class="load-wer-name">${esc(k.person.fullName || k.person.name)}</div>
? stundenFormat(h) + ' h von ' + stundenFormat(kap.stunden) + ' h (' + prozent(q) + ')' + (kap.abwesend ? ', ' + kap.abwesend + ' Tag(e) abwesend' : '') <div class="load-wer-meta">${esc(k.rollen.join(' · ') || '—')}</div>
: 'abwesend' + (h > 0 ? ', ' + stundenFormat(h) + ' h geplant' : '')); </div>
if(q === null) return `<span class="load-saeule ist-abwesend" title="${esc(titel)}"></span>`; <div class="load-band" title="Kapazität: ${stundenFormat(kap)} h/Woche">
const hoehe = Math.max(h > 0 ? 3 : 0, Math.min(q, MAX_QUOTE) / MAX_QUOTE * 100); <div class="load-seg load-seg-summe" style="width:${pct}%;background:${ampel}"></div>
return `<span class="load-saeule${i === 0 ? ' ist-jetzt' : ''}${kap.abwesend ? ' ist-teil' : ''}" style="height:${hoehe}%;background:${loadAmpelColor(q)}" title="${esc(titel)}"></span>`; <div class="load-kapazitaet" title="Volle Kapazität"></div>
}).join(''); </div>
const kws = wochen.map((wo, i) => `<span${i === 0 ? ' class="ist-jetzt"' : ''}>${wo.kw}</span>`).join(''); <div class="load-wert" style="color:${ampel}">${stundenFormat(k.stunden)}<span class="load-einheit">h</span></div>
const hinweis = anzahl === 0 ? '<div class="load-hinweis ist-leer">Keinem Projekt zugeordnet.</div>' </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>` html += `</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-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-kws" aria-hidden="true">${kws}</div>
${hinweis}
${chips ? `<div class="load-chips${offen ? ' ist-offen' : ''}">${chips}</div>` : ''}
</article>`;
}).join('') + '</div>';
grid.innerHTML = html; // --- 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>
</div>
<div class="load-band" title="Volles Band = ${stundenFormat(kap)} h/Woche">
${segmente || '<span class="load-band-leer">keine Zuordnung</span>'}
</div>
<div class="load-wert" style="color:${ampel}">${stundenFormat(std)}<span class="load-einheit">h</span></div>
</div>`;
});
html += `</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;
} }
+195 -354
View File
@@ -4,7 +4,7 @@
// Dieses Modul stellt die drei PDF-Exporte der Anwendung bereit — jeder in // Dieses Modul stellt die drei PDF-Exporte der Anwendung bereit — jeder in
// seinem eigenen, klar abgegrenzten Abschnitt dieser Datei: // seinem eigenen, klar abgegrenzten Abschnitt dieser Datei:
// * exportWochenberichtPDF() — Wochenbericht (Übersicht + Akte je Gewerk) // * exportWochenberichtPDF() — Wochenbericht (Übersicht + Akte je Gewerk)
// * exportSandboxPlanPDF() — Terminplan (Balkenplan eines Projekts oder Planung-Plans) // * exportSandboxPlanPDF() — Planung (Balkenplan eines Sandbox-Terminplans)
// * exportProtokollPDF() — Protokolle (Besprechungsprotokoll je Projekt) // * exportProtokollPDF() — Protokolle (Besprechungsprotokoll je Projekt)
// Ein früherer, inzwischen entfernter dritter Export, exportPDF() (eine // Ein früherer, inzwischen entfernter dritter Export, exportPDF() (eine
// gewerkeübergreifende A3-Tabellenübersicht der ganzen Sitzung, samt // gewerkeübergreifende A3-Tabellenübersicht der ganzen Sitzung, samt
@@ -19,9 +19,10 @@
// PDF_PAD_X/PDF_LINE_W). // PDF_PAD_X/PDF_LINE_W).
// //
// Was unten bleibt, wird von MEHREREN der drei Exporte gebraucht (nicht // Was unten bleibt, wird von MEHREREN der drei Exporte gebraucht (nicht
// zwingend allen): Farbkonstanten, PDF_STYLES (der Terminplan bringt seine // zwingend allen): Farbkonstanten, die A3-quer-Seitengeometrie
// eigene Geometrie in PDF_TP mit, Wochenbericht/Protokoll sind A4 hochkant // PDF_PAGE_W/PDF_MARGIN_X/PDF_USABLE_W (nur exportSandboxPlanPDF() — die
// mit PDF_WB_PAGE_W/PDF_WB_MARGIN_X weiter unten), pdfStatusText() // beiden anderen sind A4 hochkant, eigene Konstanten PDF_WB_PAGE_W/
// PDF_WB_MARGIN_X weiter unten), PDF_STYLES, pdfStatusText()
// (Kommentar-HTML -> pdfmake-Rich-Text), pdfNormalizeColor() und // (Kommentar-HTML -> pdfmake-Rich-Text), pdfNormalizeColor() und
// loadLogoPng(). // loadLogoPng().
@@ -34,6 +35,14 @@ const PDF_ASSIGN_MAIN = '#248A3D';
const PDF_ASSIGN_OPT = '#D18B00'; const PDF_ASSIGN_OPT = '#D18B00';
// --- Seitengeometrie (A3 quer, in pt) — nur von exportSandboxPlanPDF() genutzt;
// exportWochenberichtPDF()/exportProtokollPDF() sind A4 hochkant, siehe
// PDF_WB_PAGE_W/PDF_WB_MARGIN_X weiter unten ---
const PDF_PAGE_W = 1190.55; // A3 quer, vorher A4: 841.89
const PDF_MARGIN_X = 20;
const PDF_USABLE_W = PDF_PAGE_W - PDF_MARGIN_X * 2; // 1150,55
// PDF_STYLES: wiederverwendbare Textstile (Überschriften, Tabellen-/Zellformat) // PDF_STYLES: wiederverwendbare Textstile (Überschriften, Tabellen-/Zellformat)
// für die pdfmake-Dokumentdefinition // für die pdfmake-Dokumentdefinition
const PDF_STYLES = { const PDF_STYLES = {
@@ -53,15 +62,7 @@ function pdfStatusText(htmlStatus){
if(!htmlStatus) return { text: '', style: 'td' }; if(!htmlStatus) return { text: '', style: 'td' };
const root = document.createElement('div'); const root = document.createElement('div');
// sanitizeStatusHtml() ist hier PFLICHT, nicht Kosmetik: Der Server root.innerHTML = htmlStatus;
// 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 = []; const parts = [];
@@ -174,13 +175,13 @@ function pdfNormalizeColor(v){
return null; return null;
} }
// loadLogoPng: lädt logo.ico und konvertiert es via Canvas nach PNG. // loadLogoPng: lädt dein_bild.ico und konvertiert es via Canvas nach PNG.
// pdfmake unterstützt nur JPEG und PNG, kein ICO. // pdfmake unterstützt nur JPEG und PNG, kein ICO.
// Gibt null zurück, wenn Laden oder Dekodieren fehlschlägt. // Gibt null zurück, wenn Laden oder Dekodieren fehlschlägt.
async function loadLogoPng(size){ async function loadLogoPng(size){
size = size || 256; size = size || 256;
try { try {
const res = await fetch(baseUrl() + '/logo.ico'); const res = await fetch(baseUrl() + '/dein_bild.ico');
if(!res.ok) return null; if(!res.ok) return null;
const blob = await res.blob(); const blob = await res.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -384,15 +385,6 @@ function pdfWbGruppentitel(name, topMargin){
// Landkarte der Sitzung — was hat sich geändert, was ist offen — bevor ab // Landkarte der Sitzung — was hat sich geändert, was ist offen — bevor ab
// Seite 2 der vollständige Text folgt. Parameter: liste (aus // Seite 2 der vollständige Text folgt. Parameter: liste (aus
// berichtProjekte()), prevWeek. Rückgabe: Array von pdfmake-Content-Blöcken. // berichtProjekte()), prevWeek. Rückgabe: Array von pdfmake-Content-Blöcken.
// pdfHaken: Haken als Vektorgrafik. Das Zeichen "✓" fehlt in der
// PDF-Schrift (Roboto aus vfs_fonts.js) und erschien als leeres Kästchen —
// Eriks Meldung. Parameter: farbe, breite (Spaltenbreite für die Mitte).
function pdfHaken(farbe, breite){
return { canvas: [{ type: 'polyline', lineWidth: 1.6, lineColor: farbe, lineCap: 'round',
points: [{ x: 0, y: 4 }, { x: 3.2, y: 7.2 }, { x: 9, y: 0.5 }] }],
margin: [Math.max(0, (breite - 9) / 2 - 4), 0, 0, 0] };
}
function pdfWbUebersichtBlock(liste, prevWeek){ function pdfWbUebersichtBlock(liste, prevWeek){
const groups = {}; const groups = {};
liste.forEach(p => (groups[p.group] || (groups[p.group] = [])).push(p)); liste.forEach(p => (groups[p.group] || (groups[p.group] = [])).push(p));
@@ -407,11 +399,11 @@ function pdfWbUebersichtBlock(liste, prevWeek){
{ text: 'Projekt', style: 'th' }, { text: 'Projekt', style: 'th' },
{ text: 'Änderung', style: 'th' }, { text: 'Änderung', style: 'th' },
{ text: 'Amt. LPH', style: 'th' }, { text: 'Amt. LPH', style: 'th' },
{ text: 'Bespr.', style: 'th', alignment: 'center' } { text: '✓', style: 'th', alignment: 'center' }
]; ];
const body = [header]; const body = [header];
projekte.forEach(p => { projekte.forEach(p => {
const change = classifyProjectChange(p, prevWeek, wochenberichtGewerk); const change = classifyProjectChange(p, prevWeek);
const info = pdfWbAktuelleLphInfo(p); const info = pdfWbAktuelleLphInfo(p);
body.push([ body.push([
pdfWbChangeDot(change.bucket), pdfWbChangeDot(change.bucket),
@@ -419,10 +411,10 @@ function pdfWbUebersichtBlock(liste, prevWeek){
{ text: p.name, style: 'td', bold: true }, { text: p.name, style: 'td', bold: true },
{ text: change.bucket !== 'unchanged' ? change.reason : '', style: 'td' }, { text: change.bucket !== 'unchanged' ? change.reason : '', style: 'td' },
{ text: info ? info.lph + ' · ' + info.pct + ' %' : '–', style: 'td' }, { text: info ? info.lph + ' · ' + info.pct + ' %' : '–', style: 'td' },
kommentarVon(p, wochenberichtGewerk).besprochen ? pdfHaken(PDF_ASSIGN_MAIN, 38) : { text: '', style: 'tdCenter' } { text: p.besprochen ? '✓' : '', style: 'tdCenter', color: PDF_ASSIGN_MAIN }
]); ]);
}); });
blocks.push({ table: { headerRows: 1, widths: [14, 40, '*', 118, 76, 38], body }, blocks.push({ table: { headerRows: 1, widths: [14, 40, '*', 118, 76, 18], body },
layout: pdfWbGridLayout(true), margin: [0, 0, 0, 4] }); layout: pdfWbGridLayout(true), margin: [0, 0, 0, 4] });
}); });
return blocks; return blocks;
@@ -436,12 +428,11 @@ function pdfWbUebersichtBlock(liste, prevWeek){
// ausdrückliches false — es gibt keinen dritten Zustand. Parameter: w // ausdrückliches false — es gibt keinen dritten Zustand. Parameter: w
// (Sitzung). Rückgabe: Array von pdfmake-Content-Blöcken. // (Sitzung). Rückgabe: Array von pdfmake-Content-Blöcken.
function pdfWbAnwesenheitBlock(w){ function pdfWbAnwesenheitBlock(w){
// Nur das Gewerk des Berichts — wie am Bildschirm (renderAttendance()). const people = Array.isArray(w.people) ? w.people : [];
const people = personenDesGewerks(w, wochenberichtGewerk);
if(!people.length){ if(!people.length){
return [ return [
pdfWbGruppentitel('Anwesenheit ' + (wochenberichtGewerk || ''), 18), pdfWbGruppentitel('Anwesenheit der Sitzung', 18),
{ text: 'Für dieses Gewerk sind in dieser Sitzung keine Projektbeteiligten hinterlegt.', { text: 'Für diese Sitzung sind keine Projektbeteiligten hinterlegt.',
fontSize: 8, italics: true, color: '#86868B' } fontSize: 8, italics: true, color: '#86868B' }
]; ];
} }
@@ -461,7 +452,7 @@ function pdfWbAnwesenheitBlock(w){
body.push(row); body.push(row);
} }
return [ return [
pdfWbGruppentitel('Anwesenheit ' + (wochenberichtGewerk || ''), 18), pdfWbGruppentitel('Anwesenheit der Sitzung', 18),
{ text: anwesend + ' von ' + people.length + ' anwesend', fontSize: 8, color: '#6E6E73', margin: [0, 0, 0, 6] }, { text: anwesend + ' von ' + people.length + ' anwesend', fontSize: 8, color: '#6E6E73', margin: [0, 0, 0, 6] },
{ table: { widths: [16, '*', 16, '*', 16, '*'], body }, layout: pdfWbGridLayout(false) } { table: { widths: [16, '*', 16, '*', 16, '*'], body }, layout: pdfWbGridLayout(false) }
]; ];
@@ -473,7 +464,7 @@ function pdfWbAnwesenheitBlock(w){
// Beteiligten-Tabelle. Parameter: p (Projekt), w (Sitzung), prevWeek // Beteiligten-Tabelle. Parameter: p (Projekt), w (Sitzung), prevWeek
// (Vorwoche-Objekt oder null), allePersonen. Rückgabe: pdfmake-Content-Block. // (Vorwoche-Objekt oder null), allePersonen. Rückgabe: pdfmake-Content-Block.
function pdfWbProjektAkte(p, w, prevWeek, allePersonen){ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
const change = classifyProjectChange(p, prevWeek, wochenberichtGewerk); const change = classifyProjectChange(p, prevWeek);
const spineColor = PDF_WB_CHANGE_COLORS[change.bucket] || PDF_WB_CHANGE_COLORS.unchanged; const spineColor = PDF_WB_CHANGE_COLORS[change.bucket] || PDF_WB_CHANGE_COLORS.unchanged;
const statusChip = auftragsstatusOf(p) === 'beauftragt' ? '' : ' · ' + auftragsstatusLabel(auftragsstatusOf(p)); const statusChip = auftragsstatusOf(p) === 'beauftragt' ? '' : ' · ' + auftragsstatusLabel(auftragsstatusOf(p));
const tags = Array.isArray(p.tags) && p.tags.length ? ' [' + p.tags.join(', ') + ']' : ''; const tags = Array.isArray(p.tags) && p.tags.length ? ' [' + p.tags.join(', ') + ']' : '';
@@ -484,7 +475,7 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
{ text: (p.nr ? p.nr + ' ' : '') + p.name, bold: true, fontSize: 10 }, { text: (p.nr ? p.nr + ' ' : '') + p.name, bold: true, fontSize: 10 },
{ text: tags + statusChip, fontSize: 7.5, color: '#6E6E73' } { text: tags + statusChip, fontSize: 7.5, color: '#6E6E73' }
], width: '*' }, ], width: '*' },
{ text: kommentarVon(p, wochenberichtGewerk).besprochen ? '✓ besprochen' : '', fontSize: 7, color: PDF_ASSIGN_MAIN, { text: p.besprochen ? '✓ besprochen' : '', fontSize: 7, color: PDF_ASSIGN_MAIN,
alignment: 'right', width: 70 } alignment: 'right', width: 70 }
], margin: [0, 0, 0, 3] ], margin: [0, 0, 0, 3]
}; };
@@ -493,17 +484,11 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
? { text: change.reason, fontSize: 7.5, bold: true, color: spineColor, margin: [0, 0, 0, 5] } ? { text: change.reason, fontSize: 7.5, bold: true, color: spineColor, margin: [0, 0, 0, 5] }
: { text: '', margin: [0, 0, 0, 1] }; : { text: '', margin: [0, 0, 0, 1] };
// Der Kommentar des Gewerks, für das der Bericht erstellt wird. const kommentarInhalt = pdfStatusText(p.status || '');
const kommentarInhalt = pdfStatusText(kommentarVon(p, wochenberichtGewerk).status || '');
const kommentar = (kommentarInhalt.text && kommentarInhalt.text.length) const kommentar = (kommentarInhalt.text && kommentarInhalt.text.length)
? Object.assign({}, kommentarInhalt, { fontSize: 8 }) ? Object.assign({}, kommentarInhalt, { fontSize: 8 })
: { text: 'Kein Kommentar.', fontSize: 7.5, italics: true, color: '#86868B' }; : { text: 'Kein Kommentar.', fontSize: 7.5, italics: true, color: '#86868B' };
// Der zuletzt geschriebene frühere Kommentar desselben Gewerks (wie die
// Zeile "Zuletzt KW …" auf dem Bildschirm, berichtVerlaufDaten() in
// wochenbericht.js) — mit Formatierung, abgesetzt unter dem aktuellen.
const vorher = pdfWbVorigerKommentar(p, w);
const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0]; const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0];
const lphTable = pdfWbLphMiniTable(p, aktLp); const lphTable = pdfWbLphMiniTable(p, aktLp);
const beteiligteTable = pdfWbBeteiligteTable(p, allePersonen); const beteiligteTable = pdfWbBeteiligteTable(p, allePersonen);
@@ -514,13 +499,13 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
rightStack.push({ text: 'BETEILIGTE', fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 10, 0, 3] }); rightStack.push({ text: 'BETEILIGTE', fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 10, 0, 3] });
rightStack.push(beteiligteTable); rightStack.push(beteiligteTable);
if(abwesend.length){ if(abwesend.length){
rightStack.push({ text: 'Heute abwesend: ' + abwesend.join(', '), fontSize: 7, italics: true, rightStack.push({ text: 'Fehlt heute: ' + abwesend.join(', '), fontSize: 7, italics: true,
color: '#D70015', margin: [0, 6, 0, 0] }); color: '#D70015', margin: [0, 6, 0, 0] });
} }
const innerContent = { const innerContent = {
columns: [ columns: [
{ width: '*', stack: [kopf, aenderung, kommentar].concat(vorher ? [vorher] : []) }, { width: '*', stack: [kopf, aenderung, kommentar] },
{ width: 190, stack: rightStack } { width: 190, stack: rightStack }
], ],
columnGap: 12 columnGap: 12
@@ -548,36 +533,6 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
}; };
} }
// pdfWbVorigerKommentar: Block mit dem jüngsten früheren Kommentar des
// Gewerks. Steht er in der direkten Vorwoche, heißt er "Vorwoche KW …",
// sonst "Zuletzt KW …". Parameter: p (Projekt), w (Sitzung). Rückgabe:
// pdfmake-Block oder null (kein früherer Kommentar).
function pdfWbVorigerKommentar(p, w){
if(!p.projectUid || typeof berichtVerlaufDaten !== 'function') return null;
const e = berichtVerlaufDaten(p, w, wochenberichtGewerk).eintraege[0];
if(!e) return null;
const inhalt = pdfStatusText(e.html || '');
if(!inhalt.text || !inhalt.text.length) return null;
const vorwoche = findPreviousWeek(w);
const titel = (vorwoche && vorwoche.label === e.week.label ? 'VORWOCHE ' : 'ZULETZT ')
+ berichtKurzKw(e.week.label).toUpperCase()
+ (e.autor ? ' · ' + e.autor : '');
return {
table: { widths: ['*'], body: [[{
stack: [
{ text: titel, fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 0, 0, 2] },
Object.assign({}, inhalt, { fontSize: 7.5, color: '#3A3A3C' })
],
fillColor: '#F5F5F7'
}]] },
layout: {
hLineWidth: () => 0, vLineWidth: () => 0,
paddingLeft: () => 6, paddingRight: () => 6, paddingTop: () => 4, paddingBottom: () => 5
},
margin: [0, 8, 0, 0]
};
}
// exportWochenberichtPDF: Hauptfunktion. Erzeugt aus der Tagesordnung der // exportWochenberichtPDF: Hauptfunktion. Erzeugt aus der Tagesordnung der
// aktuellen Sitzung ein ablagefähiges, zweiteiliges PDF — Seite 1 die // aktuellen Sitzung ein ablagefähiges, zweiteiliges PDF — Seite 1 die
// Übersicht samt Anwesenheit, ab Seite 2 die vollständige Akte je Projekt, // Übersicht samt Anwesenheit, ab Seite 2 die vollständige Akte je Projekt,
@@ -611,7 +566,7 @@ async function exportWochenberichtPDF(){
ROLES.forEach(r => grouped[r].forEach(e => allePersonen.push(e))); ROLES.forEach(r => grouped[r].forEach(e => allePersonen.push(e)));
const logo = await loadLogoPng(); const logo = await loadLogoPng();
const ab = liste.filter(p => kommentarVon(p, wochenberichtGewerk).besprochen).length; const ab = liste.filter(p => p.besprochen).length;
const gewerkLabel = (typeof GEWERK_LABELS === 'object' && GEWERK_LABELS[wochenberichtGewerk]) || wochenberichtGewerk; const gewerkLabel = (typeof GEWERK_LABELS === 'object' && GEWERK_LABELS[wochenberichtGewerk]) || wochenberichtGewerk;
const kopf = []; const kopf = [];
@@ -677,306 +632,218 @@ async function exportWochenberichtPDF(){
} }
} }
// ----------------------------- // -----------------------------
// Export PDF — Terminplan (Projekt und Planung) // Export PDF — Planung (Sandbox-Terminplan)
// ----------------------------- // -----------------------------
// Balkenplan EINES Terminplans, A3 quer, Gestaltung „A2 — volles Raster“ // exportSandboxPlanPDF() ist der ANDERE der beiden verbleibenden PDF-Exporte
// (Eriks Wahl aus dem Entwurfsvergleich): links eine Vorgangsliste (Vorgang, // (der erste ist exportWochenberichtPDF() oben): ein klassischer Balkenplan
// Start, Ende), rechts die Balken über Monaten (im Wechsel hinterlegt) und // (Entwurf A der Design-Vergleiche, siehe CLAUDE.md "Planung —
// Kalenderwochen, Gitterlinien über das ganze Blatt. Gegliedert nach // projektunabhängige Terminplan-Sandbox") für EINEN Terminplan der
// Leistungsphasen wie der Terminplan am Bildschirm: jeder Phasenbalken // Planung-Sandbox — durchgehende Zeitachse, Balken je Aufgabe, Rauten für
// (balkenPhase()) wird eine dunkelblaue Kopfzeile mit Sammelbalken und // Meilensteine, eine rote Linie für "heute", genau wie am Bildschirm im
// Fortschritt aus dem Wochenbericht, darunter die Aufgaben, die in ihm // Terminplan des Projektbereichs. A3 quer — eine Zeitachse braucht die
// beginnen (aufgabenPhase()), und die Meilensteine in seinem Zeitraum. Der // Breite —, aber kein Übersichts-Raster wie eine Sitzungstabelle, sondern
// Rest steht unter „Weitere Aufgaben“. Keine Zuständigen, keine Heute-Linie // EIN Terminplan, deshalb kein eigenes Deckblatt.
// (Eriks Vorgabe).
// //
// Technik: Jede Seite wird frei positioniert (absolutePosition) — ein // Es gab vor diesem Export keinerlei Gantt-Balken-PDF in dieser Anwendung —
// Canvas für Flächen, Linien und Balken, Texte als eigene Knoten darüber. // nur flache Prozent-Balken (LPH-/Auslastungs-Balken oben). Die Technik ist
// pdfmake kann Text nicht innerhalb eines Canvas setzen. Passen nicht alle // dieselbe (canvas-Rechtecke mit berechnetem x/w), nur nach Datum statt nach
// Zeilen auf ein Blatt, geht es auf weiteren Seiten mit demselben Kopf weiter. // 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.
const PDF_TP = { const PDF_PLAN_LABEL_W = 190;
W: 1190.55, H: 841.89, M: 32, const PDF_PLAN_ROW_H = 18;
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'
};
const PDF_TP_LPH_NAMEN = { // pdfPlanDayX: x-Position (pt, relativ zum Beginn der Zeitachse) eines
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung', // Datums innerhalb der Chart-Spalte. Parameter: date (Date), rangeStart
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe', // (Date), totalDays (Zahl), chartW (pt). Rückgabe: Zahl (pt), auf den
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung', // sichtbaren Bereich begrenzt.
'W+M': 'Wartung und Monitoring' function pdfPlanDayX(date, rangeStart, totalDays, chartW){
}; const tage = Math.max(0, Math.min(totalDays, daysBetween(rangeStart, date)));
return chartW * tage / totalDays;
// pdfTpDatum: 'TT.MM.JJ'. Parameter: Date. Rückgabe: String.
function pdfTpDatum(d){
return String(d.getDate()).padStart(2, '0') + '.' + String(d.getMonth() + 1).padStart(2, '0') + '.'
+ String(d.getFullYear()).slice(2);
} }
// pdfTpKuerzen: kürzt Text auf eine Breite (pt) bei gegebener Schriftgröße — // pdfPlanMonatsAchse: teilt [rangeStart, rangeEnd] in Monatsabschnitte für
// grob geschätzt, Roboto ist im Mittel gut eine halbe Schriftgröße breit. // die Kopfzeile der Zeitachse. Rückgabe: Array {label, x1, x2}.
function pdfTpKuerzen(text, breite, groesse){ function pdfPlanMonatsAchse(rangeStart, rangeEnd, totalDays, chartW){
const max = Math.floor(breite / (groesse * 0.52)); const monate = [];
const t = String(text || ''); let cur = new Date(rangeStart.getFullYear(), rangeStart.getMonth(), 1);
return t.length > max ? t.slice(0, Math.max(1, max - 1)) + '…' : t; 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;
} }
// pdfTpText: frei positionierter Text in einem Kasten der Breite w. // pdfPlanAchsenZeile: die Kopfzeile der Zeitachse (Monatsbeschriftungen),
// opts: size, bold, italics, color, align. // über pdfmake columns statt canvas — Textlabel lassen sich in pdfmake nicht
function pdfTpText(x, y, w, text, opts){ // frei innerhalb eines Canvas positionieren, wohl aber über Spaltenbreiten.
const o = opts || {}; function pdfPlanAchsenZeile(monate){
w = Math.max(1, Math.min(w, PDF_TP.W - PDF_TP.M - x)); // nie über den rechten Rand
return { return {
absolutePosition: { x, y }, columns: [
columns: [{ width: w, text: String(text), fontSize: o.size || 9, bold: !!o.bold, italics: !!o.italics, { width: PDF_PLAN_LABEL_W, text: 'Aufgabe / Meilenstein', fontSize: 7.5, bold: true, color: '#6E6E73' },
color: o.color || PDF_TP.ink, alignment: o.align || 'left', noWrap: true }] ...monate.map(m => ({
width: Math.max(2, m.x2 - m.x1), text: m.label, fontSize: 7, color: '#6E6E73'
}))
],
margin: [0, 0, 0, 3]
}; };
} }
// pdfTpZeilen: die Zeilen des Plans in Anzeigereihenfolge — je Phase eine // pdfPlanBarFarbe: Balkenfarbe nach Aufgabenstand — dieselben drei Zustände,
// Kopfzeile, ihre Aufgaben und Meilensteine, am Ende „Weitere Aufgaben“. // die die Aufgabe tatsächlich kennt (t.done, t.progress); anders als der
// Parameter: p (Projekt). Rückgabe: [{art:'phase'|'aufgabe'|'ms', …}]. // Entwurf im Design-Vergleich gibt es hier keine vierte, erfundene Kategorie
function pdfTpZeilen(p){ // "geplant", weil dafür kein echtes Datenfeld existiert.
const plan = p.plan || {}; function pdfPlanBarFarbe(t){
const todos = (Array.isArray(plan.todos) ? plan.todos : []) if(t.done) return PDF_ASSIGN_MAIN;
.filter(t => t.start && t.end && t.showInGantt !== false && parseDateISO(t.start) && parseDateISO(t.end)); if((Number(t.progress) || 0) > 0) return '#0071E3';
const ms = (Array.isArray(plan.milestones) ? plan.milestones : []).filter(m => parseDateISO(m.date)); return '#D2D2D7';
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;
} }
// pdfTpLegende: die Legende als Spalten für die Fußzeile (Zeichen + Text). // pdfPlanZeile: eine Zeile des Balkenplans (Aufgabe ODER Meilenstein) als
function pdfTpLegende(){ // pdfmake columns-Objekt. Parameter: label (String), aktiv (Boolean, fett),
const T = PDF_TP, sp = []; // canvasInhalt (Array von canvas-Primitiven für die Chart-Spalte).
[ function pdfPlanZeile(label, kursiv, canvasInhalt){
[{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.phase }, 'Leistungsphase'], return {
[{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.inArbeit }, 'in Arbeit'], columns: [
[{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.erledigt }, 'erledigt'], { width: PDF_PLAN_LABEL_W, text: label, fontSize: 7.5, italics: !!kursiv,
[{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: '#ffffff', lineColor: T.aufgabeRand, lineWidth: 0.8 }, 'offen'], color: kursiv ? '#6E6E73' : '#1D1D1F', margin: [0, (PDF_PLAN_ROW_H - 9) / 2, 4, 0] },
[{ 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'] { width: '*', canvas: canvasInhalt }
].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), function pdfPlanTodoZeile(t, rangeStart, totalDays, chartW, todayX){
// rowH, x (Datum → x), range {start, ende}, kopf (Knoten des Seitenkopfs), const s = parseDateISO(t.start) || rangeStart;
// ersteSeite. Rückgabe: Array von pdfmake-Knoten. const e = parseDateISO(t.end) || s;
function pdfTpSeite(zeilen, rowH, x, range, kopf, ersteSeite){ const x1 = pdfPlanDayX(s, rangeStart, totalDays, chartW);
const T = PDF_TP, M = T.M, cx0 = M + T.spalten.reduce((s, c) => s + c[1], 0), cx1 = T.W - M; const x2 = Math.max(x1 + 2, pdfPlanDayX(addDays(e, 1), rangeStart, totalDays, chartW));
const bodyTop = T.top + T.kopfH, bodyBot = bodyTop + zeilen.length * rowH; const canvasInhalt = [
const plus1 = d => addDays(d, 1); { type: 'rect', x: x1, y: 4, w: x2 - x1, h: PDF_PLAN_ROW_H - 8, color: pdfPlanBarFarbe(t) },
const flaechen = [], linien = [], balken = [], texte = []; { type: 'line', x1: 0, y1: PDF_PLAN_ROW_H, x2: chartW, y2: PDF_PLAN_ROW_H, lineWidth: 0.4, lineColor: '#ECECEC' }
// 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++;
}
// Kalenderwochen: Linie je Woche, Nummer nur so oft, wie Platz ist
const wocheB = x(addDays(range.start, 7)) - x(range.start);
const schritt = Math.max(1, Math.ceil(11 / Math.max(0.1, wocheB)));
for(let w = new Date(range.start), n = 0; w <= range.ende; w = addDays(w, 7), n++){
const a = x(w);
linien.push({ type: 'line', x1: a, y1: T.top + 17, x2: a, y2: bodyBot, lineWidth: 0.5, lineColor: T.woche });
if(n % schritt === 0) texte.push(pdfTpText(a, T.top + 23, wocheB * schritt, getWeekNumber(w), { size: 6.5, color: T.grau, align: schritt === 1 ? 'center' : 'left' }));
}
linien.push({ type: 'line', x1: cx0, y1: T.top + 17, x2: cx1, y2: T.top + 17, lineWidth: 0.6, lineColor: T.gitter });
// 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 }
]; ];
if(todayX !== null){
const knoten = [{ canvas: [...flaechen, ...linien, ...balken, ...rahmen], absolutePosition: { x: 0, y: 0 } }]; canvasInhalt.push({ type: 'line', x1: todayX, y1: 0, x2: todayX, y2: PDF_PLAN_ROW_H, lineWidth: 0.75, lineColor: '#D70015' });
if(!ersteSeite) knoten[0].pageBreak = 'before'; }
return [...knoten, ...kopf, ...texte]; return pdfPlanZeile(t.title || 'Aufgabe', false, canvasInhalt);
} }
// exportSandboxPlanPDF: Einstieg vom Knopf „Ansicht → Als PDF exportieren“. function pdfPlanMeilensteinZeile(m, rangeStart, totalDays, chartW, todayX){
// Findet einen Planung-Plan oder das im Projektbereich geöffnete Projekt. const d = parseDateISO(m.date) || rangeStart;
// Parameter: planId. Keine Rückgabe. 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' });
}
return pdfPlanZeile('◆ ' + (m.name || 'Meilenstein'), true, canvasInhalt);
}
// exportSandboxPlanPDF: Hauptfunktion. Erzeugt den Balkenplan-PDF-Export
// EINES Terminplans der Planung-Sandbox. Parameter: planId. Keine Rückgabe.
async function exportSandboxPlanPDF(planId){ async function exportSandboxPlanPDF(planId){
const sandbox = (db.sandboxWeek && Array.isArray(db.sandboxWeek.projects))
? db.sandboxWeek.projects.find(x => x.id === planId) : null;
const app = window.PlanApp;
const p = sandbox || (app && app.project && app.project.id === planId ? app.project : null);
return exportTerminplanPDF(p, !!sandbox, sandbox ? null : (app && app.week));
}
// exportTerminplanPDF: erzeugt das PDF. Parameter: p (Projekt), sandbox
// (Boolean, Planung-Plan), week (Sitzung des Projekts oder null).
async function exportTerminplanPDF(p, sandbox, week){
if(!window.pdfMake){ if(!window.pdfMake){
alert('PDF-Export nicht verfügbar: pdfmake wurde nicht geladen. ' alert('PDF-Export nicht verfügbar: pdfmake wurde nicht geladen. '
+ 'Bitte Internetverbindung bzw. Skript-Einbindung prüfen.'); + 'Bitte Internetverbindung bzw. Skript-Einbindung prüfen.');
return; return;
} }
const p = (db.sandboxWeek && Array.isArray(db.sandboxWeek.projects))
? db.sandboxWeek.projects.find(x => x.id === planId) : null;
if(!p){ if(!p){
alert('Dieser Terminplan wurde nicht gefunden.'); alert('Dieser Terminplan wurde nicht gefunden.');
return; return;
} }
try { try {
const T = PDF_TP, M = T.M; const plan = p.plan || {};
const zeilen = pdfTpZeilen(p); const todos = (Array.isArray(plan.todos) ? plan.todos.slice() : [])
const daten = []; .sort((a, b) => (parseDateISO(a.start) || 0) - (parseDateISO(b.start) || 0));
zeilen.forEach(z => { if(z.von) daten.push(z.von, z.bis); if(z.datum) daten.push(z.datum); }); const milestones = (Array.isArray(plan.milestones) ? plan.milestones.slice() : [])
if(!daten.length){ .sort((a, b) => (parseDateISO(a.date) || 0) - (parseDateISO(b.date) || 0));
alert('Dieser Terminplan enthält noch keine Aufgaben mit Termin oder Meilensteine — es gibt nichts abzubilden.'); if(!todos.length && !milestones.length){
alert('Dieser Terminplan enthält noch keine Aufgaben oder Meilensteine — es gibt nichts abzubilden.');
return; return;
} }
// Zeitraum: Montag der ersten bis Sonntag der letzten Woche const range = ganttRangeFromProjects([p], 7);
const min = new Date(Math.min(...daten)), max = new Date(Math.max(...daten)); const totalDays = Math.max(1, daysBetween(range.start, range.end) + 1);
const start = addDays(min, -((min.getDay() + 6) % 7)); const chartW = PDF_USABLE_W - PDF_PLAN_LABEL_W - 10;
const ende = addDays(max, 6 - ((max.getDay() + 6) % 7)); const heute = new Date();
const range = { start, ende }; heute.setHours(0, 0, 0, 0);
const cx0 = M + T.spalten.reduce((s, c) => s + c[1], 0), cx1 = T.W - M; const todayX = (heute >= range.start && heute <= range.end)
const spanne = daysBetween(start, ende) + 1; ? pdfPlanDayX(heute, range.start, totalDays, chartW) : null;
const x = d => cx0 + (cx1 - cx0) * daysBetween(start, d) / spanne;
// Zeilenhöhe: so groß wie möglich (höchstens 30 pt), mindestens 17 pt;
// reicht das nicht, geht es auf weiteren Seiten weiter.
const platz = T.H - T.unten - T.top - T.kopfH;
const rowH = Math.max(17, Math.min(30, platz / zeilen.length));
const jeSeite = Math.max(1, Math.floor(platz / rowH));
// Seitenkopf
const logo = await loadLogoPng(); const logo = await loadLogoPng();
const titel = (!sandbox && p.nr ? p.nr + ' ' : '') + (p.name || 'Terminplan'); const kopf = [];
const teile = [sandbox ? 'Terminplan — Planung, unabhängig von einem laufenden Projekt' : 'Terminplan']; if(logo) kopf.push({ image: logo, width: 46, margin: [0, 0, 0, 6] });
if(!sandbox && week && week.label) teile.push('Stand ' + week.label); kopf.push(
if(Number(p.honorar) > 0) teile.push('Honorar ' + Number(p.honorar).toLocaleString('de-DE') + ' €'); { text: p.name, fontSize: 17, bold: true, color: '#1D1D1F' },
if(Array.isArray(p.beauftragteLP) && p.beauftragteLP.length) teile.push('beauftragt ' + p.beauftragteLP.join(', ')); { text: 'Terminplan — Planung, unabhängig von einem laufenden Projekt',
// Je Seite neu gebaut: pdfmake verändert die Knoten beim Setzen, ein fontSize: 9, color: '#6E6E73', margin: [0, 2, 0, 0] }
// wiederverwendeter Kopf fehlte ab Seite 2. );
const kopf = () => { const honorar = Number(p.honorar) || 0;
const k = [ const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
pdfTpText(M, 30, 900, pdfTpKuerzen(titel, 900, 18), { size: 18, bold: true }), if(honorar > 0){
pdfTpText(M, 54, 900, teile.join(' · '), { size: 10, color: T.grau }) kopf.push({ text: 'Honorar ' + honorar.toLocaleString('de-DE') + ' €'
]; + (beauftragt.length ? ' · ' + beauftragt.join(', ') : ''),
if(logo) k.push({ image: logo, fit: [40, 40], absolutePosition: { x: T.W - M - 40, y: 26 } }); fontSize: 8, color: '#6E6E73', margin: [0, 2, 0, 0] });
return k;
};
const content = [];
for(let i = 0; i < zeilen.length; i += jeSeite){
content.push(...pdfTpSeite(zeilen.slice(i, i + jeSeite), rowH, x, range, kopf(), i === 0));
} }
kopf.push({ text: 'erstellt am ' + new Date().toLocaleDateString('de-DE'),
fontSize: 7.5, color: '#6E6E73', margin: [0, 2, 0, 16] });
const content = [...kopf];
content.push(pdfPlanAchsenZeile(pdfPlanMonatsAchse(range.start, range.end, totalDays, chartW)));
todos.forEach(t => content.push(pdfPlanTodoZeile(t, range.start, totalDays, chartW, todayX)));
milestones.forEach(m => content.push(pdfPlanMeilensteinZeile(m, range.start, totalDays, chartW, todayX)));
content.push({
columns: [
{ width: 'auto', text: [{ text: '■ ', color: PDF_ASSIGN_MAIN }, { text: 'Erledigt' }], fontSize: 8 },
{ width: 'auto', text: [{ text: '■ ', color: '#0071E3' }, { text: 'In Arbeit' }], fontSize: 8 },
{ width: 'auto', text: [{ text: '■ ', color: '#D2D2D7' }, { text: 'Offen' }], fontSize: 8 },
{ width: 'auto', text: [{ text: '◆ ', color: '#0071E3' }, { text: 'Meilenstein' }], fontSize: 8 },
{ width: 'auto', text: [{ text: '┃ ', color: '#D70015' }, { text: 'heute' }], fontSize: 8 }
],
columnGap: 16, margin: [0, 12, 0, 0]
});
const fussText = 'Terminplan' + (!sandbox && p.nr ? ' ' + p.nr : '') + ' · erstellt am ' + new Date().toLocaleDateString('de-DE');
const doc = { const doc = {
pageSize: 'A3', pageSize: 'A3',
pageOrientation: 'landscape', pageOrientation: 'landscape',
pageMargins: [M, 0, M, 36], // oben 0: Die Seite ist frei positioniert; mit Rand passte der Canvas über die ganze Höhe nicht. pageMargins: [PDF_MARGIN_X, 30, PDF_MARGIN_X, 30],
defaultStyle: { font: 'Roboto', fontSize: 9, color: T.ink }, defaultStyle: { font: 'Roboto', fontSize: 8, color: '#1D1D1F' },
// Legende links und Seitenangabe rechts in der Fußzeile — als frei styles: PDF_STYLES,
// positionierter Text am Seitenende rutschte die Legende auf eine
// eigene, sonst leere Seite.
footer: (page, count) => ({ footer: (page, count) => ({
margin: [M, 12, M, 0], text: 'Planung · ' + p.name + ' · Seite ' + page + ' von ' + count,
columns: [ alignment: 'right', fontSize: 6.5, color: '#6E6E73',
...pdfTpLegende(), margin: [0, 8, PDF_MARGIN_X, 0]
{ width: '*', text: fussText + ' · Seite ' + page + ' von ' + count, alignment: 'right', fontSize: 8, color: T.grau }
]
}), }),
info: { title: 'Terminplan ' + (p.name || ''), creator: 'Projektübersicht' }, info: { title: 'Terminplan ' + p.name, creator: 'Projektübersicht' },
content content: content
}; };
const filename = (sandbox ? 'Terminplan_Planung_' : 'Terminplan_' + (p.nr ? p.nr + '_' : '')) const filename = 'Terminplan_Planung_' + String(p.name || 'Plan').replace(/[^\w]/g, '_') + '.pdf';
+ String(p.name || 'Plan').replace(/[^\w]/g, '_') + '.pdf';
pdfMake.createPdf(doc).download(filename); pdfMake.createPdf(doc).download(filename);
} catch(err){ } catch(err){
console.error('Terminplan-PDF-Export fehlgeschlagen:', err); console.error('Planung-PDF-Export fehlgeschlagen:', err);
alert('PDF-Export fehlgeschlagen: ' + (err.message || err)); alert('PDF-Export fehlgeschlagen: ' + (err.message || err));
} }
} }
@@ -1099,38 +966,12 @@ async function exportProtokollPDF(protokollId){
const nummerParts = [nummer]; const nummerParts = [nummer];
if(p.neu) nummerParts.push({ text: '\nNEU', fontSize: 5.5, bold: true, color: '#8A6D00' }); 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' }); 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 nummerText = { text: nummerParts };
const statusLabel = { offen: 'offen', erledigt: 'erledigt', info: 'Info' }[p.status] || 'offen'; const statusLabel = { offen: 'offen', erledigt: 'erledigt', info: 'Info' }[p.status] || 'offen';
const statusCell = zelle({ text: statusLabel, alignment: 'center' }); 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([ body.push([
zelle(nummerText, { bold: true }), zelle(nummerText, { bold: true }),
themaZelle, zelle({ text: [p.thema ? { text: p.thema, bold: true } : '', p.text ? { text: (p.thema ? '\n' : '') + p.text } : ''] }),
zelle({ text: p.zustaendig || '' }), zelle({ text: p.zustaendig || '' }),
zelle({ text: formatDateDE(parseDateISO(p.termin)) || '—' }), zelle({ text: formatDateDE(parseDateISO(p.termin)) || '—' }),
statusCell statusCell
+3 -3
View File
@@ -1,7 +1,7 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Personen-Ansicht (global, NUR LESEND) // Projektübersicht — Personen-Ansicht (global, NUR LESEND)
// ----------------------------- // -----------------------------
// Diese Datei rendert den Personen-Tab (Tab "Projektbeteiligte" in `App.html`) // Diese Datei rendert den Personen-Tab (Tab "Projektbeteiligte" in `Test.html`)
// als reine Anzeige. Seit der Ablösung von people/person_roles/person_absences // als reine Anzeige. Seit der Ablösung von people/person_roles/person_absences
// (server.js) werden Projektbeteiligte ausschließlich in der zentralen // (server.js) werden Projektbeteiligte ausschließlich in der zentralen
// Benutzerverwaltung (Bereich „Admin“) gepflegt; Anlegen/Bearbeiten/Löschen/ // 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` // 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`/ // (befüllt via `GET /load-all`, serverseitig jetzt aus `users`/`user_roles`/
// `user_absences` zusammengesetzt, firmenweit ohne Abteilungstrennung). Sie // `user_absences` zusammengesetzt, firmenweit ohne Abteilungstrennung). Sie
// wird von `App.html` nach `js/board/board.js` (vor `js/projects/projects.js`) // wird von `Test.html` nach `js/board/board.js` (vor `js/projects/projects.js`)
// geladen und stellt die globale Funktion `renderPeople()` bereit, die u.a. // geladen und stellt die globale Funktion `renderPeople()` bereit, die u.a.
// von `js/tabs-auth.js` (`showTab()`) und `js/init.js` aufgerufen wird. // von `js/tabs-auth.js` (`showTab()`) und `js/init.js` aufgerufen wird.
@@ -28,7 +28,7 @@
// Parameter, keine Rückgabe. // Parameter, keine Rückgabe.
function renderPeople(){ function renderPeople(){
const people = db.globalPeople || []; const people = db.globalPeople || [];
const isAdmin = hatRecht('a.benutzer'); // Teilnehmerkreis pflegen: wie Benutzerverwaltung const isAdmin = typeof currentUser !== 'undefined' && !!currentUser && currentUser.role === 'admin';
const w = currentWeek(); const w = currentWeek();
const inWeek = new Set((w && Array.isArray(w.people) ? w.people : []).map(p => p.id)); const inWeek = new Set((w && Array.isArray(w.people) ? w.people : []).map(p => p.id));
const locked = !w || isWeekLocked(w); const locked = !w || isWeekLocked(w);
+14 -115
View File
@@ -86,7 +86,7 @@
// aufgerufen. Mit showTodos wird deren HTML per DOM-Bearbeitung um die // aufgerufen. Mit showTodos wird deren HTML per DOM-Bearbeitung um die
// TODO- und Meilensteinzeilen aller übergebenen Projekte ergänzt und als // TODO- und Meilensteinzeilen aller übergebenen Projekte ergänzt und als
// HTML-String zurückgegeben. Parameter: projects (Array), start/end (ISO- // HTML-String zurückgegeben. Parameter: projects (Array), start/end (ISO-
// Datum), options (Optionen inkl. editable). Rückgabe: // Datum), options (Optionen inkl. editable und criticalPath). Rückgabe:
// String mit dem kompletten GANTT-HTML. // String mit dem kompletten GANTT-HTML.
window.buildGanttHtml = function(projects, start, end, options = {}){ window.buildGanttHtml = function(projects, start, end, options = {}){
if(!options.showTodos) return originalBuildGanttHtml(projects, start, end, options); if(!options.showTodos) return originalBuildGanttHtml(projects, start, end, options);
@@ -169,11 +169,12 @@
const span = daysBetween(barStart, barEnd) + 1; const span = daysBetween(barStart, barEnd) + 1;
const dragAttrs = editable ? ` data-gantt-type="todo" data-gantt-project="${esc(p.id)}" data-gantt-index="${tIdx}" data-gantt-start="${formatDateISO(s)}" data-gantt-end="${formatDateISO(e)}" draggable="false"` : ''; const dragAttrs = editable ? ` data-gantt-type="todo" data-gantt-project="${esc(p.id)}" data-gantt-index="${tIdx}" data-gantt-start="${formatDateISO(s)}" data-gantt-end="${formatDateISO(e)}" draggable="false"` : '';
const handles = editable ? `<span class="gantt-resize gantt-resize-left" data-resize="left"></span><span class="gantt-resize gantt-resize-right" data-resize="right"></span>` : ''; const handles = editable ? `<span class="gantt-resize gantt-resize-left" data-resize="left"></span><span class="gantt-resize gantt-resize-right" data-resize="right"></span>` : '';
const isCritical = options.criticalPath && options.criticalPath.includes(todo.id);
const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0)); const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0));
const conflictTitle = conflicts.length > 0 const conflictTitle = conflicts.length > 0
? '\nAchtung: ' + conflicts.map(c => `${c.name} ${c.type.toLowerCase()} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('; ') ? '\nAchtung: ' + conflicts.map(c => `${c.name} ${c.type.toLowerCase()} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('; ')
: ''; : '';
barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${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' : ''}${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>`;
} }
} }
// Geister-Balken zuerst einfügen, damit der aktuelle Balken darüber liegt. // Geister-Balken zuerst einfügen, damit der aktuelle Balken darüber liegt.
@@ -203,22 +204,10 @@
? `<span class="gantt-conflict-badge" title="${esc(conflicts.map(c => `${c.name}: ${c.type} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('\n'))}">${icon('warning')}</span>` ? `<span class="gantt-conflict-badge" title="${esc(conflicts.map(c => `${c.name}: ${c.type} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('\n'))}">${icon('warning')}</span>`
: ''; : '';
// Ruhigere Namensspalte (Eriks Wahl "A"): Vorgänger stehen nicht mehr 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')}">
// als Schildchen im Namen, sondern im Tooltip (und als Linie im
// Diagramm); rechts die Kürzel der Zuständigen.
const vorgaengerText = depBadge ? '\nVorgänger: ' + (depBadge.match(/title="Vorgänger: ([^"]*)"/) || [,''])[1] : '';
const vorgaengerKonflikt = depBadge.indexOf('conflict') !== -1;
const leute = (Array.isArray(todo.assignees) ? todo.assignees : []).map(id => {
const pers = ((app.week && app.week.people) || []).find(x => x.id === id);
return pers ? pers.name : '';
}).filter(Boolean);
const leuteHtml = leute.length
? `<span class="gantt-leute">${leute.slice(0, 3).map(k => `<span>${esc(k)}</span>`).join('')}${leute.length > 3 ? `<span>+${leute.length - 3}</span>` : ''}</span>` : '';
row.innerHTML = `<div class="gantt-label" data-todo-id="${esc(todo.id)}" style="padding-left:24px;font-weight:400;color:var(--text-muted)" title="${esc(todo.title || 'TODO')}${vorgaengerText}">
${editable ? '<span class="gantt-todo-drag">' + icon('grip') + '</span>' : ''} ${editable ? '<span class="gantt-todo-drag">' + icon('grip') + '</span>' : ''}
<span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}</span> <span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}${depBadge}</span>
${vorgaengerKonflikt ? '<span class="gantt-vorgaenger-warn" title="Beginnt vor dem Ende eines Vorgängers">!</span>' : ''} ${conflictBadge}
${conflictBadge}${leuteHtml}
</div> </div>
<div class="gantt-timeline">${barHtml}</div>`; <div class="gantt-timeline">${barHtml}</div>`;
return row; return row;
@@ -271,73 +260,16 @@
} }
if(!projectRow) return; if(!projectRow) return;
// Die Projektzeile trägt die Gesamtdauer und Meilensteine ohne eigene
// Zeile; sie heißt jetzt schlicht so, statt den Projektnamen ein
// zweites Mal zu wiederholen.
projectRow.classList.add('tp-projektzeile');
const projektLabel = projectRow.querySelector('.gantt-label');
if(projektLabel) projektLabel.textContent = 'Projekt gesamt';
let insertAfter = projectRow; let insertAfter = projectRow;
const insertedMilestoneIds = new Set(); const insertedMilestoneIds = new Set();
// Gliederung nach Leistungsphasen (Eriks Wahl "A"): Phasenbalken todos.forEach((todo, tIdx) => {
// (balkenPhase(), load.js) werden Gruppenköpfe; jede andere Aufgabe
// steht unter der Phase, in deren Balken sie beginnt. Nur die
// ANZEIGE wird umsortiert — plan.todos übernimmt die Reihenfolge erst,
// wenn jemand eine Zeile zieht (reorderPlanRows(), plan-drag.js).
const istPhase = t => typeof balkenPhase === 'function' && !!balkenPhase(t);
const hatBalken = t => t.start && t.end && t.showInGantt !== false;
const phasenKoepfe = todos.filter(t => hatBalken(t) && istPhase(t))
.sort((a, b) => String(a.start).localeCompare(String(b.start)));
const gruppeVon = t => {
if(istPhase(t)) return t.id;
const s0 = parseDateISO(t.start);
const k = s0 && phasenKoepfe.find(p => s0 >= parseDateISO(p.start) && s0 <= parseDateISO(p.end));
return k ? k.id : '';
};
let reihe = todos;
if(phasenKoepfe.length){
reihe = [];
phasenKoepfe.forEach(p => { reihe.push(p); todos.forEach(t => { if(!istPhase(t) && gruppeVon(t) === p.id) reihe.push(t); }); });
todos.forEach(t => { if(reihe.indexOf(t) === -1) reihe.push(t); });
}
const heuteIsoWert = formatDateISO(new Date());
const filter = options.filter || 'alle';
const passtFilter = t => filter === 'alle'
|| (filter === 'meine' && options.eigenePersonId && Array.isArray(t.assignees) && t.assignees.indexOf(options.eigenePersonId) !== -1)
|| (filter === 'ueberfaellig' && !t.done && t.end && t.end < heuteIsoWert);
const phasenZu = options.phasenZu || {};
let aktuelleGruppeZu = false;
let restKopfGesetzt = false;
const einfuegen = row => { insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); insertAfter = row; };
// Meilensteine ohne Aufgabenbezug: je eine eigene Zeile direkt unter
// "Projekt gesamt", nach Datum sortiert (Eriks Meldung: ohne Aufgaben
// standen alle Meilensteine übereinander in derselben Zeile).
milestones.filter(m => !m.position)
.sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')))
.forEach(m => einfuegen(buildMilestoneRow(m, milestones.indexOf(m))));
reihe.forEach(todo => {
const tIdx = todos.indexOf(todo);
if(phasenKoepfe.length && hatBalken(todo)){
if(istPhase(todo)) aktuelleGruppeZu = !!phasenZu[balkenPhase(todo)];
else if(!gruppeVon(todo) && !restKopfGesetzt){
// Aufgaben außerhalb jedes Phasenbalkens bekommen einen eigenen Kopf.
const kopf = document.createElement('div');
kopf.className = 'gantt-row tp-gruppe';
kopf.innerHTML = '<div class="gantt-label">Weitere Aufgaben</div><div class="gantt-timeline"></div>';
einfuegen(kopf);
restKopfGesetzt = true;
aktuelleGruppeZu = false;
}
}
// Meilensteine, die VOR dieser Aufgabe positioniert sind // Meilensteine, die VOR dieser Aufgabe positioniert sind
milestones.filter(m => m.position === 'before:' + todo.id).forEach(m => { milestones.filter(m => m.position === 'before:' + todo.id).forEach(m => {
const mIdx = milestones.indexOf(m); const mIdx = milestones.indexOf(m);
const row = buildMilestoneRow(m, mIdx); const row = buildMilestoneRow(m, mIdx);
if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu'); insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
einfuegen(row); insertAfter = row;
insertedMilestoneIds.add(m.id); insertedMilestoneIds.add(m.id);
}); });
@@ -349,51 +281,18 @@
// Terminplan nicht mit leeren Zeilen ohne Balken zustellen. Nicht // Terminplan nicht mit leeren Zeilen ohne Balken zustellen. Nicht
// jede Aufgabe aus "Aufgaben" landet also im GANTT, wohl aber jede // jede Aufgabe aus "Aufgaben" landet also im GANTT, wohl aber jede
// Aufgabe aus dem GANTT in "Aufgaben" (dieselbe Liste, siehe oben). // Aufgabe aus dem GANTT in "Aufgaben" (dieselbe Liste, siehe oben).
// UND showInGantt darf nicht ausdrücklich false sein — die Checkbox if(todo.start && todo.end){
// "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); const todoRow = buildTodoRow(todo, tIdx);
if(phasenKoepfe.length && istPhase(todo)){ insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
const ph = balkenPhase(todo); insertAfter = todoRow;
const zu = !!phasenZu[ph];
const kinder = todos.filter(t => !istPhase(t) && hatBalken(t) && gruppeVon(t) === todo.id);
todoRow.classList.add('gantt-phase');
const label = todoRow.querySelector('.gantt-label');
if(label){
const knopf = document.createElement('button');
knopf.type = 'button';
knopf.className = 'tp-klappen';
knopf.setAttribute('aria-expanded', String(!zu));
knopf.setAttribute('aria-label', (zu ? 'Aufklappen: ' : 'Zuklappen: ') + ph);
knopf.setAttribute('onclick', `event.stopPropagation(); tpPhaseUmschalten('${ph.replace(/'/g, '')}')`);
knopf.textContent = zu ? '▶' : '▼';
label.insertBefore(knopf, label.firstChild);
const info = document.createElement('span');
info.className = 'tp-phase-info';
info.textContent = kinder.length + ' · ' + (parseInt(todo.progress, 10) || 0) + ' %';
info.title = (kinder.length === 1 ? '1 Aufgabe' : kinder.length + ' Aufgaben') + ', Phase zu ' + (parseInt(todo.progress, 10) || 0) + ' % erledigt';
label.appendChild(info);
}
} else {
if(aktuelleGruppeZu) todoRow.classList.add('gantt-zu');
if(!passtFilter(todo)) todoRow.classList.add('gantt-gefiltert');
}
einfuegen(todoRow);
} }
// Meilensteine, die NACH dieser Aufgabe positioniert sind // Meilensteine, die NACH dieser Aufgabe positioniert sind
milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => { milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => {
const mIdx = milestones.indexOf(m); const mIdx = milestones.indexOf(m);
const row = buildMilestoneRow(m, mIdx); const row = buildMilestoneRow(m, mIdx);
if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu'); insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
einfuegen(row); insertAfter = row;
insertedMilestoneIds.add(m.id); insertedMilestoneIds.add(m.id);
}); });
}); });
+14 -29
View File
@@ -113,28 +113,25 @@
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : []; const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : []; const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
// Nur terminierte Aufgaben (Start UND Ende gesetzt) MIT showInGantt !== // Nur terminierte Aufgaben (Start UND Ende gesetzt) haben überhaupt eine
// false haben überhaupt eine eigene GANTT-Zeile (exakt dieselbe // eigene GANTT-Zeile (siehe plan-build-gantt.js) und damit einen
// Bedingung wie in plan-build-gantt.js) und damit einen Ziehgriff — // Ziehgriff — Aufgaben ohne Termin bleiben an ihrer Stelle
// Aufgaben ohne Termin oder bewusst aus dem GANTT ausgeblendete bleiben // in plan.todos stehen, sie können hier gar nicht bewegt worden sein.
// an ihrer Stelle in plan.todos stehen, sie können hier gar nicht bewegt const scheduledIds = new Set(todos.filter(t => t.start && t.end).map(t => t.id));
// 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 // Alle beweglichen Zeilen in aktueller DOM-Reihenfolge
const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)]; 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 todoRowIds = rows.filter(r => r.classList.contains('gantt-todo')).map(r => r.dataset.todoId).filter(Boolean);
const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean); const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean);
// Sicherheitscheck: Anzahl muss stimmen. Jeder Meilenstein hat eine // Sicherheitscheck: Anzahl muss stimmen. Nicht jedes Meilenstein wird als
// eigene .gantt-milestone-row (auch ohne Position, dann oben unter // eigene .gantt-milestone-row gerendert – nur die mit einer TODO-basierten
// "Projekt gesamt"). // Position (before:/after:) sind eigene Zeilen. Meilensteine ohne Position
// liegen in der Projektzeile und werden hier nicht umsortiert, deshalb nur
// die positionierten zählen.
if(todoRowIds.length !== scheduledIds.size) return; if(todoRowIds.length !== scheduledIds.size) return;
if(milestoneRowIds.length !== milestones.length) return; const positionedIds = milestones.filter(m => m.position && /^(before|after):/.test(m.position)).map(m => m.id);
if(milestoneRowIds.length !== positionedIds.length) return;
// TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden // TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden
// gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne // gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne
@@ -150,7 +147,7 @@
// Regeln: // Regeln:
// - Steht der Meilenstein direkt vor einer TODO -> before:todoId // - Steht der Meilenstein direkt vor einer TODO -> before:todoId
// - Steht er direkt nach einer TODO -> after:todoId // - Steht er direkt nach einer TODO -> after:todoId
// - Steht er ganz am Anfang (vor erster TODO) oder es gibt keine TODO -> '' (eigene Zeile oben) // - Steht er ganz am Anfang (vor erster TODO) oder es gibt keine TODO -> '' (oben in Projektzeile)
// - Steht er ganz am Ende (nach letzter TODO) -> after:lastTodoId // - Steht er ganz am Ende (nach letzter TODO) -> after:lastTodoId
rows.forEach((row, idx) => { rows.forEach((row, idx) => {
if(!row.classList.contains('gantt-milestone-row')) return; if(!row.classList.contains('gantt-milestone-row')) return;
@@ -162,11 +159,7 @@
const prevRow = rows[idx - 1]; const prevRow = rows[idx - 1];
const nextRow = rows[idx + 1]; const nextRow = rows[idx + 1];
const todoDavor = rows.slice(0, idx).some(r => r.classList.contains('gantt-todo')); if(prevRow && prevRow.classList.contains('gantt-todo')){
if(!todoDavor){
// Vor der ersten Aufgabe: ohne Position (eigene Zeile oben).
position = '';
} else if(prevRow && prevRow.classList.contains('gantt-todo')){
position = 'after:' + prevRow.dataset.todoId; position = 'after:' + prevRow.dataset.todoId;
} else if(nextRow && nextRow.classList.contains('gantt-todo')){ } else if(nextRow && nextRow.classList.contains('gantt-todo')){
position = 'before:' + nextRow.dataset.todoId; position = 'before:' + nextRow.dataset.todoId;
@@ -450,14 +443,6 @@
const globalAbsence = globalPerson && Array.isArray(globalPerson.absences) const globalAbsence = globalPerson && Array.isArray(globalPerson.absences)
? globalPerson.absences.find(x => x.id === absenceId) : null; ? globalPerson.absences.find(x => x.id === absenceId) : null;
if(!globalAbsence) return; if(!globalAbsence) return;
// Abwesenheit einer anderen Person: Art und Notiz kennt nur sie selbst
// (loadGlobalPeople() schickt sie verdeckt). Das Verschieben legt neu an
// und bräuchte die Art — sonst wäre der Eintrag nach dem Löschen weg.
if(globalAbsence.verdeckt){
app.showToast('Abwesenheiten anderer Personen lassen sich hier nicht verschieben.');
renderPlanGantt();
return;
}
const type = globalAbsence.type || ''; const type = globalAbsence.type || '';
const note = globalAbsence.note || ''; const note = globalAbsence.note || '';
// Ursprungstermine merken: Scheitert das Speichern (Netz, fehlende // Ursprungstermine merken: Scheitert das Speichern (Netz, fehlende
+3 -3
View File
@@ -58,7 +58,7 @@
const hint = document.getElementById('filesNoProjectHint'); const hint = document.getElementById('filesNoProjectHint');
const uploadSection = document.getElementById('filesUploadSection'); const uploadSection = document.getElementById('filesUploadSection');
if(hint) hint.style.display = hasNr ? 'none' : ''; if(hint) hint.style.display = hasNr ? 'none' : '';
if(uploadSection) uploadSection.style.display = (hasNr && hatRecht('proj.dateien')) ? '' : 'none'; if(uploadSection) uploadSection.style.display = hasNr ? '' : 'none';
const fileInput = document.getElementById('fileUploadInput'); const fileInput = document.getElementById('fileUploadInput');
if(fileInput) fileInput.value = ''; if(fileInput) fileInput.value = '';
if(hasNr) loadReviewerCheckboxes(); if(hasNr) loadReviewerCheckboxes();
@@ -175,7 +175,7 @@
// renderFilesSummary: füllt die Kompakt-Vorschau der "Dateien & Freigaben"- // renderFilesSummary: füllt die Kompakt-Vorschau der "Dateien & Freigaben"-
// Karte unter dem GANTT (Cockpit-Layout, #planFilesSummary in // Karte unter dem GANTT (Cockpit-Layout, #planFilesSummary in
// App.html) aus filesCache — Name der zuletzt hochgeladenen Datei // Test.html) aus filesCache — Name der zuletzt hochgeladenen Datei
// (Server liefert /project-files bereits nach created_at DESC sortiert, // (Server liefert /project-files bereits nach created_at DESC sortiert,
// filesCache[0] ist also die neueste) sowie einen Hinweis auf noch // filesCache[0] ist also die neueste) sowie einen Hinweis auf noch
// ausstehende Prüfungen. // ausstehende Prüfungen.
@@ -212,7 +212,7 @@
// Dieselbe Regel wie im Server (DELETE /project-files/:id): der Uploader, // Dieselbe Regel wie im Server (DELETE /project-files/:id): der Uploader,
// solange die Datei noch offen ist — oder ein Administrator. Vorher stand // solange die Datei noch offen ist — oder ein Administrator. Vorher stand
// der Knopf bei jeder Datei und endete für andere in einer Fehlermeldung. // der Knopf bei jeder Datei und endete für andere in einer Fehlermeldung.
const isAdmin = hatRecht('proj.dateienFremd'); // Recht „Fremde Dateien löschen“ const isAdmin = !!(typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin');
const isUploader = !!(typeof currentUser !== 'undefined' && currentUser && f.uploadedByUserId === currentUser.id); const isUploader = !!(typeof currentUser !== 'undefined' && currentUser && f.uploadedByUserId === currentUser.id);
const canDelete = isAdmin || (isUploader && f.status === 'pending'); const canDelete = isAdmin || (isUploader && f.status === 'pending');
const reviewerChips = (f.reviewers || []).map(r => const reviewerChips = (f.reviewers || []).map(r =>
+104 -235
View File
@@ -9,12 +9,9 @@
// (computeResourceLoads/renderResourceLoad/openResourceModal, Cockpit-Layout: // (computeResourceLoads/renderResourceLoad/openResourceModal, Cockpit-Layout:
// Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter) // Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter)
// und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt, // und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt,
// offene Aufgaben und nächster Meilenstein aus denselben Plan-Daten; // offene Aufgaben, nächster Meilenstein, Team-Auslastung, alle aus denselben
// Team-Auslastung dagegen über aufgabenVerlaufJePerson() aus js/load/load.js, // Plan-Daten abgeleitet). renderPlanGantt ist die zentrale Neu-Render-
// siehe computeResourceLoads() weiter unten — dieselbe Rechnung wie im // Funktion (rendert am Ende auch Auslastung und KPIs neu) und wird nach
// 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 // Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js erneut
// aufgerufen. // aufgerufen.
// //
@@ -26,7 +23,7 @@
// renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts // renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts
// neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den // neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den
// Projektzeitraum), berücksichtigt Urlaub, Sperrstatus und // Projektzeitraum), berücksichtigt Urlaub, kritischen Pfad, Sperrstatus und
// Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die // Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die
// Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert. // Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert.
window.renderPlanGantt = function(){ window.renderPlanGantt = function(){
@@ -41,23 +38,16 @@
document.getElementById('planGanttStart').value = formatDateISO(start); document.getElementById('planGanttStart').value = formatDateISO(start);
document.getElementById('planGanttEnd').value = formatDateISO(end); document.getElementById('planGanttEnd').value = formatDateISO(end);
} }
// Zeitraum auch sichtbar ins Feld schreiben (es zeigte sonst nur den
// Platzhalter, solange niemand einen Zeitraum gewählt hatte).
const zeitraumAnzeige = document.getElementById('planGanttRange');
if(zeitraumAnzeige) zeitraumAnzeige.value = formatDateDE(start) + ' – ' + formatDateDE(end);
const showVacation = document.getElementById('planShowVacation').checked; const showVacation = document.getElementById('planShowVacation').checked;
const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id); const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id);
const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : [];
const ghost = collectGhostDates(); const ghost = collectGhostDates();
container.innerHTML = buildGanttHtml([app.project], start, end, { container.innerHTML = buildGanttHtml([app.project], start, end, {
showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id, showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id,
showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath,
ghostTodos: ghost.todos, ghostMilestones: ghost.milestones, ghostLabel: ghost.label, ghostTodos: ghost.todos, ghostMilestones: ghost.milestones, ghostLabel: ghost.label,
filter: app.ganttFilter || 'alle', phasenZu: app.phasenZu || (app.phasenZu = {}),
eigenePersonId: (typeof currentUser !== 'undefined' && currentUser && currentUser.personId) || '',
}); });
renderOhneTermin();
container.scrollLeft = prevScroll; container.scrollLeft = prevScroll;
if(typeof ganttGriffeEinsetzen === 'function') ganttGriffeEinsetzen(container);
requestAnimationFrame(() => renderGanttDependencies(container)); requestAnimationFrame(() => renderGanttDependencies(container));
renderPlanWarnings(); renderPlanWarnings();
renderResourceLoad(); renderResourceLoad();
@@ -68,7 +58,6 @@
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei // Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich. // Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
if(typeof renderPlanTasks === 'function') renderPlanTasks(); if(typeof renderPlanTasks === 'function') renderPlanTasks();
if(typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(typeof renderPlanDashboard === 'function') renderPlanDashboard(); if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
}; };
@@ -102,7 +91,7 @@
} }
// renderPlanWarnings: füllt das Hinweisband über dem GANTT // renderPlanWarnings: füllt das Hinweisband über dem GANTT
// (#planWarnings in App.html) mit den Urlaubs-Kollisionen des // (#planWarnings in Test.html) mit den Urlaubs-Kollisionen des
// Projekts — Aufgaben, deren Zeitraum in die Abwesenheit einer zugeordneten // Projekts — Aufgaben, deren Zeitraum in die Abwesenheit einer zugeordneten
// Person fällt. Die Prüfung selbst liegt in plan-build-gantt.js // Person fällt. Die Prüfung selbst liegt in plan-build-gantt.js
// (app.absenceConflictsForTodo), damit Balken-Markierung und diese Liste // (app.absenceConflictsForTodo), damit Balken-Markierung und diese Liste
@@ -129,68 +118,71 @@
</div>`; </div>`;
}; };
// bandZelle: eine Kennzahl im Lageband des Dashboards. Parameter: label, // kpiTile: baut das HTML einer einzelnen Kachel für renderPlanKpis().
// value, sub (Texte), warn (Unterzeile rot). Rückgabe: HTML-String. // Parameter: label (Kachel-Titel), value (Hauptzahl), sub (Unterzeile),
function bandZelle(label, value, sub, warn){ // warn (optional, hebt den Wert farblich hervor, z. B. bei überfälligen
return `<div class="plan-lage-zelle"> // Aufgaben). Rückgabe: HTML-String.
function kpiTile(label, value, sub, warn){
return `<div class="plan-kpi-tile${warn ? ' warn' : ''}">
<div class="kpi-label">${esc(label)}</div> <div class="kpi-label">${esc(label)}</div>
<div class="kpi-value">${esc(value)}</div> <div class="kpi-value">${esc(value)}</div>
<div class="kpi-sub${warn ? ' ist-rot' : ''}">${esc(sub)}</div> <div class="kpi-sub">${esc(sub)}</div>
</div>`; </div>`;
} }
// renderPlanKpis: füllt das Lageband oben im Dashboard (#planKpiRow, // renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout,
// Anordnung "Lagebild"): links die laufende Leistungsphase mit Ring // #planKpiRow in Test.html) mit vier Kennzahlen, die alle aus den
// (amtierende LPH und ihr Fortschritt aus dem Wochenbericht), daneben offene // bereits vorhandenen Plan-Daten abgeleitet sind (keine neue Datenquelle):
// Aufgaben (mit Überfälligem), nächster Meilenstein und Team-Auslastung // Gesamtfortschritt (Ø-Fortschritt aller TODOs), offene Aufgaben (inkl.
// (computeResourceLoads(), dieselbe Zahl wie im Auslastungs-Tab). Der // überfällige), nächster anstehender Meilenstein und Team-Auslastung
// frühere "Gesamtfortschritt" (Ø der Aufgaben) ist entfallen — für den // (Ø-Prozentwert aus computeResourceLoads(), dieselbe Zahl wie im
// Stand eines Projekts zählt die Leistungsphase. Wird nach jedem // Auslastungs-Modal). Wird nach jedem GANTT-Rendering aufgerufen, damit sie
// GANTT-Rendering aufgerufen, damit es sofort aktuell ist. // nach TODO-/Meilenstein-/Zuordnungsänderungen sofort aktuell ist.
window.renderPlanKpis = function(){ window.renderPlanKpis = function(){
const row = document.getElementById('planKpiRow'); const row = document.getElementById('planKpiRow');
if(!row || !app.project) return; if(!row || !app.project) return;
const p = app.project;
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : []; const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : []; const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const lph = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || ''; let progressTile;
const roh = lph && p.lph ? p.lph[lph] : ''; if(todos.length === 0){
const pct = roh === undefined || roh === '' ? null : Math.max(0, Math.min(100, Number(roh) || 0)); progressTile = kpiTile('Gesamtfortschritt', '–', 'Keine Aufgaben angelegt');
const phase = `<div class="plan-lage-zelle plan-lage-phase"> } else {
<span class="plan-lage-ring" style="--p:${pct || 0}"></span> const avg = Math.round(todos.reduce((s, t) => s + (parseInt(t.progress, 10) || 0), 0) / todos.length);
<div><div class="kpi-label">Laufende Phase</div> const doneCount = todos.filter(t => t.done).length;
<div class="kpi-value">${lph ? esc(lph) + (pct === null ? '' : ' · ' + pct + ' %') : '–'}</div> progressTile = kpiTile('Gesamtfortschritt', avg + '%', doneCount + ' von ' + todos.length + ' Aufgaben erledigt');
<div class="kpi-sub">${lph ? (pct === 100 ? 'abgeschlossen' : 'Fortschritt laut Wochenbericht') : 'Keine amtierende Phase gesetzt'}</div></div> }
</div>`;
const open = todos.filter(t => !t.done); const open = todos.filter(t => !t.done);
const overdue = open.filter(t => t.end && parseDateISO(t.end) < today); const overdue = open.filter(t => t.end && parseDateISO(t.end) < today);
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'im Zeitplan' : 'alle erledigt'); 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 upcoming = milestones const upcoming = milestones
.filter(m => m.date && parseDateISO(m.date) >= today) .filter(m => m.date && parseDateISO(m.date) >= today)
.sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date)); .sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date));
const msZelle = upcoming.length > 0 let milestoneTile;
? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein') if(upcoming.length > 0){
: bandZelle('Nächster Meilenstein', '–', milestones.length > 0 ? 'keine anstehenden' : 'keine angelegt'); milestoneTile = kpiTile('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein');
const { loads } = computeResourceLoads();
const loadValues = Object.values(loads);
let loadZelle;
if(loadValues.length === 0){
loadZelle = bandZelle('Team-Auslastung', '–', 'niemand zugeordnet');
} else { } else {
const avgPct = Math.round(loadValues.reduce((s, l) => s + l.quote * 100, 0) / loadValues.length); milestoneTile = kpiTile('Nächster Meilenstein', '–', milestones.length > 0 ? 'Keine anstehenden Meilensteine' : 'Keine Meilensteine angelegt');
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 = phase + bandZelle('Offene Aufgaben', String(open.length), openSub, overdue.length > 0) + msZelle + loadZelle; const { loads, maxDays } = computeResourceLoads();
const loadValues = Object.values(loads);
let loadTile;
if(loadValues.length === 0){
loadTile = kpiTile('Team-Auslastung', '–', 'Keine Beteiligten zugeordnet');
} else {
const avgPct = Math.round(loadValues.reduce((s, l) => s + (l.days / maxDays * 100), 0) / loadValues.length);
const top = loadValues.slice().sort((a, b) => b.days - a.days)[0];
loadTile = kpiTile('Team-Auslastung', avgPct + '%', top.days > 0 ? 'Höchste Auslastung: ' + top.person.name : 'Noch keine Aufgaben zugewiesen');
}
row.innerHTML = progressTile + openTile + milestoneTile + loadTile;
}; };
// renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die // renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die
@@ -207,13 +199,7 @@
let svg = chart.querySelector('.gantt-dependencies-svg'); let svg = chart.querySelector('.gantt-dependencies-svg');
if(svg) svg.remove(); if(svg) svg.remove();
// Linien abgeschaltet (Menü "Mehr") — dann nichts zeichnen. const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'));
const pfeile = document.getElementById('planShowPfeile');
if(pfeile && !pfeile.checked) return;
// Ausgeblendete Zeilen (zugeklappte Phase, Filter) haben keine Lage —
// Linien dorthin würden ins Leere zeigen.
const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'))
.filter(b => b.offsetParent !== null);
if(nodeBars.length === 0) return; if(nodeBars.length === 0) return;
const chartRect = chart.getBoundingClientRect(); const chartRect = chart.getBoundingClientRect();
@@ -294,97 +280,6 @@
renderPlanGantt(); renderPlanGantt();
}; };
// ---------------------------------------------------------------------------
// Ruhige Leiste (Eriks Wahl "A"): Maßstab, Heute, Filter, Menüs.
// ---------------------------------------------------------------------------
const TP_STUFEN = { fit: null, woche: 8, tag: 32 };
// tpMassstab: Maßstab "Übersicht" (an die Breite angepasst), "Wochen" oder
// "Tage" — feste Pixel je Tag statt der früheren −/+-Knöpfe.
window.tpMassstab = function(stufe){
app.pxPerDay = TP_STUFEN[stufe] === undefined ? null : TP_STUFEN[stufe];
document.querySelectorAll('#tpMassstab button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.stufe === stufe)));
renderPlanGantt();
if(app.pxPerDay) tpZuHeuteScrollen();
};
function tpZuHeuteScrollen(){
const c = document.getElementById('planGanttContainer');
const heute = c && c.querySelector('.gantt-grid-today');
if(!c || !heute) return;
const r = heute.getBoundingClientRect(), cr = c.getBoundingClientRect();
c.scrollLeft += (r.left - cr.left) - c.clientWidth / 3;
}
// tpHeute: Zeitraum ab zwei Wochen vor heute, gleiche Länge wie bisher
// (mindestens 12 Wochen), und heute ins Bild holen.
window.tpHeute = function(){
const s = parseDateISO(document.getElementById('planGanttStart').value);
const e = parseDateISO(document.getElementById('planGanttEnd').value);
const tage = s && e ? Math.max(84, daysBetween(s, e)) : 84;
const von = new Date(); von.setHours(0, 0, 0, 0); von.setDate(von.getDate() - 14);
const bis = new Date(von); bis.setDate(bis.getDate() + tage);
document.getElementById('planGanttStart').value = formatDateISO(von);
document.getElementById('planGanttEnd').value = formatDateISO(bis);
const anzeige = document.getElementById('planGanttRange');
if(anzeige) anzeige.value = formatDateDE(von) + ' – ' + formatDateDE(bis);
renderPlanGantt();
if(app.pxPerDay) tpZuHeuteScrollen();
};
// tpFilter: Alle / Meine (eigene Zuständigkeit) / Überfällig. Gefilterte
// Zeilen werden nur ausgeblendet, nicht weggelassen — das Umsortieren per
// Ziehen (plan-drag.js) zählt die Zeilen im DOM.
window.tpFilter = function(filter){
app.ganttFilter = filter;
document.querySelectorAll('#tpFilter button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.filter === filter)));
renderPlanGantt();
};
// tpPhaseUmschalten: klappt eine Leistungsphase im Terminplan auf/zu.
window.tpPhaseUmschalten = function(phase){
if(!app.phasenZu) app.phasenZu = {};
app.phasenZu[phase] = !app.phasenZu[phase];
renderPlanGantt();
};
// tpMenueZu: schließt die Menüs "Mehr" und "+ Neu".
window.tpMenueZu = function(){
document.querySelectorAll('.tp-menue[open]').forEach(d => d.removeAttribute('open'));
};
// Ein Klick außerhalb schließt ein offenes Menü; ein Menü öffnen schließt
// das andere.
document.addEventListener('click', e => {
document.querySelectorAll('.tp-menue[open]').forEach(d => { if(!d.contains(e.target)) d.removeAttribute('open'); });
});
document.addEventListener('keydown', e => { if(e.key === 'Escape') window.tpMenueZu(); });
// Die Liste hängt rechtsbündig unter dem Knopf. Bricht die Leiste in einem
// schmalen Fenster um, steht "+ Neu" links und die Liste rutschte unter die
// Seitenleiste — dann linksbündig aufklappen (Klasse .nach-rechts).
document.addEventListener('toggle', e => {
const d = e.target;
if(!d.classList || !d.classList.contains('tp-menue') || !d.open) return;
const liste = d.querySelector('.tp-menue-liste');
if(!liste) return;
liste.classList.remove('nach-rechts');
const seite = document.querySelector('.side-menu');
const r = seite ? seite.getBoundingClientRect() : null;
const linkeGrenze = r && r.width && getComputedStyle(seite).position !== 'fixed' ? r.right : 0;
if(liste.getBoundingClientRect().left < linkeGrenze + 8) liste.classList.add('nach-rechts');
}, true);
// renderOhneTermin: Hinweis unter dem Diagramm auf Aufgaben, die keinen
// Balken haben (ohne Zeitraum oder bewusst ausgeblendet) — sie stehen nur
// unter "Aufgaben". Vorher fehlte jeder Hinweis, dass es sie gibt.
function renderOhneTermin(){
const el = document.getElementById('planOhneTermin');
if(!el) return;
const todos = (app.project && app.project.plan && Array.isArray(app.project.plan.todos)) ? app.project.plan.todos : [];
const ohne = todos.filter(t => !t.done && !(t.start && t.end && t.showInGantt !== false));
el.hidden = ohne.length === 0;
el.innerHTML = ohne.length ? `<span class="tp-ohne-zahl">${ohne.length}</span> ${ohne.length === 1 ? 'offene Aufgabe' : 'offene Aufgaben'} ohne Termin <span class="tp-ohne-hinweis">stehen nur unter „Aufgaben“ →</span>` : '';
}
// zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms. // zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms.
// Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit). // Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit).
window.zoomPlanGantt = function(dir){ window.zoomPlanGantt = function(dir){
@@ -393,7 +288,7 @@
let end = parseDateISO(document.getElementById('planGanttEnd').value); let end = parseDateISO(document.getElementById('planGanttEnd').value);
if(!start || !end) return; if(!start || !end) return;
const totalDays = Math.max(1, daysBetween(start, end) + 1); const totalDays = Math.max(1, daysBetween(start, end) + 1);
const cw = container ? Math.max(200, container.clientWidth - (ganttSpaltenBreite() + 30)) : 900; const cw = container ? Math.max(200, container.clientWidth - 250) : 900;
const fitPx = Math.max(3, cw / totalDays); const fitPx = Math.max(3, cw / totalDays);
const base = app.pxPerDay || fitPx; const base = app.pxPerDay || fitPx;
let next; let next;
@@ -409,87 +304,66 @@
// ---------- Ressourcen-Auslastung ---------- // ---------- Ressourcen-Auslastung ----------
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten // computeResourceLoads: berechnet die Auslastung der Projektbeteiligten in
// NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel: // Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs.
// 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 // Gemeinsam genutzt von renderResourceLoad() (Detailkarten im
// Terminplan-Bereich) und renderPlanKpis() (Team-Auslastung-Kachel), damit // Auslastungs-Modal) und renderPlanKpis() (Team-Auslastung-Kachel), damit
// beide dieselbe Zahl zeigen. Rückgabe: { loads } — loads ist ein Objekt // beide dieselbe Zahl zeigen.
// Person-ID -> { person, roles, kapazitaet, stundenProjekt, stundenAndere, //
// stunden (Summe), quote (stunden/kapazitaet), andereProjekte, todos // Neben der Auslastung IN DIESEM Projekt (days/todos) wird zusätzlich
// (Titel der eigenen Aufgaben in DIESEM Projekt, ohne Phasenbalken), // erfasst, wie stark dieselbe Person in den ÜBRIGEN Projekten derselben
// offenProjekt (offene Aufgabenstunden dieses Projekts, alle Wochen). // 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).
function computeResourceLoads(){ function computeResourceLoads(){
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : []; const todos = Array.isArray(plan.todos) ? plan.todos.filter(t => t.start && t.end) : [];
const assignees = projectAssignees(app.project, app.week); const assignees = projectAssignees(app.project, app.week);
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
const loads = {}; const loads = {};
assignees.forEach(a => { assignees.forEach(a => {
if(!loads[a.person.id]){ if(!loads[a.person.id]){
loads[a.person.id] = { loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [], otherDays: 0, otherProjects: [] };
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); if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role);
}); });
// Zuständige einer Aufgabe zählen mit, auch wenn sie unter „Beteiligte“
// fehlen — wie auf der Auslastungsseite. Sonst fielen ihre
// Aufgabenstunden hier stillschweigend weg.
const personen = (app.week && app.week.people) || [];
todos.forEach(t => { todos.forEach(t => {
if(!Array.isArray(t.assignees) || balkenPhase(t)) return; if(!Array.isArray(t.assignees) || t.assignees.length === 0) return;
const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end));
t.assignees.forEach(pid => { t.assignees.forEach(pid => {
if(!loads[pid]){ if(!loads[pid]) return;
const person = personen.find(x => x.id === pid); loads[pid].days += days;
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); loads[pid].todos.push(t.title);
}); });
}); });
// Offene Aufgabenstunden dieses Projekts insgesamt (nicht nur diese // Auslastung in den anderen Projekten derselben Sitzung
// Woche) — liegt eine Aufgabe erst später, zeigte die Wochenzahl sonst 0. ((app.week && app.week.projects) || []).forEach(other => {
Object.values(loads).forEach(l => { l.offenProjekt = 0; }); if(other.id === app.project.id) return;
offeneAufwandsAufgaben(app.project).forEach(({ t, rest }) => { const otherTodos = (other.plan && Array.isArray(other.plan.todos)) ? other.plan.todos : [];
t.assignees.forEach(pid => { if(loads[pid]) loads[pid].offenProjekt += rest / t.assignees.length; }); otherTodos.forEach(t => {
}); if(!t.start || !t.end || !Array.isArray(t.assignees)) return;
const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end));
// Stunden dieser Woche allein aus Aufgaben mit Aufwand — dieselbe t.assignees.forEach(pid => {
// Rechnung wie auf der Auslastungsseite (aufgabenVerlaufJePerson(), load.js). if(!loads[pid]) return; // nur Personen, die auch hier beteiligt sind
((app.week && app.week.projects) || []).forEach(pr => { loads[pid].otherDays += days;
const av = typeof aufgabenVerlaufJePerson === 'function' ? aufgabenVerlaufJePerson(pr) : {}; const label = other.nr ? (other.nr + ' ' + (other.name || '')) : (other.name || 'Projekt');
Object.keys(av).forEach(pid => { if(!loads[pid].otherProjects.includes(label)) loads[pid].otherProjects.push(label);
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);
}
}); });
}); });
Object.values(loads).forEach(l => { const maxDays = Math.max(1, Object.values(loads).reduce((m, l) => Math.max(m, l.days), 0));
l.stunden = l.stundenProjekt + l.stundenAndere; return { loads, maxDays };
l.quote = l.kapazitaet > 0 ? l.stunden / l.kapazitaet : 0;
});
return { loads };
} }
// openResourceModal: springt in den Bereich "Terminplan", in dem die // openResourceModal: springt in den Bereich "Terminplan", in dem die
@@ -513,7 +387,7 @@
const container = document.getElementById('resourceLoadContainer'); const container = document.getElementById('resourceLoadContainer');
const summaryEl = document.getElementById('planResourceSummary'); const summaryEl = document.getElementById('planResourceSummary');
if(!container || !app.project) return; if(!container || !app.project) return;
const { loads } = computeResourceLoads(); const { loads, maxDays } = computeResourceLoads();
const loadValues = Object.values(loads); const loadValues = Object.values(loads);
if(loadValues.length === 0){ if(loadValues.length === 0){
@@ -524,10 +398,11 @@
if(summaryEl){ if(summaryEl){
const cells = loadValues.map(l => { const cells = loadValues.map(l => {
const otherHint = l.stundenAndere > 0 ? ` (+${stundenFormat(l.stundenAndere)} h anderswo)` : ''; const pct = Math.round(l.days / maxDays * 100);
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>`; 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>`;
}).join(''); }).join('');
const alsoBooked = loadValues.filter(l => l.stundenAndere > 0).length; const alsoBooked = loadValues.filter(l => l.otherDays > 0).length;
const chip = alsoBooked > 0 const chip = alsoBooked > 0
? `<span class="plan-mini-chip warn">${alsoBooked} auch in anderen Projekten</span>` ? `<span class="plan-mini-chip warn">${alsoBooked} auch in anderen Projekten</span>`
: `<span class="plan-mini-chip">nur in diesem Projekt</span>`; : `<span class="plan-mini-chip">nur in diesem Projekt</span>`;
@@ -535,11 +410,10 @@
<div class="plan-mini-chip-row">${chip}</div>`; <div class="plan-mini-chip-row">${chip}</div>`;
} }
let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Wochenstunden, wie im Auslastungs-Tab)</div>'; let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)</div>';
html += '<div class="load-grid plan-resource-grid">'; html += '<div class="load-grid plan-resource-grid">';
Object.values(loads).forEach(l => { Object.values(loads).forEach(l => {
const ampel = loadAmpelColor(l.quote); const pct = Math.round(l.days / maxDays * 100);
const breite = Math.min(100, l.quote * 100);
const absCount = app.countPersonAbsences(l.person.id); 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>` : ''; const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">${absCount} Abwesenheit(en)</div>` : '';
@@ -553,24 +427,19 @@
} }
} }
// Gegenüberstellung: Stunden aus diesem Projekt vs. aus den übrigen // Gegenüberstellung: Tage in diesem Projekt vs. in den übrigen Projekten
// Projekten der Sitzung (siehe computeResourceLoads) — macht sichtbar, // derselben Sitzung (siehe computeResourceLoads) — macht sichtbar, wenn
// wenn jemand hier entspannt aussieht, aber anderswo ausgebucht ist. // jemand hier entspannt aussieht, aber anderswo ausgebucht ist.
const otherBadge = l.stundenAndere > 0 const otherBadge = l.otherDays > 0
? `<div class="plan-resource-other" title="${esc(l.andereProjekte.join('\n'))}"> ? `<div class="plan-resource-other" title="${esc(l.otherProjects.join('\n'))}">
+${stundenFormat(l.stundenAndere)} h in ${l.andereProjekte.length} weiteren Projekt${l.andereProjekte.length === 1 ? '' : 'en'} +${l.otherDays} Tage in ${l.otherProjects.length} weiteren Projekt${l.otherProjects.length === 1 ? '' : 'en'}
</div>` </div>`
: `<div class="plan-resource-other muted">keine weiteren Projekte</div>`; : `<div class="plan-resource-other muted">keine weiteren Projekte</div>`;
const offenText = l.offenProjekt > 0
? `<div class="plan-resource-other muted">${stundenFormat(l.offenProjekt)} h offen in Aufgaben dieses Projekts</div>`
: `<div class="plan-resource-other muted">keine Aufgaben mit Aufwand</div>`;
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten"> html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3> <h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3>
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h diese Woche</span></div> <div class="load-num" style="font-size:18px">${l.days}</div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div> <div class="bar" style="height:6px;margin-top:4px"><div style="width:${pct}%;background:${pct>75?'#c53030':pct>45?'#d69e2e':'#2f855a'}"></div></div>
${offenText}
${otherBadge} ${otherBadge}
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div> <div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
${absBadge} ${absBadge}
+78 -10
View File
@@ -1,16 +1,19 @@
// ----------------------------- // -----------------------------
// Projektplan — Verlauf über mehrere Sitzungen // Projektplan — Verlauf über mehrere Sitzungen
// ----------------------------- // -----------------------------
// Dieses Modul liefert die Fassungen eines Projekts über die Sitzungen // Dieses Modul macht die Sitzungs-Historie eines Projekts sichtbar: die
// (Vorwochen-Helfer für den GANTT). Angezeigt wird der Kommentar-Verlauf // Kompakt-Karte "Verlauf" unter dem GANTT zeigt den letzten Kommentarwechsel,
// im Projekt-Dashboard von plan-info.js. // ein Klick öffnet #planHistoryModal mit dem vollständigen Verlauf.
// //
// Der Verlauf in Wochenbericht und Projektliste wird von // Der Verlauf selbst wird NICHT hier gezeichnet, sondern von
// renderProjektVerlauf() in js/utils.js gezeichnet. Zuvor // renderProjektVerlauf() in js/utils.js — derselben Darstellung, die auch der
// Wochenbericht unter der Projektkarte und die Projektliste zeigen. Zuvor
// hatte jede dieser Stellen ihre eigene Fassung mit eigener Auswahl (hier nur // hatte jede dieser Stellen ihre eigene Fassung mit eigener Auswahl (hier nur
// Sitzungen mit Kommentarwechsel, im Bericht auch Termine und // Sitzungen mit Kommentarwechsel, im Bericht auch Termine und
// Leistungsphasen), weshalb derselbe Projektverlauf je nach Aufrufort anders // Leistungsphasen), weshalb derselbe Projektverlauf je nach Aufrufort anders
// aussah und sich widersprechen konnte. // 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.
// //
// Hintergrund: Beim wöchentlichen cloneWeek() (js/weeks.js) wird der // Hintergrund: Beim wöchentlichen cloneWeek() (js/weeks.js) wird der
// Kommentartext in die neue Sitzung kopiert und dort überschrieben. Der alte // Kommentartext in die neue Sitzung kopiert und dort überschrieben. Der alte
@@ -83,10 +86,75 @@
return null; return null;
} }
// Die Kompakt-Karte "Verlauf" (renderPlanHistory()) und der Dialog // historyEntries: die Verlaufsliste des aktuell geöffneten Projekts
// #planHistoryModal (openHistoryModal()/closeHistoryModal()) sind entfallen: // (neueste zuerst, Wiederholungen desselben Textes gefiltert). Logik in
// Der Kommentar-Verlauf steht jetzt in der Kachel "Kommentar der Sitzung" // js/utils.js, siehe projectStatusHistory().
// des Projekt-Dashboards (renderPlanKommentarVerlauf(), plan-info.js). 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';
};
// Export für die anderen plan-Module (plan-gantt.js/plan-build-gantt.js: // Export für die anderen plan-Module (plan-gantt.js/plan-build-gantt.js:
// Geister-Balken der letzten geplanten Sitzung). // Geister-Balken der letzten geplanten Sitzung).
+3 -10
View File
@@ -94,11 +94,7 @@
} }
const stundensatz = honorarConfig.stundensatz; const stundensatz = honorarConfig.stundensatz;
// Beginnt der Plan an einem Wochenende/Feiertag, auf den nächsten
// Arbeitstag schieben — wie der Assistent "Neues Projekt". Sonst stand der
// Balken ab Samstag, gezählt wurde aber erst ab Montag.
let cursor = startDate; let cursor = startDate;
while(!isWorkday(cursor)) cursor = addDays(cursor, 1);
let summeStunden = 0, summeTage = 0, summeHonorar = 0; let summeStunden = 0, summeTage = 0, summeHonorar = 0;
const zeilen = phasen.map(lph => { const zeilen = phasen.map(lph => {
@@ -141,7 +137,7 @@
// Eingaben gelten als 0 = "kein Honorar hinterlegt". // Eingaben gelten als 0 = "kein Honorar hinterlegt".
// Parameter: val (Eingabewert). Keine Rückgabe. // Parameter: val (Eingabewert). Keine Rückgabe.
window.setHonorarBetrag = function(val){ window.setHonorarBetrag = function(val){
if(app.stammGesperrt()) return; if(app.isLocked()) return;
const v = parseFloat(String(val).replace(',', '.')); const v = parseFloat(String(val).replace(',', '.'));
app.project.honorar = Number.isFinite(v) && v > 0 ? v : 0; app.project.honorar = Number.isFinite(v) && v > 0 ? v : 0;
app.savePlan(); app.savePlan();
@@ -156,7 +152,7 @@
// Reihenfolge von LPHS gehalten, damit der erzeugte Terminplan // Reihenfolge von LPHS gehalten, damit der erzeugte Terminplan
// chronologisch aufsteigt. Parameter: lph, checked (Boolean). // chronologisch aufsteigt. Parameter: lph, checked (Boolean).
window.toggleBeauftragteLp = function(lph, checked){ window.toggleBeauftragteLp = function(lph, checked){
if(app.stammGesperrt()) return; if(app.isLocked()) return;
const p = app.project; const p = app.project;
const set = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []); const set = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
if(checked) set.add(lph); else set.delete(lph); if(checked) set.add(lph); else set.delete(lph);
@@ -264,7 +260,7 @@
function renderHonorarHint(){ function renderHonorarHint(){
const el = document.getElementById('honorarAdminHint'); const el = document.getElementById('honorarAdminHint');
if(!el) return; if(!el) return;
el.style.display = hatRecht('a.honorar') ? '' : 'none'; el.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
} }
// invalidateHonorarConfig: verwirft den Zwischenspeicher, damit der nächste // invalidateHonorarConfig: verwirft den Zwischenspeicher, damit der nächste
@@ -330,9 +326,6 @@
plan.todos.push({ plan.todos.push({
id: uid(), id: uid(),
title: `${z.lph} (${fmtNum(z.stunden, 0)} h)`, title: `${z.lph} (${fmtNum(z.stunden, 0)} h)`,
// Kennzeichen für die Auslastung: Die Reststunden dieser Phase
// werden auf die Wochen dieses Balkens verteilt (load.js).
lph: z.lph,
start: formatDateISO(z.start), start: formatDateISO(z.start),
end: formatDateISO(z.ende), end: formatDateISO(z.ende),
done: false, done: false,
+42 -170
View File
@@ -4,15 +4,16 @@
// Dieses Modul füllt die Projektangaben des Projektbereichs. Es unterscheidet // Dieses Modul füllt die Projektangaben des Projektbereichs. Es unterscheidet
// dabei zwei Arten von Angaben, und das ist der Kern der Sache: // dabei zwei Arten von Angaben, und das ist der Kern der Sache:
// //
// * NUR ANZEIGE — Kommentar der Sitzung (#planStatusEdit) und Fortschritt // * NUR ANZEIGE — Kommentar der Sitzung (#planStatusEdit), Fortschritt je
// je Leistungsphase (#planLphList). Beide entstehen in der wöchentlichen // Leistungsphase (#planLphList) und die Zuordnung der Beteiligten
// (#planAssignMatrix). Diese drei entstehen in der wöchentlichen
// Besprechung und werden ausschließlich im Wochenbericht erfasst. Neben // Besprechung und werden ausschließlich im Wochenbericht erfasst. Neben
// jeder von ihnen steht ein Verweis dorthin (setzeBerichtVerweis()), der // jeder von ihnen steht ein Verweis dorthin (setzeBerichtVerweis()), der
// hinüberwechselt UND dort dieses Projekt einstellt. // hinüberwechselt UND dort dieses Projekt einstellt.
// * STAMMDATEN, hier pflegbar — Gruppe (#planGroupSelect) und amtierende // * STAMMDATEN, hier pflegbar — Gruppe (#planGroupSelect) und amtierende
// Leistungsphase (#planActiveLpSelect), beide im Bereich "Einstellungen". // Leistungsphase (#planActiveLpSelect), beide im Bereich "Einstellungen".
// //
// Warum: Dieselben Angaben waren zuvor an DREI Stellen bearbeitbar — // Warum: Dieselben drei Angaben waren zuvor an DREI Stellen bearbeitbar —
// Projektboard, Wochenbericht und hier. Niemand wusste, welche davon die // Projektboard, Wochenbericht und hier. Niemand wusste, welche davon die
// richtige ist, und zwei Personen konnten dasselbe Feld an verschiedenen // richtige ist, und zwei Personen konnten dasselbe Feld an verschiedenen
// Stellen ändern. Seitdem hat jede Angabe genau einen Ort, an dem sie // Stellen ändern. Seitdem hat jede Angabe genau einen Ort, an dem sie
@@ -20,20 +21,11 @@
// früheren Schreibfunktionen dieses Moduls (planStatusBlur(), setPlanLph(), // früheren Schreibfunktionen dieses Moduls (planStatusBlur(), setPlanLph(),
// setPlanAssign()) sind damit entfallen. // setPlanAssign()) sind damit entfallen.
// //
// Die Zuordnung der Beteiligten (p.assign) gehörte früher ebenfalls zu // BEWUSST NICHT hier: Projektnummer, Bezeichnung, Schlagwörter, Honorar und
// dieser "nur Anzeige"-Gruppe — auf Eriks ausdrücklichen Wunsch ist sie // beauftragte Leistungsphasen. Sie sind ebenfalls Stammdaten, werden aber von
// seitdem umgekehrt: kein reines Anzeigefeld mehr, sondern ein echtes // js/plan/plan-settings.js gerendert (Honorar und beauftragte Phasen
// Stammdatum, und zwar NUR HIER im Projektbereich editierbar (Bereich // geschrieben von setHonorarBetrag()/toggleBeauftragteLp() aus
// "Einstellungen", gerendert von js/plan/plan-settings.js — // js/plan/plan-honorar.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 // renderPlanInfo() wird von
// renderPlanPage() (plan-state.js) nach jedem Laden aufgerufen; Änderungen // renderPlanPage() (plan-state.js) nach jedem Laden aufgerufen; Änderungen
@@ -50,7 +42,7 @@
// alle echten Leistungsphasen (inkl. LPH 9), aber ohne die Sammelkategorie // alle echten Leistungsphasen (inkl. LPH 9), aber ohne die Sammelkategorie
// "W+M". Entspricht AUFWAND_LPS in js/load/load.js, hier separat definiert, // "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 // weil load.js auf der Projektplan-Seite bewusst nicht geladen wird (siehe
// Kopfkommentar in App.html). Bewusst per Filter statt per // Kopfkommentar in Test.html). Bewusst per Filter statt per
// Index-Schnitt, damit eine spätere Ergänzung von LPHS nicht stillschweigend // Index-Schnitt, damit eine spätere Ergänzung von LPHS nicht stillschweigend
// durchs Raster fällt. // durchs Raster fällt.
const PLAN_ACTIVE_LPS = LPHS.filter(l => l !== 'W+M'); const PLAN_ACTIVE_LPS = LPHS.filter(l => l !== 'W+M');
@@ -70,102 +62,9 @@
// amtierende Leistungsphase, LPH-Prozentliste) aus app.project auf. Wird // amtierende Leistungsphase, LPH-Prozentliste) aus app.project auf. Wird
// nach jedem Laden (renderPlanPage(), plan-state.js) sowie nach jeder // nach jedem Laden (renderPlanPage(), plan-state.js) sowie nach jeder
// Änderung hier selbst erneut aufgerufen. Keine Parameter, keine Rückgabe. // Änderung hier selbst erneut aufgerufen. Keine Parameter, keine Rückgabe.
// ---------------------------------------------------------------------------
// Kommentar-Verlauf im Projekt-Dashboard
// ---------------------------------------------------------------------------
// planKommentarDaten: sucht über alle Sitzungen dieses Projekts (bis
// einschließlich der angezeigten) den jüngsten Kommentar und listet die
// früheren je Gewerk. Ein Eintrag im Verlauf entsteht nur, wenn sich der
// Text eines Gewerks gegenüber der Sitzung davor geändert hat — ein
// unverändert mitgeführter Kommentar stünde sonst jede Woche erneut da.
// Rückgabe: { letzter: {week, k, aktuell} | null, verlauf: [{week, gewerk, html, autor, at}] }
function planKommentarDaten(p, week){
const bisKey = week && week.label ? weekSortKey(week.label) : null;
let versionen = (typeof projectVersionsForUid === 'function' && p.projectUid)
? projectVersionsForUid(p.projectUid).filter(v => !bisKey || weekSortKey(v.week.label) <= bisKey)
: [];
// Angezeigte Sitzung immer mit dem lebenden Projektobjekt (Sandbox, oder
// ein Projekt, das in db.weeks noch nicht steht).
versionen = versionen.filter(v => !week || v.week.label !== week.label);
versionen.push({ week: week || { label: '' }, project: p });
let letzter = null;
for(let i = versionen.length - 1; i >= 0; i--){
const k = kommentarVon(versionen[i].project, null);
if(statusPlainText(k.status)){
letzter = { week: versionen[i].week, k, aktuell: i === versionen.length - 1 };
break;
}
}
const gewerke = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
const verlauf = [];
gewerke.forEach(g => {
let vorher = '';
versionen.forEach(v => {
const k = kommentarVon(v.project, g);
const text = statusPlainText(k.status);
if(text && text !== vorher && !(letzter && v.week === letzter.week)){
verlauf.push({ week: v.week, gewerk: g, html: sanitizeStatusHtml(k.status), autor: k.statusAuthor || '', at: k.statusAt || '' });
}
vorher = text;
});
});
// Derselbe Text in mehreren Gewerken derselben Sitzung (Altbestand: die
// Umstellung auf Kommentare je Gewerk hat ihn in jedes kopiert) wird ein
// Eintrag mit allen Gewerken, statt zweimal untereinander zu stehen.
const zusammen = [];
verlauf.forEach(e => {
const gleich = zusammen.find(z => z.week === e.week && statusPlainText(z.html) === statusPlainText(e.html));
if(gleich){ gleich.gewerk += ', ' + e.gewerk; if(!gleich.autor) gleich.autor = e.autor; }
else zusammen.push(Object.assign({}, e));
});
zusammen.sort((a, b) => weekSortKey(b.week.label).localeCompare(weekSortKey(a.week.label))
|| String(b.at).localeCompare(String(a.at)));
return { letzter, verlauf: zusammen };
}
// "KW 43 / 2026" -> "KW 43" (das Jahr nur, wenn es nicht das laufende ist)
function kurzKw(label){
const m = /^(KW\s*\d+)\s*\/\s*(\d{4})$/.exec(String(label || '').trim());
if(!m) return label || '';
return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2];
}
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(){ window.renderPlanInfo = function(){
const p = app.project; const p = app.project;
const locked = app.stammGesperrt(); const locked = app.isLocked();
// Kommentar, Fortschritt und Zuordnung sind hier NUR ZU SEHEN. // Kommentar, Fortschritt und Zuordnung sind hier NUR ZU SEHEN.
// //
@@ -176,42 +75,15 @@
// Personen konnten dasselbe Feld an verschiedenen Stellen pflegen. // Personen konnten dasselbe Feld an verschiedenen Stellen pflegen.
// Was hier bleibt, sind die Stammdaten des Projekts (Bereich // Was hier bleibt, sind die Stammdaten des Projekts (Bereich
// "Einstellungen", siehe plan-settings.js). // "Einstellungen", siehe plan-settings.js).
// Gezeigt wird der ZULETZT GESCHRIEBENE Kommentar (Eriks Wunsch, Entwurf
// "A"): Hat die Sitzung noch keinen, der jüngste aus einer früheren — mit
// Hinweis, aus welcher. Darunter der Verlauf der früheren Kommentare.
const kom = planKommentarDaten(p, app.week);
const editEl = document.getElementById('planStatusEdit'); const editEl = document.getElementById('planStatusEdit');
if(editEl){ if(editEl){
editEl.className = 'status-edit is-ansicht'; editEl.className = 'status-edit is-ansicht';
editEl.contentEditable = 'false'; editEl.contentEditable = 'false';
// Alle Gewerke zusammen, je mit Gewerk davor (kommentarVon(), js/utils.js). const text = sanitizeStatusHtml(p.status || '');
const text = kom.letzter ? sanitizeStatusHtml(kom.letzter.k.status) : ''; editEl.innerHTML = text || '<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>';
editEl.innerHTML = text || '<span class="hint" style="margin:0">Zu diesem Projekt ist noch kein Kommentar erfasst.</span>';
} }
const hinweisEl = document.getElementById('planStatusHinweis');
if(hinweisEl){
const aelter = kom.letzter && !kom.letzter.aktuell;
hinweisEl.hidden = !aelter;
hinweisEl.innerHTML = aelter
? `In dieser Sitzung noch kein Kommentar — angezeigt wird der letzte aus <b>${esc(kurzKw(kom.letzter.week.label))}</b>.`
: '';
}
// Wer den Kommentar wann geschrieben hat (jüngster Eintrag aller Gewerke).
const autorEl = document.getElementById('planStatusAutor');
if(autorEl){
const k = kom.letzter ? kom.letzter.k : {};
autorEl.innerHTML = k.status && k.statusAuthor
? `<span class="plan-autor-ava">${esc(initialsForName(k.statusAuthor))}</span>${esc(k.statusAuthor)}`
+ (k.statusAt && typeof formatCommentStamp === 'function' ? ', ' + esc(formatCommentStamp(k.statusAt)) : '')
: '';
}
renderPlanKommentarVerlauf(kom.verlauf);
const statusWrap = document.getElementById('planStatusWrap'); const statusWrap = document.getElementById('planStatusWrap');
// Nicht mehr als gesperrt markieren: .locked-cell dimmt auf 55 % und legt im if(statusWrap) statusWrap.classList.add('locked-cell');
// Dunkelmodus ein Grau darunter — der Kommentar war dadurch kaum lesbar.
// Dass er hier nur angezeigt wird, zeigen schon .is-ansicht und der
// Verweis "Im Wochenbericht bearbeiten".
if(statusWrap) statusWrap.classList.remove('locked-cell');
setzeBerichtVerweis('planStatusVerweis', locked); setzeBerichtVerweis('planStatusVerweis', locked);
// Gruppe — Stammdatum, bleibt hier pflegbar // Gruppe — Stammdatum, bleibt hier pflegbar
@@ -229,22 +101,11 @@
lpSel.disabled = locked; 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'); const lphList = document.getElementById('planLphList');
if(lphList){ if(lphList){
if(!p.lph) p.lph = {}; if(!p.lph) p.lph = {};
lphList.innerHTML = sichtbareLphs.map(l => { lphList.innerHTML = LPHS.map(l => {
const v = (p.lph[l] !== undefined && p.lph[l] !== '') ? p.lph[l] : ''; const v = (p.lph[l] !== undefined && p.lph[l] !== '') ? p.lph[l] : '';
const aktiv = l === aktLp; const aktiv = l === aktLp;
const pct = v !== '' ? Math.min(100, parseFloat(v)||0) : 0; const pct = v !== '' ? Math.min(100, parseFloat(v)||0) : 0;
@@ -256,8 +117,25 @@
}).join(''); }).join('');
} }
setzeBerichtVerweis('planLphVerweis', locked); setzeBerichtVerweis('planLphVerweis', locked);
// Beteiligte: siehe js/plan/plan-settings.js (renderPlanSettings()) —
// Stammdatum, nicht mehr hier. // 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);
}; };
// setzeBerichtVerweis: füllt einen Hinweis-Platz mit dem Weg zur // setzeBerichtVerweis: füllt einen Hinweis-Platz mit dem Weg zur
@@ -296,7 +174,7 @@
// setPlanGroup: ändert die Gruppe des Projekts. Parameter: val (String). // setPlanGroup: ändert die Gruppe des Projekts. Parameter: val (String).
// Keine Rückgabe; bei gesperrter Woche abgebrochen. // Keine Rückgabe; bei gesperrter Woche abgebrochen.
window.setPlanGroup = function(val){ window.setPlanGroup = function(val){
if(app.stammGesperrt()) return; if(app.isLocked()) return;
app.project.group = val; app.project.group = val;
app.savePlan(); app.savePlan();
}; };
@@ -305,23 +183,17 @@
// Projekts auf. Parameter: val (LPH-Name oder leer). Rendert das // Projekts auf. Parameter: val (LPH-Name oder leer). Rendert das
// Infopanel neu, damit die Markierung in der LPH-Liste sofort mitzieht. // Infopanel neu, damit die Markierung in der LPH-Liste sofort mitzieht.
window.setPlanActiveLp = function(val){ window.setPlanActiveLp = function(val){
if(app.stammGesperrt()) return; if(app.isLocked()) return;
if(val) app.project.aktuelleLP = [val]; if(val) app.project.aktuelleLP = [val];
else delete app.project.aktuelleLP; else delete app.project.aktuelleLP;
app.savePlan(); app.savePlan();
window.renderPlanInfo(); window.renderPlanInfo();
// Abschnitt "Leistungsphasen" der Einstellungen: Kennzeichen "läuft"
// und die Kurzfassung in der Unternavigation nachziehen.
if(typeof renderPlanSettings === 'function') renderPlanSettings();
}; };
// Die Schreibfunktionen setPlanLph()/setPlanAssign()/planStatusBlur() sind // Die Schreibfunktionen setPlanLph()/setPlanAssign()/planStatusBlur() sind
// entfallen. Fortschritt und Kommentar werden allein im Wochenbericht // entfallen. Fortschritt, Zuordnung und Kommentar werden allein im
// erfasst (siehe den Kopf dieser Datei); hier gab es damit keinen Aufrufer // Wochenbericht erfasst (siehe den Kopf dieser Datei); hier gab es damit
// mehr. Die gleichnamigen Funktionen des Projektboards // keinen Aufrufer mehr. Die gleichnamigen Funktionen des Projektboards
// (editLph()/saveBoardComment() in js/board/board.js) sind davon // (editLph()/cycleAssign()/saveBoardComment() in js/board/board.js) sind
// unberührt — dort hängt die Erfassung des Wochenberichts dran. Die // davon unberührt — dort hängt die Erfassung des Wochenberichts dran.
// 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.
})(); })();
-326
View File
@@ -1,326 +0,0 @@
// 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';
}
})();
+23 -198
View File
@@ -33,15 +33,8 @@
// (Index des Meilensteins oder null für einen neuen). Befüllt Name, Datum, // (Index des Meilensteins oder null für einen neuen). Befüllt Name, Datum,
// Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und // Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und
// TODOs) aus dem Plan. // TODOs) aus dem Plan.
// msNachSchliessen: wie todoNachSchliessen — "Termin aus Mail" öffnet window.openMilestoneModal = function(index){
// 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; if(app.isLocked()) return;
const pre = prefill || {};
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = []; if(!Array.isArray(plan.milestones)) plan.milestones = [];
// Migration alter Meilensteine // Migration alter Meilensteine
@@ -50,18 +43,10 @@
const isNew = index === null || plan.milestones[index] === undefined; const isNew = index === null || plan.milestones[index] === undefined;
const currentMs = isNew ? null : plan.milestones[index]; const currentMs = isNew ? null : plan.milestones[index];
document.getElementById('milestoneEditName').value = isNew ? (pre.name || '') : (currentMs.name || ''); document.getElementById('milestoneEditName').value = isNew ? '' : (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) — // Verstecktes ISO-Feld + sichtbares Anzeigefeld (deutsches Format) —
// dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite. // dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite.
const msDateIso = isNew ? (pre.date || '') : (currentMs.date || ''); const msDateIso = isNew ? '' : (currentMs.date || '');
document.getElementById('milestoneEditDate').value = msDateIso; document.getElementById('milestoneEditDate').value = msDateIso;
const msDateDisplay = document.getElementById('milestoneEditDateDisplay'); const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : ''; if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
@@ -70,7 +55,7 @@
if(positionSelect){ if(positionSelect){
const currentPosition = currentMs && currentMs.position ? currentMs.position : ''; const currentPosition = currentMs && currentMs.position ? currentMs.position : '';
const defaultAfterTodo = isNew && plan.todos && plan.todos.length > 0 ? plan.todos[plan.todos.length - 1].id : null; const defaultAfterTodo = isNew && plan.todos && plan.todos.length > 0 ? plan.todos[plan.todos.length - 1].id : null;
let opts = '<option value="">Oben, eigene Zeile</option>'; let opts = '<option value="">Oben in der Projektzeile</option>';
if(plan.todos && plan.todos.length > 0){ if(plan.todos && plan.todos.length > 0){
plan.todos.forEach(t => { plan.todos.forEach(t => {
const beforeVal = 'before:' + t.id; const beforeVal = 'before:' + t.id;
@@ -84,14 +69,6 @@
} }
positionSelect.innerHTML = opts; positionSelect.innerHTML = opts;
} }
window.msLageSync();
// Seitenpanel: Überschrift und "Löschen" nur beim Bearbeiten — vorher
// stand "Löschen" auch beim Anlegen eines neuen Meilensteins.
const msTitel = document.getElementById('milestoneModalTitle');
if(msTitel) msTitel.textContent = isNew ? 'Neuer Meilenstein' : 'Meilenstein bearbeiten';
const msLoeschen = document.getElementById('milestoneEditDelete');
if(msLoeschen) msLoeschen.hidden = isNew;
const dependsOnList = document.getElementById('milestoneEditDependsOn'); const dependsOnList = document.getElementById('milestoneEditDependsOn');
if(dependsOnList){ if(dependsOnList){
@@ -133,43 +110,11 @@
document.getElementById('milestoneEditName').focus(); document.getElementById('milestoneEditName').focus();
}; };
// msLageSync/msLageSetzen: "Wo im Terminplan" als Umschalter "In der
// Projektzeile | Bei einer Aufgabe" über dem bisherigen Auswahlfeld
// (#milestoneEditPosition, Wert '' = Projektzeile, sonst before:/after:
// einer Aufgabe). Gespeichert wird weiter allein der Wert des Felds.
window.msLageSync = function(){
const sel = document.getElementById('milestoneEditPosition');
const seg = document.getElementById('milestoneEditLage');
if(!sel || !seg) return;
const beiAufgabe = !!sel.value;
const hatAufgaben = sel.options.length > 1;
seg.querySelectorAll('button').forEach(b => {
b.setAttribute('aria-pressed', String((b.dataset.lage === 'aufgabe') === beiAufgabe));
if(b.dataset.lage === 'aufgabe') b.disabled = !hatAufgaben;
});
sel.hidden = !beiAufgabe;
// Die Option "Oben, eigene Zeile" steht schon im Umschalter.
if(sel.options[0] && sel.options[0].value === '') sel.options[0].hidden = true;
};
window.msLageSetzen = function(art){
const sel = document.getElementById('milestoneEditPosition');
if(!sel) return;
if(art === 'zeile') sel.value = '';
else if(!sel.value){
const nachOptionen = Array.from(sel.options).filter(o => o.value.indexOf('after:') === 0);
if(nachOptionen.length) sel.value = nachOptionen[nachOptionen.length - 1].value;
}
window.msLageSync();
};
// closeMilestoneModal: blendet das Meilenstein-Modal aus und setzt den // closeMilestoneModal: blendet das Meilenstein-Modal aus und setzt den
// Bearbeitungsindex zurück. // Bearbeitungsindex zurück.
window.closeMilestoneModal = function(){ window.closeMilestoneModal = function(){
document.getElementById('milestoneModal').style.display = 'none'; document.getElementById('milestoneModal').style.display = 'none';
app.currentMilestoneIndex = null; app.currentMilestoneIndex = null;
const danach = msNachSchliessen;
msNachSchliessen = null;
if(danach) setTimeout(danach, 0);
}; };
// saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position, // saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position,
@@ -187,21 +132,18 @@
const positionSelect = document.getElementById('milestoneEditPosition'); const positionSelect = document.getElementById('milestoneEditPosition');
const position = positionSelect ? positionSelect.value : ''; const position = positionSelect ? positionSelect.value : '';
const msNotizEl = document.getElementById('milestoneEditNotiz');
const notiz = msNotizEl ? msNotizEl.value.trim() : '';
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = []; if(!Array.isArray(plan.milestones)) plan.milestones = [];
if(app.currentMilestoneIndex === null){ if(app.currentMilestoneIndex === null){
plan.milestones.push({ id: uid(), name, date, dependsOn, position, notiz }); plan.milestones.push({ id: uid(), name, date, dependsOn, position });
} else if(plan.milestones[app.currentMilestoneIndex]){ } else if(plan.milestones[app.currentMilestoneIndex]){
const m = plan.milestones[app.currentMilestoneIndex]; const m = plan.milestones[app.currentMilestoneIndex];
m.name = name; m.name = name;
m.date = date; m.date = date;
m.dependsOn = dependsOn; m.dependsOn = dependsOn;
m.position = position; m.position = position;
m.notiz = notiz;
} }
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date)); plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
app.savePlan(); app.savePlan();
@@ -244,9 +186,6 @@
// todoModalSnapshot: Stand der Eingaben beim Öffnen — Vergleichsbasis für // todoModalSnapshot: Stand der Eingaben beim Öffnen — Vergleichsbasis für
// die Rückfrage beim Abbrechen (siehe todoFormSnapshot()). // die Rückfrage beim Abbrechen (siehe todoFormSnapshot()).
let todoModalSnapshot = ''; let todoModalSnapshot = '';
// todoNachSchliessen: Rückruf nach dem Schließen des Panels (gespeichert
// oder verworfen) — "Termin aus Mail" öffnet damit den nächsten Vorschlag.
let todoNachSchliessen = null;
// todoProgressMemo: zuletzt im Dialog eingestellter Fortschritt. "Offen" und // todoProgressMemo: zuletzt im Dialog eingestellter Fortschritt. "Offen" und
// "Erledigt" haben feste Werte (0/100); wer von dort zurück auf "In Arbeit" // "Erledigt" haben feste Werte (0/100); wer von dort zurück auf "In Arbeit"
@@ -419,69 +358,6 @@
list.querySelectorAll('.todo-depends-group').forEach(group => { group.hidden = !!query; }); list.querySelectorAll('.todo-depends-group').forEach(group => { group.hidden = !!query; });
}; };
// ---------------------------------------------------------------------------
// Leistungsphase als Balken (Eriks Wunsch): "+ Neu" → "Leistungsphase"
// öffnet dasselbe Panel im Phasen-Modus. Ein Balken ist NUR über das Feld
// t.lph eine Phase (balkenPhase(), load.js) — nicht mehr über seinen Titel.
// ---------------------------------------------------------------------------
const LPH_NAMEN = {
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung', 'W+M': 'Wartung und Monitoring'
};
let todoPhasenModus = false;
let todoPhaseAutoTitel = '';
const phasenTitel = l => l ? (l + (LPH_NAMEN[l] ? ' ' + LPH_NAMEN[l] : '')) : '';
// addPlanPhase: "+ Neu" → "Leistungsphase".
// Optional lph: diese Phase vorwählen (Bereich "Leistungsphasen", Kachel
// ohne Zeitraum).
window.addPlanPhase = function(lph){ openTodoModal(null, { phase: true, lph: typeof lph === 'string' ? lph : '' }); };
// todoPhaseGewechselt: Auswahl der Phase geändert — den vorgeschlagenen
// Titel mitziehen, solange niemand ihn von Hand geändert hat.
window.todoPhaseGewechselt = function(){
const sel = document.getElementById('todoEditLph');
const titel = document.getElementById('todoEditTitle');
if(!sel || !titel) return;
if(!titel.value.trim() || titel.value === todoPhaseAutoTitel){
todoPhaseAutoTitel = phasenTitel(sel.value);
titel.value = todoPhaseAutoTitel;
}
};
// todoPhasenModusSetzen: Panel für eine Leistungsphase herrichten (Auswahl
// füllen, "Wer"/"Speichern & neu" ausblenden) oder zurück zur Aufgabe.
function todoPhasenModusSetzen(an, todo, isNew, vorgabeLph){
todoPhasenModus = an;
const abs = document.getElementById('todoEditPhaseAbs');
const wer = document.getElementById('todoEditWerAbs');
const neu = document.getElementById('todoEditSaveNew');
if(abs) abs.hidden = !an;
if(wer) wer.hidden = an;
if(neu) neu.hidden = an;
const titelEl = document.getElementById('todoModalTitle');
if(titelEl) titelEl.textContent = an ? (isNew ? 'Neue Leistungsphase' : 'Leistungsphase bearbeiten') : (isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten');
if(!an) return;
const plan = app.ensurePlanLocal();
const vergeben = new Set((plan.todos || []).filter(t => t !== todo && t.lph).map(t => t.lph));
const beauftragt = Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : [];
const sel = document.getElementById('todoEditLph');
if(!sel) return;
sel.innerHTML = LPHS.map(l => {
const zusatz = vergeben.has(l) ? ' (schon im Plan)' : (beauftragt.length && !beauftragt.includes(l) ? ' (nicht beauftragt)' : '');
return `<option value="${esc(l)}"${vergeben.has(l) ? ' disabled' : ''}>${esc(phasenTitel(l) + zusatz)}</option>`;
}).join('');
const frei = l => !vergeben.has(l);
sel.value = (todo && todo.lph) || (vorgabeLph && frei(vorgabeLph) ? vorgabeLph : '') || LPHS.find(l => frei(l) && (!beauftragt.length || beauftragt.includes(l))) || LPHS.find(frei) || '';
if(isNew){
todoPhaseAutoTitel = phasenTitel(sel.value);
document.getElementById('todoEditTitle').value = todoPhaseAutoTitel;
} else {
todoPhaseAutoTitel = '';
}
}
// openTodoModal: öffnet das TODO-Modal. Parameter: index (Index der Aufgabe // openTodoModal: öffnet das TODO-Modal. Parameter: index (Index der Aufgabe
// oder null für eine neue), prefill (optional, Vorbelegung für "Speichern & // oder null für eine neue), prefill (optional, Vorbelegung für "Speichern &
// Neu": { start, end, assignees }). Befüllt Titel, Zeitraum samt sichtbarer // Neu": { start, end, assignees }). Befüllt Titel, Zeitraum samt sichtbarer
@@ -504,18 +380,7 @@
const titleEl = document.getElementById('todoModalTitle'); const titleEl = document.getElementById('todoModalTitle');
if(titleEl) titleEl.textContent = isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten'; if(titleEl) titleEl.textContent = isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten';
document.getElementById('todoEditTitle').value = isNew ? (pre.title || '') : (currentTodo.title || ''); document.getElementById('todoEditTitle').value = isNew ? '' : (currentTodo.title || '');
const notizEl = document.getElementById('todoEditNotiz');
if(notizEl) notizEl.value = isNew ? (pre.notiz || '') : (currentTodo.notiz || '');
// Hinweis über der Notiz, nur bei einem Vorschlag aus "Termin aus Mail".
const mailHinweisEl = document.getElementById('todoEditMailHinweis');
if(mailHinweisEl){
mailHinweisEl.hidden = !(isNew && pre.mailHinweis);
mailHinweisEl.textContent = (isNew && pre.mailHinweis) || '';
}
todoNachSchliessen = isNew && typeof pre.nachSchliessen === 'function' ? pre.nachSchliessen : null;
const saveNewBtn = document.getElementById('todoEditSaveNew');
if(saveNewBtn) saveNewBtn.hidden = !!todoNachSchliessen;
// Zeitraum: neben den versteckten ISO-Feldern muss auch die sichtbare // Zeitraum: neben den versteckten ISO-Feldern muss auch die sichtbare
// Anzeige gesetzt werden — sonst stünde dort der Platzhalter bzw. der // Anzeige gesetzt werden — sonst stünde dort der Platzhalter bzw. der
@@ -532,20 +397,6 @@
} }
refreshTodoDuration(); 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) : '';
}
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0); const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50; todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
applyTodoStatus(todoStatusFrom(currentTodo), storedProgress); applyTodoStatus(todoStatusFrom(currentTodo), storedProgress);
@@ -606,25 +457,13 @@
uniquePeople.push(a.person); uniquePeople.push(a.person);
} }
}); });
// Bereits Zuständige, die (nicht mehr) dem Projekt zugeordnet sind —
// etwa über "Aufgabe aus Kommentar" aus dem ganzen Teilnehmerkreis
// gewählt — ebenfalls als Chip zeigen. Ohne sie gingen sie beim
// Speichern stillschweigend verloren, weil saveTodoEdit() nur die
// gedrückten Chips liest.
currentAssignees.forEach(id => {
if(seenPeople.has(id)) return;
seenPeople.add(id);
const p = ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
uniquePeople.push(Object.assign({}, p, { nichtImProjekt: true }));
});
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die // Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
// Personenspalten im Projektboard. // Personenspalten im Projektboard.
assigneesDiv.innerHTML = uniquePeople.map(person => { assigneesDiv.innerHTML = uniquePeople.map(person => {
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false'; const pressed = currentAssignees.includes(person.id) ? 'true' : 'false';
const short = person.name || initialsForName(person.fullName || '') || '?'; const short = person.name || initialsForName(person.fullName || '') || '?';
const full = person.fullName || person.name || ''; const full = person.fullName || person.name || '';
const fremd = person.nichtImProjekt ? ' title="Nicht (mehr) dem Projekt zugeordnet"' : ''; return `<button type="button" class="person-chip" data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">`
return `<button type="button" class="person-chip${person.nichtImProjekt ? ' ist-fremd' : ''}"${fremd} data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">`
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`; + `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`;
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>'; }).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
} }
@@ -665,10 +504,8 @@
const read = id => { const el = document.getElementById(id); return el ? el.value : ''; }; const read = id => { const el = document.getElementById(id); return el ? el.value : ''; };
const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value); const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value);
const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId); const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId);
const gantt = document.getElementById('todoEditShowInGantt');
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'), return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
currentTodoEditStatus(), read('todoEditProgress'), deps, people, currentTodoEditStatus(), read('todoEditProgress'), deps, people]);
gantt ? gantt.checked : true, read('todoEditAufwand'), read('todoEditLph'), read('todoEditNotiz')]);
} }
// closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex // closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex
@@ -682,9 +519,6 @@
document.getElementById('todoModal').style.display = 'none'; document.getElementById('todoModal').style.display = 'none';
app.currentTodoIndex = null; app.currentTodoIndex = null;
todoModalSnapshot = ''; todoModalSnapshot = '';
const danach = todoNachSchliessen;
todoNachSchliessen = null;
if(danach) setTimeout(danach, 0);
return true; return true;
}; };
@@ -729,23 +563,11 @@
const assigneesDiv = document.getElementById('todoEditAssignees'); 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) : [];
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(); const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.todos)) plan.todos = []; if(!Array.isArray(plan.todos)) plan.todos = [];
if(app.currentTodoIndex === null){ if(app.currentTodoIndex === null){
plan.todos.push(Object.assign({ id: uid(), title, start, end, done, progress, dependsOn, assignees, showInGantt, aufwand, notiz }, lph ? { lph, assignees: [], aufwand: 0 } : {})); plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees });
} else if(plan.todos[app.currentTodoIndex]){ } else if(plan.todos[app.currentTodoIndex]){
const t = plan.todos[app.currentTodoIndex]; const t = plan.todos[app.currentTodoIndex];
t.title = title; t.title = title;
@@ -755,10 +577,6 @@
t.progress = progress; t.progress = progress;
t.dependsOn = dependsOn; t.dependsOn = dependsOn;
t.assignees = assignees; t.assignees = assignees;
t.showInGantt = showInGantt;
t.aufwand = aufwand;
t.notiz = notiz;
if(todoPhasenModus) t.lph = lph;
} }
app.savePlan(); app.savePlan();
// Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt // Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt
@@ -766,7 +584,7 @@
todoModalSnapshot = todoFormSnapshot(); todoModalSnapshot = todoFormSnapshot();
closeTodoModal(); closeTodoModal();
renderPlanGantt(); renderPlanGantt();
if(andNew) openTodoModal(null, { start, end, assignees, showInGantt }); if(andNew) openTodoModal(null, { start, end, assignees });
}; };
// deleteTodoEdit: löscht nach Bestätigung die aktuell bearbeitete Aufgabe, // deleteTodoEdit: löscht nach Bestätigung die aktuell bearbeitete Aufgabe,
@@ -797,18 +615,25 @@
openTodoModal(null); 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, // toggleTodoDone: schaltet den Erledigt-Status der Aufgabe am Index um,
// speichert und rendert neu. // speichert und rendert neu.
window.toggleTodoDone = function(idx){ window.toggleTodoDone = function(idx){
if(app.isLocked()) return; if(app.isLocked()) return;
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
if(!plan.todos[idx]) return; if(!plan.todos[idx]) return;
const t = plan.todos[idx]; plan.todos[idx].done = !plan.todos[idx].done;
t.done = !t.done;
// Fortschritt mitführen: Sonst blieb nach dem Zurücknehmen "100 %" stehen,
// und der Dialog las daraus wieder "fertig" (todoStatusFrom()).
if(t.done) t.progress = 100;
else if(Number(t.progress) >= 100) t.progress = 0;
app.savePlan(); app.savePlan();
renderPlanGantt(); renderPlanGantt();
}; };
-180
View File
@@ -1,180 +0,0 @@
// -----------------------------
// 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');
};
})();
-464
View File
@@ -1,464 +0,0 @@
// -----------------------------
// 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">&#9656;</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.');
}
})();
+121 -460
View File
@@ -2,7 +2,7 @@
// Projektplan — Protokolle (Besprechungsprotokolle je Projekt) // Projektplan — Protokolle (Besprechungsprotokolle je Projekt)
// ----------------------------- // -----------------------------
// Bereich "Protokolle" des Projektbereichs (siehe js/plan/plan-sections.js) // Bereich "Protokolle" des Projektbereichs (siehe js/plan/plan-sections.js)
// plus eine Kachel im Dashboard (#planDashProtokolle, springt per // plus eine Kompakt-Karte im Dashboard (#planProtokolleSummary, springt per
// Klick über openProtokollePanel() in diesen Bereich — gleiches Muster wie // Klick über openProtokollePanel() in diesen Bereich — gleiches Muster wie
// #planFilesSummary/openFilesPanel() in plan-files.js). // #planFilesSummary/openFilesPanel() in plan-files.js).
// //
@@ -77,103 +77,28 @@
}); });
} }
// renderProtokolleSummary: füllt die Dashboard-Kachel "Protokolle" // renderProtokolleSummary: füllt die Kompakt-Vorschau der Dashboard-Karte
// (#planDashProtokolle, Eriks Wunsch — vorher nur eine Zeile in // (#planProtokolleSummary) — letztes Protokoll + Anzahl offener Punkte,
// "Unterlagen"). Oben das letzte Protokoll, darunter dessen OFFENE Punkte // gleiches Muster wie renderFilesSummary() in plan-files.js.
// (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(){ function renderProtokolleSummary(){
const el = document.getElementById('planDashProtokolle'); const el = document.getElementById('planProtokolleSummary');
const panel = document.getElementById('planDashProtokollePanel');
if(!el) return; if(!el) return;
// Ohne Projektnummer (Planung-Terminplan) gibt es keine Protokolle — if(!app.project || !app.project.nr){
// die Kachel verschwindet wie der Bereich selbst. el.innerHTML = '<span class="hint">Keine Projektnummer.</span>';
if(panel) panel.hidden = !(app.project && app.project.nr); return;
if(!app.project || !app.project.nr){ el.innerHTML = ''; return; } }
if(protokolleCache.length === 0){ if(protokolleCache.length === 0){
el.innerHTML = `<div class="plan-dash-leer">Noch keine Protokolle. el.innerHTML = '<span class="hint">Noch keine Protokolle.</span>';
<button type="button" class="plan-panel-link" onclick="dashNeuesProtokoll()">+ Neues Protokoll</button></div>`;
return; return;
} }
const letztes = protokolleCache[0]; // Server liefert nach nummer DESC const letztes = protokolleCache[0]; // Server liefert nach nummer DESC
const frueher = protokolleCache.slice(1, 1 + DASH_PROTOKOLL_FRUEHERE); const chip = letztes.offenCount > 0
const zahlen = [ ? `<span class="plan-mini-chip warn">${letztes.offenCount} offen</span>`
letztes.offenCount ? `<span class="plan-mini-chip warn">${letztes.offenCount} offen</span>` : '<span class="plan-mini-chip">nichts offen</span>', : '<span class="plan-mini-chip">Alle erledigt</span>';
letztes.erledigtCount ? `<span class="plan-mini-chip">${letztes.erledigtCount} erledigt</span>` : '', el.innerHTML = `<div class="plan-mini-card-preview">Nr. ${esc(letztes.nummer)} · ${esc(letztes.betreff || 'ohne Betreff')}</div>
letztes.infoCount ? `<span class="plan-mini-chip">${letztes.infoCount} Info</span>` : '' <div class="plan-mini-chip-row">${chip}</div>`;
].join('');
el.innerHTML = `
<button type="button" class="plan-prot-kopf" onclick="dashOeffneProtokoll('${esc(letztes.id)}')">
<span class="plan-prot-nr">Nr. ${esc(letztes.nummer)}</span>
<span class="plan-prot-titel"><b>${esc(letztes.betreff || 'ohne Betreff')}</b>
<span>${esc(datumDe(letztes.datum))}${letztes.ort ? ' · ' + esc(letztes.ort) : ''}${letztes.teilnehmerCount ? ' · ' + letztes.teilnehmerCount + ' Teilnehmer' : ''}</span></span>
</button>
<div class="plan-mini-chip-row">${zahlen}</div>
<div class="plan-prot-punkte" id="planDashProtokollPunkte">${letztes.offenCount ? '<span class="hint">Lade offene Punkte…</span>' : ''}</div>
${frueher.length ? `<div class="plan-termin-gruppe">Frühere Protokolle</div>
${frueher.map(pr => `<button type="button" class="plan-prot-frueher" onclick="dashOeffneProtokoll('${esc(pr.id)}')">
<span>Nr. ${esc(pr.nummer)} · ${esc(datumDe(pr.datum))}</span>
<span class="plan-prot-frueher-titel">${esc(pr.betreff || 'ohne Betreff')}</span>
<span class="plan-prot-frueher-zahl">${pr.offenCount ? pr.offenCount + ' offen' : ''}</span>
</button>`).join('')}` : ''}`;
if(letztes.offenCount) ladeOffenePunkte(letztes.id);
} }
function ladeOffenePunkte(id){
const nr = ++dashProtokollAbruf;
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(id))
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(d => {
if(nr !== dashProtokollAbruf) return; // inzwischen anderes Projekt
const ziel = document.getElementById('planDashProtokollPunkte');
if(!ziel || !d.protokoll) return;
const punkte = d.protokoll.punkte || [];
const nummern = window.berechneProtokollNummern(punkte);
const heute = typeof heuteIso === 'function' ? heuteIso() : new Date().toISOString().slice(0, 10);
const offen = punkte.map((p, i) => Object.assign({}, p, { anzeigeNr: nummern[i] }))
.filter(p => p.status === 'offen')
// Überfälliges zuerst, dann nach Termin, ohne Termin zuletzt
.sort((a, b) => (a.termin || '9999').localeCompare(b.termin || '9999'));
const zeigen = offen.slice(0, DASH_PROTOKOLL_PUNKTE);
const rest = offen.length - zeigen.length;
ziel.innerHTML = zeigen.map(p => {
const ueber = p.termin && p.termin < heute;
const titel = p.thema || statusPlainText(p.text).slice(0, 80) || 'ohne Thema';
return `<div class="plan-termin ist-klickbar" onclick="dashOeffneProtokoll('${esc(d.protokoll.id)}')">
<span class="plan-termin-art">${esc(p.anzeigeNr)}</span>
<span class="plan-termin-text"><b>${esc(titel)}</b><span>${p.gruppe ? esc(p.gruppe) + ' · ' : ''}${p.zustaendig ? esc(p.zustaendig) : 'ohne Zuständigen'}</span></span>
<span class="plan-termin-datum${ueber ? ' ist-rot' : ''}">${p.termin ? esc(datumDe(p.termin)) + (typeof relativerTag === 'function' ? '<em>' + esc(relativerTag(p.termin)) + '</em>' : '') : '—'}</span>
</div>`;
}).join('') + (rest > 0 ? `<button type="button" class="plan-panel-link plan-prot-mehr" onclick="dashOeffneProtokoll('${esc(d.protokoll.id)}')">… ${rest} weitere offene Punkte</button>` : '');
})
.catch(err => {
if(nr !== dashProtokollAbruf) return;
const ziel = document.getElementById('planDashProtokollPunkte');
if(ziel) ziel.innerHTML = '<span class="hint">Offene Punkte konnten nicht geladen werden.</span>';
console.error('Offene Protokollpunkte laden fehlgeschlagen:', err);
});
}
// dashOeffneProtokoll: aus der Kachel direkt in DIESES Protokoll.
window.dashOeffneProtokoll = function(id){
window.openProtokollePanel();
window.oeffneProtokoll(id);
};
window.dashNeuesProtokoll = function(){
window.openProtokollePanel();
window.neuesProtokoll();
};
// openProtokollePanel: springt in den Bereich "Protokolle" — Gegenstück zu // openProtokollePanel: springt in den Bereich "Protokolle" — Gegenstück zu
// openFilesPanel()/openResourceModal(). // openFilesPanel()/openResourceModal().
window.openProtokollePanel = function(){ window.openProtokollePanel = function(){
@@ -219,20 +144,17 @@
}; };
// gruppenReihenfolge: liefert die vorkommenden Gruppennamen in der // gruppenReihenfolge: liefert die vorkommenden Gruppennamen in der
// Reihenfolge ihres ersten Auftretens unter den Punkten (array_order). // Reihenfolge ihres ersten Auftretens unter den Punkten (array_order) —
// Jeder Punkt gehört jetzt zu einer Gruppe (siehe fuegeNeueGruppeHinzu()/ // "Ohne Gruppe" (leerer String) immer zuerst, wenn vorhanden.
// "+ 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(){ function gruppenReihenfolge(){
const namen = []; const namen = [];
let hatOhneGruppe = false;
(currentProtokoll.punkte || []).forEach(p => { (currentProtokoll.punkte || []).forEach(p => {
const g = String(p.gruppe || '').trim(); const g = String(p.gruppe || '').trim();
if(!g){ hatOhneGruppe = true; return; }
if(!namen.includes(g)) namen.push(g); if(!namen.includes(g)) namen.push(g);
}); });
return namen; return (hatOhneGruppe ? [''] : []).concat(namen);
} }
// ---------- Nachträgliches Umordnen ---------- // ---------- Nachträgliches Umordnen ----------
@@ -240,17 +162,19 @@
// Die Reihenfolge in `currentProtokoll.punkte` (= array_order) bestimmt // Die Reihenfolge in `currentProtokoll.punkte` (= array_order) bestimmt
// sowohl die Anzeige-Reihenfolge als auch — über berechneProtokollNummern() // sowohl die Anzeige-Reihenfolge als auch — über berechneProtokollNummern()
// — die Nummern. Zwei getrennte, aufeinander abgestimmte Stellschrauben: // — die Nummern. Zwei getrennte, aufeinander abgestimmte Stellschrauben:
// ein Punkt lässt sich nur INNERHALB seiner eigenen Gruppe verschieben (die // ein Punkt lässt sich nur INNERHALB seiner eigenen Gruppe (bzw. innerhalb
// Zeile steht dort ohnehin nur im passenden Abschnitt), eine ganze Gruppe // "Ohne Gruppe") verschieben (die Zeile steht dort ohnehin nur im passenden
// lässt sich als Block gegenüber den anderen Gruppen verschieben — das // Abschnitt), eine ganze Gruppe lässt sich als Block gegenüber den anderen
// ändert, welche Gruppe die Nummer "1" bekommt. // 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.
// verschiebePunkt: vertauscht einen Punkt mit seinem Vorgänger/Nachfolger // verschiebePunkt: vertauscht einen Punkt mit seinem Vorgänger/Nachfolger
// INNERHALB derselben Gruppe. Parameter: idx (Index in // INNERHALB derselben Gruppe (bzw. "Ohne Gruppe"). Parameter: idx (Index in
// currentProtokoll.punkte), richtung (-1 = nach oben, +1 = nach unten). // currentProtokoll.punkte), richtung (-1 = nach oben, +1 = nach unten).
// Keine Rückgabe; rendert nur den Punkte-Block neu. // Keine Rückgabe; rendert nur den Punkte-Block neu.
window.verschiebePunkt = function(idx, richtung){ window.verschiebePunkt = function(idx, richtung){
if(!currentProtokoll || app.protokolleGesperrt()) return; if(!currentProtokoll || app.isLocked()) return;
const punkte = currentProtokoll.punkte; const punkte = currentProtokoll.punkte;
const gruppe = String(punkte[idx].gruppe || '').trim(); const gruppe = String(punkte[idx].gruppe || '').trim();
const gleicheGruppeIdx = punkte.map((p, i) => i).filter(i => String(punkte[i].gruppe || '').trim() === gruppe); const gleicheGruppeIdx = punkte.map((p, i) => i).filter(i => String(punkte[i].gruppe || '').trim() === gruppe);
@@ -268,24 +192,24 @@
// verschiebeGruppe: verschiebt eine ganze Gruppe (alle ihre Punkte als // verschiebeGruppe: verschiebt eine ganze Gruppe (alle ihre Punkte als
// Block, ihre eigene Reihenfolge bleibt untereinander erhalten) gegenüber // Block, ihre eigene Reihenfolge bleibt untereinander erhalten) gegenüber
// den anderen Gruppen — ändert damit auch die Gruppennummer. Baut die // den anderen Gruppen — ändert damit auch die Gruppennummer. Baut die
// Punkteliste dafür komplett in der neuen Gruppenreihenfolge neu auf. // Punkteliste dafür komplett neu auf: "Ohne Gruppe" zuerst (unverändert),
// Parameter: gruppe (Name), richtung (-1 = nach vorn, +1 = nach hinten). // danach jede Gruppe in der neuen Reihenfolge. Parameter: gruppe (Name),
// Keine Rückgabe. // richtung (-1 = nach vorn, +1 = nach hinten). Keine Rückgabe.
window.verschiebeGruppe = function(gruppe, richtung){ window.verschiebeGruppe = function(gruppe, richtung){
if(!currentProtokoll || app.protokolleGesperrt()) return; if(!currentProtokoll || app.isLocked()) return;
const punkte = currentProtokoll.punkte; const punkte = currentProtokoll.punkte;
const namen = []; const namen = [];
punkte.forEach(p => { punkte.forEach(p => {
const g = String(p.gruppe || '').trim(); const g = String(p.gruppe || '').trim();
if(!namen.includes(g)) namen.push(g); if(g && !namen.includes(g)) namen.push(g);
}); });
const pos = namen.indexOf(String(gruppe || '').trim()); const pos = namen.indexOf(gruppe);
const zielPos = pos + richtung; const zielPos = pos + richtung;
if(pos === -1 || zielPos < 0 || zielPos >= namen.length) return; if(pos === -1 || zielPos < 0 || zielPos >= namen.length) return;
const tmpName = namen[pos]; const tmpName = namen[pos];
namen[pos] = namen[zielPos]; namen[pos] = namen[zielPos];
namen[zielPos] = tmpName; namen[zielPos] = tmpName;
const neu = []; const neu = punkte.filter(p => !String(p.gruppe || '').trim());
namen.forEach(g => { namen.forEach(g => {
punkte.filter(p => String(p.gruppe || '').trim() === g).forEach(p => neu.push(p)); punkte.filter(p => String(p.gruppe || '').trim() === g).forEach(p => neu.push(p));
}); });
@@ -305,7 +229,7 @@
function renderProtokolleListe(){ function renderProtokolleListe(){
const container = document.getElementById('protokolleContainer'); const container = document.getElementById('protokolleContainer');
if(!container) return; if(!container) return;
const locked = app.protokolleGesperrt(); const locked = app.isLocked();
const anlegeBtns = locked ? '' : `<button onclick="neuesProtokoll()">+ Neues Protokoll</button> const anlegeBtns = locked ? '' : `<button onclick="neuesProtokoll()">+ Neues Protokoll</button>
<button class="secondary" onclick="oeffneProtokollImportDialog()">Protokoll importieren</button>`; <button class="secondary" onclick="oeffneProtokollImportDialog()">Protokoll importieren</button>`;
if(protokolleCache.length === 0){ if(protokolleCache.length === 0){
@@ -337,7 +261,7 @@
// "anwesend" zurückgesetzt), sonst Fallback auf projectAssignees(), sonst // "anwesend" zurückgesetzt), sonst Fallback auf projectAssignees(), sonst
// leer. Das eigentliche Fortschreiben der Punkte übernimmt der Server. // leer. Das eigentliche Fortschreiben der Punkte übernimmt der Server.
window.neuesProtokoll = async function(){ window.neuesProtokoll = async function(){
if(app.protokolleGesperrt()) return; if(app.isLocked()) return;
let teilnehmer = []; let teilnehmer = [];
try { try {
if(protokolleCache.length > 0){ if(protokolleCache.length > 0){
@@ -392,7 +316,7 @@
// firmenweiten Stand, und kann sich seit dem letzten Öffnen geändert // firmenweiten Stand, und kann sich seit dem letzten Öffnen geändert
// haben. // haben.
window.oeffneProtokollImportDialog = function(){ window.oeffneProtokollImportDialog = function(){
if(app.protokolleGesperrt()) return; if(app.isLocked()) return;
document.getElementById('protokollImportFile').value = ''; document.getElementById('protokollImportFile').value = '';
document.getElementById('protokollImportBetreff').value = ''; document.getElementById('protokollImportBetreff').value = '';
document.getElementById('protokollImportOrt').value = ''; document.getElementById('protokollImportOrt').value = '';
@@ -535,7 +459,7 @@
const container = document.getElementById('protokolleContainer'); const container = document.getElementById('protokolleContainer');
if(!container || !currentProtokoll) return; if(!container || !currentProtokoll) return;
const p = currentProtokoll; const p = currentProtokoll;
const locked = app.protokolleGesperrt(); const locked = app.isLocked();
const datumDisplay = formatDateDE(parseDateISO(p.datum)) || ''; const datumDisplay = formatDateDE(parseDateISO(p.datum)) || '';
const quelleHinweis = p.quelleDateiname const quelleHinweis = p.quelleDateiname
@@ -614,275 +538,97 @@
</div>`; </div>`;
} }
// renderPunkteBlockHtml: baut nur den Punkte-Block — EINE durchgehende // renderPunkteBlockHtml: baut nur den Punkte-Block (in Abschnitten je
// Tabelle (dieselben Spalten wie der PDF-Export, js/pdf.js), mit einer // Gruppe) — eigene Funktion, weil er nach einer Gruppenänderung isoliert
// 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 // neu gerendert wird, ohne den Rest der Detailansicht (und damit den Fokus
// in anderen Feldern) anzufassen. // 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(){ function renderPunkteBlockHtml(){
const p = currentProtokoll; const p = currentProtokoll;
const locked = app.protokolleGesperrt(); const locked = app.isLocked();
const gruppen = gruppenReihenfolge(); const gruppen = gruppenReihenfolge();
const gruppenNamen = gruppen.filter(g => g); // ohne '' für die Datalist-Vorschläge
const nummern = window.berechneProtokollNummern(p.punkte); 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 const punkteDerGruppe = p.punkte
.map((punkt, idx) => ({ punkt, idx })) .map((punkt, idx) => ({ punkt, idx }))
.filter(x => String(x.punkt.gruppe || '').trim() === gruppe); .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 = ''; let moveBtns = '';
if(!locked){ if(gruppe && !locked){
const gPos = gruppen.indexOf(gruppe); const gPos = gruppenNamen.indexOf(gruppe);
const hochDisabled = gPos <= 0 ? 'disabled' : '';
const runterDisabled = gPos >= gruppenNamen.length - 1 ? 'disabled' : '';
moveBtns = `<span class="protokoll-gruppe-move"> moveBtns = `<span class="protokoll-gruppe-move">
<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" ${hochDisabled} 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> <button type="button" class="secondary small" ${runterDisabled} onclick="verschiebeGruppe('${esc(gruppe)}', 1)" title="Gruppe nach hinten">↓</button>
</span>`; </span>`;
} }
const kopf = `<div class="protokoll-liste-gruppe" style="--protokoll-gruppe-farbe: ${protokollGruppeFarbe(gruppe)}"> const zeilen = punkteDerGruppe.map(({ punkt, idx }, pos) => renderPunktZeileHtml(punkt, idx, nummern[idx], pos === 0, pos === punkteDerGruppe.length - 1)).join('');
<input class="protokoll-gruppe-heading" value="${esc(gruppe)}" placeholder="Gruppenname …" ${locked ? 'disabled' : ''} return `<div class="protokoll-gruppe">
onchange="currentProtokollUmbenenneGruppe('${esc(gruppe)}', this.value)"> <div class="protokoll-gruppe-head"><span class="protokoll-gruppe-name${gruppe ? '' : ' is-leer'}">${titel}</span>${moveBtns}${addBtn}</div>
<span class="count">${punkteDerGruppe.length}</span>${moveBtns} ${zeilen || '<span class="hint">Keine Punkte in dieser Gruppe.</span>'}
</div>`; </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(''); }).join('');
const leerHinweis = listeHtml ? '' : '<div class="protokoll-liste-leer">Noch keine Punkte erfasst.</div>'; const datalist = `<datalist id="protokollGruppenListe">${gruppenNamen.map(g => `<option value="${esc(g)}">`).join('')}</datalist>`;
const neueGruppe = locked ? '' : `<div class="protokoll-liste-neu ist-gruppe" onclick="fuegeNeueGruppeHinzu()">+ Neue Gruppe</div>`; const addOhneGruppe = locked ? '' : `<div class="add-row-btn" onclick="fuegePunktHinzu('')">+ Punkt (ohne Gruppe)</div>`;
return `<div class="protokoll-block-head"><h4>Punkte</h4></div> return `<div class="protokoll-block-head"><h4>Punkte</h4></div>
<div class="protokoll-split"> ${gruppenHtml || '<span class="hint">Noch keine Punkte.</span>'}
<div class="protokoll-liste"> ${datalist}
<div class="protokoll-liste-kopf">${esc(kopfText)}</div> ${addOhneGruppe}
${listeHtml}${leerHinweis}${neueGruppe}
</div>
${renderSchreibbereichHtml(aktivIdx, nummern)}
</div>
<div class="legend"> <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 neu"></span>Neu in diesem Protokoll</span>
<span class="legend-item"><span class="legend-swatch info"></span>Info (läuft nicht weiter mit)</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>
</div>`; </div>`;
} }
// renderListenZeileHtml: ein Eintrag der linken Liste. Bewusst knapp — // PROTOKOLL_PUNKT_STATUS_LABEL: Anzeigetext je Status im <select> unten —
// Nummer, Thema, Status-Punkt und darunter Zuständig/Termin; alles // einzige Stelle, die die drei Werte kennt, damit ein vierter Status
// Weitere steht im Schreibbereich. Parameter: punkt, idx, nummer, // (sollte es den je geben) nur hier ergänzt werden müsste.
// 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' }; const PROTOKOLL_PUNKT_STATUS_LABEL = { offen: 'Offen', erledigt: 'Erledigt', info: 'Info' };
// renderSchreibbereichHtml: die rechte Hälfte — der EINE gerade function renderPunktZeileHtml(punkt, idx, nummer, istErste, istLetzte){
// ausgewählte Punkt, mit echtem Platz zum Schreiben. Parameter: idx (Index const locked = app.isLocked();
// 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 terminDisplay = formatDateDE(parseDateISO(punkt.termin)) || '';
const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : ''; 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 status = punkt.status || 'offen';
const moveBtns = locked ? '' : `<span class="protokoll-punkt-move">
// Verschieben gilt wie bisher nur INNERHALB der eigenen Gruppe — die <button type="button" class="secondary small" ${istErste ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, -1)" title="Innerhalb der Gruppe nach oben">↑</button>
// Knöpfe sind an den Rändern dieser Gruppe gesperrt, nicht an denen der <button type="button" class="secondary small" ${istLetzte ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, 1)" title="Innerhalb der Gruppe nach unten">↓</button>
// Gesamtliste. </span>`;
const gruppe = String(punkt.gruppe || '').trim(); const statusOptions = Object.keys(PROTOKOLL_PUNKT_STATUS_LABEL)
const idxDerGruppe = currentProtokoll.punkte .map(s => `<option value="${s}" ${s === status ? 'selected' : ''}>${PROTOKOLL_PUNKT_STATUS_LABEL[s]}</option>`).join('');
.map((x, i) => ({ x, i })) return `<div class="protokoll-punkt${punkt.neu ? ' is-neu' : ''}${status === 'erledigt' ? ' is-erledigt' : ''}${status === 'info' ? ' is-info' : ''}" id="protokollPunkt_${idx}">
.filter(e => String(e.x.gruppe || '').trim() === gruppe) <div class="protokoll-punkt-top-row">
.map(e => e.i); <span class="protokoll-punkt-nr" title="Errechnet sich automatisch aus Gruppe und Reihenfolge">${esc(nummer)}</span>
const posInGruppe = idxDerGruppe.indexOf(idx); ${moveBtns}
const kopfAktionen = locked ? '' : `<span class="protokoll-punkt-move"> <input type="text" class="protokoll-punkt-gruppe" list="protokollGruppenListe" value="${esc(punkt.gruppe)}"
<button type="button" class="secondary small" ${posInGruppe <= 0 ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, -1)" title="Innerhalb der Gruppe nach oben">↑</button> placeholder="Gruppe (z. B. ELT, HLS) …" ${locked ? 'disabled' : ''}
<button type="button" class="secondary small" ${posInGruppe >= idxDerGruppe.length - 1 ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, 1)" title="Innerhalb der Gruppe nach unten">↓</button> onchange="currentProtokollSetzePunktGruppe(${idx}, this.value)">
</span> ${neuChip}
<span class="protokoll-row-remove" onclick="entfernePunkt(${idx})" title="Punkt entfernen">✕</span>`; <input type="text" class="protokoll-punkt-thema" value="${esc(punkt.thema)}" placeholder="Thema" ${locked ? 'disabled' : ''}
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>
<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)"> 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>`}
</div> </div>
<textarea class="protokoll-punkt-text" rows="2" placeholder="Text / Vereinbarung" ${locked ? 'disabled' : ''}
<div class="richtext-wrap"> oninput="currentProtokollSetzePunktFeld(${idx}, 'text', this.value)">${esc(punkt.text)}</textarea>
${locked ? '' : richtextToolbarHtml('.protokoll-punkt-text')} <div class="protokoll-punkt-foot">
<div class="protokoll-schreiben-feld"> <label>Zuständig <input type="text" value="${esc(punkt.zustaendig)}" ${locked ? 'disabled' : ''}
<span class="protokoll-feld-label">Vereinbarung · Text</span> oninput="currentProtokollSetzePunktFeld(${idx}, 'zustaendig', this.value)"></label>
${richtextFeldHtml('protokoll-punkt-text' + textGeaendertClass, sanitizeStatusHtml(punkt.text), <label>Termin
'Was wurde besprochen oder vereinbart?', <input type="text" id="protokollPunktTerminDisplay_${idx}" class="range-picker-display" value="${esc(terminDisplay)}" placeholder="—" readonly
`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}); } })`}"> 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)}"> <input type="hidden" id="protokollPunktTermin_${idx}" value="${esc(punkt.termin)}">
</div> </label>
</div> <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 class="protokoll-schreiben-fuss">
Erstellt am ${esc(formatDateDE(parseDateISO(punkt.erstelltAm)) || '—')}
<span class="hint">— bleibt beim Fortschreiben unverändert</span>
</div> </div>
</div>`; </div>`;
} }
@@ -901,47 +647,32 @@
if(!currentProtokoll || !currentProtokoll.teilnehmer[idx]) return; if(!currentProtokoll || !currentProtokoll.teilnehmer[idx]) return;
currentProtokoll.teilnehmer[idx][feld] = wert; 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){ window.currentProtokollSetzePunktFeld = function(idx, feld, wert){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return; if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
currentProtokoll.punkte[idx][feld] = wert; currentProtokoll.punkte[idx][feld] = wert;
if(feld === 'thema' || feld === 'zustaendig') aktualisiereListenZeile(idx);
}; };
window.syncProtokollPunktTermin = function(idx){ window.syncProtokollPunktTermin = function(idx){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return; if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
const el = document.getElementById('protokollPunktTermin_' + idx); const el = document.getElementById('protokollPunktTermin_' + idx);
if(el) currentProtokoll.punkte[idx].termin = el.value; if(el) currentProtokoll.punkte[idx].termin = el.value;
aktualisiereListenZeile(idx);
}; };
window.currentProtokollSetzePunktStatus = function(idx, status){ window.currentProtokollSetzePunktStatus = function(idx, status){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return; if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
currentProtokoll.punkte[idx].status = status; currentProtokoll.punkte[idx].status = status;
// Gezielt die beiden betroffenen Stellen nachziehen statt neu zu rendern: const row = document.getElementById('protokollPunkt_' + idx);
// der aktive Knopf im Umschalter und der farbige Punkt im Listeneintrag. if(row){
document.querySelectorAll('.protokoll-schreiben .protokoll-status-switch button').forEach(btn => { row.classList.toggle('is-erledigt', status === 'erledigt');
btn.classList.toggle('is-active', btn.classList.contains(status)); row.classList.toggle('is-info', status === 'info');
}); }
aktualisiereListenZeile(idx);
}; };
// currentProtokollUmbenenneGruppe: ersetzt currentProtokollSetzePunktGruppe // currentProtokollSetzePunktGruppe: einzige Punkt-Feldänderung, die neu
// (bis eben: ein Feld PRO Punkt, das genau diesen einen Punkt umhängte). // rendert (nur den Punkte-Block, siehe renderPunkteBlockHtml) — ändert
// Jetzt ist die Gruppen-Kopfzeile selbst das Eingabefeld (Eriks Wunsch) — // sich die Gruppe, muss der Punkt sichtbar in den passenden Abschnitt
// ein Umbenennen wirkt auf ALLE Punkte dieser Gruppe gleichzeitig. Trägt // wandern. Läuft über onchange (Blur/Enter), nicht oninput, damit während
// der neue Name den einer bereits bestehenden anderen Gruppe, verschmelzen // des Tippens nicht bei jedem Zeichen neu gerendert wird.
// beide Gruppen schlicht dadurch, dass ab jetzt beide Punktmengen denselben window.currentProtokollSetzePunktGruppe = function(idx, wert){
// gruppe-Wert tragen — kein Sonderfall nötig. Läuft über onchange if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
// (Blur/Enter), nicht oninput, damit während des Tippens nicht bei jedem currentProtokoll.punkte[idx].gruppe = wert;
// 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'); const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml(); if(block) block.innerHTML = renderPunkteBlockHtml();
}; };
@@ -949,81 +680,32 @@
// ---------- Strukturelle Änderungen (Zeile hinzufügen/entfernen) ---------- // ---------- Strukturelle Änderungen (Zeile hinzufügen/entfernen) ----------
window.fuegeTeilnehmerHinzu = function(){ window.fuegeTeilnehmerHinzu = function(){
if(!currentProtokoll || app.protokolleGesperrt()) return; if(!currentProtokoll || app.isLocked()) return;
currentProtokoll.teilnehmer.push({ name: '', firma: '', anwesend: true }); currentProtokoll.teilnehmer.push({ name: '', firma: '', anwesend: true });
renderProtokollDetail(); renderProtokollDetail();
}; };
window.entferneTeilnehmer = function(idx){ window.entferneTeilnehmer = function(idx){
if(!currentProtokoll || app.protokolleGesperrt()) return; if(!currentProtokoll || app.isLocked()) return;
currentProtokoll.teilnehmer.splice(idx, 1); currentProtokoll.teilnehmer.splice(idx, 1);
renderProtokollDetail(); renderProtokollDetail();
}; };
window.fuegePunktHinzu = function(gruppe){ window.fuegePunktHinzu = function(gruppe){
if(!currentProtokoll || app.protokolleGesperrt()) return; if(!currentProtokoll || app.isLocked()) return;
// nummer wird nicht gesetzt — sie ergibt sich beim Rendern automatisch // nummer wird nicht gesetzt — sie ergibt sich beim Rendern automatisch
// aus Gruppe und Position (siehe berechneProtokollNummern() oben). // aus Gruppe und Position (siehe berechneProtokollNummern() oben).
// erstelltAm nur als Vorschau bis zum nächsten Speichern — maßgeblich ist // erstelltAm nur als Vorschau bis zum nächsten Speichern — maßgeblich ist
// immer, was der Server beim Speichern setzt (server.js, PUT .../:id). // immer, was der Server beim Speichern setzt (server.js, PUT .../:id).
const neuerPunkt = { currentProtokoll.punkte.push({
id: null, nummer: '', gruppe: gruppe || '', neu: true, id: null, nummer: '', gruppe: gruppe || '', neu: true,
thema: '', text: '', zustaendig: '', termin: '', status: 'offen', thema: '', text: '', zustaendig: '', termin: '', status: 'offen',
erstelltAm: currentProtokoll.datum || '', entfaelltNaechstes: false erstelltAm: currentProtokoll.datum || ''
}; });
// 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'); const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml(); 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){ window.entfernePunkt = function(idx){
if(!currentProtokoll || app.protokolleGesperrt()) return; if(!currentProtokoll || app.isLocked()) 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); 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'); const block = document.getElementById('protokollPunkteBlock');
if(block) block.innerHTML = renderPunkteBlockHtml(); if(block) block.innerHTML = renderPunkteBlockHtml();
}; };
@@ -1034,22 +716,13 @@
// Punkte komplett (siehe server.js). Ausdrückliches Speichern, kein // Punkte komplett (siehe server.js). Ausdrückliches Speichern, kein
// Auto-Save, wie die Board-Kommentare. // Auto-Save, wie die Board-Kommentare.
window.speichereProtokoll = function(){ window.speichereProtokoll = function(){
if(!currentProtokoll || app.protokolleGesperrt()) return; if(!currentProtokoll || app.isLocked()) return;
// Errechnete Nummern vor dem Senden in die Punkte schreiben, damit die // Errechnete Nummern vor dem Senden in die Punkte schreiben, damit die
// gespeicherten Werte mit der Anzeige übereinstimmen (maßgeblich ist beim // gespeicherten Werte mit der Anzeige übereinstimmen (maßgeblich ist beim
// Anzeigen aber immer die Neuberechnung, nie dieser gespeicherte Wert — // Anzeigen aber immer die Neuberechnung, nie dieser gespeicherte Wert —
// siehe berechneProtokollNummern()). // siehe berechneProtokollNummern()).
const nummern = window.berechneProtokollNummern(currentProtokoll.punkte); const nummern = window.berechneProtokollNummern(currentProtokoll.punkte);
// text kommt live aus dem contenteditable-Feld (this.innerHTML, currentProtokoll.punkte.forEach((p, idx) => { p.nummer = nummern[idx]; });
// 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 = { const body = {
datum: currentProtokoll.datum, datum: currentProtokoll.datum,
betreff: currentProtokoll.betreff, betreff: currentProtokoll.betreff,
@@ -1057,11 +730,6 @@
teilnehmer: currentProtokoll.teilnehmer.filter(t => String(t.name || '').trim()), teilnehmer: currentProtokoll.teilnehmer.filter(t => String(t.name || '').trim()),
punkte: currentProtokoll.punkte 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), { fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json; charset=utf-8' }, headers: { 'Content-Type': 'application/json; charset=utf-8' },
@@ -1071,7 +739,6 @@
.then(({ ok, d }) => { .then(({ ok, d }) => {
if(!ok || !d.ok){ alert('Protokoll konnte nicht gespeichert werden: ' + (d && d.error ? d.error : 'unbekannter Fehler')); return; } if(!ok || !d.ok){ alert('Protokoll konnte nicht gespeichert werden: ' + (d && d.error ? d.error : 'unbekannter Fehler')); return; }
currentProtokoll = d.protokoll; currentProtokoll = d.protokoll;
ausgewaehlterPunkt = (auswahlPos >= 0 && currentProtokoll.punkte[auswahlPos]) || null;
renderProtokollDetail(); renderProtokollDetail();
ladeProtokollListe(); ladeProtokollListe();
}) })
@@ -1082,7 +749,7 @@
}; };
window.loescheProtokoll = function(){ window.loescheProtokoll = function(){
if(!currentProtokoll || app.protokolleGesperrt()) return; if(!currentProtokoll || app.isLocked()) return;
if(!confirm('Protokoll Nr. ' + currentProtokoll.nummer + ' wirklich löschen?')) return; if(!confirm('Protokoll Nr. ' + currentProtokoll.nummer + ' wirklich löschen?')) return;
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), { method: 'DELETE' }) fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), { method: 'DELETE' })
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); }) .then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
@@ -1097,12 +764,6 @@
}); });
}; };
// 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() // renderPlanProtokolle: zentrale Render-Funktion, von showPlanSection()
// (plan-sections.js) beim Wechsel in den Bereich aufgerufen — zeigt je // (plan-sections.js) beim Wechsel in den Bereich aufgerufen — zeigt je
// nach Zustand Liste oder Detail (rendert dabei nichts neu, nur aus dem // nach Zustand Liste oder Detail (rendert dabei nichts neu, nur aus dem
+115 -7
View File
@@ -1,11 +1,13 @@
// ----------------------------- // -----------------------------
// Projektplan — Auto-Schedule & Mail-Link // Projektplan — Kritischer Pfad, Auto-Schedule & Mail-Link
// ----------------------------- // -----------------------------
// Dieses Modul verschiebt bei autoScheduleTodos TODOs und Meilensteine // Dieses Modul berechnet über computeCriticalPath die Aufgabe(n) auf dem
// automatisch hinter ihre Vorgänger (aufgerufen beim Ziehen eines Balkens, // kritischen Pfad (längste Vorgänger-Kette) und hebt sie im GANTT hervor,
// plan-drag.js, und über "Auto-Schedule" im Bereich Aufgaben) und erstellt // verschiebt bei autoScheduleTodos TODOs und Meilensteine automatisch hinter
// bei todoMail einen mailto-Link an alle zugeordneten Personen. Die frühere // ihre Vorgänger und erstellt bei todoMail einen mailto-Link an alle
// Hervorhebung des kritischen Pfads ist auf Eriks Wunsch entfallen. // 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).
// //
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js). // (plan-state.js).
@@ -13,7 +15,113 @@
(function(){ (function(){
const app = window.PlanApp; const app = window.PlanApp;
// ---------- Auto-Schedule ---------- // ---------- 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.');
}
}
};
// autoScheduleTodos: verschiebt alle TODOs und Meilensteine, die von // autoScheduleTodos: verschiebt alle TODOs und Meilensteine, die von
// anderen Elementen abhängen, automatisch auf den frühesten möglichen // anderen Elementen abhängen, automatisch auf den frühesten möglichen
+46 -118
View File
@@ -3,8 +3,8 @@
// ----------------------------- // -----------------------------
// Der Projektbereich ist der EIGENE BEREICH eines Projekts — seit dem Umbau // Der Projektbereich ist der EIGENE BEREICH eines Projekts — seit dem Umbau
// kein eigenes Dokument mehr, sondern der Bereich #plan der Hauptseite // kein eigenes Dokument mehr, sondern der Bereich #plan der Hauptseite
// App.html. Er gliedert sich in sieben Unterbereiche, zwischen denen die // Test.html. Er gliedert sich in sieben Unterbereiche, zwischen denen die
// Leiste .plan-nav umschaltet (Markup in App.html): // Leiste .plan-nav umschaltet (Markup in Test.html):
// //
// dashboard — Kennzahlen, Sitzungskommentar, Leistungsphasen- // dashboard — Kennzahlen, Sitzungskommentar, Leistungsphasen-
// Fortschritt, nächste Termine, Team, Kompakt-Karten // Fortschritt, nächste Termine, Team, Kompakt-Karten
@@ -35,8 +35,8 @@
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der // PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
// Navigationsleiste. Muss zu den data-section-Attributen in // Navigationsleiste. Muss zu den data-section-Attributen in
// App.html passen. // Test.html passen.
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'phasen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen']; const PLAN_SECTIONS = ['dashboard', 'terminplan', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen // showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist // Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
@@ -63,7 +63,6 @@
if(!app.project) return; if(!app.project) return;
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt(); if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
if(key === 'phasen' && typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks(); if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung(); if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle(); if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
@@ -107,88 +106,53 @@
if(nav) nav.style.display = ''; if(nav) nav.style.display = '';
} }
// upcomingEntries: offene Aufgaben mit Endtermin und Meilensteine ab heute // upcomingEntries: sammelt die nächsten Termine des Projekts — offene
// (vergangene Meilensteine haben kein Erledigt-Kennzeichen und gelten als // Aufgaben mit Endtermin und Meilensteine mit Datum — in einer gemeinsamen,
// gewesen), chronologisch. idx verweist in plan.todos/plan.milestones, damit // chronologisch sortierten Liste. Überfällige Einträge stehen zuerst, damit
// ein Klick denselben Dialog öffnet wie im Terminplan. // sie im Dashboard nicht untergehen. Rückgabe: Array von
// Rückgabe: Array von { art, idx, titel, datum, iso, wer, meins }. // { kind, title, date, overdue }.
function upcomingEntries(){ function upcomingEntries(){
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
const heute = heuteIso(); const today = new Date();
const ich = currentUser && currentUser.personId; today.setHours(0, 0, 0, 0);
const namen = {};
((app.week && app.week.people) || []).forEach(x => { namen[x.id] = x.fullName || x.name; });
const rows = []; const rows = [];
(plan.todos || []).forEach((t, idx) => {
if(t.done || !t.end) return; (plan.todos || []).forEach(t => {
const wer = (t.assignees || []).map(id => namen[id] || id); if(t.done) return;
rows.push({ art: 'aufgabe', idx, titel: t.title || 'Aufgabe', iso: t.end, wer, const d = parseDateISO(t.end);
meins: !!ich && (t.assignees || []).includes(ich) }); if(!d) return;
rows.push({ kind: 'Aufgabe', title: t.title || 'Aufgabe', date: d, overdue: d < today });
}); });
(plan.milestones || []).forEach((m, idx) => { (plan.milestones || []).forEach(m => {
if(!m.date || m.date < heute) return; const d = parseDateISO(m.date);
rows.push({ art: 'meilenstein', idx, titel: m.name || 'Meilenstein', iso: m.date, wer: [], meins: false }); if(!d) return;
rows.push({ kind: 'Meilenstein', title: m.name || 'Meilenstein', date: d, overdue: d < today });
}); });
rows.sort((a, b) => a.iso.localeCompare(b.iso));
rows.sort((a, b) => a.date - b.date);
return rows; return rows;
} }
// PLAN_DASH_ANTEIL: wie viele Einträge je Gruppe offen dastehen — jede // renderPlanDashboard: füllt die beiden eigenen Karten des Dashboards
// Gruppe bekommt ihren eigenen Anteil, sonst füllten 25 überfällige // ("Nächste Termine" und "Team"). Die übrigen Inhalte des Bereichs
// Aufgaben die Liste und "Diese Woche" stand ohne Einträge da. Der Rest // stammen aus bereits vorhandenen Render-Funktionen: die KPI-Kacheln aus
// steckt hinter "… weitere in Aufgaben". // renderPlanKpis() (plan-gantt.js), Kommentar und Leistungsphasen aus
const PLAN_DASH_ANTEIL = { ueber: 4, woche: 4, spaeter: 3 }; // renderPlanInfo() (plan-info.js), die Kompakt-Karten aus
// renderResourceLoad()/renderProjectFiles()/renderPlanHistory().
// renderPlanDashboard: füllt "Termine und Aufgaben" (gegliedert nach // Keine Parameter, keine Rückgabe.
// Überfällig / Diese Woche / Später) und "Team" (eine Zeile je PERSON mit
// ihren Rollen als Chips — vorher stand jemand mit drei Rollen dreimal da).
// Kennzahlenband, Kommentar, Leistungsphasen und Unterlagen füllen
// renderPlanKpis()/renderPlanInfo()/renderResourceLoad()/
// renderProjectFiles(). Keine Parameter.
window.renderPlanDashboard = function(){ window.renderPlanDashboard = function(){
if(!app.project) return; if(!app.project) return;
const locked = app.isLocked();
const upcomingEl = document.getElementById('planDashUpcoming'); const upcomingEl = document.getElementById('planDashUpcoming');
if(upcomingEl){ if(upcomingEl){
const heute = heuteIso(); const rows = upcomingEntries().slice(0, 8);
const wocheEnde = isoPlusTage(7); upcomingEl.innerHTML = rows.length === 0
const alle = upcomingEntries(); ? '<div class="plan-task-empty">Keine offenen Termine hinterlegt.</div>'
if(alle.length === 0){ : '<div class="plan-upcoming-list">' + rows.map(r => `<div class="plan-upcoming-item${r.overdue ? ' overdue' : ''}">
upcomingEl.innerHTML = '<div class="plan-task-empty">Keine offenen Termine. Aufgaben und Meilensteine entstehen im Terminplan.</div>'; <span class="plan-upcoming-date">${esc(formatDateDE(r.date))}</span>
} else { <span>${esc(r.title)}</span>
const gruppen = [ <span class="plan-upcoming-kind">${esc(r.kind)}</span>
{ key: 'ueber', titel: 'Überfällig', klasse: 'ist-rot', treffer: r => r.art === 'aufgabe' && r.iso < heute }, </div>`).join('') + '</div>';
{ key: 'woche', titel: 'Diese Woche', klasse: '', treffer: r => r.iso >= heute && r.iso <= wocheEnde },
{ key: 'spaeter', titel: 'Später', klasse: '', treffer: r => r.iso > wocheEnde },
];
let gezeigt = 0;
let html = '';
gruppen.forEach(g => {
const alleDerGruppe = alle.filter(g.treffer);
const sichtbar = alleDerGruppe.slice(0, PLAN_DASH_ANTEIL[g.key]);
gezeigt += sichtbar.length;
if(!alleDerGruppe.length) return;
html += `<div class="plan-termin-gruppe ${g.klasse}">${esc(g.titel)}<span>${alleDerGruppe.length}</span></div>`;
html += sichtbar.map(r => {
const klick = locked ? '' : ` onclick="${r.art === 'meilenstein' ? 'openMilestoneModal' : 'openTodoModal'}(${r.idx})"`;
const zeichen = r.art === 'meilenstein' ? '◆' : (r.meins ? 'A' : 'a');
const klasse = r.art === 'meilenstein' ? 'ist-ms' : (r.meins ? 'ist-meins' : '');
const wer = r.art === 'meilenstein' ? 'Meilenstein' : (r.wer.length ? r.wer.join(', ') : 'niemand zugewiesen');
const rot = r.art === 'aufgabe' && r.iso < heute;
return `<div class="plan-termin${locked ? '' : ' ist-klickbar'}"${klick}>
<span class="plan-termin-art ${klasse}">${zeichen}</span>
<span class="plan-termin-text"><b>${esc(r.titel)}</b><span>${esc(wer)}</span></span>
<span class="plan-termin-datum${rot ? ' ist-rot' : ''}">${esc(formatDateDE(parseDateISO(r.iso)))}<em>${esc(relativerTag(r.iso))}</em></span>
</div>`;
}).join('');
});
const verborgen = alle.length - gezeigt;
if(verborgen > 0){
html += `<button type="button" class="plan-panel-link plan-termin-mehr" onclick="showPlanSection('aufgaben')">${verborgen} weitere in „Aufgaben“</button>`;
}
upcomingEl.innerHTML = html;
}
} }
const teamEl = document.getElementById('planDashTeam'); const teamEl = document.getElementById('planDashTeam');
@@ -198,50 +162,14 @@
teamEl.innerHTML = '<div class="plan-task-empty">Diesem Projekt ist noch niemand zugeordnet. Zuordnung im Bereich „Einstellungen“.</div>'; teamEl.innerHTML = '<div class="plan-task-empty">Diesem Projekt ist noch niemand zugeordnet. Zuordnung im Bereich „Einstellungen“.</div>';
} else { } else {
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
const jePerson = new Map(); teamEl.innerHTML = '<div class="plan-dash-team">' + assignees.map(a => {
assignees.forEach(a => { const offen = (plan.todos || []).filter(t => !t.done && Array.isArray(t.assignees) && t.assignees.indexOf(a.person.id) !== -1).length;
if(!jePerson.has(a.person.id)) jePerson.set(a.person.id, { person: a.person, rollen: [] }); const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : '');
jePerson.get(a.person.id).rollen.push({ rolle: a.role, state: a.state }); 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>
const zeile = e => { <span>${esc(a.person.fullName || a.person.name)}</span>
const offen = (plan.todos || []).filter(t => !t.done && Array.isArray(t.assignees) && t.assignees.indexOf(e.person.id) !== -1).length; <span class="plan-upcoming-kind">${esc(a.role || '')}</span>
const haupt = e.rollen.find(r => r.state === 1) || e.rollen[0]; <span class="plan-task-meta" style="margin-left:auto">${offen} offen</span>
return `<div class="plan-team-zeile">
<span class="board-avatar ${app.planRoleClass(haupt.rolle)} state-${haupt.state}">${esc(e.person.name)}</span>
<span class="plan-team-text"><b>${esc(e.person.fullName || e.person.name)}</b>
<span class="plan-team-rollen">${e.rollen.map(r => `<span class="plan-rolle${r.state === 2 ? ' ist-neben' : ''}" title="${r.state === 2 ? 'Unterstützung' : 'Hauptverantwortung'}">${esc(r.rolle || '')}</span>`).join('')}</span></span>
<span class="plan-team-offen">${offen} offen</span>
</div>`;
};
// Nach Gewerk gruppiert (Eriks Wahl "A"): erst die Gewerke des
// Projekts in fester Reihenfolge — auch unbesetzte, mit Hinweis —,
// dann Gewerke von Beteiligten, die nicht zum Projekt gehören, zuletzt
// Personen ohne bekanntes Gewerk. Das Gewerk einer Person kommt aus
// ihrem Konto (personGewerk(), users.department).
const reihenfolge = Object.keys(DEPARTMENTS);
const projGewerke = reihenfolge.filter(g => (app.project.gewerke || []).indexOf(g) !== -1);
const gruppen = new Map(projGewerke.map(g => [g, []]));
[...jePerson.values()].forEach(e => {
const g = personGewerk(e.person) || '';
if(!gruppen.has(g)) gruppen.set(g, []);
gruppen.get(g).push(e);
});
const schluessel = [...gruppen.keys()].sort((x, y) => {
const rang = g => g === '' ? 99 : (projGewerke.indexOf(g) !== -1 ? projGewerke.indexOf(g) : 10 + reihenfolge.indexOf(g));
return rang(x) - rang(y);
});
teamEl.innerHTML = '<div class="plan-team">' + schluessel.map(g => {
const leute = gruppen.get(g);
const fremd = g !== '' && projGewerke.indexOf(g) === -1;
const titel = g === '' ? 'Ohne Gewerk' : ((DEPARTMENTS[g] && DEPARTMENTS[g].label !== g) ? DEPARTMENTS[g].label : '');
return `<div class="plan-team-gruppe">
<div class="plan-team-gewerk">
${g ? `<span class="plan-gewerk-schild">${esc(g)}</span>` : ''}
<span class="plan-gewerk-titel">${esc(titel)}${fremd ? (titel ? ' · ' : '') + 'kein Gewerk des Projekts' : ''}</span>
<span class="plan-gewerk-linie"></span>
<span class="plan-gewerk-anzahl">${leute.length === 1 ? '1 Person' : leute.length + ' Personen'}</span>
</div>
${leute.length ? leute.map(zeile).join('') : '<div class="plan-team-luecke">Noch niemand aus diesem Gewerk zugeordnet.</div>'}
</div>`; </div>`;
}).join('') + '</div>'; }).join('') + '</div>';
} }
+77 -356
View File
@@ -13,7 +13,7 @@
// gespeichert von renderPlanInfo()/setPlanGroup()/setPlanActiveLp() in // gespeichert von renderPlanInfo()/setPlanGroup()/setPlanActiveLp() in
// js/plan/plan-info.js — dieses Modul rührt sie nicht an) // js/plan/plan-info.js — dieses Modul rührt sie nicht an)
// * Honorar (netto) und die BEAUFTRAGTEN LEISTUNGSPHASEN // * Honorar (netto) und die BEAUFTRAGTEN LEISTUNGSPHASEN
// * Beteiligte (Rollen-/Personen-Matrix, #planAssignMatrix) // * Beteiligte (Rollen-/Personen-Matrix, ebenfalls aus plan-info.js)
// //
// Honorar und beauftragte Leistungsphasen standen früher ausschließlich im // Honorar und beauftragte Leistungsphasen standen früher ausschließlich im
// Dialog "Terminplan aus Honorar" (js/plan/plan-honorar.js). Sie sind // Dialog "Terminplan aus Honorar" (js/plan/plan-honorar.js). Sie sind
@@ -24,21 +24,9 @@
// aus plan-honorar.js und rendern einander nach jeder Änderung neu, sodass // aus plan-honorar.js und rendern einander nach jeder Änderung neu, sodass
// sie nicht auseinanderlaufen können. // 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 // Gespeichert wird über den normalen Weg app.savePlan() (die Felder sind Teil
// des Wochen-JSON, Tabellen projects.honorar/project_assign bzw. // des Wochen-JSON, Tabellen projects.honorar bzw. project_beauftragte_lph) —
// project_beauftragte_lph) — es gibt dafür keinen eigenen Endpunkt. // es gibt dafür keinen eigenen Endpunkt.
// //
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js). // (plan-state.js).
@@ -51,289 +39,10 @@
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) und nach // Wechsel in den Bereich (showPlanSection(), plan-sections.js) und nach
// jeder Änderung an Honorar oder beauftragten Leistungsphasen aufgerufen. // jeder Änderung an Honorar oder beauftragten Leistungsphasen aufgerufen.
// Keine Parameter, keine Rückgabe. // Keine Parameter, keine Rückgabe.
// HOAI-Bezeichnungen der Leistungsphasen für die Zeilen im Abschnitt
// "Leistungsphasen" (nur Anzeige).
const LPH_BEZEICHNUNG = {
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung'
};
// Gewählter Abschnitt der Einstellungen (Allgemein/Auftrag/Phasen/Team).
let einstTeil = 'allgemein';
window.zeigeEinstTeil = function(teil){
einstTeil = teil;
document.querySelectorAll('#planSection-einstellungen .plan-einst-reiter').forEach(b => {
const an = b.dataset.teil === teil;
b.classList.toggle('is-active', an);
b.setAttribute('aria-current', an ? 'true' : 'false');
});
document.querySelectorAll('#planSection-einstellungen .plan-einst-teil').forEach(t => {
t.hidden = t.dataset.teil !== teil;
});
};
// einstTeamPersonen: alle Personen, die im Abschnitt "Beteiligte" vorkommen
// können — jede Person der Sitzung mit mindestens einer Fachrolle
// (peopleByRole()), je mit den Rollen, die sie hat. Dazu, wer dem Projekt
// zugeordnet ist, obwohl sein Konto die Rolle inzwischen nicht mehr trägt —
// sonst wäre so jemand unsichtbar und ließe sich nicht mehr entfernen.
// Rückgabe: Array {person, rollen:Set}.
function einstTeamPersonen(){
const grouped = peopleByRole(app.week);
const map = new Map();
ROLES.forEach(r => (grouped[r] || []).forEach(entry => {
if(!map.has(entry.person.id)) map.set(entry.person.id, { person: entry.person, rollen: new Set() });
map.get(entry.person.id).rollen.add(r);
}));
const p = app.project;
const leute = (app.week && app.week.people) || [];
Object.keys((p && p.assign) || {}).forEach(key => {
if(!(p.assign[key] > 0)) return;
const [id, r] = key.split('|');
if(map.has(id)) { map.get(id).rollen.add(r); return; }
const person = leute.find(x => x.id === id);
if(person) map.set(id, { person, rollen: new Set([r]) });
});
return [...map.values()];
}
const einstName = person => person.fullName || person.name || '';
const einstNachname = person => einstName(person).split(' ').slice(-1)[0];
const einstBeteiligt = (p, id) => ROLES.some(r => (p.assign[assignKey(id, r)] || 0) > 0);
function einstGewerkRang(p){
const reihenfolge = Object.keys(DEPARTMENTS);
const projGewerke = reihenfolge.filter(g => (p.gewerke || []).indexOf(g) !== -1);
return { projGewerke, rang: g => g === '' ? 99 : (projGewerke.indexOf(g) !== -1 ? projGewerke.indexOf(g) : 10 + reihenfolge.indexOf(g)) };
}
// Zustand des Abschnitts "Beteiligte": zuletzt hinzugefügte Person (kurz
// hervorgehoben) und die zuletzt entfernte samt ihrer Zuordnung, damit
// "Rückgängig" sie wiederherstellen kann.
let einstNeu = '';
let einstEntfernt = null;
let einstToastTimer = null;
// renderEinstTeam: Besetzungsleiste, Sperrhinweis, Suchfeld und die
// Personenliste nach Gewerk (Eriks Wahl "B"). Keine Rückgabe.
function renderEinstTeam(p, locked){
if(!p.assign) p.assign = {};
const personen = einstTeamPersonen();
const { projGewerke, rang } = einstGewerkRang(p);
const sperre = document.getElementById('planEinstSperre');
if(sperre) sperre.hidden = !locked;
const sucheWrap = document.getElementById('planEinstSucheWrap');
if(sucheWrap) sucheWrap.hidden = locked;
// Besetzungsleiste: je Rolle, wer die Hauptverantwortung trägt.
const besetzung = document.getElementById('planEinstBesetzung');
if(besetzung){
besetzung.innerHTML = ROLES.map(r => {
const haupt = personen.filter(e => p.assign[assignKey(e.person.id, r)] === 1);
const neben = personen.filter(e => p.assign[assignKey(e.person.id, r)] === 2);
const fehlt = haupt.length === 0;
const text = fehlt
? (neben.length ? 'nur Unterstützung, Haupt fehlt' : 'niemand zugeordnet')
: haupt.map(e => einstNachname(e.person)).join(', ') + (neben.length ? ' + ' + neben.length + ' Unterst.' : '');
return `<div class="plan-einst-besetzt${fehlt ? ' ist-luecke' : ''}"><b>${esc(r)}</b><span>${esc(text)}</span></div>`;
}).join('');
}
const el = document.getElementById('planAssignMatrix');
if(!el) return;
const drin = personen.filter(e => einstBeteiligt(p, e.person.id));
if(!drin.length){
el.innerHTML = personen.length
? `<div class="plan-einst-leer"><b>Noch niemand zugeordnet</b><span>Über das Suchfeld Personen hinzufügen. Vorgeschlagen werden zuerst die Mitarbeiter aus den Gewerken des Projekts.</span></div>`
: '<div class="plan-einst-leer"><b>Niemand verfügbar</b><span>In dieser Sitzung ist niemand mit einer Fachrolle hinterlegt (Admin → Benutzerverwaltung).</span></div>';
} else {
const gruppen = new Map();
drin.forEach(e => {
const g = personGewerk(e.person) || '';
if(!gruppen.has(g)) gruppen.set(g, []);
gruppen.get(g).push(e);
});
el.innerHTML = [...gruppen.keys()].sort((x, y) => rang(x) - rang(y)).map(g => {
const liste = gruppen.get(g).sort((a, b) => einstName(a.person).localeCompare(einstName(b.person), 'de'));
const titel = g === '' ? 'Ohne Gewerk' : ((typeof GEWERK_LABELS === 'object' && GEWERK_LABELS[g]) || '')
+ (projGewerke.indexOf(g) === -1 ? ', kein Gewerk des Projekts' : '');
return `<div class="plan-einst-gruppe">
${g ? `<span class="plan-gewerk-schild">${esc(g)}</span>` : ''}
<span class="plan-gewerk-titel">${esc(titel)}</span>
<span class="plan-gewerk-linie"></span>
<span class="plan-gewerk-anzahl">${liste.length === 1 ? '1 Person' : liste.length + ' Personen'}</span>
</div>` + liste.map(e => {
const id = e.person.id;
const name = einstName(e.person);
const kapseln = ROLES.map(r => {
const hat = e.rollen.has(r);
const st = p.assign[assignKey(id, r)] || 0;
const art = st === 1 ? 'ist-haupt' : (st === 2 ? 'ist-neben' : '');
const titel = !hat ? `${name} hat die Rolle ${r} nicht` : `${r}: ${st === 1 ? 'Hauptverantwortung' : st === 2 ? 'Unterstützung' : 'nicht beteiligt'}`;
return `<button type="button" class="plan-einst-kapsel ${art}${hat ? '' : ' ist-fremd'}" aria-pressed="${st > 0}"
${!hat || locked ? 'disabled' : ''} title="${esc(titel)}"
onclick="planCycleAssign('${esc(id)}','${esc(r)}')">${esc(r)}</button>`;
}).join('');
return `<div class="plan-einst-zeile${einstNeu === id ? ' ist-neu' : ''}">
<span class="plan-einst-person"><span class="plan-einst-ava">${esc(e.person.name)}</span><b>${esc(name)}</b></span>
<span class="plan-einst-kapseln" role="group" aria-label="Rollen von ${esc(name)}">${kapseln}</span>
${locked ? '' : `<button type="button" class="plan-einst-weg" title="Aus dem Projekt entfernen" aria-label="${esc(name)} aus dem Projekt entfernen" onclick="einstEntfernen('${esc(id)}')">×</button>`}
</div>`;
}).join('');
}).join('');
}
const toast = document.getElementById('planEinstToast');
if(toast){
toast.hidden = !einstEntfernt || locked;
toast.innerHTML = einstEntfernt
? `<span>${esc(einstEntfernt.name)} aus dem Projekt entfernt</span><button type="button" onclick="einstRueckgaengig()">Rückgängig</button>` : '';
}
}
// einstNachAenderung: speichern und alles nachziehen, was die Zuordnung
// zeigt (dieselben Wege wie planCycleAssign()).
function einstNachAenderung(){
app.savePlan();
renderPlanSettings();
if(typeof renderBoard === 'function') renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht();
}
// einstEntfernen: nimmt eine Person mit allen Rollen aus dem Projekt und
// merkt sich die Zuordnung für "Rückgängig" (8 Sekunden).
window.einstEntfernen = function(personId){
const p = app.project;
if(!p || app.stammGesperrt()) return;
const eintrag = {};
ROLES.forEach(r => {
const key = assignKey(personId, r);
if(p.assign[key]) { eintrag[key] = p.assign[key]; delete p.assign[key]; }
});
const e = einstTeamPersonen().find(x => x.person.id === personId);
const person = e ? e.person : ((app.week.people || []).find(x => x.id === personId) || { name: personId });
einstEntfernt = { name: einstName(person), eintrag };
einstNeu = '';
clearTimeout(einstToastTimer);
einstToastTimer = setTimeout(() => { einstEntfernt = null; const t = document.getElementById('planEinstToast'); if(t) t.hidden = true; }, 8000);
einstNachAenderung();
};
window.einstRueckgaengig = function(){
const p = app.project;
if(!p || !einstEntfernt || app.stammGesperrt()) return;
Object.assign(p.assign, einstEntfernt.eintrag);
const key = Object.keys(einstEntfernt.eintrag)[0];
einstNeu = key ? key.split('|')[0] : '';
einstEntfernt = null;
clearTimeout(einstToastTimer);
einstNachAenderung();
};
// Suchfeld "Person hinzufügen": Vorschläge aus allen Personen mit
// Fachrolle, die noch nicht beteiligt sind — erst die Gewerke des
// Projekts, dann die übrigen. Gerendert wird nur die Vorschlagsliste,
// damit das Eingabefeld beim Tippen seinen Fokus behält.
function einstVorschlaege(){
const p = app.project;
const feld = document.getElementById('planEinstSuche');
const s = (feld ? feld.value : '').trim().toLowerCase();
const { projGewerke } = einstGewerkRang(p);
const frei = einstTeamPersonen()
.filter(e => !einstBeteiligt(p, e.person.id))
.filter(e => !s || einstName(e.person).toLowerCase().indexOf(s) !== -1 || String(e.person.name || '').toLowerCase().indexOf(s) !== -1)
.sort((a, b) => einstName(a.person).localeCompare(einstName(b.person), 'de'));
const eigen = frei.filter(e => projGewerke.indexOf(personGewerk(e.person)) !== -1);
const andere = frei.filter(e => projGewerke.indexOf(personGewerk(e.person)) === -1);
return [['Aus den Gewerken des Projekts', eigen], ['Andere Gewerke', andere]].filter(([, l]) => l.length);
}
window.einstSucheZeigen = function(){
const box = document.getElementById('planEinstVorschlaege');
if(!box || !app.project || app.stammGesperrt()) return;
const gruppen = einstVorschlaege();
if(!gruppen.length){
const feld = document.getElementById('planEinstSuche');
box.innerHTML = feld && feld.value.trim() ? '<div class="plan-einst-vorschlag-leer">Niemand gefunden.</div>' : '';
box.hidden = !box.innerHTML;
return;
}
box.innerHTML = gruppen.map(([titel, liste]) => `<div class="plan-einst-vorschlag-titel">${esc(titel)}</div>`
+ liste.map(e => `<button type="button" class="plan-einst-vorschlag" onmousedown="event.preventDefault()"
onclick="einstHinzufuegen('${esc(e.person.id)}')">
<span class="plan-einst-ava">${esc(e.person.name)}</span>
<span><b>${esc(einstName(e.person))}</b><span>${esc([personGewerk(e.person), ROLES.filter(r => e.rollen.has(r)).join(', ')].filter(Boolean).join(', '))}</span></span>
</button>`).join('')).join('');
box.hidden = false;
};
window.einstSucheSchliessen = function(){
setTimeout(() => { const box = document.getElementById('planEinstVorschlaege'); if(box) box.hidden = true; }, 120);
};
// Enter übernimmt den ersten Vorschlag, Esc schließt die Liste.
window.einstSucheTaste = function(event){
if(event.key === 'Escape'){ const box = document.getElementById('planEinstVorschlaege'); if(box) box.hidden = true; return; }
if(event.key !== 'Enter') return;
event.preventDefault();
const erste = einstVorschlaege()[0];
if(erste && erste[1][0]) window.einstHinzufuegen(erste[1][0].person.id);
};
// einstHinzufuegen: nimmt eine Person mit ihrer ersten Fachrolle als
// Hauptverantwortung auf; weitere Rollen setzt man über die Kapseln.
window.einstHinzufuegen = function(personId){
const p = app.project;
if(!p || app.stammGesperrt()) return;
const e = einstTeamPersonen().find(x => x.person.id === personId);
if(!e) return;
const rolle = ROLES.find(r => e.rollen.has(r));
if(!rolle) return;
if(!p.assign) p.assign = {};
p.assign[assignKey(personId, rolle)] = 1;
einstNeu = personId;
einstEntfernt = null;
const feld = document.getElementById('planEinstSuche');
if(feld) feld.value = '';
const box = document.getElementById('planEinstVorschlaege');
if(box) box.hidden = true;
einstNachAenderung();
};
// renderEinstUebersicht: die Kurzfassung unter jedem Abschnitt der
// Unternavigation und welcher Abschnitt sichtbar ist.
function renderEinstUebersicht(){
const p = app.project;
if(!p) return;
const sandbox = app.isSandboxPlan();
const setze = (id, text) => { const el = document.getElementById(id); if(el) el.textContent = text; };
const gewerke = Array.isArray(p.gewerke) ? p.gewerke : [];
const amtierend = Array.isArray(p.aktuelleLP) && p.aktuelleLP[0] ? p.aktuelleLP[0] : '';
setze('einstSumAllgemein', sandbox ? [p.name || '', amtierend].filter(Boolean).join(' · ')
: [p.nr || 'ohne Nummer', gewerke.join(', ') || 'kein Gewerk', amtierend].filter(Boolean).join(' · '));
const honorar = Number(p.honorar) || 0;
const honorarText = honorar > 0 ? honorar.toLocaleString('de-DE') + ' €' : 'kein Honorar';
const status = (typeof AUFTRAGSSTATUS !== 'undefined' && typeof auftragsstatusOf === 'function')
? (AUFTRAGSSTATUS.find(s => s.key === auftragsstatusOf(p)) || {}).label : '';
setze('einstSumAuftrag', sandbox ? honorarText : [status, honorarText].filter(Boolean).join(' · '));
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP.length : 0;
const laeuft = Array.isArray(p.aktuelleLP) && p.aktuelleLP[0] ? ' · läuft: ' + p.aktuelleLP[0] : '';
setze('einstSumPhasen', `${beauftragt} von ${LPHS.length} beauftragt${laeuft}`);
const ids = new Set(Object.keys(p.assign || {}).filter(k => p.assign[k] > 0).map(k => k.split('|')[0]));
const unbesetzt = gewerke.filter(g => !einstTeamPersonen().some(e => ids.has(e.person.id) && personGewerk(e.person) === g));
setze('einstSumTeam', (ids.size === 1 ? '1 Person' : ids.size + ' Personen') + (unbesetzt.length ? ' · ' + unbesetzt.join(', ') + ' unbesetzt' : ''));
window.zeigeEinstTeil(einstTeil);
}
window.renderPlanSettings = function(){ window.renderPlanSettings = function(){
const p = app.project; const p = app.project;
if(!p) return; if(!p) return;
// Kürzel in der Titelleiste mitziehen — sonst zeigte sie eine geänderte const locked = app.isLocked();
// 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 sandbox = app.isSandboxPlan();
const lockHint = document.getElementById('planSettingsLockHint'); const lockHint = document.getElementById('planSettingsLockHint');
@@ -341,20 +50,16 @@
// Felder, die nur bei einem echten Projekt Bedeutung haben (Auftrags- // Felder, die nur bei einem echten Projekt Bedeutung haben (Auftrags-
// Workflow, Dateien-Zuordnung über die Nummer, Gewerke-Filter des // Workflow, Dateien-Zuordnung über die Nummer, Gewerke-Filter des
// Wochenberichts) — für die Planung-Sandbox // Wochenberichts, Auslastungs-Ersatzgröße) — für die Planung-Sandbox
// ausgeblendet, siehe CLAUDE.md "Planung — projektunabhängige // ausgeblendet, siehe CLAUDE.md "Planung — projektunabhängige
// Terminplan-Sandbox". Honorar + beauftragte Leistungsphasen (Grundlage // Terminplan-Sandbox". Honorar + beauftragte Leistungsphasen (Grundlage
// des Terminplan-Rechners) und die Beteiligten-Matrix bleiben sichtbar. // des Terminplan-Rechners) und die Beteiligten-Matrix bleiben sichtbar.
['settingsProjNr', 'settingsAuftragsstatus', 'settingsGewerke'] ['settingsProjNr', 'settingsAuftragsstatus', 'settingsGewerke', 'settingsGroesse']
.forEach(id => { .forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
const row = el && el.closest('.form-row'); const row = el && el.closest('.form-row');
if(row) row.style.display = sandbox ? 'none' : ''; if(row) row.style.display = sandbox ? 'none' : '';
}); });
['settingsProjNrHinweis'].forEach(id => {
const el = document.getElementById(id);
if(el) el.hidden = sandbox;
});
// Projektnummer und -name: das fokussierte Feld nicht überschreiben // Projektnummer und -name: das fokussierte Feld nicht überschreiben
// (dieselbe Vorsichtsmaßnahme wie beim Honorar weiter unten). // (dieselbe Vorsichtsmaßnahme wie beim Honorar weiter unten).
@@ -369,6 +74,25 @@
nameEl.disabled = locked; nameEl.disabled = locked;
} }
// Projektgröße: Sie ist der Ersatz für ein fehlendes Honorar, deshalb
// steht unter dem Regler, ob sie gerade überhaupt zählt. Eine Zahl, die
// wirkungslos ist, ohne dass man es sieht, wäre schlimmer als keine.
const groesseEl = document.getElementById('settingsGroesse');
if(groesseEl){
const g = Number(p.groesse) || 0;
if(document.activeElement !== groesseEl) groesseEl.value = g > 0 ? g : '';
groesseEl.disabled = locked;
const hinweisEl = document.getElementById('settingsGroesseHinweis');
if(hinweisEl){
const honorar = Number(p.honorar) || 0;
hinweisEl.textContent = honorar > 0
? 'Ein Honorar ist hinterlegt — die Auslastung rechnet damit, die Größe bleibt unberücksichtigt.'
: (g > 0
? 'Kein Honorar hinterlegt: Die Auslastung rechnet mit dieser Größe. 0 = keine Angabe, 1 = großes Projekt.'
: 'Ohne Honorar und ohne Größe fließt dieses Projekt in keine Auslastung ein. 0 = keine Angabe, 1 = großes Projekt.');
}
}
// Schlagwörter: dieselbe Chip-Liste wie im früheren Projekte-Tab. // Schlagwörter: dieselbe Chip-Liste wie im früheren Projekte-Tab.
const tagsEl = document.getElementById('settingsProjTags'); const tagsEl = document.getElementById('settingsProjTags');
if(tagsEl){ if(tagsEl){
@@ -388,12 +112,11 @@
const gewerkeEl = document.getElementById('settingsGewerke'); const gewerkeEl = document.getElementById('settingsGewerke');
if(gewerkeEl && typeof GEWERK_LABELS === 'object'){ if(gewerkeEl && typeof GEWERK_LABELS === 'object'){
const gesetzt = new Set(Array.isArray(p.gewerke) ? p.gewerke : []); const gesetzt = new Set(Array.isArray(p.gewerke) ? p.gewerke : []);
// Als Umschalt-Knöpfe (Einstellungen "C"): angetippt = gesetzt. gewerkeEl.innerHTML = Object.keys(GEWERK_LABELS).map(g => `<label class="beauftragte-lp-item" title="${esc(GEWERK_LABELS[g])}">
gewerkeEl.innerHTML = Object.keys(GEWERK_LABELS).map(g => { <input type="checkbox" value="${esc(g)}" ${gesetzt.has(g) ? 'checked' : ''} ${locked ? 'disabled' : ''}
const an = gesetzt.has(g); onchange="togglePlanGewerk('${esc(g)}', this.checked)">
return `<button type="button" class="plan-einst-chip${an ? ' an' : ''}" aria-pressed="${an}" title="${esc(GEWERK_LABELS[g])}" ${esc(g)}
${locked ? 'disabled' : ''} onclick="togglePlanGewerk('${esc(g)}', ${!an})">${esc(g)}</button>`; </label>`).join('');
}).join('');
} }
// Auftragsstatus: Das Auswahlfeld kommt fertig aus js/auftrag.js, damit // Auftragsstatus: Das Auswahlfeld kommt fertig aus js/auftrag.js, damit
@@ -404,6 +127,8 @@
statusEl.innerHTML = auftragsstatusSelectHtml(p, { disabled: locked }); statusEl.innerHTML = auftragsstatusSelectHtml(p, { disabled: locked });
} }
const aufwandBtn = document.getElementById('settingsAufwandBtn');
if(aufwandBtn) aufwandBtn.disabled = locked || typeof openLpModal !== 'function';
// Honorar: das fokussierte Feld nicht überschreiben, sonst springt beim // Honorar: das fokussierte Feld nicht überschreiben, sonst springt beim
// Tippen der Cursor (gleiche Vorsichtsmaßnahme wie im Honorar-Dialog). // Tippen der Cursor (gleiche Vorsichtsmaßnahme wie im Honorar-Dialog).
@@ -415,54 +140,17 @@
honorarEl.disabled = locked; honorarEl.disabled = locked;
} }
// Beauftragte Leistungsphasen: je Phase eine Zeile mit Umschalter // Beauftragte Leistungsphasen: dieselbe Kreuzchen-Liste wie im
// "Beauftragt | Nicht beauftragt", die laufende Phase trägt "läuft". // Honorar-Dialog, gespeichert über toggleBeauftragteLp() (plan-honorar.js).
// Gespeichert über toggleBeauftragteLp() (plan-honorar.js).
const lpEl = document.getElementById('settingsBeauftragteLp'); const lpEl = document.getElementById('settingsBeauftragteLp');
if(lpEl){ if(lpEl){
const gesetzt = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []); const gesetzt = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
const laeuft = Array.isArray(p.aktuelleLP) ? p.aktuelleLP[0] : ''; lpEl.innerHTML = LPHS.map(l => `<label class="beauftragte-lp-item">
lpEl.innerHTML = LPHS.map(l => { <input type="checkbox" value="${esc(l)}" ${gesetzt.has(l) ? 'checked' : ''} ${locked ? 'disabled' : ''}
const an = gesetzt.has(l); onchange="toggleBeauftragteLp('${esc(l)}', this.checked)">
return `<div class="plan-einst-phase${an ? ' ist-an' : ''}"> ${esc(l)}
<span class="plan-einst-phase-name"><b>${esc(l)}</b><span>${esc(LPH_BEZEICHNUNG[l] || '')}</span>${l === laeuft ? '<em>läuft</em>' : ''}</span> </label>`).join('');
<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. // setPlanProjNr: ändert die Projektnummer.
@@ -474,7 +162,7 @@
// Dateien nicht mehr; deshalb die Rückfrage. Parameter: val. Keine Rückgabe. // Dateien nicht mehr; deshalb die Rückfrage. Parameter: val. Keine Rückgabe.
window.setPlanProjNr = function(val){ window.setPlanProjNr = function(val){
const p = app.project; const p = app.project;
if(!p || app.stammGesperrt()) return; if(!p || app.isLocked()) return;
const neuVal = (val || '').trim(); const neuVal = (val || '').trim();
if(neuVal === (p.nr || '')) return; if(neuVal === (p.nr || '')) return;
if(p.nr && !confirm('Die Projektnummer "' + p.nr + '" wird zu "' + neuVal + '".\n\n' if(p.nr && !confirm('Die Projektnummer "' + p.nr + '" wird zu "' + neuVal + '".\n\n'
@@ -490,7 +178,7 @@
// setPlanProjName: ändert die Projektbezeichnung. Parameter: val. // setPlanProjName: ändert die Projektbezeichnung. Parameter: val.
window.setPlanProjName = function(val){ window.setPlanProjName = function(val){
const p = app.project; const p = app.project;
if(!p || app.stammGesperrt()) return; if(!p || app.isLocked()) return;
p.name = (val || '').trim(); p.name = (val || '').trim();
app.savePlan(); app.savePlan();
// Die Bezeichnung steht an drei weiteren Stellen: in der Kopfzeile des // Die Bezeichnung steht an drei weiteren Stellen: in der Kopfzeile des
@@ -508,12 +196,28 @@
renderPlanSettings(); renderPlanSettings();
}; };
// setPlanGroesse: übernimmt die Projektgröße (0–1). Gespeichert wird erst
// beim Loslassen des Reglers, nicht bei jedem Zwischenwert — sonst liefe
// ein Zug über die Skala als zwanzig Speichervorgänge auf den Server.
// Parameter: val. Keine Rückgabe.
window.setPlanGroesse = function(val){
const p = app.project;
if(!p || app.isLocked()) return;
const n = Math.max(0, Math.min(1, parseFloat(val) || 0));
if(n === (Number(p.groesse) || 0)) return;
p.groesse = n;
app.savePlan();
renderPlanSettings();
// Die Auslastung rechnet mit diesem Wert.
if(typeof renderLoad === 'function' && document.getElementById('loadGrid')) renderLoad();
};
// planTagKeydown: übernimmt ein Schlagwort mit Enter. Parameter: event. // planTagKeydown: übernimmt ein Schlagwort mit Enter. Parameter: event.
window.planTagKeydown = function(event){ window.planTagKeydown = function(event){
if(event.key !== 'Enter') return; if(event.key !== 'Enter') return;
event.preventDefault(); event.preventDefault();
const p = app.project; const p = app.project;
if(!p || app.stammGesperrt()) return; if(!p || app.isLocked()) return;
const tag = (event.target.value || '').trim(); const tag = (event.target.value || '').trim();
if(!tag) return; if(!tag) return;
if(!Array.isArray(p.tags)) p.tags = []; if(!Array.isArray(p.tags)) p.tags = [];
@@ -526,7 +230,7 @@
// planRemoveTag: entfernt ein Schlagwort. Parameter: tag. // planRemoveTag: entfernt ein Schlagwort. Parameter: tag.
window.planRemoveTag = function(tag){ window.planRemoveTag = function(tag){
const p = app.project; const p = app.project;
if(!p || app.stammGesperrt() || !Array.isArray(p.tags)) return; if(!p || app.isLocked() || !Array.isArray(p.tags)) return;
p.tags = p.tags.filter(t => t !== tag); p.tags = p.tags.filter(t => t !== tag);
app.savePlan(); app.savePlan();
renderPlanSettings(); renderPlanSettings();
@@ -539,7 +243,7 @@
// GEWERK_LABELS (js/projekt-wizard.js), damit sie nicht bei jeder Änderung // GEWERK_LABELS (js/projekt-wizard.js), damit sie nicht bei jeder Änderung
// durcheinandergerät. Parameter: gewerk, checked (Boolean). // durcheinandergerät. Parameter: gewerk, checked (Boolean).
window.togglePlanGewerk = function(gewerk, checked){ window.togglePlanGewerk = function(gewerk, checked){
if(app.stammGesperrt()) return; if(app.isLocked()) return;
const p = app.project; const p = app.project;
const set = new Set(Array.isArray(p.gewerke) ? p.gewerke : []); const set = new Set(Array.isArray(p.gewerke) ? p.gewerke : []);
if(checked) set.add(gewerk); else set.delete(gewerk); if(checked) set.add(gewerk); else set.delete(gewerk);
@@ -547,4 +251,21 @@
app.savePlan(); app.savePlan();
renderPlanSettings(); renderPlanSettings();
}; };
// planOpenAufwand: öffnet die Aufwands-Gewichtungsmatrix. Der Dialog selbst
// liegt in js/load/load.js (openLpModal) und wird hier nur aufgerufen —
// er gehörte immer zum Projekt, stand aber bisher nur im Projekte-Tab.
window.planOpenAufwand = function(){
const p = app.project;
if(!p || typeof openLpModal !== 'function') return;
// openLpModal() sucht das Projekt in currentWeek(). Zeigt der
// Projektbereich eine ANDERE Sitzung, findet es dort nichts und täte
// stillschweigend nichts — deshalb hier der ausdrückliche Hinweis.
const w = typeof currentWeek === 'function' ? currentWeek() : null;
if(!w || !Array.isArray(w.projects) || !w.projects.some(x => x.id === p.id)){
app.showToast('Der Aufwand lässt sich nur in der laufenden Sitzung bearbeiten.');
return;
}
openLpModal(p.id);
};
})(); })();
+19 -72
View File
@@ -1,7 +1,7 @@
// ----------------------------- // -----------------------------
// Projektbereich — Zustand, Einstieg & Projektauswahl // Projektbereich — Zustand, Einstieg & Projektauswahl
// ----------------------------- // -----------------------------
// Dieses Modul wird als erstes der plan-*-Module von public/App.html geladen // Dieses Modul wird als erstes der plan-*-Module von public/Test.html geladen
// und definiert window.PlanApp als gemeinsamen Zustand aller Module des // und definiert window.PlanApp als gemeinsamen Zustand aller Module des
// Projektbereichs. // Projektbereichs.
// //
@@ -9,7 +9,7 @@
// einmal eine eigene Seite (public/project-plan.html) mit eigenem // einmal eine eigene Seite (public/project-plan.html) mit eigenem
// Einstiegspunkt. Sie las Sitzung und Projekt aus der URL und lud sich ihren // Einstiegspunkt. Sie las Sitzung und Projekt aus der URL und lud sich ihren
// Datenbestand über GET /load-all selbst. Beides ist entfallen: Der // Datenbestand über GET /load-all selbst. Beides ist entfallen: Der
// Projektbereich ist jetzt ein Bereich der Hauptseite (#plan in App.html), // Projektbereich ist jetzt ein Bereich der Hauptseite (#plan in Test.html),
// der über showTab('plan') eingeblendet wird wie jeder andere. Er arbeitet // der über showTab('plan') eingeblendet wird wie jeder andere. Er arbeitet
// deshalb auf dem bereits geladenen In-Memory-Bestand db.weeks (js/store.js) // deshalb auf dem bereits geladenen In-Memory-Bestand db.weeks (js/store.js)
// und braucht weder einen eigenen Ladevorgang noch eine eigene // und braucht weder einen eigenen Ladevorgang noch eine eigene
@@ -36,6 +36,7 @@ window.PlanApp = {
week: null, // planWeek week: null, // planWeek
project: null, // planProject project: null, // planProject
pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen) pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen)
criticalPathActive: false, // Hervorhebung des kritischen Pfads im GANTT aktiv
// Drag-Zustände // Drag-Zustände
todoDrag: null, // Drag-and-Drop von TODO/Meilenstein-Zeilen todoDrag: null, // Drag-and-Drop von TODO/Meilenstein-Zeilen
ganttDrag: null, // Balken/Meilenstein/Urlaub verschieben ganttDrag: null, // Balken/Meilenstein/Urlaub verschieben
@@ -99,21 +100,8 @@ window.PlanApp = {
// showTab('plan') gerufen (und von dort aus auch nach jedem erneuten // showTab('plan') gerufen (und von dort aus auch nach jedem erneuten
// Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt // Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt
// gefunden und gerendert wurde. // 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(){ window.openPlanView = function(){
app.showPlanNav(); 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 // Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte
// "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js) // "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js)
@@ -134,9 +122,6 @@ window.PlanApp = {
app.projNr = ''; app.projNr = '';
app.projName = app.project.name || ''; app.projName = app.project.name || '';
renderPlanPage(); renderPlanPage();
// showTab() hat die Adresse schon vor diesem Aufruf geschrieben, als
// der Name noch fehlte (/Projektplan/?…) — jetzt mit Namen nachziehen.
if(typeof updateRouteUrl === 'function') updateRouteUrl('plan');
return true; return true;
} }
@@ -156,19 +141,6 @@ window.PlanApp = {
} }
app.week = db.weeks.find(w => w.label === app.weekLabel) || null; app.week = db.weeks.find(w => w.label === app.weekLabel) || null;
if(!app.week){ 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.'); planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.');
return false; return false;
} }
@@ -205,21 +177,10 @@ window.PlanApp = {
return app.project.plan; return app.project.plan;
} }
// isLocked: liefert true, wenn der TERMINPLAN nicht bearbeitet werden darf // isLocked: liefert true, wenn die aktuelle Woche gesperrt ist (dann sind
// — gesperrte Sitzung oder fehlendes Recht „Terminplan bearbeiten“ (bei // Bearbeitungen im Plan gesperrt). Rückgabe: Boolescher Wert.
// einem Planung-Plan: „Planung“; Rollen & Rechte). Rückgabe: Boolean.
function isLocked(){ function isLocked(){
return !!app.week.locked || !hatRecht(isSandboxPlan() ? 'wz.planung' : 'proj.plan'); return !!app.week.locked;
}
// stammGesperrt: dasselbe für Projekteinstellungen, Gruppe, amtierende
// Leistungsphase und Honorar (Recht „Stammdaten und Beteiligte“).
function stammGesperrt(){
return !!app.week.locked || !hatRecht(isSandboxPlan() ? 'wz.planung' : 'proj.stamm');
}
// protokolleGesperrt: Protokolle (Recht „Protokolle bearbeiten“), in einer
// gesperrten Sitzung wie bisher gesperrt.
function protokolleGesperrt(){
return !!app.week.locked || !hatRecht('proj.protokolle');
} }
// isSandboxPlan: liefert true, wenn der aktuell geöffnete Plan zur // isSandboxPlan: liefert true, wenn der aktuell geöffnete Plan zur
@@ -277,6 +238,8 @@ window.PlanApp = {
// Bereichs), lädt bei vorhandener Projektnummer die Protokollliste. // Bereichs), lädt bei vorhandener Projektnummer die Protokollliste.
if(typeof initPlanProtokoll === 'function') initPlanProtokoll(); if(typeof initPlanProtokoll === 'function') initPlanProtokoll();
// Verlauf über alle Sitzungen (Kommentar-Historie) — siehe js/plan/plan-history.js.
renderPlanHistory();
// Einstellungen-Bereich (Honorar, beauftragte Leistungsphasen, // Einstellungen-Bereich (Honorar, beauftragte Leistungsphasen,
// Projektangaben) — siehe js/plan/plan-settings.js. // Projektangaben) — siehe js/plan/plan-settings.js.
@@ -284,12 +247,10 @@ window.PlanApp = {
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten // Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche: // Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
// Terminplan (…Btn), Aufgaben (…BtnList) und Leistungsphasen (…BtnPhasen), // Terminplan (…Btn) und Aufgaben (…BtnList), siehe Test.html.
// siehe App.html.
const locked = isLocked(); const locked = isLocked();
['addMilestoneBtn', 'addPhaseBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn', ['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'autoScheduleBtn',
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList', 'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'autoScheduleBtnList']
'honorarBtnPhasen', 'addPhaseBtnPhasen']
.forEach(id => { .forEach(id => {
const btn = document.getElementById(id); const btn = document.getElementById(id);
if(btn) btn.style.display = locked ? 'none' : ''; if(btn) btn.style.display = locked ? 'none' : '';
@@ -302,8 +263,10 @@ window.PlanApp = {
document.querySelectorAll('#planNav .plan-nav-item[data-section="dateien"], #planNav .plan-nav-item[data-section="leistung"], #planNav .plan-nav-item[data-section="protokolle"]') document.querySelectorAll('#planNav .plan-nav-item[data-section="dateien"], #planNav .plan-nav-item[data-section="leistung"], #planNav .plan-nav-item[data-section="protokolle"]')
.forEach(btn => { btn.style.display = sandbox ? 'none' : ''; }); .forEach(btn => { btn.style.display = sandbox ? 'none' : ''; });
// Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gilt für // Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gibt es nur für
// Planung-Pläne UND echte Projekte, der Knopf ist immer sichtbar. // Sandbox-Pläne der Planung — ein echtes Projekt hat kein Äquivalent.
const sandboxPdfBtn = document.getElementById('sandboxPdfBtn');
if(sandboxPdfBtn) sandboxPdfBtn.style.display = sandbox ? '' : 'none';
// GANTT inkl. TODOs (rendert am Ende auch Aufgabenliste und Dashboard mit) // GANTT inkl. TODOs (rendert am Ende auch Aufgabenliste und Dashboard mit)
fitPlanGanttRange(); fitPlanGanttRange();
@@ -312,22 +275,6 @@ window.PlanApp = {
// js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle // js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle
// Bereiche befüllt sind, wird einer davon eingeblendet. // Bereiche befüllt sind, wird einer davon eingeblendet.
app.initPlanSections(); 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) // Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine)
@@ -355,8 +302,10 @@ window.PlanApp = {
function renderProjectAssignees(){ function renderProjectAssignees(){
const assignees = projectAssignees(app.project, app.week); const assignees = projectAssignees(app.project, app.week);
if(assignees.length === 0) return ''; if(assignees.length === 0) return '';
// Eine Person mit mehreren Rollen nur einmal (beteiligteJePerson(), utils.js). return assignees.map(a => {
return beteiligteJePerson(assignees).map(b => beteiligtenAvatarHtml(b, planRoleClass(b.role))).join(''); 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('');
} }
// savePlan: merkt die Sitzung des Projektbereichs als geändert vor und // savePlan: merkt die Sitzung des Projektbereichs als geändert vor und
@@ -445,8 +394,6 @@ window.PlanApp = {
// (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können. // (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können.
app.ensurePlanLocal = ensurePlanLocal; app.ensurePlanLocal = ensurePlanLocal;
app.isLocked = isLocked; app.isLocked = isLocked;
app.stammGesperrt = stammGesperrt;
app.protokolleGesperrt = protokolleGesperrt;
app.isSandboxPlan = isSandboxPlan; app.isSandboxPlan = isSandboxPlan;
app.removeDependencyRefs = removeDependencyRefs; app.removeDependencyRefs = removeDependencyRefs;
app.renderProjectAssignees = renderProjectAssignees; app.renderProjectAssignees = renderProjectAssignees;
+2 -36
View File
@@ -75,18 +75,6 @@
return e < today; return e < today;
} }
// Phasenfilter der Aufgabenliste: '' = alle, 'LPH 5', oder '-' = ohne
// Phase. Gilt nur für das Projekt, für das er gesetzt wurde.
let filter = { projekt: null, phase: '' };
const filterPhase = () => filter.projekt === app.project ? filter.phase : '';
// setzeAufgabenFilter: von außen (Bereich Leistungsphasen) oder über die
// Filterknöpfe. Parameter: phase ('' | 'LPH x' | '-').
window.setzeAufgabenFilter = function(phase){
filter = { projekt: app.project, phase: phase || '' };
window.renderPlanTasks();
};
// renderPlanTasks: baut die Aufgaben- und die Meilensteinliste des Bereichs // renderPlanTasks: baut die Aufgaben- und die Meilensteinliste des Bereichs
// "Aufgaben" neu auf und schreibt die Zähler in die Kartenüberschriften. // "Aufgaben" neu auf und schreibt die Zähler in die Kartenüberschriften.
// Keine Parameter, keine Rückgabe. // Keine Parameter, keine Rückgabe.
@@ -97,25 +85,7 @@
// ---- Aufgaben ---- // ---- Aufgaben ----
const todoEl = document.getElementById('planTodoList'); const todoEl = document.getElementById('planTodoList');
const alleTodos = Array.isArray(plan.todos) ? plan.todos : []; const todos = Array.isArray(plan.todos) ? plan.todos : [];
// Phasenbalken stehen im eigenen Bereich "Leistungsphasen"
// (plan-phasen.js), nicht in der Aufgabenliste. idx bleibt der Index in
// plan.todos, den die Dialoge erwarten.
const eintraege = alleTodos.map((t, idx) => ({ t, idx, phase: aufgabenPhase(t, alleTodos) }))
.filter(e => !balkenPhase(e.t));
const todos = eintraege.map(e => e.t);
const fp = filterPhase();
const sichtbar = eintraege.filter(e => !fp || (fp === '-' ? !e.phase : e.phase === fp));
const filterEl = document.getElementById('planTodoFilter');
if(filterEl){
const phasen = LPHS.filter(l => eintraege.some(e => e.phase === l));
const knopf = (wert, text) => `<button type="button" class="plan-task-filter-btn${fp === wert ? ' is-active' : ''}" onclick="setzeAufgabenFilter('${esc(wert)}')">${esc(text)}</button>`;
filterEl.innerHTML = phasen.length
? '<span>Phase:</span>' + knopf('', 'Alle') + phasen.map(l => knopf(l, l)).join('')
+ (eintraege.some(e => !e.phase) ? knopf('-', 'ohne Phase') : '')
: '';
}
if(todoEl){ if(todoEl){
// In gesperrten Sitzungen sind die Anlege-Schaltflächen ausgeblendet // In gesperrten Sitzungen sind die Anlege-Schaltflächen ausgeblendet
// (renderPlanPage(), plan-state.js) — dann darf der Leertext auch nicht // (renderPlanPage(), plan-state.js) — dann darf der Leertext auch nicht
@@ -125,9 +95,7 @@
: 'Noch keine Aufgaben. Über „+ Aufgabe“, „+ Vorlage“ oder „+ Terminplan“ anlegen.'; : 'Noch keine Aufgaben. Über „+ Aufgabe“, „+ Vorlage“ oder „+ Terminplan“ anlegen.';
todoEl.innerHTML = todos.length === 0 todoEl.innerHTML = todos.length === 0
? '<div class="plan-task-empty">' + leerTextTodos + '</div>' ? '<div class="plan-task-empty">' + leerTextTodos + '</div>'
: sichtbar.length === 0 : todos.map((t, idx) => {
? '<div class="plan-task-empty">Keine Aufgaben in dieser Auswahl.</div>'
: sichtbar.map(({ t, idx, phase }) => {
const status = todoStatus(t); const status = todoStatus(t);
const range = dateRangeText(t); const range = dateRangeText(t);
const overdue = isOverdue(t); const overdue = isOverdue(t);
@@ -141,8 +109,6 @@
${check} ${check}
<span class="plan-task-dot ${status}"></span> <span class="plan-task-dot ${status}"></span>
<span class="plan-task-title">${esc(t.title || 'Aufgabe')}</span> <span class="plan-task-title">${esc(t.title || 'Aufgabe')}</span>
${t.notiz ? `<span class="plan-task-notiz" title="${esc(String(t.notiz).slice(0, 300))}">${icon('fileText')}</span>` : ''}
${phase ? `<span class="plan-task-phase" title="Liegt im Zeitraum dieser Leistungsphase">${esc(phase)}</span>` : ''}
${assigneeAvatars(t.assignees)} ${assigneeAvatars(t.assignees)}
${progress} ${progress}
<span class="plan-task-meta${overdue ? ' overdue' : ''}" title="${range ? '' : 'Ohne Termin — erscheint nicht im Terminplan'}">${range ? esc(range) : '<span class="plan-task-no-schedule">ohne Termin</span>'}</span> <span class="plan-task-meta${overdue ? ' overdue' : ''}" title="${range ? '' : 'Ohne Termin — erscheint nicht im Terminplan'}">${range ? esc(range) : '<span class="plan-task-no-schedule">ohne Termin</span>'}</span>
+20 -127
View File
@@ -4,8 +4,7 @@
// In einem Ingenieurbüro wiederholt sich der Ablauf einer Leistungsphase über // In einem Ingenieurbüro wiederholt sich der Ablauf einer Leistungsphase über
// alle Projekte hinweg. Dieses Modul macht daraus wiederverwendbare Vorlagen: // alle Projekte hinweg. Dieses Modul macht daraus wiederverwendbare Vorlagen:
// Der Button "+ Vorlage" in der GANTT-Toolbar öffnet #lphTemplateModal, dort // Der Button "+ Vorlage" in der GANTT-Toolbar öffnet #lphTemplateModal, dort
// wählt man Gewerk, Leistungsphase und Startdatum (Vorlagen gibt es je // wählt man eine Leistungsphase und ein Startdatum, hakt die gewünschten
// Gewerk), hakt die gewünschten
// Schritte an und fügt sie mit einem Klick als TODOs in den Projektplan ein. // Schritte an und fügt sie mit einem Klick als TODOs in den Projektplan ein.
// //
// Der Honorar-Terminplan (js/plan/plan-honorar.js) ist ein eigener Dialog mit // Der Honorar-Terminplan (js/plan/plan-honorar.js) ist ein eigener Dialog mit
@@ -17,7 +16,7 @@
// lph_templates): GET für alle angemeldeten Nutzer, POST/DELETE nur für // lph_templates): GET für alle angemeldeten Nutzer, POST/DELETE nur für
// Admins — das Einfügen darf jeder, das Pflegen der firmenweiten Vorlagen // Admins — das Einfügen darf jeder, das Pflegen der firmenweiten Vorlagen
// nicht. Die Pflege-Maske selbst steht seit dem Umbau NICHT mehr in diesem // nicht. Die Pflege-Maske selbst steht seit dem Umbau NICHT mehr in diesem
// Dialog, sondern im Admin-Bereich „Aufgaben-Vorlagen" (js/admin/admin.js) — // Dialog, sondern im Bereich „Einstellungen" (js/settings/settings.js) —
// hier bleibt für Administratoren nur ein Verweis dorthin. // hier bleibt für Administratoren nur ein Verweis dorthin.
// //
// WICHTIG: Eingefügte Aufgaben sind danach ganz normale plan_todos ohne // WICHTIG: Eingefügte Aufgaben sind danach ganz normale plan_todos ohne
@@ -47,28 +46,13 @@
if(app.isLocked()) return; if(app.isLocked()) return;
const lphSelect = document.getElementById('templateLphSelect'); const lphSelect = document.getElementById('templateLphSelect');
// Bei JEDEM Öffnen neu vorbelegen: Der Projektbereich zeigt nacheinander if(lphSelect && !lphSelect.options.length){
// verschiedene Projekte im selben Dokument — sonst blieb die amtierende
// Leistungsphase des zuerst geöffneten Projekts vorgewählt.
if(lphSelect){
// Vorbelegung: die amtierende Leistungsphase des Projekts, sonst LPH 1 // Vorbelegung: die amtierende Leistungsphase des Projekts, sonst LPH 1
const aktLp = Array.isArray(app.project.aktuelleLP) ? app.project.aktuelleLP[0] : ''; const aktLp = Array.isArray(app.project.aktuelleLP) ? app.project.aktuelleLP[0] : '';
lphSelect.innerHTML = TEMPLATE_LPHS.map(l => lphSelect.innerHTML = TEMPLATE_LPHS.map(l =>
`<option value="${esc(l)}" ${l === aktLp ? 'selected' : ''}>${esc(l)}</option>` `<option value="${esc(l)}" ${l === aktLp ? 'selected' : ''}>${esc(l)}</option>`
).join(''); ).join('');
} }
// Gewerk vorbelegen: das eigene, wenn das Projekt es hat, sonst das
// erste Gewerk des Projekts, sonst das eigene (Vorlagen gibt es je
// Gewerk, Eriks Wunsch).
const gewSelect = document.getElementById('templateGewerkSelect');
if(gewSelect){
const projGew = Array.isArray(app.project.gewerke) ? app.project.gewerke : [];
const eigen = currentUser && Object.keys(DEPARTMENTS).includes(currentUser.department) ? currentUser.department : '';
const vorwahl = (eigen && projGew.includes(eigen)) ? eigen : (projGew[0] || eigen || Object.keys(DEPARTMENTS)[0]);
gewSelect.innerHTML = Object.keys(DEPARTMENTS).map(d =>
`<option value="${esc(d)}" ${d === vorwahl ? 'selected' : ''}>${esc(d)}${projGew.includes(d) ? '' : ' (nicht im Projekt)'}</option>`
).join('');
}
// Startdatum vorbelegen: heute // Startdatum vorbelegen: heute
const startInput = document.getElementById('templateStart'); const startInput = document.getElementById('templateStart');
const startDisplay = document.getElementById('templateStartDisplay'); const startDisplay = document.getElementById('templateStartDisplay');
@@ -78,7 +62,6 @@
if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today)); if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today));
} }
templatePhaseVorbelegen();
document.getElementById('lphTemplateModal').style.display = 'flex'; document.getElementById('lphTemplateModal').style.display = 'flex';
loadTemplates(); loadTemplates();
}; };
@@ -105,88 +88,12 @@
}); });
} }
// templatePhaseVorbelegen: Hat das Projekt einen Phasenbalken der gewählten
// Leistungsphase, übernehmen Startdatum und „Dauer der Phase“ seinen
// Zeitraum (in Arbeitstagen). Sonst bleibt das Startdatum, die Dauer ist
// von Hand anzugeben. Keine Parameter.
window.templatePhaseVorbelegen = function(){
const lph = (document.getElementById('templateLphSelect') || {}).value || '';
const todos = (app.ensurePlanLocal().todos || []);
const balken = todos.find(t => typeof balkenPhase === 'function' && balkenPhase(t) === lph && t.start && t.end);
const tageEl = document.getElementById('templatePhasenTage');
const hinweis = document.getElementById('templatePhasenHinweis');
if(balken){
const s = parseDateISO(balken.start), e = parseDateISO(balken.end);
const startInput = document.getElementById('templateStart');
const startDisplay = document.getElementById('templateStartDisplay');
if(startInput) startInput.value = balken.start;
if(startDisplay) startDisplay.value = formatDateDE(s);
if(tageEl) tageEl.value = Math.max(1, workdaysBetween(s, e));
if(hinweis) hinweis.textContent = 'aus dem Phasenbalken ' + lph + ' (' + formatDateDE(s) + ' – ' + formatDateDE(e) + ')';
} else {
if(tageEl) tageEl.value = '';
if(hinweis) hinweis.textContent = 'Kein Phasenbalken für ' + lph + ' — Dauer bitte angeben.';
}
};
// templatePlan: Termine der Schritte nach ihrem Anteil (Eriks Wunsch, z. B.
// Siemon-Tabelle): Jeder Schritt mit Anteil bekommt Anteil ÷ Summe aller
// Anteile der Phase an deren Dauer, nacheinander ab dem Startdatum in der
// gepflegten Reihenfolge (Arbeitstage, ohne Wochenenden/Feiertage). Die
// Grenzen werden über die laufende Summe gerundet, damit sich keine
// Rundungsfehler aufaddieren. Schritte ohne Anteil: Start/Dauer der
// Vorlage, ohne Dauer ohne Termin. Parameter: entries (alle Schritte der
// Phase), startDate, phasenTage. Rückgabe: Map id → {s, e} oder null.
function templatePlan(entries, startDate, phasenTage){
const out = new Map();
const summe = entries.reduce((a, t) => a + (Number(t.anteil) || 0), 0);
let kum = 0;
entries.forEach(t => {
const ant = Number(t.anteil) || 0;
if(ant > 0 && summe > 0){
if(!startDate || !(phasenTage > 0)){ out.set(t.id, null); return; }
const von = Math.round(phasenTage * kum / summe);
const bis = Math.round(phasenTage * (kum + ant) / summe);
kum += ant;
const s = addWorkdays(startDate, von);
const e = addWorkdays(s, Math.max(1, bis - von) - 1);
out.set(t.id, { s, e, anteil: ant / summe });
} else if(t.enthaltenIn){
out.set(t.id, null); // zweiter Durchgang unten
} else if(t.durationDays > 0 && startDate){
const s = addDays(startDate, t.offsetDays);
out.set(t.id, { s, e: addDays(s, t.durationDays - 1) });
} else {
out.set(t.id, null);
}
});
// „in a) enthalten“ (Siemon): zeitgleich mit den genannten Schritten —
// vom frühesten Beginn bis zum spätesten Ende. Der Buchstabe eines
// Schritts steht am Ende seines Titels („… (a)“).
const buchstabe = t => (String(t.title).match(/\(([a-z])\)\s*$/i) || [])[1];
entries.filter(t => t.enthaltenIn).forEach(t => {
const wirte = t.enthaltenIn.split(',')
.map(b => entries.find(x => !x.enthaltenIn && (buchstabe(x) || '').toLowerCase() === b))
.map(x => x && out.get(x.id)).filter(Boolean);
if(!wirte.length) return;
out.set(t.id, {
s: new Date(Math.min(...wirte.map(w => w.s.getTime()))),
e: new Date(Math.max(...wirte.map(w => w.e.getTime()))),
enthaltenIn: t.enthaltenIn
});
});
return out;
}
// templatesForSelectedLph: die Vorlagen-Schritte der gerade gewählten // templatesForSelectedLph: die Vorlagen-Schritte der gerade gewählten
// Leistungsphase, in ihrer gepflegten Reihenfolge. Rückgabe: Array. // Leistungsphase, in ihrer gepflegten Reihenfolge. Rückgabe: Array.
function templatesForSelectedLph(){ function templatesForSelectedLph(){
const lphSelect = document.getElementById('templateLphSelect'); const lphSelect = document.getElementById('templateLphSelect');
const gewSelect = document.getElementById('templateGewerkSelect');
const lph = lphSelect ? lphSelect.value : ''; const lph = lphSelect ? lphSelect.value : '';
const g = gewSelect ? gewSelect.value : ''; return templateCache.filter(t => t.lph === lph);
// Vorlagen ohne Gewerk (Altbestand) gelten für alle Gewerke
return templateCache.filter(t => t.lph === lph && (!t.gewerk || t.gewerk === g));
} }
// renderTemplateList: baut die Auswahlliste der Schritte (Checkbox je // renderTemplateList: baut die Auswahlliste der Schritte (Checkbox je
@@ -200,23 +107,18 @@
const entries = templatesForSelectedLph(); const entries = templatesForSelectedLph();
const startIso = (document.getElementById('templateStart') || {}).value || ''; const startIso = (document.getElementById('templateStart') || {}).value || '';
const startDate = parseDateISO(startIso); const startDate = parseDateISO(startIso);
const phasenTage = parseInt((document.getElementById('templatePhasenTage') || {}).value, 10) || 0;
const plan = templatePlan(entries, startDate, phasenTage);
if(entries.length === 0){ if(entries.length === 0){
listEl.innerHTML = '<span class="hint">Für dieses Gewerk und diese Leistungsphase ist noch keine Vorlage hinterlegt.</span>'; listEl.innerHTML = '<span class="hint">Für diese Leistungsphase ist noch keine Vorlage hinterlegt.</span>';
} else { } else {
listEl.innerHTML = entries.map(t => { listEl.innerHTML = entries.map(t => {
// Vorschau der konkreten Termine (nur wenn ein Startdatum gesetzt ist) // Vorschau der konkreten Termine (nur wenn ein Startdatum gesetzt ist)
const termin = plan.get(t.id); let dateHint = `Tag +${t.offsetDays}, ${t.durationDays} Tag${t.durationDays === 1 ? '' : 'e'}`;
let dateHint; if(startDate){
if(termin) dateHint = formatDateDE(termin.s) + '–' + formatDateDE(termin.e) const s = addDays(startDate, t.offsetDays);
+ (termin.anteil ? ' · ' + String(t.anteil).replace('.', ',') + ' %' : '') const e = addDays(s, Math.max(0, t.durationDays - 1));
+ (termin.enthaltenIn ? ' · in ' + termin.enthaltenIn.split(',').map(x => x + ')').join(' + ') : ''); dateHint = formatDateDE(s) + '–' + formatDateDE(e);
else if(t.enthaltenIn) dateHint = 'in ' + t.enthaltenIn.split(',').map(x => x + ')').join(' + ') + ' — Dauer der Phase fehlt'; }
else if(Number(t.anteil) > 0) dateHint = String(t.anteil).replace('.', ',') + ' % — Dauer der Phase fehlt';
else if(t.durationDays > 0) dateHint = `Tag +${t.offsetDays}, ${t.durationDays} Tag${t.durationDays === 1 ? '' : 'e'}`;
else dateHint = 'ohne Termin';
return `<label class="todo-depends-item"> return `<label class="todo-depends-item">
<input type="checkbox" value="${esc(t.id)}" checked> <input type="checkbox" value="${esc(t.id)}" checked>
<span style="flex:1">${esc(t.title)}</span> <span style="flex:1">${esc(t.title)}</span>
@@ -229,13 +131,13 @@
}; };
// renderTemplateHint: blendet für Administratoren den Verweis auf den // renderTemplateHint: blendet für Administratoren den Verweis auf den
// Admin-Bereich „Aufgaben-Vorlagen" ein. Das Anlegen und Löschen von Schritten stand // Bereich „Einstellungen" ein. Das Anlegen und Löschen von Schritten stand
// bis zum Umbau hier im Dialog; es liegt jetzt in js/admin/admin.js. // bis zum Umbau hier im Dialog; es liegt jetzt in js/settings/settings.js.
// Dieser Dialog FÜGT Vorlagen nur noch ein. Keine Parameter, keine Rückgabe. // Dieser Dialog FÜGT Vorlagen nur noch ein. Keine Parameter, keine Rückgabe.
function renderTemplateHint(){ function renderTemplateHint(){
const el = document.getElementById('templateAdminHint'); const el = document.getElementById('templateAdminHint');
if(!el) return; if(!el) return;
el.style.display = hatRecht('a.vorlagen') ? '' : 'none'; el.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
} }
// applyTemplate: erzeugt aus den angehakten Vorlagen-Schritten neue TODOs im // applyTemplate: erzeugt aus den angehakten Vorlagen-Schritten neue TODOs im
@@ -247,6 +149,7 @@
if(app.isLocked()) return; if(app.isLocked()) return;
const startIso = (document.getElementById('templateStart') || {}).value || ''; const startIso = (document.getElementById('templateStart') || {}).value || '';
const startDate = parseDateISO(startIso); const startDate = parseDateISO(startIso);
if(!startDate){ alert('Bitte ein Startdatum wählen.'); return; }
const listEl = document.getElementById('templateTaskList'); const listEl = document.getElementById('templateTaskList');
const checkedIds = listEl const checkedIds = listEl
@@ -254,26 +157,16 @@
: []; : [];
if(checkedIds.length === 0){ alert('Bitte mindestens einen Schritt auswählen.'); return; } if(checkedIds.length === 0){ alert('Bitte mindestens einen Schritt auswählen.'); return; }
const alle = templatesForSelectedLph(); const entries = templatesForSelectedLph().filter(t => checkedIds.includes(t.id));
const entries = alle.filter(t => checkedIds.includes(t.id));
// Ein Startdatum braucht es nur für Schritte mit Anteil oder Dauer —
// ohne beides „ohne Termin“ (Aufgabe nur in der Liste, nicht im Gantt).
if(!startDate && entries.some(t => t.durationDays > 0 || Number(t.anteil) > 0 || t.enthaltenIn)){ alert('Bitte ein Startdatum wählen.'); return; }
const phasenTage = parseInt((document.getElementById('templatePhasenTage') || {}).value, 10) || 0;
if(!phasenTage && entries.some(t => Number(t.anteil) > 0 || t.enthaltenIn)){ alert('Bitte die Dauer der Phase in Arbeitstagen angeben — die Schritte bekommen ihren Anteil davon.'); return; }
// Anteile beziehen sich auf ALLE Schritte der Phase: Abgewählte lassen
// ihre Lücke, die übrigen behalten ihren Platz im Ablauf.
const termine = templatePlan(alle, startDate, phasenTage);
const plan = app.ensurePlanLocal(); const plan = app.ensurePlanLocal();
entries.forEach(t => { entries.forEach(t => {
const termin = termine.get(t.id); const s = addDays(startDate, t.offsetDays);
const s = termin ? termin.s : null; const e = addDays(s, Math.max(0, t.durationDays - 1));
const e = termin ? termin.e : null;
plan.todos.push({ plan.todos.push({
id: uid(), id: uid(),
title: t.title, title: t.title,
start: s ? formatDateISO(s) : '', start: formatDateISO(s),
end: e ? formatDateISO(e) : '', end: formatDateISO(e),
done: false, done: false,
progress: 0, progress: 0,
dependsOn: [], dependsOn: [],
-305
View File
@@ -1,305 +0,0 @@
// -----------------------------
// 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 = '';
}
+1 -4
View File
@@ -106,7 +106,7 @@ function erstellePlanungTerminplan(){
id: uid(), projectUid: uid(), id: uid(), projectUid: uid(),
nr: '', name, group: '', gewerke: [], tags: [], nr: '', name, group: '', gewerke: [], tags: [],
status: '', order: plaene.length, lph: {}, assign: {}, plan: null, status: '', order: plaene.length, lph: {}, assign: {}, plan: null,
auftragsstatus: 'beauftragt', honorar: 0, auftragsstatus: 'beauftragt', honorar: 0, groesse: 0,
beauftragteLP: [], aktuelleLP: [], besprochen: false, beauftragteLP: [], aktuelleLP: [], besprochen: false,
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
@@ -126,8 +126,5 @@ function erstellePlanungTerminplan(){
if(typeof showPlanSection === 'function') showPlanSection('einstellungen'); if(typeof showPlanSection === 'function') showPlanSection('einstellungen');
const honorarEl = document.getElementById('settingsHonorar'); const honorarEl = document.getElementById('settingsHonorar');
if(honorarEl) honorarEl.focus(); if(honorarEl) honorarEl.focus();
} else if(typeof showPlanSection === 'function'){
// "Leer beginnen" führt direkt in den (leeren) Terminplan.
showPlanSection('terminplan');
} }
} }
+5 -14
View File
@@ -25,20 +25,10 @@
// delProjectFromList: löscht ein Projekt (per Bestätigungsdialog) in der // delProjectFromList: löscht ein Projekt (per Bestätigungsdialog) in der
// Listen-Ansicht. Parameter `id`: Projekt-ID. Keine Rückgabe; bei gesperrter // Listen-Ansicht. Parameter `id`: Projekt-ID. Keine Rückgabe; bei gesperrter
// Sitzung abgebrochen. Nur für Projektleiter und Administratoren // Sitzung abgebrochen.
// (darfProjektlisteAendern(), js/auth.js; serverseitig in /save-week function delProjectFromList(id){
// 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(guardWeekLocked()) return;
if(!darfProjektlisteAendern()){ if(!confirm("Projekt aus dieser Sitzung löschen?")) return;
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(); const w = currentWeek();
w.projects = w.projects.filter(p=>p.id!==id); w.projects = w.projects.filter(p=>p.id!==id);
save(); renderProjects(); renderBoard(); save(); renderProjects(); renderBoard();
@@ -169,7 +159,8 @@ function renderProjects(){
${locked ? '' : `<input type="text" class="tag-input" placeholder="+ Tag" onkeydown="handleProjectTagKeydown(event,'${p.id}')">`} ${locked ? '' : `<input type="text" class="tag-input" placeholder="+ Tag" onkeydown="handleProjectTagKeydown(event,'${p.id}')">`}
</div> </div>
<div class="project-card-footer"> <div class="project-card-footer">
<button class="danger small" onclick="delProjectFromList('${p.id}')" ${locked || !darfProjektlisteAendern() ? 'disabled' : ''}>Löschen</button> <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>
</div> </div>
</div>`; </div>`;
}); });
+93 -47
View File
@@ -9,7 +9,7 @@
// Die Schritte: // Die Schritte:
// //
// 1. PROJEKT Nummer, Bezeichnung, GEWERKE, Gruppe, Schlagwörter // 1. PROJEKT Nummer, Bezeichnung, GEWERKE, Gruppe, Schlagwörter
// 2. AUFTRAG Auftragsstatus, Honorar (freiwillig), beauftragte // 2. AUFTRAG Auftragsstatus, Honorar ODER Projektgröße, beauftragte
// Leistungsphasen // Leistungsphasen
// 3. STAND amtierende Leistungsphase // 3. STAND amtierende Leistungsphase
// 4. TEAM Beteiligte je Rolle — eingeschränkt auf die Gewerke des // 4. TEAM Beteiligte je Rolle — eingeschränkt auf die Gewerke des
@@ -20,10 +20,10 @@
// //
// Warum ein Assistent: Honorar, Team und Schlagwörter mussten zuvor NACH dem // Warum ein Assistent: Honorar, Team und Schlagwörter mussten zuvor NACH dem
// Anlegen an drei weiteren Stellen nachgetragen werden — und wurden es meist // Anlegen an drei weiteren Stellen nachgetragen werden — und wurden es meist
// nicht. // nicht. Genau daher rühren die Projekte, die in der Auslastung als "nicht
// bewertbar" erscheinen.
// //
// Pflichtangaben sperren "Weiter", solange etwas fehlt (das Honorar ist // JEDER SCHRITT IST PFLICHT: "Weiter" bleibt gesperrt, solange etwas fehlt,
// freiwillig, Eriks Vorgabe 10/2026),
// und unter dem Knopf steht, was fehlt. Ein Assistent, der unvollständige // und unter dem Knopf steht, was fehlt. Ein Assistent, der unvollständige
// Projekte durchlässt, löst das Problem nicht, das er lösen soll. // Projekte durchlässt, löst das Problem nicht, das er lösen soll.
// //
@@ -51,20 +51,14 @@ const GEWERK_LABELS = { ELT: 'Elektrotechnik', HLS: 'Heizung · Lüftung · Sani
let wizard = null; let wizard = null;
// openNewProjWizard: öffnet den Assistenten mit leerem Zustand. // 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(){ function openNewProjWizard(){
if(guardWeekLocked()) return; if(guardWeekLocked()) return;
if(!darfProjektlisteAendern()){
alert('Projekte anlegen dürfen nur Projektleiter und Administratoren.');
return;
}
wizard = { wizard = {
schritt: 0, schritt: 0,
fertig: null, // angelegtes Projekt, solange der Abschluss steht fertig: null, // angelegtes Projekt, solange der Abschluss steht
nr: '', name: '', gewerke: [], gruppe: 'Aktive Projekte', tags: [], nr: '', name: '', gewerke: [], gruppe: 'Aktive Projekte', tags: [],
status: AUFTRAGSSTATUS_VORGABE, honorar: '', status: AUFTRAGSSTATUS_VORGABE, grundlage: 'honorar', honorar: '', groesse: '',
beauftragt: [], amtierend: '', beauftragt: [], amtierend: '',
team: {}, fremde: [], team: {}, fremde: [],
planErzeugen: true, planStart: heuteIso(), planPersonen: '', planErzeugen: true, planStart: heuteIso(), planPersonen: '',
@@ -133,12 +127,24 @@ function wizardHonorar(){
const n = parseFloat(String(wizard.honorar).replace(',', '.')); const n = parseFloat(String(wizard.honorar).replace(',', '.'));
return (!Number.isFinite(n) || n <= 0) ? 0 : n; return (!Number.isFinite(n) || n <= 0) ? 0 : n;
} }
// wizardBasis: der Geldbetrag für den Terminplan — allein das Honorar. function wizardGroesse(){
// Es ist beim Anlegen freiwillig (Eriks Vorgabe); ohne Honorar entsteht const n = parseFloat(String(wizard.groesse).replace(',', '.'));
// kein Terminplan, er lässt sich später im Projektbereich erzeugen. 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.
function wizardBasis(){ function wizardBasis(){
const h = wizardHonorar(); if(wizard.grundlage === 'honorar'){
return h > 0 ? { betrag: h, quelle: 'Honorar' } : { betrag: 0, quelle: null }; 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 };
} }
// wizardPlanPersonen: wie viele gleichzeitig an einer Phase arbeiten. // wizardPlanPersonen: wie viele gleichzeitig an einer Phase arbeiten.
@@ -156,7 +162,7 @@ function wizardPlanPersonen(){
// aus dem Honorar gerechnet werden kann). // aus dem Honorar gerechnet werden kann).
function wizardPlanZeilen(){ function wizardPlanZeilen(){
const b = wizardBasis(); const b = wizardBasis();
if(b.betrag <= 0) return []; if(wizard.grundlage !== 'honorar' || b.betrag <= 0) return [];
const phasen = wizard.beauftragt.length ? wizard.beauftragt : AUFWAND_LPS; const phasen = wizard.beauftragt.length ? wizard.beauftragt : AUFWAND_LPS;
const summe = phasen.reduce((s, l) => s + (Number(HONORAR_ANTEILE[l]) || 0), 0); const summe = phasen.reduce((s, l) => s + (Number(HONORAR_ANTEILE[l]) || 0), 0);
if(summe <= 0) return []; if(summe <= 0) return [];
@@ -190,8 +196,9 @@ function wizardPlanZeilen(){
// Klartexten; leer = der Schritt ist vollständig. // Klartexten; leer = der Schritt ist vollständig.
// //
// Der Assistent lässt niemanden weiter, solange hier etwas steht: Genau die // Der Assistent lässt niemanden weiter, solange hier etwas steht: Genau die
// Lücken, die sonst entstehen (keine Phase, niemand zugeordnet), fielen // Lücken, die sonst entstehen (kein Honorar, keine Phase, niemand
// später im Wochenbericht und im Projektbereich auf. // zugeordnet), sind der Grund, warum Projekte später in der Auslastung nicht
// bewertbar sind.
function wizardFehlt(key){ function wizardFehlt(key){
const fehlt = []; const fehlt = [];
if(key === 'projekt'){ if(key === 'projekt'){
@@ -200,6 +207,8 @@ function wizardFehlt(key){
if(!wizard.gewerke.length) fehlt.push('mindestens ein Gewerk'); if(!wizard.gewerke.length) fehlt.push('mindestens ein Gewerk');
} }
if(key === 'auftrag'){ if(key === 'auftrag'){
if(wizard.grundlage === 'honorar' && wizardHonorar() <= 0) fehlt.push('Honorar');
if(wizard.grundlage === 'groesse' && wizardGroesse() <= 0) fehlt.push('Projektgröße');
if(!wizard.beauftragt.length) fehlt.push('mindestens eine beauftragte Leistungsphase'); if(!wizard.beauftragt.length) fehlt.push('mindestens eine beauftragte Leistungsphase');
} }
if(key === 'stand'){ if(key === 'stand'){
@@ -209,7 +218,7 @@ function wizardFehlt(key){
if(wizardTeamAnzahl() === 0) fehlt.push('mindestens eine beteiligte Person'); if(wizardTeamAnzahl() === 0) fehlt.push('mindestens eine beteiligte Person');
} }
if(key === 'terminplan'){ if(key === 'terminplan'){
if(wizardHonorar() > 0 && wizard.planErzeugen && !wizard.planStart) fehlt.push('Beginn'); if(wizard.grundlage === 'honorar' && wizard.planErzeugen && !wizard.planStart) fehlt.push('Beginn');
} }
return fehlt; return fehlt;
} }
@@ -302,19 +311,35 @@ function wizardProjektHtml(){
} }
function wizardAuftragHtml(){ function wizardAuftragHtml(){
const perHonorar = wizard.grundlage === 'honorar';
return ` return `
<div class="form-row"> <div class="form-row">
<label>Auftragsstatus</label> <label>Auftragsstatus</label>
<select onchange="wizardSetz('status', this.value)"> <select onchange="wizardSetz('status', this.value)">
${auftragsstatusOptionsHtml(wizard.status)} ${auftragsstatusOptionsHtml(wizard.status)}
</select> </select>
<span class="hint">Anfrage und Angebot wandern in die Gruppe „In Anbahnung".</span> <span class="hint">Anfrage und Angebot wandern in die Gruppe „In Anbahnung" und zählen nicht zur Auslastung.</span>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Honorar (netto, €)</label> <label>Grundlage der Rechnung</label>
<input type="number" id="wizHonorar" min="0" step="1000" value="${esc(wizard.honorar)}" placeholder="z. B. 480000" <div class="wiz-chips">
oninput="wizardSetz('honorar', this.value)"> <button type="button" class="wiz-chip" aria-pressed="${perHonorar}" onclick="wizardSetz('grundlage','honorar')">Vereinbartes Honorar</button>
<span class="hint" id="wizHinweisHonorar">${wizardHinweisHonorar()}</span> <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>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Beauftragte Leistungsphasen <span class="wiz-pflicht">*</span></label> <label>Beauftragte Leistungsphasen <span class="wiz-pflicht">*</span></label>
@@ -327,13 +352,18 @@ function wizardAuftragHtml(){
</div>`; </div>`;
} }
// Der Hinweistext steht als eigene Funktion da, weil er beim Tippen // Die beiden Hinweistexte stehen als eigene Funktionen da, weil sie beim
// mitläuft, ohne dass das Eingabefeld neu gebaut wird — sonst springt der // Tippen mitlaufen müssen, ohne dass das Eingabefeld neu gebaut wird — sonst
// Cursor ans Feldende. // springt der Cursor ans Feldende.
function wizardHinweisHonorar(){ function wizardHinweisHonorar(){
return wizardHonorar() > 0 return wizard.grundlage === 'honorar'
? 'Grundlage für Leistungsstand und Terminplan.' ? 'Grundlage für Leistungsstand, Auslastung und Terminplan.'
: 'Freiwillig. Ohne Honorar entsteht kein Terminplan; es lässt sich später in den Projekteinstellungen nachtragen.'; : '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.';
} }
function wizardStandHtml(){ function wizardStandHtml(){
@@ -346,7 +376,8 @@ function wizardStandHtml(){
<button type="button" class="wiz-chip" aria-pressed="${wizard.amtierend === l}" <button type="button" class="wiz-chip" aria-pressed="${wizard.amtierend === l}"
onclick="wizardSetz('amtierend','${esc(l)}')">${esc(l)}</button>`).join('')} onclick="wizardSetz('amtierend','${esc(l)}')">${esc(l)}</button>`).join('')}
</div> </div>
<span class="hint">Woran gerade gearbeitet wird.</span> <span class="hint">Woran gerade gearbeitet wird. Ohne diese Angabe erschiene das Projekt
in der Auslastung als nicht bewertbar.</span>
</div>`; </div>`;
} }
@@ -406,11 +437,14 @@ function wizardTeamHtml(){
} }
function wizardTerminplanHtml(){ function wizardTerminplanHtml(){
// Ohne Honorar kein Terminplan — er lässt sich später im Projektbereich // Aus der Projektgröße lässt sich kein Terminplan rechnen: Sie ist eine
// erzeugen („Terminplan aus Honorar"). // Schätzung des Umfangs, kein vereinbarter Betrag. Termine, die daraus
if(wizardHonorar() <= 0){ // entstünden, sähen im Gantt so verbindlich aus wie errechnete.
if(wizard.grundlage !== 'honorar'){
return `<div class="wiz-sperre"> return `<div class="wiz-sperre">
<b>Kein Terminplan ohne Honorar.</b> <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>
<p>Sobald das Honorar feststeht, lässt sich der Terminplan im Projektbereich jederzeit <p>Sobald das Honorar feststeht, lässt sich der Terminplan im Projektbereich jederzeit
nachträglich erzeugen („Terminplan aus Honorar").</p> nachträglich erzeugen („Terminplan aus Honorar").</p>
</div>`; </div>`;
@@ -497,7 +531,9 @@ function wizardPruefenHtml(){
${zeile('Gruppe', esc(wizard.gruppe))} ${zeile('Gruppe', esc(wizard.gruppe))}
${zeile('Schlagwörter', wizard.tags.length ? esc(wizard.tags.join(', ')) : 'keine', !wizard.tags.length)} ${zeile('Schlagwörter', wizard.tags.length ? esc(wizard.tags.join(', ')) : 'keine', !wizard.tags.length)}
${zeile('Auftragsstatus', esc(auftragsstatusLabel(wizard.status)))} ${zeile('Auftragsstatus', esc(auftragsstatusLabel(wizard.status)))}
${zeile('Honorar', wizardHonorar() > 0 ? eurFormat(wizardHonorar()) : 'nicht angegeben', !(wizardHonorar() > 0))} ${zeile('Grundlage', wizard.grundlage === 'honorar'
? eurFormat(wizardHonorar()) + ' Honorar'
: 'Projektgröße ' + faktorFormat(wizardGroesse()))}
${zeile('Beauftragte Phasen', esc(wizard.beauftragt.join(', ')))} ${zeile('Beauftragte Phasen', esc(wizard.beauftragt.join(', ')))}
${zeile('Amtierende Phase', esc(wizard.amtierend))} ${zeile('Amtierende Phase', esc(wizard.amtierend))}
${zeile('Team', esc(team))} ${zeile('Team', esc(team))}
@@ -505,12 +541,14 @@ function wizardPruefenHtml(){
const p = wizardPersonen().find(x => x.id === id); const p = wizardPersonen().find(x => x.id === id);
return p ? p.kuerzel + (p.gewerk ? ' (' + p.gewerk + ')' : '') : id; return p ? p.kuerzel + (p.gewerk ? ' (' + p.gewerk + ')' : '') : id;
}).join(', '))) : ''} }).join(', '))) : ''}
${zeile('Terminplan', !(wizardHonorar() > 0) ${zeile('Terminplan', wizard.grundlage !== 'honorar'
? 'nicht möglich — kein Honorar angegeben' ? 'nicht möglich — Grundlage ist die Projektgröße'
: (!wizard.planErzeugen ? 'wird nicht erzeugt' : (!wizard.planErzeugen ? 'wird nicht erzeugt'
: (plan.length ? plan.length + ' Phasen bis ' + formatWeekDate(isoVon(plan[plan.length - 1].ende)) : 'nicht errechenbar')), : (plan.length ? plan.length + ' Phasen bis ' + formatWeekDate(isoVon(plan[plan.length - 1].ende)) : 'nicht errechenbar')),
!(wizardHonorar() > 0) || !wizard.planErzeugen || !plan.length)} wizard.grundlage !== 'honorar' || !wizard.planErzeugen || !plan.length)}
</div>`; </div>
<p class="hint" style="margin-top:14px">Die Gewichtung des Aufwands je Phase und Rolle übernimmt die
firmenweite Vorlage; sie lässt sich später im Projektbereich anpassen.</p>`;
} }
function wizardAbschlussHtml(){ function wizardAbschlussHtml(){
@@ -541,8 +579,13 @@ function wizardSetz(feld, wert){
if(wert === 'anfrage' || wert === 'angebot') wizard.gruppe = 'In Anbahnung'; if(wert === 'anfrage' || wert === 'angebot') wizard.gruppe = 'In Anbahnung';
else if(wizard.gruppe === 'In Anbahnung') wizard.gruppe = 'Aktive Projekte'; else if(wizard.gruppe === 'In Anbahnung') wizard.gruppe = 'Aktive Projekte';
} }
if(feld === 'grundlage'){
// Das nun gesperrte Feld leeren: Ein Wert, der dasteht und nicht mehr
// zählt, führt in die Irre.
if(wert === 'honorar') wizard.groesse = ''; else wizard.honorar = '';
}
// Freitextfelder nicht neu bauen — sonst springt der Cursor ans Feldende. // Freitextfelder nicht neu bauen — sonst springt der Cursor ans Feldende.
if(feld === 'nr' || feld === 'name' || feld === 'honorar' || feld === 'planPersonen'){ if(feld === 'nr' || feld === 'name' || feld === 'honorar' || feld === 'groesse' || feld === 'planPersonen'){
wizardNurAbhaengiges(); wizardNurAbhaengiges();
return; return;
} }
@@ -575,6 +618,8 @@ function wizardNurAbhaengiges(){
const hh = document.getElementById('wizHinweisHonorar'); const hh = document.getElementById('wizHinweisHonorar');
if(hh) hh.textContent = wizardHinweisHonorar(); if(hh) hh.textContent = wizardHinweisHonorar();
const hg = document.getElementById('wizHinweisGroesse');
if(hg) hg.textContent = wizardHinweisGroesse();
const tab = document.getElementById('wizPlanTabelle'); const tab = document.getElementById('wizPlanTabelle');
if(tab) tab.innerHTML = wizardPlanTabelleHtml(); if(tab) tab.innerHTML = wizardPlanTabelleHtml();
@@ -674,13 +719,14 @@ function wizardAnlegen(){
status: '', statusState: undefined, order: nextOrder, status: '', statusState: undefined, order: nextOrder,
lph: {}, assign: {}, plan: null, lph: {}, assign: {}, plan: null,
auftragsstatus: auftragsstatusOf({ auftragsstatus: wizard.status }), auftragsstatus: auftragsstatusOf({ auftragsstatus: wizard.status }),
honorar: wizardHonorar(), honorar: wizard.grundlage === 'honorar' ? wizardHonorar() : 0,
groesse: wizard.grundlage === 'groesse' ? wizardGroesse() : 0,
beauftragteLP: wizard.beauftragt.slice(), beauftragteLP: wizard.beauftragt.slice(),
aktuelleLP: [wizard.amtierend], aktuelleLP: [wizard.amtierend],
}; };
// Zuordnungen: Zustand 1 = Hauptverantwortung (dieselbe Bedeutung wie in // Zuordnungen: Zustand 1 = Hauptverantwortung (dieselbe Bedeutung wie in
// planCycleAssign(), js/plan/plan-settings.js). // cycleAssign(), js/board/board.js).
ROLES.forEach(r => { ROLES.forEach(r => {
wizard.team[r].forEach(id => { pr.assign[assignKey(id, r)] = 1; }); wizard.team[r].forEach(id => { pr.assign[assignKey(id, r)] = 1; });
}); });
@@ -692,13 +738,13 @@ function wizardAnlegen(){
todos: zeilen.map((z, i) => ({ todos: zeilen.map((z, i) => ({
id: uid(), id: uid(),
title: z.lph, title: z.lph,
lph: z.lph,
start: isoVon(z.start), start: isoVon(z.start),
end: isoVon(z.ende), end: isoVon(z.ende),
done: false, done: false,
progress: 0, progress: 0,
assignees: [], assignees: [],
dependsOn: [], deps: [],
order: i,
})), })),
milestones: [], milestones: [],
}; };
+13 -29
View File
@@ -3,7 +3,7 @@
// ----------------------------- // -----------------------------
// Diese Datei kapselt die HTTP-Kommunikation mit dem Express-Server (siehe // 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 // server.js im Projekt-Root) und wird nach js/date-picker.js von beiden
// HTML-Einstiegspunkten geladen. Sie stellt baseUrl, // HTML-Einstiegspunkten geladen. Sie stellt baseUrl, blobToDataUrl,
// sendWeekToServer und deleteWeekFromServer bereit. Genutzt wird sie von // sendWeekToServer und deleteWeekFromServer bereit. Genutzt wird sie von
// js/store.js (syncAllToServer, Speichern aller Wochen), js/weeks.js // js/store.js (syncAllToServer, Speichern aller Wochen), js/weeks.js
// (Wochen-Löschung) und js/plan/plan-state.js (savePlan schreibt direkt die // (Wochen-Löschung) und js/plan/plan-state.js (savePlan schreibt direkt die
@@ -25,22 +25,28 @@ function baseUrl(){
return 'http://localhost:3003'; 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 // sendWeekToServer: POSTet ein komplettes Wochen-Objekt (JSON) an
// <baseUrl>/save-week. Parameter: week (Objekt). Rückgabe: Promise mit // <baseUrl>/save-week. Parameter: week (Objekt). Rückgabe: Promise mit
// Server-Antwort. `baseUpdatedAt` übermittelt die Revision, auf der die // Server-Antwort. `baseUpdatedAt` übermittelt die Revision, auf der die
// lokalen Daten beruhen; meldet der Server 409 (conflict), wird ein Error // lokalen Daten beruhen; meldet der Server 409 (conflict), wird ein Error
// mit `err.conflict = true` und `err.updatedAt` geworfen, damit der Aufrufer // mit `err.conflict = true` und `err.updatedAt` geworfen, damit der Aufrufer
// den Konflikt anzeigen kann. // den Konflikt anzeigen kann.
// CLIENT_ID: zufällige Kennung dieses Browser-Tabs. Geht bei jedem Speichern
// als X-Client-Id mit und bei GET /ereignisse als ?client= — so bekommt der
// Tab seine eigenen Änderungen nicht als "fremdes" Live-Ereignis zurück.
const CLIENT_ID = Math.random().toString(36).slice(2) + Date.now().toString(36);
function sendWeekToServer(week){ function sendWeekToServer(week){
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar')); if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
return fetch(baseUrl() + '/save-week',{ return fetch(baseUrl() + '/save-week',{
method:'POST', method:'POST',
headers:{'Content-Type':'application/json; charset=utf-8', 'X-Client-Id': CLIENT_ID}, headers:{'Content-Type':'application/json; charset=utf-8'},
body:JSON.stringify({ body:JSON.stringify({
week, week,
baseUpdatedAt:(week && week.updatedAt) ? week.updatedAt : undefined baseUpdatedAt:(week && week.updatedAt) ? week.updatedAt : undefined
@@ -63,28 +69,6 @@ function sendWeekToServer(week){
}); });
} }
// sendProjectsToServer: speichert nur einzelne Projekte einer Sitzung
// (POST /save-projects, siehe js/store.js syncAllToServer()). Parameter:
// label, projekte (Array von Projekt-Objekten; ihr updatedAt ist die Basis
// für die Konfliktprüfung je Projekt). Rückgabe: Promise mit
// {ok, updatedAt, projekte:{id: updatedAt}}; bei 409 ein Error mit
// conflict=true, wie sendWeekToServer().
function sendProjectsToServer(label, projekte){
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
return fetch(baseUrl() + '/save-projects', {
method: 'POST',
headers: {'Content-Type':'application/json; charset=utf-8', 'X-Client-Id': CLIENT_ID},
body: JSON.stringify({ label, projects: projekte.map(p => ({ project: p, baseUpdatedAt: p.updatedAt || '' })) })
}).then(r => r.json().catch(() => null).then(json => {
if(r.ok && json) return json;
const err = new Error((json && json.error) || ('Server HTTP ' + r.status));
if(json && json.conflict){ err.conflict = true; err.projekte = json.projekte || []; }
err.status = r.status;
err.label = label;
throw err;
}));
}
// deleteWeekFromServer: fordert das Löschen einer Woche über POST /delete-week an // deleteWeekFromServer: fordert das Löschen einer Woche über POST /delete-week an
// Parameter: label (String, das Wochen-Label). Rückgabe: Promise mit Server-Antwort. // Parameter: label (String, das Wochen-Label). Rückgabe: Promise mit Server-Antwort.
// Bei einer Fehler-Antwort (z. B. 403 „Keine Berechtigung“) trägt der geworfene // Bei einer Fehler-Antwort (z. B. 403 „Keine Berechtigung“) trägt der geworfene
+2 -118
View File
@@ -24,82 +24,9 @@
// gerufen. Keine Parameter, keine Rueckgabe. // gerufen. Keine Parameter, keine Rueckgabe.
function renderSettings(){ function renderSettings(){
renderSettingsAccount(); renderSettingsAccount();
renderSettingsKalender();
renderSettingsAiKeys(); 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 ---------- // ---------- Mein Konto ----------
// renderSettingsAccount: zeigt die Stammdaten des angemeldeten Kontos an. // renderSettingsAccount: zeigt die Stammdaten des angemeldeten Kontos an.
@@ -120,17 +47,12 @@ function renderSettingsAccount(){
+ `<div class="settings-kv-row"> + `<div class="settings-kv-row">
<span class="settings-kv-key">E-Mail</span> <span class="settings-kv-key">E-Mail</span>
<span class="settings-email-edit"> <span class="settings-email-edit">
<input type="email" id="settingsEmailInput" value="${esc(currentUser.email || '')}" placeholder="${esc(emailVorschlag(currentUser.displayName) || 'v.nachname@' + EMAIL_DOMAIN)}"> <input type="email" id="settingsEmailInput" value="${esc(currentUser.email || '')}" placeholder="name@firma.de">
<button class="secondary" onclick="submitEmailChange()">Speichern</button> <button class="secondary" onclick="submitEmailChange()">Speichern</button>
<span id="settingsEmailError" class="settings-warn" hidden></span> <span id="settingsEmailError" class="settings-warn" hidden></span>
</span> </span>
</div>` </div>`
+ row('Rolle', currentUser.rolleName || currentUser.role) + row('Rolle', currentUser.role === 'admin' ? 'Administrator' : 'Mitglied')
// Glas-Darstellung (public/glas.css) je Browser abschaltbar — etwa auf
// einem langsamen Rechner, dem die Unschärfe zu viel Leistung kostet.
+ row('Darstellung', `<label class="settings-glas-schalter"><input type="checkbox" id="settingsGlas"
${document.documentElement.classList.contains('glas') ? 'checked' : ''} onchange="setGlasDarstellung(this.checked)">
Glas-Darstellung (Transparenz und Unschärfe)</label>`)
// Ohne Personen-Zuordnung bleibt „Meine Aufgaben" leer, weil die Aufgaben // Ohne Personen-Zuordnung bleibt „Meine Aufgaben" leer, weil die Aufgaben
// über plan_todo_assignees.person_id zugeordnet werden (siehe // über plan_todo_assignees.person_id zugeordnet werden (siehe
// GET /my-dashboard). Deshalb steht der Hinweis hier und nicht nur dort. // GET /my-dashboard). Deshalb steht der Hinweis hier und nicht nur dort.
@@ -142,14 +64,6 @@ function renderSettingsAccount(){
: ''); : '');
} }
// setGlasDarstellung: schaltet die Glas-Darstellung in diesem Browser ein
// oder aus (Klasse html.glas, gemerkt in localStorage). Wirkt sofort, ohne
// Neuladen. Parameter: an (Boolean). Keine Rückgabe.
function setGlasDarstellung(an){
document.documentElement.classList.toggle('glas', !!an);
try { localStorage.setItem('projuebersicht-glas', an ? 'an' : 'aus'); } catch(e){ /* nur Komfort */ }
}
// submitEmailChange: schickt die eigene E-Mail-Adresse an POST /change-email. // submitEmailChange: schickt die eigene E-Mail-Adresse an POST /change-email.
// Anders als der Passwortwechsel braucht es kein aktuelles Passwort zur // Anders als der Passwortwechsel braucht es kein aktuelles Passwort zur
// Bestätigung — die Route ist ohnehin an die eigene, per Session // Bestätigung — die Route ist ohnehin an die eigene, per Session
@@ -225,7 +139,6 @@ function setAiMode(provider, modus){
async function renderSettingsAiKeys(){ async function renderSettingsAiKeys(){
const statusEl = { chatgpt: document.getElementById('aiOpenaiStatus'), claude: document.getElementById('aiClaudeStatus') }; const statusEl = { chatgpt: document.getElementById('aiOpenaiStatus'), claude: document.getElementById('aiClaudeStatus') };
const urlEl = { chatgpt: document.getElementById('setAiOpenaiUrl'), claude: document.getElementById('setAiClaudeUrl') }; 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 clearBtn = { chatgpt: document.getElementById('aiOpenaiClearBtn'), claude: document.getElementById('aiClaudeClearBtn') };
const reconnectBtn = { chatgpt: document.getElementById('aiOpenaiReconnectBtn'), claude: document.getElementById('aiClaudeReconnectBtn') }; const reconnectBtn = { chatgpt: document.getElementById('aiOpenaiReconnectBtn'), claude: document.getElementById('aiClaudeReconnectBtn') };
const testErrorEl = { chatgpt: document.getElementById('aiOpenaiTestError'), claude: document.getElementById('aiClaudeTestError') }; const testErrorEl = { chatgpt: document.getElementById('aiOpenaiTestError'), claude: document.getElementById('aiClaudeTestError') };
@@ -243,7 +156,6 @@ async function renderSettingsAiKeys(){
chip.innerHTML = '<span class="dot"></span>' + (info.configured ? 'Verbunden' : 'Nicht eingerichtet'); chip.innerHTML = '<span class="dot"></span>' + (info.configured ? 'Verbunden' : 'Nicht eingerichtet');
} }
if(urlEl[provider] && document.activeElement !== urlEl[provider]) urlEl[provider].value = info.apiUrl || ''; 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(clearBtn[provider]) clearBtn[provider].disabled = !info.configured;
if(reconnectBtn[provider]) reconnectBtn[provider].disabled = !info.configured; if(reconnectBtn[provider]) reconnectBtn[provider].disabled = !info.configured;
// Ein neuer Seitenaufruf ist ein neuer Anlauf — ein Fehler aus einem // Ein neuer Seitenaufruf ist ein neuer Anlauf — ein Fehler aus einem
@@ -307,8 +219,6 @@ function putMyAiKey(provider, patch){
body: JSON.stringify(body) body: JSON.stringify(body)
}).then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => { }).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.'); 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; return json;
}); });
} }
@@ -360,32 +270,6 @@ 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 // clearMyAiKey: löscht nur den Schlüssel, lässt eine hinterlegte API-URL
// unangetastet (patch enthält absichtlich kein apiUrl-Feld). Parameter: // unangetastet (patch enthält absichtlich kein apiUrl-Feld). Parameter:
// provider. Keine Rückgabe. // provider. Keine Rückgabe.
+170
View File
@@ -0,0 +1,170 @@
// -----------------------------
// 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();
}
});
+31 -618
View File
@@ -6,7 +6,7 @@
// den kompletten Datenbestand (alle Wochen + globale Personenliste). Die Daten // den kompletten Datenbestand (alle Wochen + globale Personenliste). Die Daten
// liegen serverseitig in einer SQLite-Datenbank und werden als strukturierte // liegen serverseitig in einer SQLite-Datenbank und werden als strukturierte
// JSON-Objekte über die API ausgetauscht (keine CSV-Strings). Sie wird von // JSON-Objekte über die API ausgetauscht (keine CSV-Strings). Sie wird von
// `App.html` nach `js/date-picker.js` und `js/server.js` geladen und stellt // `Test.html` nach `js/date-picker.js` und `js/server.js` geladen und stellt
// die globalen Funktionen `save()`, `syncAllToServer()`, `load()`, `seed()` // die globalen Funktionen `save()`, `syncAllToServer()`, `load()`, `seed()`
// und `demoProj()` bereit. Aufrufer sind u.a. `js/weeks.js`, `js/board/board.js`, // 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 // `js/people/people.js`, `js/projects/projects.js` und `js/load/load.js`; das
@@ -112,43 +112,6 @@ function syncAllToServer(){
// weekChangeCounter): Nur wenn er bei der Antwort unverändert ist, sind // weekChangeCounter): Nur wenn er bei der Antwort unverändert ist, sind
// wirklich alle Änderungen dieser Woche beim Server angekommen. // wirklich alle Änderungen dieser Woche beim Server angekommen.
const sentGeneration = weekChangeCounter.get(label) || 0; const sentGeneration = weekChangeCounter.get(label) || 0;
// Nur die geänderten Projekte schicken, wenn sich an der Sitzung selbst
// nichts geändert hat (geaenderteProjekte(), siehe unten). Sonst — oder
// ohne bekannten Server-Stand — wie bisher die ganze Sitzung.
const delta = geaenderteProjekte(wochenBasis.get(label), w);
if(delta && delta.length === 0){
// Nichts zu senden (z. B. eine Änderung, die wieder zurückgenommen wurde).
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
return;
}
if(delta){
const gesendeteProjekte = delta.map(p => JSON.parse(JSON.stringify(p)));
jobs.push(sendProjectsToServer(label, gesendeteProjekte)
.then(res => {
console.log('Projekte gespeichert:', label, gesendeteProjekte.length);
const basis = wochenBasis.get(label);
gesendeteProjekte.forEach(gp => {
const rev = (res.projekte || {})[gp.id] || res.updatedAt;
gp.updatedAt = rev;
const lokal = (w.projects || []).find(x => x.id === gp.id);
if(lokal) lokal.updatedAt = rev;
// Basis nur für die gesendeten Projekte nachziehen — fremde
// Änderungen, die während des Speicherns eintrafen, stehen dort schon.
if(basis){
const i = basis.projects.findIndex(x => x.id === gp.id);
if(i >= 0) basis.projects[i] = gp;
}
});
revisionUebernehmen(w, res.updatedAt);
konfliktVersuche.delete(label);
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
})
.catch(err => { problem = speicherFehler(label, w, err) || problem; }));
return;
}
// Genau dieser Stand geht an den Server — nach Erfolg ist er die neue
// Basis für ein späteres Zusammenführen (wochenBasis).
const gesendet = wocheKopie(w);
jobs.push(sendWeekToServer(w) jobs.push(sendWeekToServer(w)
.then(res => { .then(res => {
if(res && res.ok){ if(res && res.ok){
@@ -156,21 +119,28 @@ function syncAllToServer(){
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves // Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
// nicht fälschlich als Konflikt gemeldet werden. // nicht fälschlich als Konflikt gemeldet werden.
if(res.updatedAt) w.updatedAt = res.updatedAt; if(res.updatedAt) w.updatedAt = res.updatedAt;
gesendet.updatedAt = w.updatedAt;
// Das ganze Speichern setzt am Server JEDES Projekt der Sitzung auf
// diese Revision (projects.updated_at) — hier nachziehen, sonst
// scheiterte das nächste Speichern einzelner Projekte als Konflikt.
(w.projects || []).forEach(x => { x.updatedAt = w.updatedAt; });
(gesendet.projects || []).forEach(x => { x.updatedAt = w.updatedAt; });
wochenBasis.set(label, gesendet);
konfliktVersuche.delete(label);
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label); if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
} else { } else {
problem = 'Sitzung "' + w.label + '" konnte nicht gespeichert werden.'; problem = 'Sitzung "' + w.label + '" konnte nicht gespeichert werden.';
console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler'); console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler');
} }
}) })
.catch(err => { problem = speicherFehler(label, w, err) || problem; })); .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.
}
}));
}); });
Promise.all(jobs).then(() => { Promise.all(jobs).then(() => {
@@ -181,58 +151,6 @@ function syncAllToServer(){
}); });
} }
// speicherFehler: gemeinsame Fehlerbehandlung beider Speicherwege. Rückgabe:
// Text für das Fehlerband oder '' (Konflikt: wird zusammengeführt).
function speicherFehler(label, w, err){
if(err && err.conflict){
// Jemand anderes hat die Sitzung bzw. das Projekt inzwischen gespeichert:
// nicht verwerfen, sondern zusammenführen und erneut speichern.
zusammenfuehrenUndSpeichern(label, w, err);
return '';
}
if(err && err.status === 403){
// Fehlendes Recht (Rollen & Rechte): Ein erneuter Versuch hilft nicht.
// Änderung verwerfen, Hinweis zeigen und den Stand vom Server holen.
dirtyWeeks.delete(label);
zeigeHinweis((err.message || 'Keine Berechtigung.') + ' Die Änderung wurde nicht gespeichert.');
reloadWeekFromServer(label);
return '';
}
if(err && err.status === 401){
// Sitzung am Server abgelaufen: Anmeldefenster zeigen, statt still
// weiterzuarbeiten und alles Weitere zu verlieren.
if(typeof showLoginOverlay === 'function') showLoginOverlay();
console.error('Speichern abgelehnt (nicht angemeldet):', label);
return 'Nicht mehr angemeldet — bitte neu anmelden, sonst gehen Änderungen verloren.';
}
console.error('Sitzung konnte nicht gespeichert werden:', label, (err && err.message) || err);
// Label bleibt dirty -> der nächste Sync versucht es erneut.
return 'Speichern fehlgeschlagen (' + (err && err.message ? err.message : 'unbekannt') + ') — neuer Versuch in 20 Sekunden.';
}
// geaenderteProjekte: vergleicht eine Sitzung mit ihrem zuletzt bekannten
// Server-Stand. Rückgabe: die geänderten Projekte (Array, evtl. leer), wenn
// sich NUR Projektinhalte geändert haben; null, wenn die ganze Sitzung
// gespeichert werden muss — Teilnehmer, Anwesenheit, Sperre, Datum, ein
// angelegtes, gelöschtes oder umsortiertes Projekt, oder kein Server-Stand.
function geaenderteProjekte(basis, w){
if(!basis || !w) return null;
const rahmen = x => { const k = Object.assign({}, x); delete k.projects; delete k.updatedAt; return k; };
if(!gleich(rahmen(basis), rahmen(w))) return null;
const bp = basis.projects || [], wp = w.projects || [];
if(bp.length !== wp.length || bp.some((p, i) => p.id !== wp[i].id)) return null;
return wp.filter((p, i) => !gleich(p, bp[i]));
}
// revisionUebernehmen: setzt die Revision einer Sitzung (lokal und Basis),
// aber nie zurück — Antworten und Ereignisse können sich überholen.
function revisionUebernehmen(w, rev){
if(!w || !rev) return;
if(!w.updatedAt || rev > w.updatedAt) w.updatedAt = rev;
const basis = wochenBasis.get(w.label);
if(basis && (!basis.updatedAt || rev > basis.updatedAt)) basis.updatedAt = rev;
}
// markWeekDirty: markiert eine bestimmte Woche (label) als geändert – für // markWeekDirty: markiert eine bestimmte Woche (label) als geändert – für
// Aktionen, die Wochen AUFßER der aktuellen betreffen (z. B. Auto-Lock). // Aktionen, die Wochen AUFßER der aktuellen betreffen (z. B. Auto-Lock).
function markWeekDirty(label){ function markWeekDirty(label){
@@ -241,406 +159,6 @@ function markWeekDirty(label){
weekChangeCounter.set(label, (weekChangeCounter.get(label) || 0) + 1); weekChangeCounter.set(label, (weekChangeCounter.get(label) || 0) + 1);
} }
// ===========================================================================
// Gleichzeitiges Arbeiten: Zusammenführen statt Verwerfen
// ---------------------------------------------------------------------------
// Eine Sitzung wird immer als Ganzes gespeichert (POST /save-week mit
// Revisionsstand). Hat jemand anderes sie inzwischen gespeichert, antwortet
// der Server mit 409. Früher verwarf der Browser daraufhin die eigenen
// Änderungen mit einem alert() — bei zwei Personen in derselben Sitzung
// passierte das ständig, auch wenn sie an verschiedenen Projekten arbeiteten.
//
// Jetzt merkt sich der Browser je Sitzung den Stand, den er zuletzt vom
// Server kennt (wochenBasis). Bei einem 409 holt er den aktuellen Stand und
// führt drei Fassungen zusammen (dreiwegeZusammenfuehren(): Basis, eigene,
// fremde): Was nur eine Seite geändert hat, wird übernommen — bis hinunter zu
// einzelnen Feldern, Projekten, Aufgaben/Meilensteinen (über ihre id),
// Kommentaren je Gewerk und der Anwesenheit je Person. Nur wenn beide
// dasselbe Feld unterschiedlich geändert haben, gilt die eigene (jüngste)
// Fassung, und ein kurzer Hinweis nennt das Projekt. Danach wird erneut
// gespeichert. Das Ergebnis wird IN die vorhandenen Objekte geschrieben
// (inPlaceUebernehmen()), damit z. B. der Projektbereich, der eine Referenz
// auf sein Projekt hält (window.PlanApp), weiter auf dasselbe Objekt zeigt.
//
// Zusätzlich gleicht wochenLiveAbgleich() alle 20 Sekunden ab: Hat sich eine
// Sitzung am Server geändert und gibt es hier nichts Ungespeichertes, werden
// die fremden Änderungen übernommen — außer während jemand gerade tippt oder
// ein Dialog offen ist (dann beim nächsten Durchlauf).
// ===========================================================================
const wochenBasis = new Map(); // label -> zuletzt bekannter Server-Stand
const konfliktVersuche = new Map(); // label -> Zahl der Zusammenführungen in Folge
function wocheKopie(w){ return JSON.parse(JSON.stringify(w)); }
function basisMerken(w){ if(w && w.label) wochenBasis.set(w.label, wocheKopie(w)); }
function istObjekt(x){ return !!x && typeof x === 'object' && !Array.isArray(x); }
function istIdListe(a){ return Array.isArray(a) && a.every(x => istObjekt(x) && x.id !== undefined && x.id !== null && x.id !== ''); }
// gleich: inhaltlicher Vergleich, unabhängig von der Reihenfolge der Felder
// (inPlaceUebernehmen() lässt vorhandene Felder an ihrer Stelle und hängt
// neue an — JSON.stringify allein hielte dann Gleiches für verschieden, und
// zwei Browser schickten sich über die Live-Ereignisse endlos dieselben
// Projekte zu). undefined-Felder zählen wie fehlende.
function stabilerText(x){
if(Array.isArray(x)) return '[' + x.map(v => v === undefined ? 'null' : stabilerText(v)).join(',') + ']';
if(x && typeof x === 'object'){
return '{' + Object.keys(x).filter(k => x[k] !== undefined).sort()
.map(k => JSON.stringify(k) + ':' + stabilerText(x[k])).join(',') + '}';
}
return x === undefined ? 'null' : JSON.stringify(x);
}
function gleich(a, b){ return a === b || stabilerText(a) === stabilerText(b); }
// dreiwegeZusammenfuehren: Basis, eigene und fremde Fassung → Ergebnis.
// konflikte sammelt die Stellen, an denen beide Seiten dasselbe
// unterschiedlich geändert haben (dort gewinnt die eigene Fassung).
// Parameter: basis, eigen, fremd, pfad (Text für Hinweise), konflikte (Array).
function dreiwegeZusammenfuehren(basis, eigen, fremd, pfad, konflikte){
if(gleich(eigen, fremd)) return eigen;
if(basis !== undefined && gleich(eigen, basis)) return fremd;
if(basis !== undefined && gleich(fremd, basis)) return eigen;
if(istObjekt(eigen) && istObjekt(fremd)){
const b = istObjekt(basis) ? basis : {};
const ergebnis = {};
new Set([...Object.keys(fremd), ...Object.keys(eigen)]).forEach(k => {
const inE = k in eigen, inF = k in fremd, inB = k in b;
if(inE && inF){ ergebnis[k] = dreiwegeZusammenfuehren(inB ? b[k] : undefined, eigen[k], fremd[k], pfad, konflikte); return; }
if(inE){ // fremd fehlt: dort gelöscht (wenn in der Basis) oder hier neu
if(inB && gleich(eigen[k], b[k])) return; // fremd hat gelöscht, hier unverändert
if(inB) konflikte.push(pfad); // hier geändert, dort gelöscht → behalten
ergebnis[k] = eigen[k]; return;
}
// eigen fehlt
if(inB && gleich(fremd[k], b[k])) return; // hier gelöscht, dort unverändert
if(inB){ konflikte.push(pfad); return; } // hier gelöscht, dort geändert → gelöscht lassen
ergebnis[k] = fremd[k]; // dort neu
});
return ergebnis;
}
if(istIdListe(eigen) && istIdListe(fremd)){
const bListe = istIdListe(basis) ? basis : [];
const bMap = new Map(bListe.map(x => [String(x.id), x]));
const eMap = new Map(eigen.map(x => [String(x.id), x]));
const fMap = new Map(fremd.map(x => [String(x.id), x]));
// Reihenfolge: hat nur diese Seite umsortiert, gilt ihre, sonst die fremde;
// Neues der anderen Seite wird an seiner Stelle angehängt.
const ids = l => l.map(x => String(x.id));
const eigeneReihe = !gleich(ids(eigen), ids(bListe)) && gleich(ids(fremd), ids(bListe));
const reihe = eigeneReihe ? ids(eigen) : ids(fremd);
(eigeneReihe ? ids(fremd) : ids(eigen)).forEach(id => { if(!reihe.includes(id)) reihe.push(id); });
const ergebnis = [];
reihe.forEach(id => {
const e = eMap.get(id), f = fMap.get(id), b = bMap.get(id);
const name = (e || f || {}).name || (e || f || {}).title || id;
const unterpfad = pfad ? pfad : name;
if(e && f){ ergebnis.push(dreiwegeZusammenfuehren(b, e, f, unterpfad, konflikte)); return; }
if(e){ if(b && gleich(e, b)) return; if(b) konflikte.push(unterpfad); ergebnis.push(e); return; }
if(f){ if(b && gleich(f, b)) return; if(b){ konflikte.push(unterpfad); return; } ergebnis.push(f); }
});
return ergebnis;
}
konflikte.push(pfad);
return eigen;
}
// inPlaceUebernehmen: schreibt quelle in das bestehende Objekt ziel — Objekte
// mit gleicher id (Projekte, Aufgaben, Meilensteine, Personen) bleiben
// dieselben Objekte, damit offene Ansichten ihre Referenzen behalten.
function inPlaceUebernehmen(ziel, quelle){
Object.keys(ziel).forEach(k => { if(!(k in quelle)) delete ziel[k]; });
Object.keys(quelle).forEach(k => {
const z = ziel[k], q = quelle[k];
if(istObjekt(z) && istObjekt(q)) inPlaceUebernehmen(z, q);
else if(istIdListe(z) && istIdListe(q)){
const alt = new Map(z.map(x => [String(x.id), x]));
const neu = q.map(x => { const o = alt.get(String(x.id)); if(o){ inPlaceUebernehmen(o, x); return o; } return x; });
z.length = 0; neu.forEach(x => z.push(x));
} else ziel[k] = q;
});
}
// wocheVomServer: holt den aktuellen Stand EINER Sitzung (über /load-all,
// die Sandbox steckt dort in sandboxPlans). Rückgabe: Promise<Woche|null>.
// Seit GET /week nur diese eine Sitzung statt aller über /load-all.
function wocheVomServer(label){
return fetch(baseUrl() + '/week?label=' + encodeURIComponent(label))
.then(r => {
if(r.status === 404) return null;
if(!r.ok) throw new Error('Server HTTP ' + r.status);
return r.json().then(data => {
const w = data && data.week;
if(!w) return null;
if(!Array.isArray(w.people)) w.people = [];
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
if(label === SANDBOX_WEEK_LABEL) return { label, projects: w.projects, updatedAt: w.updatedAt || '' };
return w;
});
});
}
// zusammenfuehren: führt eine lokale Woche mit einem frischen Server-Stand
// zusammen und schreibt das Ergebnis in die lokale Woche. Rückgabe: Array
// der Projekte/Stellen mit echtem Konflikt.
function zusammenfuehren(lokal, frisch){
const basis = wochenBasis.get(lokal.label);
const ohneRev = w => { const k = wocheKopie(w); delete k.updatedAt; return k; };
const konflikte = [];
let ergebnis;
if(lokal.label === SANDBOX_WEEK_LABEL){
// Die Sandbox: nur die Terminpläne zusammenführen, der Rest ist lokal gebaut.
ergebnis = Object.assign({}, ohneRev(lokal), {
projects: dreiwegeZusammenfuehren(basis ? basis.projects : undefined, wocheKopie(lokal.projects), frisch.projects, '', konflikte)
});
} else {
ergebnis = dreiwegeZusammenfuehren(basis ? ohneRev(basis) : undefined, ohneRev(lokal), ohneRev(frisch), '', konflikte);
}
inPlaceUebernehmen(lokal, ergebnis);
lokal.updatedAt = frisch.updatedAt;
const serverStand = lokal.label === SANDBOX_WEEK_LABEL ? Object.assign(wocheKopie(lokal), { projects: wocheKopie(frisch.projects) }) : wocheKopie(frisch);
wochenBasis.set(lokal.label, serverStand);
return [...new Set(konflikte.filter(Boolean))];
}
// zusammenfuehrenUndSpeichern: Antwort auf einen 409 beim Speichern.
function zusammenfuehrenUndSpeichern(label, lokal, err){
const n = (konfliktVersuche.get(label) || 0) + 1;
konfliktVersuche.set(label, n);
if(n > 3){
// Ungewöhnlich: dreimal in Folge überholt. Dann der alte, sichere Weg.
konfliktVersuche.delete(label);
dirtyWeeks.delete(label);
handleWeekConflict(lokal, err);
return;
}
wocheVomServer(label).then(frisch => {
if(!frisch){
// Die Sitzung wurde am Server gelöscht — hier nicht wiederbeleben.
dirtyWeeks.delete(label);
handleWeekConflict(lokal, err);
return;
}
const konflikte = zusammenfuehren(lokal, frisch);
markWeekDirty(label);
ansichtAuffrischen();
if(konflikte.length){
zeigeHinweis('Gleichzeitig geändert: ' + konflikte.slice(0, 3).join(', ')
+ (konflikte.length > 3 ? ' …' : '') + '. Ihre Fassung wurde übernommen.');
}
scheduleSessionSync();
}).catch(e => {
console.error('Zusammenführen fehlgeschlagen:', e);
showSyncProblem('Speichern fehlgeschlagen — neuer Versuch in 20 Sekunden.');
scheduleSyncRetry();
});
}
// ansichtAuffrischen: zeichnet die offene Ansicht nach übernommenen Daten neu.
function ansichtAuffrischen(){
if(typeof renderAll === 'function') renderAll();
if(typeof currentTab !== 'undefined' && currentTab === 'plan'){
if(typeof renderPlanGantt === 'function') renderPlanGantt();
} else if(typeof currentTab !== 'undefined' && currentTab === 'wand'){
if(typeof renderWandboard === 'function') renderWandboard();
}
}
// zeigeHinweis: kurze, nicht blockierende Meldung unten rechts (8 s).
let hinweisTimer = null;
function zeigeHinweis(text){
let el = document.getElementById('syncHinweis');
if(!el){
el = document.createElement('div');
el.id = 'syncHinweis';
el.className = 'sync-problem-bar sync-hinweis';
document.body.appendChild(el);
}
el.textContent = text;
el.style.display = '';
clearTimeout(hinweisTimer);
hinweisTimer = setTimeout(() => { el.style.display = 'none'; }, 8000);
}
// wirdGeradeBearbeitet: tippt jemand oder ist ein Dialog offen? Dann keine
// fremden Änderungen einspielen — ein Neuzeichnen würde Eingaben verwerfen.
function wirdGeradeBearbeitet(){
const a = document.activeElement;
if(a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName))) return true;
return [...document.querySelectorAll('.modal-bg')].some(m => m.style.display && m.style.display !== 'none');
}
// wochenLiveAbgleich: übernimmt Änderungen anderer, ohne dass jemand neu
// laden muss. Läuft alle 20 Sekunden; die Wandtafel lädt ohnehin selbst.
let liveAbgleichLaeuft = false;
function wochenLiveAbgleich(){
if(liveAbgleichLaeuft || !currentUser || !hatRecht('app')) return;
if(syncInFlight || dirtyWeeks.size > 0 || wirdGeradeBearbeitet()) return;
if(!Array.isArray(db.weeks) || db.weeks.length === 0) return;
liveAbgleichLaeuft = true;
fetch(baseUrl() + '/week-revisionen')
.then(r => r.ok ? r.json() : null)
.then(data => {
if(!data || !data.ok) return;
const rev = data.revisionen || {};
const lokal = db.weeks.concat(db.sandboxWeek ? [db.sandboxWeek] : []);
const geaendert = lokal.filter(w => w.label in rev && rev[w.label] && rev[w.label] !== (w.updatedAt || ''));
if(!geaendert.length) return;
return Promise.all(geaendert.map(w => wocheVomServer(w.label).then(frisch => ({ w, frisch })))).then(liste => {
// Zwischenzeitlich doch etwas bearbeitet? Dann beim nächsten Mal.
if(dirtyWeeks.size > 0 || syncInFlight || wirdGeradeBearbeitet()) return;
liste.forEach(({ w, frisch }) => { if(frisch) zusammenfuehren(w, frisch); });
ansichtAuffrischen();
});
})
.catch(() => { /* still — der nächste Durchlauf versucht es wieder */ })
.finally(() => { liveAbgleichLaeuft = false; });
}
// Nur noch Rückfall: Änderungen kommen sofort über die Live-Ereignisse
// (liveVerbinden(), unten). Der Abgleich fängt ab, was während einer
// Verbindungsunterbrechung verpasst wurde, und läuft zusätzlich direkt nach
// jedem Wiederverbinden.
setInterval(wochenLiveAbgleich, 60000);
// ===========================================================================
// Live-Ereignisse (GET /ereignisse, Server-Sent Events)
// ---------------------------------------------------------------------------
// Sobald jemand speichert, meldet der Server es allen anderen Browsern —
// bei einzelnen Projekten gleich mit deren neuem Stand, sodass nur diese
// übertragen werden. Eigene ungespeicherte Änderungen an demselben Projekt
// bleiben erhalten (Dreiwege-Zusammenführung wie beim Speicherkonflikt).
// Während jemand tippt oder ein Dialog offen ist, werden die Daten
// übernommen, aber erst danach neu gezeichnet (ansichtNachBearbeitung()).
// ===========================================================================
let liveQuelle = null;
let liveGetrennt = false;
let liveNeuzeichnenAusstehend = false;
function liveVerbinden(){
if(liveQuelle || typeof EventSource === 'undefined' || !currentUser) return;
liveQuelle = new EventSource(baseUrl() + '/ereignisse?client=' + encodeURIComponent(CLIENT_ID));
liveQuelle.onmessage = ev => {
let d; try { d = JSON.parse(ev.data); } catch(e){ return; }
liveEreignis(d);
};
liveQuelle.onopen = () => {
// Nach einer Unterbrechung nachholen, was in der Zeit passiert ist.
if(liveGetrennt){ liveGetrennt = false; wochenLiveAbgleich(); }
};
liveQuelle.onerror = () => { liveGetrennt = true; }; // EventSource verbindet selbst neu
}
function liveTrennen(){
if(liveQuelle){ liveQuelle.close(); liveQuelle = null; }
}
// Beim Verlassen der Seite die Verbindung sauber schließen.
window.addEventListener('pagehide', liveTrennen);
function liveEreignis(d){
if(!d || !d.typ) return;
if(d.typ === 'projekte') return liveProjekte(d);
if(d.typ === 'woche') return liveWocheNachladen(d.label, d.geloescht);
if(d.typ === 'neu') return liveAllesNeu();
if(d.typ === 'personen') return personenNachladen();
}
// personenNachladen: Personenliste samt Abwesenheiten neu holen (nach einer
// Abwesenheits-Änderung, hier oder in einem anderen Browser) und neu zeichnen.
// Rückgabe: Promise.
function personenNachladen(){
return fetch(baseUrl() + '/personen', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(d => {
if(!d || !Array.isArray(d.people)) return;
db.globalPeople = d.people;
ansichtNachBearbeitung();
})
.catch(err => console.error('Personen nachladen fehlgeschlagen:', err));
}
// liveProjekte: übernimmt die mitgeschickten Projekte einer Sitzung.
function liveProjekte(d){
const w = d.label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === d.label);
if(!w) return;
const basis = wochenBasis.get(d.label);
const konflikte = [];
let eigeneOffen = false;
for(const fp of (d.projekte || [])){
const lp = (w.projects || []).find(x => x.id === fp.id);
if(!lp) return liveWocheNachladen(d.label); // Projekt hier unbekannt: ganze Sitzung holen
const bp = basis ? (basis.projects || []).find(x => x.id === fp.id) : undefined;
if(bp && gleich(lp, bp)){
inPlaceUebernehmen(lp, JSON.parse(JSON.stringify(fp)));
} else {
// Hier gibt es noch ungespeicherte Änderungen an diesem Projekt.
const erg = dreiwegeZusammenfuehren(bp, JSON.parse(JSON.stringify(lp)), fp, lp.name || '', konflikte);
inPlaceUebernehmen(lp, erg);
lp.updatedAt = fp.updatedAt;
eigeneOffen = true;
}
if(basis){
const i = basis.projects.findIndex(x => x.id === fp.id);
if(i >= 0) basis.projects[i] = JSON.parse(JSON.stringify(fp));
}
}
revisionUebernehmen(w, d.updatedAt);
if(eigeneOffen){ markWeekDirty(d.label); scheduleSessionSync(); }
if(konflikte.length){
zeigeHinweis('Gleichzeitig geändert: ' + [...new Set(konflikte.filter(Boolean))].slice(0, 3).join(', ') + '. Ihre Fassung wurde übernommen.');
}
ansichtNachBearbeitung();
}
// liveWocheNachladen: eine ganze Sitzung hat sich geändert — nur sie holen.
function liveWocheNachladen(label, geloescht){
if(geloescht || !label) return liveAllesNeu();
// Während eines eigenen Speicherns nicht dazwischenfunken; kurz danach.
if(syncInFlight){ setTimeout(() => liveWocheNachladen(label), 1000); return; }
const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : (db.weeks || []).find(x => x.label === label);
if(!w){
// 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 // handleWeekConflict: wird aufgerufen, wenn der Server eine zwischenzeitlich
// geänderte Woche meldet (409). Nur diese eine Woche wird vom Server neu // geänderte Woche meldet (409). Nur diese eine Woche wird vom Server neu
// geladen; andere lokal geänderte Wochen (dirtyWeeks) und die Personenliste // geladen; andere lokal geänderte Wochen (dirtyWeeks) und die Personenliste
@@ -654,109 +172,16 @@ function handleWeekConflict(week, err){
else load(); 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 // reloadWeekFromServer: lädt alle Sitzungen neu und ersetzt NUR die
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde). // übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von // Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
// syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten. // syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten.
function reloadWeekFromServer(label){ function reloadWeekFromServer(label){
wocheVomServer(label) fetch(baseUrl() + '/load-all')
.then(fresh => { .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);
const idx = db.weeks.findIndex(w => w.label === label); const idx = db.weeks.findIndex(w => w.label === label);
if(idx >= 0){ if(idx >= 0){
if(fresh){ if(fresh){
@@ -765,7 +190,6 @@ function reloadWeekFromServer(label){
if(!Array.isArray(fresh.projects)) fresh.projects = []; if(!Array.isArray(fresh.projects)) fresh.projects = [];
if(!fresh.attendance) fresh.attendance = {}; if(!fresh.attendance) fresh.attendance = {};
db.weeks[idx] = fresh; db.weeks[idx] = fresh;
basisMerken(fresh);
} else { } else {
db.weeks.splice(idx, 1); db.weeks.splice(idx, 1);
} }
@@ -832,13 +256,7 @@ function loadAppSettings(){
return Promise.all([settings, holidays, honorar]); return Promise.all([settings, holidays, honorar]);
} }
// load: lädt die Sitzungsdaten vom Server neu. Parameter: silent (optional) — function load(){
// 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: // Nach einem (Neu-)Load sind die lokalen Daten serverseitig aktuell:
// laufenden Debounce abbrechen und Dirty-Zustand verwerfen. // laufenden Debounce abbrechen und Dirty-Zustand verwerfen.
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; } if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
@@ -847,9 +265,7 @@ function load(silent){
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.'); alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
return; return;
} }
// Nur die jüngsten SITZUNGEN_START Sitzungen (aktuelle + drei davor); const url = baseUrl() + '/load-all';
// ältere holt weitereSitzungenLaden() bei Bedarf (Eriks Wunsch).
const url = baseUrl() + '/load-all?wochen=' + SITZUNGEN_START;
console.log('Loading from', url); console.log('Loading from', url);
// Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen: // Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen:
// renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala, // renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala,
@@ -879,14 +295,13 @@ function load(silent){
db.weeks = (Array.isArray(data.weeks) ? data.weeks : []) db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
.filter(w => w && typeof w.label === 'string') .filter(w => w && typeof w.label === 'string')
.map(wocheAbsichern); .map(w => {
// Alle Sitzungen schlank (Name, Datum, Sperre) — auch die nicht geladenen. // Felder absichern, damit alte/kaputte Dokumente nicht crashen
db.wochenListe = Array.isArray(data.wochenListe) ? data.wochenListe : db.weeks.map(sitzungKurz); if(!Array.isArray(w.people)) w.people = [];
if(!Array.isArray(w.projects)) w.projects = [];
// Server-Stand als Basis für späteres Zusammenführen merken. if(!w.attendance) w.attendance = {};
wochenBasis.clear(); return w;
db.weeks.forEach(basisMerken); });
basisMerken(db.sandboxWeek);
if(db.weeks.length === 0){ if(db.weeks.length === 0){
console.warn('Server lieferte 0 Sitzungen. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob die SQLite-Datenbank sessions/projektuebersicht.db existiert.'); console.warn('Server lieferte 0 Sitzungen. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob die SQLite-Datenbank sessions/projektuebersicht.db existiert.');
@@ -906,11 +321,9 @@ function load(silent){
// wenn der Tab per Deep-Link-URL (z. B. "/Projekte") gewählt wurde. // wenn der Tab per Deep-Link-URL (z. B. "/Projekte") gewählt wurde.
renderAll(); renderAll();
showTab(currentTab); showTab(currentTab);
liveVerbinden();
}) })
.catch(err => { .catch(err => {
console.error('Laden vom Server fehlgeschlagen:', err); console.error('Laden vom Server fehlgeschlagen:', err);
if(silent) return;
const serverUrl = baseUrl(); const serverUrl = baseUrl();
const msg = 'Server unter ' + serverUrl + ' nicht erreichbar.\n\n' + const msg = 'Server unter ' + serverUrl + ' nicht erreichbar.\n\n' +
'Mögliche Ursachen:\n' + 'Mögliche Ursachen:\n' +
+17 -30
View File
@@ -1,21 +1,20 @@
// ----------------------------- // -----------------------------
// Projektübersicht — Tabs (Navigation) // Projektübersicht — Tabs (Navigation)
// ----------------------------- // -----------------------------
// Modul für die Tab-Navigation der Hauptseite (nur von App.html geladen, nach // Modul für die Tab-Navigation der Hauptseite (nur von Test.html geladen, nach
// js/wand/wand.js, vor js/pdf.js). showTab() wechselt zwischen Board, // js/showmode.js, vor js/pdf.js). showTab() wechselt zwischen Board,
// Wochenbericht, Auslastung, Projekte, Projektbeteiligte, Planung, Plan // Wochenbericht, Auslastung, Projekte, Projektbeteiligte, Meine Aufgaben,
// Checker, Meine Aufgaben, Wandtafel, Einstellungen, Admin und dem // Einstellungen, Admin und dem Projektbereich eines einzelnen Projekts
// Projektbereich eines einzelnen Projekts ('plan', siehe TAB_PANELS). // ('plan', siehe TAB_PANELS).
// Einstellungen und Admin sind keine eigenen Tabs mehr, sondern aufklappbare // Einstellungen und Admin sind keine eigenen Tabs mehr, sondern aufklappbare
// Bereiche direkt in der Seitenleiste (siehe toggleSidebarAccordion() in // Bereiche direkt in der Seitenleiste (siehe toggleSidebarAccordion() in
// js/ui.js). Gerendert werden beim Tab-Wechsel renderBoard()/ // js/ui.js). Gerendert werden beim Tab-Wechsel renderBoard()/
// renderWochenbericht()/renderLoad()/renderProjects()/renderPeople()/ // renderWochenbericht()/renderLoad()/renderProjects()/renderPeople()/
// renderDashboard()/renderWandboard() aus js/board/board.js, // renderDashboard() aus js/board/board.js, js/wochenbericht/wochenbericht.js,
// js/wochenbericht/wochenbericht.js, js/load/load.js, js/projects/projects.js, // js/load/load.js, js/projects/projects.js, js/people/people.js,
// js/people/people.js, js/dashboard/dashboard.js, js/wand/wand.js. Der // js/dashboard/dashboard.js. Der frühere clientseitige Passwortschutz für die
// frühere clientseitige Passwortschutz für die Tabs 'projects'/'people' wurde // Tabs 'projects'/'people' wurde entfernt — der Zugriffsschutz läuft jetzt
// entfernt — der Zugriffsschutz läuft jetzt serverseitig über die Session // serverseitig über die Session (siehe js/auth.js, server.js).
// (siehe js/auth.js, server.js).
// //
// TAB_PANELS: alle Bereiche der Hauptseite. 'plan' ist der Projektbereich // TAB_PANELS: alle Bereiche der Hauptseite. 'plan' ist der Projektbereich
// EINES Projekts (#plan) — früher die eigenständige Seite project-plan.html, // EINES Projekts (#plan) — früher die eigenständige Seite project-plan.html,
@@ -23,17 +22,15 @@
// Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der // Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der
// Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom // Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom
// Dashboard ins Projektboard. // Dashboard ins Projektboard.
const TAB_PANELS = ['board','wochenbericht','load','abwesenheit','people','planung','planchecker','dashboard','wand','settings','admin','plan']; const TAB_PANELS = ['board','wochenbericht','load','people','planung','dashboard','settings','admin','plan'];
function showTab(t){ function showTab(t){
// Weichen nach Rollen & Rechte (UI-Gate, die Routen dahinter prüft der // Der Bereich „Admin" steht nur Administratoren offen. Ohne diese Weiche
// Server mit requireRecht()): „Admin“ nur mit einem Admin-Recht, die // zeigte ein weitergereichter Deep-Link „/Admin" einem Mitglied eine leere
// Wandtafel nur mit „Wandtafel anzeigen“, und eine Rolle ohne „Anwendung // Seite — die Karten darin rendert renderAdminArea() für Nicht-Admins gar
// nutzen“ (früher das Kiosk-Konto) landet immer auf der Wandtafel. // nicht erst. (Ein UI-Gate, mehr nicht: Die Routen dahinter sind
if(currentUser && !hatRecht('app') && hatRecht('wand')) t = 'wand'; // serverseitig mit requireRole('admin') abgesichert.)
if(t === 'admin' && !hatAdminRecht()) t = DEFAULT_TAB; if(t === 'admin' && !(currentUser && currentUser.role === 'admin')) t = DEFAULT_TAB;
if(t === 'abwesenheit' && currentUser && !darfIrgendeineAbwesenheit()) t = DEFAULT_TAB;
if(t === 'wand' && currentUser && !hatRecht('wand')) t = DEFAULT_TAB;
if(t !== currentTab) previousTab = currentTab; if(t !== currentTab) previousTab = currentTab;
currentTab = t; currentTab = t;
saveUiState(); saveUiState();
@@ -49,23 +46,13 @@ function showTab(t){
// Kopfzeile des Projektbereichs. // Kopfzeile des Projektbereichs.
const containerEl = document.querySelector('.app-main > .container'); const containerEl = document.querySelector('.app-main > .container');
if(containerEl) containerEl.style.display = (t === 'plan') ? 'none' : ''; 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)); document.querySelectorAll('.tab').forEach(el=>el.classList.toggle('active',el.dataset.tab===t));
if(t==='board' && currentWeek()) renderBoard(); if(t==='board' && currentWeek()) renderBoard();
if(t==='wochenbericht') renderWochenbericht(); if(t==='wochenbericht') renderWochenbericht();
if(t==='load') renderLoad(); if(t==='load') renderLoad();
if(t==='abwesenheit'){ abwBeimOeffnen(); renderAbwesenheiten(); }
if(t==='people') renderPeople(); if(t==='people') renderPeople();
if(t==='planung') renderPlanung(); if(t==='planung') renderPlanung();
if(t==='planchecker') renderPlanChecker();
if(t==='dashboard') renderDashboard(); if(t==='dashboard') renderDashboard();
if(t==='wand') renderWandboard();
if(t==='settings') renderSettings(); if(t==='settings') renderSettings();
if(t==='admin') renderAdminArea(); if(t==='admin') renderAdminArea();
syncAdminNavState(t); syncAdminNavState(t);
+3 -3
View File
@@ -3,12 +3,12 @@
// ----------------------------- // -----------------------------
// Diese Datei verwaltet den reinen UI-Zustand: den kollabierbaren Header sowie // Diese Datei verwaltet den reinen UI-Zustand: den kollabierbaren Header sowie
// das Light/Dark-Theme. Sie wird nach js/globals.js von beiden HTML- // das Light/Dark-Theme. Sie wird nach js/globals.js von beiden HTML-
// Einstiegspunkt App.html geladen. Die // Einstiegspunkt Test.html geladen. Die
// bereitgestellten Funktionen werden von js/init.js beim Start sowie von // bereitgestellten Funktionen werden von js/init.js beim Start sowie von
// js/tabs-auth.js (Tab-Wechsel, showTab) aufgerufen; im Theme-Umschalter der // js/tabs-auth.js (Tab-Wechsel, showTab) aufgerufen; im Theme-Umschalter der
// Seite werden toggleTheme/applyTheme direkt eingebunden. // Seite werden toggleTheme/applyTheme direkt eingebunden.
// Seit der Projektbereich eines Projekts kein eigenes Dokument mehr ist, // Seit der Projektbereich eines Projekts kein eigenes Dokument mehr ist,
// sondern der Bereich #plan von App.html, kennen applyUrlRoute() und // sondern der Bereich #plan von Test.html, kennen applyUrlRoute() und
// updateRouteUrl() auch dessen Adresse "/Projektplan/<Projektname>". // updateRouteUrl() auch dessen Adresse "/Projektplan/<Projektname>".
// saveUiState: speichert den aktiven Tab in den LocalStorage // saveUiState: speichert den aktiven Tab in den LocalStorage
@@ -258,7 +258,7 @@ function toggleSidebarCollapse(){
// toggleSidebarAccordion: klappt einen der beiden Aktions-Bereiche in der // toggleSidebarAccordion: klappt einen der beiden Aktions-Bereiche in der
// Seitenleiste (Einstellungen/Admin) auf oder zu. Erwartet die zugehörigen // Seitenleiste (Einstellungen/Admin) auf oder zu. Erwartet die zugehörigen
// Elemente unter den IDs "<key>AccordionToggle" (Kopf-Button) und // Elemente unter den IDs "<key>AccordionToggle" (Kopf-Button) und
// "<key>Subnav" (Liste der Aktionen), siehe App.html. Kein Persistieren des // "<key>Subnav" (Liste der Aktionen), siehe Test.html. Kein Persistieren des
// Zustands — jeder Bereich startet beim Neuladen zugeklappt. // Zustands — jeder Bereich startet beim Neuladen zugeklappt.
// Parameter: key ('settings'|'admin'). Rückgabe: keine. // Parameter: key ('settings'|'admin'). Rückgabe: keine.
function toggleSidebarAccordion(key){ function toggleSidebarAccordion(key){
+42 -692
View File
@@ -87,54 +87,6 @@ function weekSortKey(label){
// Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und // Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und
// Zuordnungen auch nach einem Neuladen stabil bleiben. // Zuordnungen auch nach einem Neuladen stabil bleiben.
// Parameter: name (String). Rückgabe: String wie 'p_ab'. // 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){ function personIdForName(name){
return 'p_' + String(name).trim().toLowerCase().replace(/[^a-z0-9]/g,'_'); return 'p_' + String(name).trim().toLowerCase().replace(/[^a-z0-9]/g,'_');
} }
@@ -152,50 +104,6 @@ function initialsForName(name){
return words.slice(0, 4).map(w => w[0]).join('').toUpperCase(); 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 // formatWeekDate: wandelt ein ISO-Datum (YYYY-MM-DD) in deutsches Format DD.MM.YYYY um
// Parameter: dateString (String). Rückgabe: formatierter String oder leer. // Parameter: dateString (String). Rückgabe: formatierter String oder leer.
function formatWeekDate(dateString){ function formatWeekDate(dateString){
@@ -338,7 +246,7 @@ function getWeekNumber(d){
// Fett, Farben, Absätze) wird sowohl im Projektboard (js/board/board.js, // Fett, Farben, Absätze) wird sowohl im Projektboard (js/board/board.js,
// Detailpanel) als auch auf der Projektplan-Seite (js/plan/plan-info.js) // Detailpanel) als auch auf der Projektplan-Seite (js/plan/plan-info.js)
// verwendet — deshalb liegen die reinen Hilfsfunktionen hier im gemeinsamen // verwendet — deshalb liegen die reinen Hilfsfunktionen hier im gemeinsamen
// Modul. Nur der // Modul. Nur statusBlur() (board.js, ruft editProj() auf) bzw. der
// Plan-Gegenpart (planStatusBlur() in plan-info.js) bleiben modul-eigen, da // Plan-Gegenpart (planStatusBlur() in plan-info.js) bleiben modul-eigen, da
// sie unterschiedliche Speicherpfade ansteuern. // sie unterschiedliche Speicherpfade ansteuern.
@@ -357,104 +265,6 @@ function statusFocus(el, pid){
currentStatusPid = pid; currentStatusPid = pid;
} }
// ---------- Kommentar und "besprochen" je Gewerk ----------
//
// ELT, HLS und ARCH halten ihre Besprechung getrennt (Eriks Wunsch: "Die
// Kommentare und Anwesenheiten von ELT brauchen nicht in HLS oder Arch zu
// sein" — und "auch wenn das Projekt in ELT besprochen ist, heißt das nicht,
// dass HLS das Projekt auch besprochen hat"). Ein Projekt mit mehreren
// Gewerken trägt deshalb je Gewerk einen eigenen Sitzungskommentar samt
// Verfasser/Zeitstempel und einen eigenen Besprochen-Haken:
// p.kommentare = { ELT: { status, statusAuthor, statusAt, besprochen }, … }
// (Tabelle project_kommentare, server.js). Die früheren Felder
// p.status/statusAuthor/statusAt/besprochen werden nicht mehr gelesen oder
// geschrieben — alles läuft über die drei Helfer unten.
// kommentarGewerk: das Gewerk, in dessen Zusammenhang gerade gelesen bzw.
// geschrieben wird — im Wochenbericht das dort gewählte, überall sonst null
// (= alle Gewerke zusammen, nur zur Anzeige). Keine Parameter.
function kommentarGewerk(){
if(typeof currentTab !== 'undefined' && currentTab === 'wochenbericht'
&& typeof wochenberichtGewerk !== 'undefined' && wochenberichtGewerk) return wochenberichtGewerk;
return null;
}
// kommentarVon: der Kommentar eines Projekts für ein Gewerk. Ohne Gewerk die
// gewerkübergreifende Anzeige: die Kommentare aller Gewerke untereinander,
// jeweils mit dem Gewerk davor (bei nur einem Gewerk ohne Beschriftung),
// Verfasser/Zeit des jüngsten, besprochen nur, wenn ALLE Gewerke des
// Projekts es abgehakt haben. Parameter: p (Projekt), g (Gewerk oder
// null/undefined). Rückgabe: { status, statusAuthor, statusAt, besprochen }.
function kommentarVon(p, g){
const alle = (p && p.kommentare) || {};
if(g){
const e = alle[g] || {};
return { status: e.status || '', statusAuthor: e.statusAuthor || '', statusAt: e.statusAt || '', besprochen: !!e.besprochen };
}
const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); });
const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== '');
const gewerke = Array.isArray(p && p.gewerke) ? p.gewerke : [];
let status = '';
if(mitText.length === 1 && gewerke.length <= 1) status = alle[mitText[0]].status;
// Jeder Kommentar in einem eigenen Block: Die Klartext-Vorschau
// (statusPlainText/statusPreviewText) trennt nur an Blockgrenzen — ohne
// eigenen Block klebten die Gewerke dort aneinander ("…fettHLS: …").
else status = mitText.map(k => `<div><b>${esc(k)}:</b></div><div>${alle[k].status}</div>`).join('');
const juengster = mitText.map(k => alle[k]).sort((a, b) => String(b.statusAt || '').localeCompare(String(a.statusAt || '')))[0] || {};
const pruefen = gewerke.length ? gewerke : Object.keys(alle);
const besprochen = pruefen.length > 0 && pruefen.every(k => alle[k] && alle[k].besprochen);
return { status, statusAuthor: juengster.statusAuthor || '', statusAt: juengster.statusAt || '', besprochen };
}
// kommentarAnzeigeHtml: der Kommentar zur ANZEIGE, bereinigt. Mit Gewerk g
// nur dessen Text; ohne Gewerk die Texte aller Gewerke, bei mehreren je mit
// dem Gewerk als eigener, dezenter Beschriftung (.kommentar-gewerk) —
// Systemtext, nicht Teil des Kommentars (anders als die fette "ELT:"-Zeile,
// die kommentarVon() für Klartext-Vorschauen einfügt). Rückgabe: HTML ('' ohne Text).
function kommentarAnzeigeHtml(p, g){
if(g) return sanitizeStatusHtml(kommentarVon(p, g).status || '');
const alle = (p && p.kommentare) || {};
const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); });
const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== '');
if(mitText.length === 0) return '';
if(mitText.length === 1) return sanitizeStatusHtml(alle[mitText[0]].status);
return mitText.map(k => `<div class="kommentar-gewerk">${esc(k)}</div><div>${sanitizeStatusHtml(alle[k].status)}</div>`).join('');
}
// kommentarSetzen: ändert den Kommentar-Eintrag eines Gewerks (nur die in
// patch genannten Felder). Parameter: p (Projekt), g (Gewerk), patch
// (Objekt). Keine Rückgabe.
function kommentarSetzen(p, g, patch){
if(!p || !g) return;
p.kommentare = p.kommentare || {};
p.kommentare[g] = Object.assign(kommentarVon(p, g), patch || {});
}
// personGewerk: das Gewerk einer Person aus dem Teilnehmerkreis einer
// Sitzung. week.people kennt es selbst nicht; es kommt aus dem Konto
// (db.globalPeople, users.department) — gesucht über die Kennung, ersatzweise
// über den vollen Namen (Altbestand mit abweichender Kennung, siehe
// kapazitaetsFaktor()). Parameter: person. Rückgabe: Gewerk oder ''.
function personGewerk(person){
const liste = (typeof db !== 'undefined' && Array.isArray(db.globalPeople)) ? db.globalPeople : [];
const treffer = liste.find(x => x.id === person.id)
|| (person.fullName ? liste.find(x => x.fullName === person.fullName) : null);
return (treffer && treffer.gewerk) || '';
}
// personenDesGewerks: der Teilnehmerkreis einer Sitzung, eingeschränkt auf
// ein Gewerk — für die Anwesenheit, die je Gewerk getrennt geführt wird.
// Personen ohne bekanntes Gewerk bleiben überall sichtbar, damit niemand
// unauffindbar wird. Ohne Gewerk alle. Parameter: w (Woche), g.
// Rückgabe: Array.
function personenDesGewerks(w, g){
const leute = Array.isArray(w && w.people) ? w.people : [];
if(!g) return leute;
return leute.filter(person => { const pg = personGewerk(person); return !pg || pg === g; });
}
// statusValueFromHtml: normalisiert den HTML-Inhalt eines Status-Editors. // statusValueFromHtml: normalisiert den HTML-Inhalt eines Status-Editors.
// Liefert bei reinem Leerraum '' zurück, sonst den unveränderten HTML-String // Liefert bei reinem Leerraum '' zurück, sonst den unveränderten HTML-String
// (Formatierung bleibt erhalten). Parameter: `html`, Rückgabe: String. // (Formatierung bleibt erhalten). Parameter: `html`, Rückgabe: String.
@@ -479,38 +289,10 @@ function formatStatus(command, value){
// eingeschleust werden können; im style-Attribut sind nur color/font-weight // eingeschleust werden können; im style-Attribut sind nur color/font-weight
// erlaubt. Parameter: `html` (String). Rückgabe: bereinigter HTML-String ('' bei // erlaubt. Parameter: `html` (String). Rückgabe: bereinigter HTML-String ('' bei
// leerem Eingabewert). // 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){ function sanitizeStatusHtml(html){
if(!html) return ''; if(!html) return '';
// Die Bereinigung läuft vollständig IM inerten Dokument ab: Würden die const div = document.createElement('div');
// Knoten vorher in ein Element des Hauptdokuments übernommen, zöge div.innerHTML = html;
// 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 allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']);
const allowedAttrs = new Set(['style']); const allowedAttrs = new Set(['style']);
// Elemente, deren INHALT mit verschwindet, statt ausgepackt zu werden: // Elemente, deren INHALT mit verschwindet, statt ausgepackt zu werden:
@@ -543,10 +325,6 @@ function sanitizeStatusHtml(html){
} }
for(let j = child.attributes.length - 1; j >= 0; j--){ for(let j = child.attributes.length - 1; j >= 0; j--){
const attr = child.attributes[j].name; const attr = child.attributes[j].name;
// <font color="…"> (ältere Kommentare, Browser-Farbbefehl) behält
// seine Farbe — vorher fiel sie hier still weg, und farbige
// Bestandskommentare erschienen überall einfarbig.
if(tag === 'FONT' && attr.toLowerCase() === 'color' && fontFarbeErlaubt(child.getAttribute(attr))) continue;
if(!allowedAttrs.has(attr.toLowerCase())) child.removeAttribute(attr); if(!allowedAttrs.has(attr.toLowerCase())) child.removeAttribute(attr);
} }
if(child.hasAttribute('style')){ if(child.hasAttribute('style')){
@@ -567,27 +345,26 @@ function sanitizeStatusHtml(html){
return div.innerHTML; return div.innerHTML;
} }
// statusTextMitZeilen: wandelt Kommentar-HTML in reinen Text und erhält dabei // statusHtmlToEditText: wandelt einen gespeicherten Kommentar (HTML) in den
// die Zeilenumbrüche (<br> und Blockgrenzen werden zu \n). Gegenstück zu // Klartext, der im Textfeld des Kommentar-Editors steht. Fettschrift wird zu
// statusPlainText() weiter unten, das Umbrüche bewusst zu Leerzeichen // **Sternchen**, Absätze und <br> werden zu Zeilenumbrüchen, alles andere
// einebnet — hier werden sie gebraucht, weil der einzige Aufrufer die ERSTE // fällt weg. Gegenstück zu statusEditTextToHtml(). Nötig, weil der Editor im
// ZEILE will: openTodoAusKommentar() (js/wochenbericht/wochenbericht.js) // Board seit der Kommentar-Überarbeitung ein normales <textarea> ist und kein
// schlägt daraus den Titel einer neuen Aufgabe vor. // contenteditable mehr (document.execCommand ist abgekündigt) — gespeichert
// // wird aber weiterhin HTML, damit alle vorhandenen Anzeigepfade (Board-
// Hieß bis zur Umstellung auf contenteditable statusHtmlToEditText() und gab // Vorschau, Wochenbericht, PDF-Export, Show-Mode, Verlauf) unverändert
// Fett als **Sternchen**, Kursiv als *Sternchen* und Farbe als // funktionieren und Altbestände lesbar bleiben.
// [[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. // Parameter: html (String). Rückgabe: String.
function statusTextMitZeilen(html){ function statusHtmlToEditText(html){
if(!html) return ''; if(!html) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.innerHTML = sanitizeStatusHtml(html); div.innerHTML = sanitizeStatusHtml(html);
// Block-Grenzen zu Zeilenumbrüchen; alle Auszeichnungen fallen durch // Fett-Auszeichnungen vor dem Auflösen in Sternchen überführen
// textContent ohnehin weg. div.querySelectorAll('b, strong').forEach(el => {
const inner = (el.textContent || '').trim();
el.replaceWith(document.createTextNode(inner ? '**' + inner + '**' : ''));
});
// Block-Grenzen zu Zeilenumbrüchen
div.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode('\n'))); div.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode('\n')));
div.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode('\n'))); div.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode('\n')));
return (div.textContent || '') return (div.textContent || '')
@@ -596,425 +373,19 @@ function statusTextMitZeilen(html){
.trim(); .trim();
} }
// ---------- Formatierbare Textfelder: contenteditable statt Markup-Zeichen ---------- // statusEditTextToHtml: wandelt den Klartext aus dem Kommentar-Editor in das
// // gespeicherte HTML. Escapt zuerst vollständig (esc()), sodass eingetippte
// Erster Anlauf (Markup-Zeichen **fett**/*kursiv*/[[c:…]] in einem normalen // spitze Klammern niemals als Markup ankommen, und setzt danach die einzige
// <textarea>, inzwischen vollständig entfernt) zeigte während des // erlaubte Auszeichnung **fett** sowie Zeilenumbrüche. Gegenstück zu
// Tippens sichtbare Stern-/Klammer-Zeichen (**fett**, [[c:…]]) statt echter // statusHtmlToEditText(). Leerer oder reiner Leerraum-Text ergibt '' — die
// Formatierung — Eriks Rückmeldung: "statt den zwei Sternchen... den Text // bisherige Bedeutung von "kein Kommentar" bleibt damit erhalten.
// richtig fett machen". Diese Felder sind jetzt contenteditable statt // Parameter: text (String). Rückgabe: String (HTML).
// <textarea>, und richtextWickeln()/richtextFett()/richtextKursiv()/ function statusEditTextToHtml(text){
// richtextFarbe() wenden die Auszeichnung direkt auf die Markierung an — über const clean = String(text || '').trim();
// die Selection/Range-API (range.extractContents()/insertNode()), bewusst if(clean === '') return '';
// KEIN document.execCommand (abgekündigt, derselbe Grund, der die vorige return esc(clean)
// contenteditable-Fassung dieser Felder schon einmal verworfen hatte — nur .replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
// diesmal ohne execCommand). Der Nutzer sieht dadurch sofort echten fetten/ .replace(/\n/g, '<br>');
// 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 -------- // -------- Projekt-Verlauf über mehrere Sitzungen --------
@@ -1066,22 +437,18 @@ function statusPlainText(html){
// wurden, tragen den Text der Vorwoche unverändert mit. Parameter: uid // wurden, tragen den Text der Vorwoche unverändert mit. Parameter: uid
// (String). Rückgabe: Array von { week, text, html, author, at, isFirst }, // (String). Rückgabe: Array von { week, text, html, author, at, isFirst },
// neueste zuerst. // neueste zuerst.
function projectStatusHistory(uid, g){ function projectStatusHistory(uid){
// Gewerk: ausdrücklich übergeben, sonst das des Zusammenhangs (im
// Wochenbericht das gewählte, siehe kommentarGewerk()).
const gewerk = g === undefined ? kommentarGewerk() : g;
const versions = projectVersionsForUid(uid); const versions = projectVersionsForUid(uid);
const entries = []; const entries = [];
let lastText = null; let lastText = null;
versions.forEach((v, idx) => { versions.forEach((v, idx) => {
const k = kommentarVon(v.project, gewerk); const text = statusPlainText(v.project.status);
const text = statusPlainText(k.status);
const entry = { const entry = {
week: v.week, week: v.week,
text, text,
html: sanitizeStatusHtml(k.status || ''), html: sanitizeStatusHtml(v.project.status || ''),
author: k.statusAuthor || '', author: v.project.statusAuthor || '',
at: k.statusAt || '', at: v.project.statusAt || '',
isFirst: idx === 0 isFirst: idx === 0
}; };
if(idx === 0){ if(idx === 0){
@@ -1187,19 +554,8 @@ function verlaufBeteiligte(version){
// die eine geänderte Zahl sonst nicht. // die eine geänderte Zahl sonst nicht.
// Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell" // Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell"
// gilt und deshalb NICHT als Kachel erscheint), locked. Rückgabe: HTML. // 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){ function renderProjektVerlauf(p, weekLabel, locked){
const versionen = projectVersionsForUid(p.projectUid); 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, '&#39;');
const offen = verlaufOffen.has(p.projectUid) ? ' open' : '';
const umschalten = ` ontoggle="verlaufUmschalten('${uidAttr}', this.open)"`;
// Neueste zuerst, damit links die jüngste Vergangenheit steht. // Neueste zuerst, damit links die jüngste Vergangenheit steht.
// //
// Ohne weekLabel werden ALLE Sitzungen gezeigt: Der Verlaufs-Dialog im // Ohne weekLabel werden ALLE Sitzungen gezeigt: Der Verlaufs-Dialog im
@@ -1210,15 +566,13 @@ function renderProjektVerlauf(p, weekLabel, locked){
const frueher = (weekLabel ? (idx > 0 ? versionen.slice(0, idx) : []) : versionen.slice()).reverse(); const frueher = (weekLabel ? (idx > 0 ? versionen.slice(0, idx) : []) : versionen.slice()).reverse();
if(frueher.length === 0){ if(frueher.length === 0){
return `<details class="timeline-block"${offen}${umschalten}> return `<details class="timeline-block">
<summary onmousedown="event.preventDefault()"> <summary onmousedown="event.preventDefault()">
<span class="comment-history-caret">▶</span> <span class="comment-history-caret">▶</span>
<span>Verlauf</span> <span>Verlauf</span>
<span class="comment-history-count zero">0</span> <span class="comment-history-count zero">0</span>
</summary> </summary>
<div class="timeline-body"><span class="hint" style="margin:0">${weitere <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>
? '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>`; </details>`;
} }
@@ -1240,10 +594,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
</div>`; </div>`;
}).join(''); }).join('');
const text = statusPlainText(kommentarVon(v.project, kommentarGewerk()).status); const text = statusPlainText(v.project.status);
// Angezeigt wird der Kommentar MIT Formatierung (Farben, fett, Absätze),
// das Gewerk als eigene Beschriftung; text dient nur Tooltip und Prüfung.
const html = text ? kommentarAnzeigeHtml(v.project, kommentarGewerk()) : '';
const termine = verlaufTermine(v.project); const termine = verlaufTermine(v.project);
const termineDavor = davor ? verlaufTermine(davor.project) : { ende:'', meilenstein:'' }; const termineDavor = davor ? verlaufTermine(davor.project) : { ende:'', meilenstein:'' };
const beteiligte = verlaufBeteiligte(v); const beteiligte = verlaufBeteiligte(v);
@@ -1277,7 +628,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
<span class="timeline-kw">${esc(v.week.label)}</span> <span class="timeline-kw">${esc(v.week.label)}</span>
<span class="timeline-datum">${esc(v.week.date ? formatWeekDate(v.week.date) : '')}</span> <span class="timeline-datum">${esc(v.week.date ? formatWeekDate(v.week.date) : '')}</span>
</header> </header>
<div class="timeline-text${text ? '' : ' timeline-leer'}" title="${esc(text)}">${text ? html : 'Kein Kommentar'}</div> <div class="timeline-text${text ? '' : ' timeline-leer'}" title="${esc(text)}">${text ? esc(text) : 'Kein Kommentar'}</div>
${uebernehmen} ${uebernehmen}
<div class="timeline-lph-list">${lphHtml}</div> <div class="timeline-lph-list">${lphHtml}</div>
${zeilen.length ? `<div class="timeline-termine">${zeilen.join('')}</div>` : ''} ${zeilen.length ? `<div class="timeline-termine">${zeilen.join('')}</div>` : ''}
@@ -1289,7 +640,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
</article>`; </article>`;
}).join(''); }).join('');
return `<details class="timeline-block"${offen}${umschalten}> return `<details class="timeline-block">
<summary onmousedown="event.preventDefault()"> <summary onmousedown="event.preventDefault()">
<span class="comment-history-caret">▶</span> <span class="comment-history-caret">▶</span>
<span>Verlauf</span> <span>Verlauf</span>
@@ -1297,7 +648,6 @@ function renderProjektVerlauf(p, weekLabel, locked){
</summary> </summary>
<div class="timeline-body"> <div class="timeline-body">
<div class="timeline-rail">${kacheln}</div> <div class="timeline-rail">${kacheln}</div>
${weitere ? `<div class="verlauf-weitere-zeile">${weitere}</div>` : ''}
</div> </div>
</details>`; </details>`;
} }
-891
View File
@@ -1,891 +0,0 @@
// -----------------------------
// 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>';
}
+2 -5
View File
@@ -2,7 +2,7 @@
// Projektübersicht — Sitzungs-/Wochen-Verwaltung // Projektübersicht — Sitzungs-/Wochen-Verwaltung
// ----------------------------- // -----------------------------
// Diese Datei verwaltet die Sitzungen (Wochen): Auswahl, Anzeige des Datums, // Diese Datei verwaltet die Sitzungen (Wochen): Auswahl, Anzeige des Datums,
// Sperren (Auto-Lock) und Löschen. Sie wird von `App.html` nach `js/store.js` // Sperren (Auto-Lock) und Löschen. Sie wird von `Test.html` nach `js/store.js`
// geladen und stellt globale Funktionen wie `currentWeek()`, `switchWeek()`, // geladen und stellt globale Funktionen wie `currentWeek()`, `switchWeek()`,
// `applyAutoLock()`, `guardWeekLocked()`, `unlockWeek()` und `deleteWeekById()` // `applyAutoLock()`, `guardWeekLocked()`, `unlockWeek()` und `deleteWeekById()`
// bereit. Genutzt wird // bereit. Genutzt wird
@@ -138,9 +138,7 @@ function unlockCurrentWeek(){ unlockWeek(currentWeek()); }
// üblichen renderAll() zusätzlich läuft — die Admin-Liste zeichnet sich // üblichen renderAll() zusätzlich läuft — die Admin-Liste zeichnet sich
// darüber selbst neu, weil renderAll() sie nicht kennt). Keine Rückgabe. // darüber selbst neu, weil renderAll() sie nicht kennt). Keine Rückgabe.
function deleteWeekById(weekId, nachDelete){ function deleteWeekById(weekId, nachDelete){
// Gezählt werden ALLE Sitzungen, nicht nur die geladenen (db.wochenListe). if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
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); const w = db.weeks.find(x => x.id === weekId);
if(!w) return; if(!w) return;
if(!confirm('Sitzung "' + w.label + '" wirklich löschen?')) return; if(!confirm('Sitzung "' + w.label + '" wirklich löschen?')) return;
@@ -153,7 +151,6 @@ function deleteWeekById(weekId, nachDelete){
console.log('Woche gelöscht:', w.label); console.log('Woche gelöscht:', w.label);
const warAktuell = (weekId === db.currentWeek); const warAktuell = (weekId === db.currentWeek);
db.weeks = db.weeks.filter(x=>x.id!==weekId); 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))); 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 // Zeigte die Oberfläche gerade die gelöschte Sitzung (oder zeigt sie aus
// irgendeinem Grund gar keine mehr existierende), auf die jüngste // irgendeinem Grund gar keine mehr existierende), auf die jüngste
+177 -437
View File
@@ -16,19 +16,17 @@
// Der Vergleich zur Vorwoche läuft über project_uid (siehe // Der Vergleich zur Vorwoche läuft über project_uid (siehe
// migrateAddProjectUid() in server.js) — die dauerhafte, wochenunabhängige // migrateAddProjectUid() in server.js) — die dauerhafte, wochenunabhängige
// Projekt-Identität, die project_id (wird bei jedem cloneWeek() neu // Projekt-Identität, die project_id (wird bei jedem cloneWeek() neu
// vergeben) nicht bieten konnte. Die Bearbeitung von Kommentar und LPH läuft // vergeben) nicht bieten konnte. Die Bearbeitung der Projekte selbst
// vollständig über die bestehenden Funktionen aus js/board/board.js // (Kommentar-Editor, LPH, Beteiligte) läuft vollständig über die bestehenden
// (editLph/renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses // Funktionen aus js/board/board.js (editProj/editLph/cycleAssign/
// Modul ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die // renderCommentEditor/renderBoardDrawer/renderBoardRow) — dieses Modul
// Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten). Die // ergänzt die Gruppierung/Diff-Logik, das Layout des Berichts und die
// Beteiligten-Zuordnung zeigt dieser Tab seit ihrer Verlagerung in die // Anwesenheitserfassung am Seitenfuß (renderAttendance(), weiter unten).
// 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 // Die Anwesenheit lag früher im Projektboard; sie gehört hierher, weil in
// diesem Tab die Sitzung abgearbeitet wird. // diesem Tab die Sitzung abgearbeitet wird.
// //
// Läuft wie board.js als klassisches globales Skript (kein Modul/IIFE), nur // Läuft wie board.js als klassisches globales Skript (kein Modul/IIFE), nur
// von App.html geladen (nach js/board/board.js). // von Test.html geladen (nach js/board/board.js).
// findPreviousWeek: liefert die chronologisch vorherige Sitzung zu `week` // findPreviousWeek: liefert die chronologisch vorherige Sitzung zu `week`
// (nach weekSortKey sortiert) oder null, wenn `week` die früheste ist. // (nach weekSortKey sortiert) oder null, wenn `week` die früheste ist.
@@ -56,11 +54,7 @@ function lphSignature(p){
// ihr (z. B. gerade erst angelegtes Projekt) gilt es als neu. Parameter: p // ihr (z. B. gerade erst angelegtes Projekt) gilt es als neu. Parameter: p
// (Projekt der aktuellen Sitzung), prevWeek (Vorwoche-Objekt oder null). // (Projekt der aktuellen Sitzung), prevWeek (Vorwoche-Objekt oder null).
// Rückgabe: { bucket: 'new'|'status'|'progress'|'unchanged', reason: String }. // Rückgabe: { bucket: 'new'|'status'|'progress'|'unchanged', reason: String }.
function classifyProjectChange(p, prevWeek, gewerk){ function classifyProjectChange(p, prevWeek){
// Kommentare werden je Gewerk verglichen (kommentarVon(), js/utils.js) —
// ein neuer ELT-Kommentar ist für die HLS-Besprechung keine Neuigkeit.
// Ohne ausdrückliches Gewerk gilt das des Zusammenhangs.
const g = gewerk === undefined ? kommentarGewerk() : gewerk;
const prev = (prevWeek && p.projectUid) const prev = (prevWeek && p.projectUid)
? prevWeek.projects.find(x => x.projectUid === p.projectUid) ? prevWeek.projects.find(x => x.projectUid === p.projectUid)
: null; : null;
@@ -68,10 +62,8 @@ function classifyProjectChange(p, prevWeek, gewerk){
if(!prev){ if(!prev){
return { bucket: 'new', reason: 'Neues Projekt' }; return { bucket: 'new', reason: 'Neues Projekt' };
} }
const kNow = kommentarVon(p, g); const statusNow = (p.status || '').trim();
const kPrev = kommentarVon(prev, g); const statusPrev = (prev.status || '').trim();
const statusNow = (kNow.status || '').trim();
const statusPrev = (kPrev.status || '').trim();
// Nur ein tatsächlich vorhandener, geänderter Text zählt als neuer // Nur ein tatsächlich vorhandener, geänderter Text zählt als neuer
// Kommentar. Seit cloneWeek() (js/weeks.js) den Text der Vorwoche nicht // Kommentar. Seit cloneWeek() (js/weeks.js) den Text der Vorwoche nicht
// mehr mitkopiert, startet jede duplizierte Sitzung mit leerem Kommentar — // mehr mitkopiert, startet jede duplizierte Sitzung mit leerem Kommentar —
@@ -87,7 +79,7 @@ function classifyProjectChange(p, prevWeek, gewerk){
// wortgleich den Text der Vorwoche, setzt dabei aber einen neuen // wortgleich den Text der Vorwoche, setzt dabei aber einen neuen
// Zeitstempel. Ohne diese Prüfung landete das Projekt unter "Unverändert", // Zeitstempel. Ohne diese Prüfung landete das Projekt unter "Unverändert",
// obwohl diese Woche ausdrücklich etwas dazu festgehalten wurde. // obwohl diese Woche ausdrücklich etwas dazu festgehalten wurde.
if(statusNow !== '' && kNow.statusAt && kNow.statusAt !== (kPrev.statusAt || '')){ if(statusNow !== '' && p.statusAt && p.statusAt !== (prev.statusAt || '')){
return { bucket: 'status', reason: 'Kommentar bestätigt' }; return { bucket: 'status', reason: 'Kommentar bestätigt' };
} }
if(lphSignature(p) !== lphSignature(prev)){ if(lphSignature(p) !== lphSignature(prev)){
@@ -97,12 +89,11 @@ function classifyProjectChange(p, prevWeek, gewerk){
} }
// BERICHT_OPTS: Darstellungs-Optionen, die dieser Tab an die gemeinsamen // BERICHT_OPTS: Darstellungs-Optionen, die dieser Tab an die gemeinsamen
// Render-Funktionen aus js/board/board.js übergibt. dicht ist das einzige, // Render-Funktionen aus js/board/board.js übergibt. Im Wochenbericht werden
// was den Wochenbericht noch vom Projektboard unterscheidet (zwei statt drei // nur die beauftragten Leistungsphasen gezeigt (p.beauftragteLP) — im
// Spalten) — nurBeauftragteLph gilt inzwischen für beide (BOARD_OPTS, // Projektboard bleibt es bei allen. Den Verlauf der Vorwochen hängt
// js/board/board.js). Den Verlauf der Vorwochen hängt renderBoardDrawer() // renderBoardDrawer() inzwischen überall an (renderProjektVerlauf() in
// überall an (renderProjektVerlauf() in js/utils.js), er braucht deshalb kein // js/utils.js), er braucht deshalb kein eigenes Kennzeichen mehr.
// eigenes Kennzeichen mehr.
const BERICHT_OPTS = { nurBeauftragteLph: true, dicht: true }; const BERICHT_OPTS = { nurBeauftragteLph: true, dicht: true };
// renderReportCard: baut die immer aufgeklappte Karte eines Projekts im // renderReportCard: baut die immer aufgeklappte Karte eines Projekts im
@@ -129,6 +120,27 @@ function renderReportCard(p, reason, allPeople, locked, weekLabel, variant){
</div>`; </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 ---------- // ---------- Anwesenheit der Sitzung ----------
// Die Anwesenheit hing ursprünglich als Zweitfunktion an der Kürzel-Legende // Die Anwesenheit hing ursprünglich als Zweitfunktion an der Kürzel-Legende
// unter dem Projektboard ("klick für Anwesenheit") — Namenserklärung und // unter dem Projektboard ("klick für Anwesenheit") — Namenserklärung und
@@ -162,32 +174,21 @@ function absenceNoteFor(personId, weekDate){
} }
// renderAttendance: baut den Anwesenheitsblock der aktuellen Sitzung. // renderAttendance: baut den Anwesenheitsblock der aktuellen Sitzung.
// Aufgerufen innerhalb dieser Datei (toggleAttendance(), setAllAttendance() // Aufgerufen aus renderBoard(). Keine Parameter, keine Rückgabe.
// sowie innerhalb/rund um renderWochenbericht()) — NICHT aus renderBoard(),
// die Anwesenheit ist allein eine Wochenbericht-Angelegenheit. Keine
// Parameter, keine Rückgabe.
function renderAttendance(){ function renderAttendance(){
const container = document.getElementById('weekAttendance'); const container = document.getElementById('weekAttendance');
if(!container) return; if(!container) return;
const w = currentWeek(); const w = currentWeek();
if(!w){ container.innerHTML = ''; return; } if(!w){ container.innerHTML = ''; return; }
// Ohne Recht „Anwesenheit erfassen“ (Rollen & Rechte) nur Anzeige. const locked = isWeekLocked(w);
const locked = isWeekLocked(w) || !hatRecht('wb.anwesenheit');
const att = w.attendance || {}; const att = w.attendance || {};
// Nur die Personen des gewählten Gewerks — jedes Gewerk erfasst die const people = Array.isArray(w.people) ? w.people : [];
// 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){ if(people.length === 0){
container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}> container.innerHTML = `<div class="attendance-block"><div class="attendance-bar">
<summary><span>Anwesenheit</span><span class="chev">${icon('chevronRight')}</span></summary> <span class="attendance-title">Anwesenheit</span></div>
<div class="bericht-disclosure-body"><span class="hint" style="margin:0">Für dieses Gewerk sind in dieser Sitzung keine Projektbeteiligten hinterlegt.</span></div> <div class="attendance-grid"><span class="hint">Für diese Sitzung sind keine Projektbeteiligten hinterlegt.</span></div></div>`;
</details>`;
return; return;
} }
@@ -196,10 +197,9 @@ function renderAttendance(){
// ein offener Kommentar-Editor beim Klick den Fokus, sein Blur-Netz baut den // ein offener Kommentar-Editor beim Klick den Fokus, sein Blur-Netz baut den
// ganzen Bericht neu auf, die Schaltfläche verschwindet dabei — und der // ganzen Bericht neu auf, die Schaltfläche verschwindet dabei — und der
// Klick kommt nie an (erst der zweite Versuch wirkte). // Klick kommt nie an (erst der zweite Versuch wirkte).
const actions = locked ? '' : `<div class="attendance-actions"> const actions = locked ? '' : `
<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(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 tiles = people.map(p => {
const present = !!att[p.id]; const present = !!att[p.id];
@@ -221,10 +221,15 @@ function renderAttendance(){
</button>`; </button>`;
}).join(''); }).join('');
container.innerHTML = `<details class="bericht-disclosure" data-bericht-abschnitt="anwesenheit" ${offen}> container.innerHTML = `<div class="attendance-block">
<summary><span>Anwesenheit ${esc(wochenberichtGewerk || "")}</span><span class="count">${anwesend} von ${people.length}</span><span class="chev">${icon('chevronRight')}</span></summary> <div class="attendance-bar">
<div class="bericht-disclosure-body">${actions}<div class="attendance-grid">${tiles}</div></div> <span class="attendance-title">Anwesenheit</span>
</details>`; <span class="attendance-count">${anwesend} von ${people.length} anwesend</span>
<span class="attendance-spacer"></span>
${actions}
</div>
<div class="attendance-grid">${tiles}</div>
</div>`;
} }
// toggleAttendance: schaltet die Anwesenheit einer Person für die aktuelle // toggleAttendance: schaltet die Anwesenheit einer Person für die aktuelle
@@ -252,7 +257,7 @@ function setAllAttendance(present){
const w = currentWeek(); const w = currentWeek();
if(!w) return; if(!w) return;
if(!w.attendance) w.attendance = {}; if(!w.attendance) w.attendance = {};
personenDesGewerks(w, wochenberichtGewerk).forEach(p => { w.attendance[p.id] = !!present; }); (w.people || []).forEach(p => { w.attendance[p.id] = !!present; });
save(); save();
renderAttendance(); renderAttendance();
} }
@@ -260,9 +265,9 @@ function setAllAttendance(present){
// renderWochenbericht: baut den kompletten Wochenbericht-Tab für die eine // renderWochenbericht: baut den kompletten Wochenbericht-Tab für die eine
// offene Sitzung auf (currentWeek(), js/weeks.js) — Hinweis auf die Vorwoche, // offene Sitzung auf (currentWeek(), js/weeks.js) — Hinweis auf die Vorwoche,
// dann die drei Gruppen. Wird nach jedem Laden sowie nach jeder Änderung // dann die drei Gruppen. Wird nach jedem Laden sowie nach jeder Änderung
// über editLph() (js/board/board.js) bzw. planCycleAssign() // über editLph()/cycleAssign() (js/board/board.js) neu aufgerufen, damit ein
// (js/plan/plan-settings.js) neu aufgerufen, damit ein Projekt sofort in die // Projekt sofort in die passende Gruppe wandert. Keine Parameter, keine
// passende Gruppe wandert. Keine Parameter, keine Rückgabe. // Rückgabe.
// ---------- Tagesordnung: Zustand ---------- // ---------- Tagesordnung: Zustand ----------
// berichtAuswahl: project_uid des Projekts, das gerade rechts im Detail // berichtAuswahl: project_uid des Projekts, das gerade rechts im Detail
@@ -368,66 +373,46 @@ function waehleBerichtProjekt(uid){
// ---------- Besprochen-Haken ---------- // ---------- Besprochen-Haken ----------
// HINWEIS: toggleBesprochen() ist entfallen — die schmale Nummern-Leiste des // toggleBesprochen: hakt ein Projekt als besprochen ab bzw. nimmt den Haken
// Fokus-Modus (Eriks Wahl "C") hat keinen Platz mehr für einen Haken je // zurück. Der Haken gehört zur Sitzung (Spalte projects.besprochen) und geht
// Zeile, und die Detail-Kopfzeile zeigt bewusst keinen eigenen mehr (siehe // deshalb durch den normalen Speicherweg. Parameter: pid. Keine Rückgabe.
// freigegebener Entwurf). Einzelne Projekte abhaken läuft über function toggleBesprochen(pid){
// besprochenUndWeiter() (einzeln, beim Weitergehen), alle zurücknehmen über if(guardWeekLocked()) return;
// setzeAlleBesprochen(false) ("Zurücksetzen" in der Kopfleiste) — ein const w = currentWeek();
// bewusstes Zurücknehmen nur EINES bereits abgehakten Projekts ohne die const p = (w.projects || []).find(x => x.id === pid);
// übrigen anzutasten ist damit nicht mehr vorgesehen. if(!p) return;
p.besprochen = !p.besprochen;
save();
renderWochenbericht();
}
// besprochenUndWeiter: hakt das aktuelle Projekt ab und springt zum nächsten, // 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 // das noch offen ist. Der Motor des Meetings — ohne ihn sucht man nach jedem
// Projekt von Hand die nächste Zeile. Parameter: pid. Keine Rückgabe. // Projekt von Hand die nächste Zeile. Parameter: pid. Keine Rückgabe.
function besprochenUndWeiter(pid){ function besprochenUndWeiter(pid){
if(guardWeekLocked() || !hatRecht('wb.besprochen')) return; if(guardWeekLocked()) return;
const w = currentWeek(); const w = currentWeek();
const p = (w.projects || []).find(x => x.id === pid); const p = (w.projects || []).find(x => x.id === pid);
if(!p) return; if(!p) return;
kommentarSetzen(p, wochenberichtGewerk, { besprochen: true }); p.besprochen = true;
save(); save();
const liste = berichtProjekte(w); const liste = berichtProjekte(w);
const idx = liste.findIndex(x => x.id === pid); const idx = liste.findIndex(x => x.id === pid);
// Erst hinter der aktuellen Stelle suchen, dann von vorn — so landet man // Erst hinter der aktuellen Stelle suchen, dann von vorn — so landet man
// auch dann beim nächsten offenen Punkt, wenn man mittendrin eingestiegen ist. // auch dann beim nächsten offenen Punkt, wenn man mittendrin eingestiegen ist.
const istAb = x => kommentarVon(x, wochenberichtGewerk).besprochen; const offen = liste.slice(idx + 1).find(x => !x.besprochen)
const offen = liste.slice(idx + 1).find(x => !istAb(x)) || liste.find(x => !x.besprochen);
|| liste.find(x => !istAb(x));
berichtAuswahl = offen ? offen.projectUid : p.projectUid; berichtAuswahl = offen ? offen.projectUid : p.projectUid;
renderWochenbericht(); renderWochenbericht();
} }
// berichtNaechstes: das Projekt direkt nach pid in der sichtbaren Liste
// (dieselbe Reihenfolge und Suche wie die Pfeiltaste ↓), sonst null.
function berichtNaechstes(w, pid){
const liste = berichtProjekte(w).filter(passtZurSuche);
const idx = liste.findIndex(x => x.id === pid);
return idx >= 0 ? (liste[idx + 1] || null) : null;
}
// berichtWeiter: springt zum nächsten Projekt, OHNE es abzuhaken — für ein
// Projekt, das heute nicht besprochen wird. Parameter: pid. Keine Rückgabe.
function berichtWeiter(pid){
const naechstes = berichtNaechstes(currentWeek(), pid);
if(naechstes) waehleBerichtProjekt(naechstes.projectUid || null);
}
// setzeAlleBesprochen: setzt alle Haken der Sitzung zurück bzw. hakt alles ab. // setzeAlleBesprochen: setzt alle Haken der Sitzung zurück bzw. hakt alles ab.
// Parameter: wert (Boolean). Keine Rückgabe. // Parameter: wert (Boolean). Keine Rückgabe.
function setzeAlleBesprochen(wert){ function setzeAlleBesprochen(wert){
if(guardWeekLocked()) return; if(guardWeekLocked()) return;
const w = currentWeek(); const w = currentWeek();
// berichtProjekte(w) statt w.projects direkt: Der Knopf wirkt auf die (w.projects || []).forEach(p => { p.besprochen = !!wert; });
// 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(); save();
renderWochenbericht(); renderWochenbericht();
} }
@@ -435,34 +420,20 @@ function setzeAlleBesprochen(wert){
// ---------- Abwesenheit ---------- // ---------- Abwesenheit ----------
// abwesendeBeteiligte: die einem Projekt zugeordneten Personen, die heute // abwesendeBeteiligte: die einem Projekt zugeordneten Personen, die heute
// laut ihrer EINGETRAGENEN Abwesenheit fehlen (Urlaub, Krank, Fortbildung … // fehlen. "Heute" ist der Anwesenheitsstand dieser Sitzung (w.attendance) —
// aus der Benutzerverwaltung, db.globalPeople[].absences) — Eriks Wunsch. // er wird am Fuß des Berichts ohnehin erfasst. Punkte, deren Zuständige
// Früher wurde stattdessen die Anwesenheitsliste am Fuß des Berichts // fehlen, vertagt man im Meeting besser.
// ausgewertet: Wer dort nicht abgehakt war, "fehlte" — auch wenn er nur // Parameter: p (Projekt), w (Woche). Rückgabe: Array von Personennamen.
// nicht in dieser Besprechung saß, und umgekehrt fehlte ein Urlauber nicht,
// solange niemand die Anwesenheit erfasst hatte. Punkte, deren Zuständige
// abwesend sind, vertagt man im Meeting besser.
// Zugeordnet = in irgendeiner Rolle (Haupt oder Unterstützung) im Projekt.
// Die Person der Sitzung wird über ihre Kennung, ersatzweise den vollen
// Namen, ihrem Konto zugeordnet (wie personGewerk(), js/utils.js).
// Parameter: p (Projekt), w (Woche). Rückgabe: Array von Texten
// "Name (Urlaub bis 28.10.)".
function abwesendeBeteiligte(p, w){ function abwesendeBeteiligte(p, w){
const heute = heuteIso(); const att = w.attendance || {};
const konten = Array.isArray(db.globalPeople) ? db.globalPeople : []; // 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 namen = []; const namen = [];
(w.people || []).forEach(person => { (w.people || []).forEach(person => {
const rollen = Array.isArray(person.role) ? person.role : [person.role]; const rollen = Array.isArray(person.role) ? person.role : [person.role];
const zugeordnet = rollen.some(r => (p.assign || {})[assignKey(person.id, r)] > 0); const zugeordnet = rollen.some(r => (p.assign || {})[assignKey(person.id, r)] > 0);
if(!zugeordnet) return; if(zugeordnet && !att[person.id] && namen.indexOf(person.name) === -1) namen.push(person.name);
const konto = konten.find(x => x.id === person.id)
|| (person.fullName ? konten.find(x => x.fullName === person.fullName) : null);
const a = konto && (konto.absences || []).find(x => x.start && x.end && x.start <= heute && heute <= x.end);
if(!a) return;
const name = person.fullName || person.name;
const bis = a.end > heute ? ' bis ' + formatWeekDate(a.end).replace(/\d{4}$/, '') : '';
const text = name + ' (' + (a.type || 'abwesend') + bis + ')';
if(!namen.some(n => n.startsWith(name + ' ('))) namen.push(text);
}); });
return namen; return namen;
} }
@@ -484,15 +455,12 @@ function passtZurSuche(p){
// ---------- Rendern ---------- // ---------- Rendern ----------
// renderWochenbericht: die Tagesordnung des Teammeetings — links eine // renderWochenbericht: die Tagesordnung des Teammeetings — links die
// schmale Nummern-Leiste über die vollständige Projektliste (Fokus-Modus, // vollständige Projektliste, rechts das gewählte Projekt in voller Größe.
// 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 // Diese Aufteilung hat die frühere Abfolge gestapelter Karten abgelöst: Bei
// 29 Projekten war der Bericht 18 Bildschirmseiten lang, und man verlor beim // 29 Projekten war der Bericht 18 Bildschirmseiten lang, und man verlor beim
// Durchgehen die Orientierung. Jetzt bleibt die Leiste stehen, man sieht // Durchgehen die Orientierung. Jetzt bleibt die Liste stehen, man sieht
// jederzeit, wo man steht und was noch kommt, und scrollt nicht mehr. // jederzeit, wo man steht und was noch kommt, und scrollt nicht mehr.
// //
// Die Liste ist nach PROJEKTGRUPPE geordnet (wie im Projektboard), nicht mehr // Die Liste ist nach PROJEKTGRUPPE geordnet (wie im Projektboard), nicht mehr
@@ -526,29 +494,29 @@ function renderWochenbericht(){
if(locked){ if(locked){
html += `<div class="lock-notice"> html += `<div class="lock-notice">
Diese Sitzung ist gesperrt. Bearbeiten nur nach Entsperren möglich. Diese Sitzung ist gesperrt. Bearbeiten nur nach Entsperren möglich.
${hatRecht('s.entsperren') ? '<button type="button" class="secondary small" onclick="unlockCurrentWeek()">Entsperren</button>' : ''} <button type="button" class="secondary small" onclick="unlockCurrentWeek()">Entsperren</button>
</div>`; </div>`;
} }
html += renderBerichtKopf(w, liste, prevWeek, locked);
if(liste.length === 0){ if(liste.length === 0){
html += renderBerichtKopf(w, liste, prevWeek, locked); html += `<div class="hint">In dieser Sitzung ist noch kein Projekt angelegt.</div>`;
html += `<div class="hint">Für ${esc(wochenberichtGewerk)} steht in dieser Sitzung kein Projekt auf der Tagesordnung.</div>`;
container.innerHTML = html; container.innerHTML = html;
renderAttendance(); renderAttendance();
return; return;
} }
// Eine Fläche (.bericht-panel), nicht zwei lose nebeneinanderstehende Karten html += `<div class="bericht-split">
// — Kopfleiste und Leiste|Hauptspalte gehören sichtbar zusammen (Entwurf <div class="bericht-liste-spalte">
// "Fokus-Modus"). Die Kopfleiste bekommt einen eigenen Container, damit das <div class="bericht-suche">
// Öffnen/Schließen des Sprungfelds (oeffneBerichtSuche()) nur sie neu <input type="search" id="berichtSuche" placeholder="Nummer oder Name…"
// zeichnen muss, nicht die ganze Tagesordnung samt offenem Kommentarfeld. value="${esc(berichtSuche)}" oninput="setzeBerichtSuche(this.value)"
html += `<div class="bericht-panel"> aria-label="Zu einem Projekt springen">
<div id="berichtKopf">${renderBerichtKopf(w, liste, prevWeek, locked)}</div> </div>
<div class="bericht-layout"> <div class="bericht-liste" id="berichtListe"></div>
<div class="bericht-rail" id="berichtListe"></div>
<div class="bericht-main" id="berichtDetail">${renderBerichtDetail(aktiv, w, prevWeek, locked)}</div>
</div> </div>
<div class="bericht-detail" id="berichtDetail">${renderBerichtDetail(aktiv, w, prevWeek, locked)}</div>
</div>`; </div>`;
container.innerHTML = html; container.innerHTML = html;
@@ -556,128 +524,72 @@ function renderWochenbericht(){
renderAttendance(); renderAttendance();
} }
// berichtSucheOffen: ob das Sprungfeld gerade als Eingabefeld statt als // renderBerichtKopf: Vergleichs-Hinweis und Fortschrittsanzeige.
// 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. // Parameter: w, liste, prevWeek, locked. Rückgabe: HTML.
function renderBerichtKopf(w, liste, prevWeek, locked){ function renderBerichtKopf(w, liste, prevWeek, locked){
const ab = liste.filter(p => kommentarVon(p, wochenberichtGewerk).besprochen).length; const ab = liste.filter(p => p.besprochen).length;
const pct = liste.length ? Math.round(ab / liste.length * 100) : 0; const pct = liste.length ? Math.round(ab / liste.length * 100) : 0;
// Der Vergleichs-Hinweis ("Vergleich mit KW …") steht nicht mehr als eigene
// Zeile da, sondern als Titel-Tooltip am Fortschritt — dieselbe Angabe,
// ohne eine weitere, dauerhaft sichtbare Textzeile im Entwurf zu ergänzen.
const vergleich = prevWeek const vergleich = prevWeek
? `Vergleich mit ${esc(prevWeek.label)}${prevWeek.date ? ' (' + esc(formatWeekDate(prevWeek.date)) + ')' : ''}.` ? `Vergleich mit ${esc(prevWeek.label)}${prevWeek.date ? ' (' + esc(formatWeekDate(prevWeek.date)) + ')' : ''}.`
: 'Dies ist die früheste vorhandene Sitzung — kein Vergleich zur Vorwoche möglich.'; : 'Dies ist die früheste vorhandene Sitzung — kein Vergleich zur Vorwoche möglich.';
const resetBtn = (locked || !hatRecht('wb.besprochen')) ? '' : (ab > 0 return `<div class="bericht-kopf">
? `<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()} ${renderGewerkDropdown()}
<span class="bericht-ring" style="background:conic-gradient(var(--green) 0% ${pct}%, var(--line-soft) ${pct}% 100%)"><span></span></span> <span class="hint" style="margin:0">${vergleich}</span>
<span class="bericht-fortschritt-text" title="${esc(vergleich)}">${ab} / ${liste.length} besprochen</span> <span class="bericht-fortschritt">
${resetBtn} <span class="bericht-fortschritt-text">${ab} von ${liste.length} ${esc(wochenberichtGewerk)}-Projekten besprochen</span>
${sucheHtml} <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>
</div>`; </div>`;
} }
// renderBerichtListe: die Tagesordnung als Leiste mit Nummer, Gruppe UND // renderBerichtListe: die Tagesordnung links. Eigene Funktion, weil das
// Name direkt sichtbar (Entwurf "Aktenregal", Eriks Wahl aus dem zweiten // Sprung-Feld sie allein neu zeichnet — das Detail rechts und vor allem ein
// Darstellungs-Vergleich — löst die vorherige schmale Icon-Leiste ab, die // gerade getippter Kommentar dürfen dabei nicht verlorengehen.
// Nummer und Namen nur im Hover-Tooltip zeigte: "so sehe ich die ganze // Keine Parameter, keine Rückgabe.
// 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(){ function renderBerichtListe(){
const el = document.getElementById('berichtListe'); const el = document.getElementById('berichtListe');
const w = currentWeek(); const w = currentWeek();
if(!el || !w) return; if(!el || !w) return;
const locked = isWeekLocked(w);
const prevWeek = findPreviousWeek(w); const prevWeek = findPreviousWeek(w);
const aktiv = aktuellesBerichtProjekt(w); const aktiv = aktuellesBerichtProjekt(w);
// voll bleibt UNGEFILTERT: die Kachel-Nummer ist die Position in der const liste = berichtProjekte(w).filter(passtZurSuche);
// 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){ if(liste.length === 0){
el.innerHTML = `<div class="hint" style="padding:10px 6px">Kein Projekt passt zu „${esc(berichtSuche)}".</div>`; el.innerHTML = `<div class="hint" style="padding:10px">Kein Projekt passt zu „${esc(berichtSuche)}".</div>`;
return; return;
} }
let gruppeVorher = null; let letzteGruppe = null;
el.innerHTML = liste.map(p => { el.innerHTML = liste.map(p => {
const info = classifyProjectChange(p, prevWeek); 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; const istAktiv = aktiv && aktiv.id === p.id;
const position = voll.findIndex(x => x.id === p.id) + 1; return kopf + `<div class="bericht-zeile${istAktiv ? ' is-aktiv' : ''}${p.besprochen ? ' is-ab' : ''}"
const gruppe = p.group || 'Ohne Gruppe'; role="button" tabindex="0" data-uid="${esc(p.projectUid || '')}" data-pid="${esc(p.id)}"
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'}"> aria-current="${istAktiv ? 'true' : 'false'}">
<span class="bericht-rail-num">${String(position).padStart(2, '0')}</span> <button type="button" class="bericht-haken${p.besprochen ? ' ab' : ''}"
${info.bucket === 'unchanged' ? '' : `<span class="bericht-rail-dot is-${esc(info.bucket)}" aria-hidden="true"></span>`} ${locked ? 'disabled' : `onclick="event.stopPropagation();toggleBesprochen('${esc(p.id)}')"`}
<span class="bericht-rail-name">${esc(name)}</span> 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>`}
</div>`; </div>`;
}).join(''); }).join('');
el.querySelectorAll('.bericht-rail-item').forEach(z => { el.querySelectorAll('.bericht-zeile').forEach(z => {
const waehlen = () => waehleBerichtProjekt(z.dataset.uid || null); const waehlen = () => waehleBerichtProjekt(z.dataset.uid || null);
z.addEventListener('click', waehlen); z.addEventListener('click', e => { if(!e.target.closest('.bericht-haken')) waehlen(); });
z.addEventListener('keydown', e => { z.addEventListener('keydown', e => {
if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); waehlen(); } if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); waehlen(); }
}); });
@@ -690,9 +602,6 @@ function renderBerichtListe(){
// "Besprochen & weiter". Parameter: p, w, prevWeek, locked. Rückgabe: HTML. // "Besprochen & weiter". Parameter: p, w, prevWeek, locked. Rückgabe: HTML.
function renderBerichtDetail(p, w, prevWeek, locked){ function renderBerichtDetail(p, w, prevWeek, locked){
if(!p) return ''; if(!p) return '';
// Kommentar und Fortschritt nur mit Recht „Kommentar und Fortschritt
// bearbeiten“, sonst wie gesperrt als Anzeige (Rollen & Rechte).
const kLocked = locked || !hatRecht('wb.kommentar');
const grouped = peopleByRole(w); const grouped = peopleByRole(w);
const allPeople = []; const allPeople = [];
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry))); ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
@@ -700,189 +609,46 @@ function renderBerichtDetail(p, w, prevWeek, locked){
const info = classifyProjectChange(p, prevWeek); const info = classifyProjectChange(p, prevWeek);
const tags = Array.isArray(p.tags) ? p.tags : []; const tags = Array.isArray(p.tags) ? p.tags : [];
const fehlen = abwesendeBeteiligte(p, w); const fehlen = abwesendeBeteiligte(p, w);
// Reiner Text wie im freigegebenen Entwurf — kein zusätzlicher const liste = berichtProjekte(w);
// Auftragsstatus-Chip mehr an dieser Stelle (der Status bleibt über die const nr = liste.findIndex(x => x.id === p.id) + 1;
// 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-head-line"> return `<div class="bericht-detail-kopf">
<span class="bericht-head-nr">${esc(p.nr || '—')}</span> <span class="bericht-detail-zaehler">${nr} / ${liste.length}</span>
${info.bucket === 'unchanged' ? '' : `<span class="bericht-change-pill is-${esc(info.bucket)}">${esc(info.reason)}</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>`}
</div> </div>
<h1 class="bericht-h1">${esc(p.name)}</h1> <div class="bericht-detail-meta">${esc(p.group || 'Ohne Gruppe')}</div>
<div class="bericht-sub">${subText}</div> ${fehlen.length ? `<div class="bericht-abwesend-hinweis">Fehlt heute: ${esc(fehlen.join(', '))} — eventuell vertagen.</div>` : ''}
${fehlen.length ? `<span class="bericht-abwesend-hinweis">Heute abwesend: ${esc(fehlen.join(', '))} — eventuell vertagen.</span>` : ''} ${renderVorwochenzeile(p, prevWeek)}
${renderBoardDrawer(p, allPeople, kLocked, w.label, Object.assign({}, BERICHT_OPTS, { ohneVerlauf: true }))} ${renderBoardDrawer(p, allPeople, locked, w.label, BERICHT_OPTS)}
<div class="bericht-detail-fuss"> ${locked ? '' : `<div class="bericht-detail-fuss">
${!locked && hatRecht('proj.plan') ? `<button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')"> <button type="button" class="secondary small" onclick="openTodoAusKommentar('${esc(p.id)}')">
Aufgabe aus Kommentar… Aufgabe aus Kommentar…
</button>` : ''} </button>
<span class="bericht-fuss-spacer"></span> <span class="bericht-fuss-spacer"></span>
<button type="button" class="secondary" onclick="berichtWeiter('${esc(p.id)}')"${berichtNaechstes(w, p.id) ? '' : ' disabled title="Letztes Projekt der Liste"'}>Weiter →</button> <button type="button" onclick="besprochenUndWeiter('${esc(p.id)}')">Besprochen &amp; weiter →</button>
${!locked && hatRecht('wb.besprochen') ? `<button type="button" onclick="besprochenUndWeiter('${esc(p.id)}')">Besprochen &amp; weiter →</button>` : ''} </div>`}`;
</div>
${renderBerichtVerlauf(p, w, kLocked)}`;
} }
// ---------- Verlauf der Vorwochen (Eriks Wahl "B") ---------- // renderVorwochenzeile: der Kommentar der Vorwoche als eine Zeile ÜBER dem
// // Eingabefeld. Die häufigste Frage im Meeting ist "was hatten wir letzte
// Löst das Scrollrad über dem Kommentarfeld (renderVorwocheRad()) und den // Woche vereinbart?" — der Verlauf beantwortet sie zwar, aber erst nach dem
// aufklappbaren Kachel-Streifen (renderProjektVerlauf(), bleibt für // Aufklappen. Parameter: p, prevWeek. Rückgabe: HTML (leer ohne Vorwoche).
// Projektliste/Projektbereich) im Wochenbericht ab. Darstellung wie im function renderVorwochenzeile(p, prevWeek){
// Projekt-Dashboard (renderPlanKommentarVerlauf(), plan-info.js): eine if(!prevWeek || !p.projectUid) return '';
// senkrechte Zeitleiste, hier als eigener Abschnitt über die volle Breite const vor = (prevWeek.projects || []).find(x => x.projectUid === p.projectUid);
// unter der Bearbeitungsmaske. Über dem Kommentarfeld bleibt nur die schmale if(!vor) return '';
// Zeile "Zuletzt KW …" (renderBerichtZuletzt()). const text = statusPlainText(vor.status);
// if(!text) return '';
// Ein Eintrag entsteht nur, wenn sich der Kommentar DES GEWÄHLTEN GEWERKS return `<div class="bericht-vorwoche">
// gegenüber der Sitzung davor geändert hat; Sitzungen dazwischen ohne neuen <span class="bericht-vorwoche-label">${esc(prevWeek.label)}</span>
// Text stehen als grauer Vermerk am Eintrag. Geänderte Leistungsphasen seit <span class="bericht-vorwoche-text">${esc(text)}</span>
// 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>`; </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 ---------- // ---------- Aufgabe aus dem Kommentar ----------
// //
// Beschlüsse fallen im Meeting und landen bisher als Fließtext im Kommentar — // Beschlüsse fallen im Meeting und landen bisher als Fließtext im Kommentar —
@@ -906,13 +672,8 @@ function openTodoAusKommentar(pid){
// Vorschlag aus dem gerade im Feld stehenden Text, nicht aus dem // Vorschlag aus dem gerade im Feld stehenden Text, nicht aus dem
// gespeicherten: Im Meeting tippt man den Beschluss und legt sofort die // gespeicherten: Im Meeting tippt man den Beschluss und legt sofort die
// Aufgabe an, ohne vorher zu speichern. // 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 feld = document.querySelector('#wochenbericht .comment-field');
const text = feld ? statusTextMitZeilen(feld.innerHTML) : statusPlainText(kommentarVon(p, wochenberichtGewerk).status); const text = feld ? feld.value : statusPlainText(p.status);
const ersteZeile = String(text || '').split('\n').map(z => z.replace(/^[-•*\s]+/, '').trim()).find(z => z) || ''; const ersteZeile = String(text || '').split('\n').map(z => z.replace(/^[-•*\s]+/, '').trim()).find(z => z) || '';
document.getElementById('todoKommentarTitel').value = ersteZeile.slice(0, 120); document.getElementById('todoKommentarTitel').value = ersteZeile.slice(0, 120);
@@ -961,13 +722,6 @@ function saveTodoAusKommentar(){
return; return;
} }
const ende = document.getElementById('todoKommentarEnde').value; const ende = document.getElementById('todoKommentarEnde').value;
// Fälligkeit vor heute ergäbe einen Balken mit negativer Breite (Start ist
// immer heute) — lieber gleich ablehnen.
if(ende && ende < formatDateISO(new Date())){
fehler.textContent = 'Das Fälligkeitsdatum liegt in der Vergangenheit.';
fehler.hidden = false;
return;
}
const personen = Array.from(document.querySelectorAll('#todoKommentarPersonen input:checked')).map(i => i.value); const personen = Array.from(document.querySelectorAll('#todoKommentarPersonen input:checked')).map(i => i.value);
if(!p.plan) p.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 }; if(!p.plan) p.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 };
@@ -1006,9 +760,8 @@ function openBerichtProtokoll(){
if(prevWeek) zeilen.push('Vergleich mit ' + prevWeek.label); if(prevWeek) zeilen.push('Vergleich mit ' + prevWeek.label);
zeilen.push(''); zeilen.push('');
const leute = personenDesGewerks(w, wochenberichtGewerk); const anwesend = (w.people || []).filter(x => (w.attendance || {})[x.id]).map(x => x.fullName || x.name);
const anwesend = leute.filter(x => (w.attendance || {})[x.id]).map(x => x.fullName || x.name); const fehlend = (w.people || []).filter(x => !(w.attendance || {})[x.id]).map(x => x.fullName || x.name);
const fehlend = leute.filter(x => !(w.attendance || {})[x.id]).map(x => x.fullName || x.name);
if(anwesend.length){ if(anwesend.length){
zeilen.push('Anwesend: ' + anwesend.join(', ')); zeilen.push('Anwesend: ' + anwesend.join(', '));
if(fehlend.length) zeilen.push('Entschuldigt: ' + fehlend.join(', ')); if(fehlend.length) zeilen.push('Entschuldigt: ' + fehlend.join(', '));
@@ -1021,7 +774,7 @@ function openBerichtProtokoll(){
if(info.bucket === 'unchanged') return; // nur, was sich geändert hat if(info.bucket === 'unchanged') return; // nur, was sich geändert hat
anzahl++; anzahl++;
zeilen.push('— ' + (p.nr ? p.nr + ' ' : '') + p.name + ' [' + info.reason + ']'); zeilen.push('— ' + (p.nr ? p.nr + ' ' : '') + p.name + ' [' + info.reason + ']');
const text = statusPlainText(kommentarVon(p, wochenberichtGewerk).status); const text = statusPlainText(p.status);
if(text) zeilen.push(' ' + text); if(text) zeilen.push(' ' + text);
// Aufgaben, die in dieser Sitzung entstanden sind, gehören ins Protokoll: // Aufgaben, die in dieser Sitzung entstanden sind, gehören ins Protokoll:
// Sie sind das Ergebnis der Besprechung. // Sie sind das Ergebnis der Besprechung.
@@ -1083,37 +836,24 @@ function protokollAlsMail(){
// ---------- Tastatur ---------- // ---------- Tastatur ----------
// //
// Wer im Meeting mitschreibt, hat keine Hand für die Maus frei. ↓/↑ wechseln // Wer im Meeting mitschreibt, hat keine Hand für die Maus frei. ↓/↑ wechseln
// das Projekt, Strg+↵ im Kommentarfeld speichert den Kommentar und geht // das Projekt, Strg+↵ speichert den Kommentar und geht weiter. Die Tasten
// weiter. Die Tasten greifen nur im Wochenbericht und nicht, während in // greifen nur im Wochenbericht und nicht, während in einem Eingabefeld
// einem Eingabefeld getippt wird. // getippt wird — mit einer Ausnahme: Strg+↵ im Kommentarfeld ist genau der
// Fall, für den es gedacht ist.
document.addEventListener('keydown', function(e){ document.addEventListener('keydown', function(e){
if(typeof currentTab === 'undefined' || currentTab !== 'wochenbericht') return; if(typeof currentTab === 'undefined' || currentTab !== 'wochenbericht') return;
const w = currentWeek(); const w = currentWeek();
if(!w) return; if(!w) return;
// isContentEditable erfasst das Kommentarfeld (seit der Umstellung auf const imFeld = e.target && e.target.matches && e.target.matches('input, textarea, select');
// 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)){ if((e.key === 'Enter') && (e.ctrlKey || e.metaKey)){
const feld = e.target && e.target.closest ? e.target.closest('.comment-field') : null; 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); const aktiv = aktuellesBerichtProjekt(w);
if(!aktiv) return; if(!aktiv) return;
e.preventDefault(); e.preventDefault();
// Der Kommentar speichert sich über den bestehenden Weg selbst; hier // Der Kommentar speichert sich über den bestehenden Weg selbst; hier
// folgt nur der Sprung zum nächsten offenen Punkt. // folgt nur der Sprung zum nächsten offenen Punkt.
if(typeof saveBoardComment === 'function'){ if(feld && typeof saveBoardComment === 'function'){
const knopf = feld.closest('.comment-block').querySelector('.comment-editor-foot button'); const knopf = feld.closest('.comment-block').querySelector('.comment-editor-foot button');
if(knopf) saveBoardComment(knopf); if(knopf) saveBoardComment(knopf);
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

-1
View File
@@ -1 +0,0 @@
<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

Before

Width:  |  Height:  |  Size: 5.0 KiB

+77 -1058
View File
File diff suppressed because it is too large. Load diff
+419 -1832
View File
File diff suppressed because it is too large. Load diff
+367 -4466
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more