feat: perinco Pulse - Zeiterfassung fuer perinco
Erste Fassung, live unter https://pulse.perinco.de. Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV. Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM. Fachlich: - Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5 - Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder aufgebrauchtem Budget, Buchung nur auf den passenden Topf - Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte - Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend) - SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
server/.env
|
||||||
|
server/node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
# Aufgabenstellung enthaelt Server-Passwoerter im Klartext
|
||||||
|
Anforderungen.txt
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# perinco Pulse
|
||||||
|
|
||||||
|
Zeiterfassung für perinco – live unter **https://pulse.perinco.de**.
|
||||||
|
Stil, Themes und Aufbau folgen dem perinco CRM.
|
||||||
|
|
||||||
|
| Teil | Technik | Wo |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Frontend | React 19, TypeScript, Tailwind 4, Vite | `/var/www/pulse/dist` |
|
||||||
|
| Backend | Express 4, mysql2, exceljs, pdfkit | `/var/www/pulse/server`, `127.0.0.1:5010`, Dienst `perinco-pulse-backend` (User `pulse`) |
|
||||||
|
| Datenbank | MariaDB 11.8 | `perinco_pulse` (User `pulse`) |
|
||||||
|
| Webserver | Apache Vhost `pulse.perinco.de`, Let's Encrypt | `/etc/apache2/sites-available/pulse.perinco.de*.conf` |
|
||||||
|
|
||||||
|
Erster Login: Benutzer `admin`, Passwort aus `ADMIN_PASSWORD` in `server/.env` (wird beim ersten Start einmalig angelegt) – danach unter *Mein Konto* (unten links auf den Namen klicken) ändern.
|
||||||
|
|
||||||
|
## Rollen und Menü
|
||||||
|
|
||||||
|
| Rolle | Startseite | Menü |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Mitarbeiter (`user`) | Buchung | Buchung, Projekte (nur zugewiesene, ohne Beträge) |
|
||||||
|
| Vorgesetzter (`lead`) | Buchung | Dashboard (nur eigenes Team, rekursiv), Buchung, Projekte, Team, Kunden, Reports |
|
||||||
|
| Management | Dashboard | wie Vorgesetzter, sieht alles; Einstellungen nur „SSO-Verwaltung“ |
|
||||||
|
| Administrator | Einstellungen | Dashboard, Projekte, Team, Kunden, Reports, Einstellungen – keine Buchung, taucht in Auswertungen nicht auf |
|
||||||
|
|
||||||
|
Die Startseite gilt nach jeder Anmeldung (Passwort und SSO), auch wenn noch eine alte Seite in der Adresse steht; Neuladen bleibt auf der aktuellen Seite. Das Logo oben links führt ebenfalls zur Startseite.
|
||||||
|
|
||||||
|
Rechte werden im Menü **und** im Backend (403) geprüft. Jeder bucht nur auf Projekte, denen er zugewiesen ist.
|
||||||
|
|
||||||
|
## Fachregeln
|
||||||
|
|
||||||
|
- **Zeiteingabe**: `1:23` = 1 h 23 min, `1,5` / `1.5` = 1 h 30 min, außerdem `2`, `:45`, `1h 30m`.
|
||||||
|
- **Projektcode** automatisch nach Muster (Standard `{KUERZEL}-{JAHR}-{NR}` → `KUKA-2026-001`), **Anzeigename** nach Muster (Standard `{KUNDE} - {PROJEKT}` → `KUKA - PMO`). Beides in *Einstellungen → Allgemein*.
|
||||||
|
- **Projekttypen**: Billable (Preis pro Stunde oder Tag), Non-billable (intern), Festpreis (virtueller Stundensatz = Festpreis ÷ gebuchte Stunden, nur intern).
|
||||||
|
- **Budgettöpfe** (mind. einer je Projekt, beliebig viele): Bezeichnung, Laufzeit von/bis, Budget € + Satz (→ Volumen in Stunden/Tagen) oder direktes Kontingent.
|
||||||
|
- Gebucht wird nur auf einen Topf, dessen **Laufzeit das Leistungsdatum enthält**; überlappen Töpfe, zuerst der früher endende.
|
||||||
|
- Volumen erreicht **oder** Ablaufdatum überschritten → Warnung **und nicht mehr buchbar**.
|
||||||
|
- Bei Billable-Projekten verbrauchen nur abrechenbare Stunden das Volumen (Reisezeit o. ä. nicht); ist ein Topf leer, ist er für alles gesperrt.
|
||||||
|
- Warnung ab 80 % Verbrauch bzw. 14 Tage vor Ablauf (einstellbar) – im Buchungsdialog, in der Buchungsansicht und an der Projektkarte.
|
||||||
|
- Wird ein Topf geändert, ordnet Pulse vorhandene Buchungen neu zu.
|
||||||
|
- **Umsatz erbracht/offen** je Topf: Billable = abrechenbare Stunden × Satz; Festpreis = anteilig nach Kontingent (sonst nach Laufzeit), abgelaufen = voll; offen = Rest in nicht abgelaufenen Töpfen.
|
||||||
|
- **Soll/Auslastung**: Wochenstunden (Standard 40, je Benutzer überschreibbar) ÷ 5 × Arbeitstage bis heute.
|
||||||
|
- **Löschen**: Benutzer, Projekte, Kunden mit Buchungen werden nicht gelöscht, sondern deaktiviert/archiviert – Reports bleiben stimmig. SSO-Benutzer werden nur deaktiviert.
|
||||||
|
|
||||||
|
## Exporte
|
||||||
|
|
||||||
|
Serverseitig erzeugt, alle mit Kopf/Fuß aus *Einstellungen → Export* (Logo, Akzentfarbe, Titel, 3 Fußspalten, Unterschriftenfelder):
|
||||||
|
|
||||||
|
- **Stundennachweis je Monat** (PDF, Excel mit Summenformeln, CSV mit UTF-8-BOM/Semikolon): eigener Nachweis, eines Teammitglieds oder aller; filterbar nach Kunde/Projekt; optional mit Sätzen und Beträgen für die Rechnung.
|
||||||
|
- **Zeitreport** nach Kunden/Projekten/Tasks/Mitarbeitern oder als Detailliste.
|
||||||
|
|
||||||
|
## SSO / Active Directory
|
||||||
|
|
||||||
|
**Einrichtung** (nur Administrator, *Einstellungen → SSO / Entra ID*): Microsoft Entra ID per OpenID Connect (Authorization Code, Client Secret AES-verschlüsselt in der DB). App-Registrierung mit Redirect-URI `https://pulse.perinco.de/api/auth/sso/callback`, delegiert `openid profile email`, Anwendung `User.Read.All` mit Administratorzustimmung.
|
||||||
|
|
||||||
|
**SSO-Verwaltung** (Management und Administrator, *Einstellungen → SSO-Verwaltung*):
|
||||||
|
|
||||||
|
- Links alle zulässigen AD-Konten, rechts die Rollen Mitarbeiter / Vorgesetzter / Management. Konto auf eine Rolle **ziehen** = nach Pulse übernehmen bzw. Rolle ändern. Alternativ Mehrfachauswahl + Knopf (Handy, Tastatur). Optional gleich einen Vorgesetzten setzen.
|
||||||
|
- Zulässig sind nur Konten mit Mail **`vorname.nachname@perinco.de`** oder **`vorname.nachname@icon-experts.de`** (Bindestriche im Namen erlaubt). Ignoriert werden Adressen mit weiteren Teilen (`adm.sascha.wolf@…`), Funktionsadressen (`info@…`), deaktivierte Konten und Konten ohne Lizenz (freigegebene Postfächer) sowie Gäste. Regel: `server/src/lib/sso.js` → `isEligible`, Tests: `cd server && npm test`.
|
||||||
|
- Existiert schon ein lokales Konto mit derselben Mail, wird es verknüpft statt doppelt angelegt.
|
||||||
|
- **Anmelden per Microsoft kann sich nur, wer übernommen wurde.** Rollen kommen ausschließlich aus Pulse, AD-Gruppen spielen keine Rolle. Administratoren können hier nicht geändert und nicht vergeben werden.
|
||||||
|
- **Abgleich**: aktualisiert Name, Mail und Jobtitel der übernommenen Konten; wer im AD gelöscht, deaktiviert oder nicht mehr zulässig ist, wird in Pulse deaktiviert. Legt nie selbst Konten an. Die AD-Liste wird 5 Minuten zwischengespeichert („Neu laden“ holt sofort).
|
||||||
|
- Externe Benutzer bleiben lokale Konten.
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install && (cd server && npm install)
|
||||||
|
cp server/.env.example server/.env # Werte setzen
|
||||||
|
# DB z. B. per Tunnel: plink -L 3307:127.0.0.1:3306 root@31.70.144.60 (DB_PORT=3307, DB_NAME=perinco_pulse_dev)
|
||||||
|
(cd server && npm run dev) # Backend :5010
|
||||||
|
npm run dev # Frontend :3110, proxyt /api
|
||||||
|
```
|
||||||
|
|
||||||
|
`perinco_pulse_dev` enthält Demodaten (Kunden, Projekte, ~1000 Buchungen, Testbenutzer mit Passwort `Test12345!`) und wird von der Produktion nicht benutzt.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PULSE_SSH_PASSWORD = '…'
|
||||||
|
powershell -File deploy/deploy.ps1 # baut, lädt hoch, tauscht dist atomar, startet Dienst neu
|
||||||
|
powershell -File deploy/deploy.ps1 -Deps # zusätzlich npm ci im Backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Produktions-`.env` liegt nur auf dem Server (`/var/www/pulse/server/.env`, 600). `SETTINGS_KEY` nicht ändern, sonst ist das gespeicherte SSO-Secret unlesbar.
|
||||||
|
|
||||||
|
## Anforderungs-Checkliste
|
||||||
|
|
||||||
|
Aus Auftrag und Call-Mitschrift.
|
||||||
|
|
||||||
|
| Anforderung | Status | Wo |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Dashboard Mgmt/Admin: Billable/Non-billable-Grafik, umschaltbar Tag/Woche/Monat/Quartal/Jahr, animiert | ✅ | Dashboard |
|
||||||
|
| Letzte Buchungen | ✅ | Dashboard |
|
||||||
|
| Mitarbeiter-Übersicht auf-/absteigend, letzte Tage/Woche/Monat/Quartal/Jahr, Filter billable/non-billable | ✅ animierte Umsortierung | Dashboard |
|
||||||
|
| Vorgesetzte sehen nur ihr Team | ✅ rekursiv, auch im Backend | Dashboard, Reports, Buchung (Teamansicht, nur lesen) |
|
||||||
|
| Buchung: Wochenansicht Standard, umschaltbar Monat/Jahr | ✅ | Buchung |
|
||||||
|
| Grünes Plus → Projekt, Task, optionaler Freitext, Zeit rechts daneben, `0:00`, `1:23` und `1,5` | ✅ | Buchungsdialog |
|
||||||
|
| Nur zugewiesene Projekte, Task optional | ✅ | Buchungsdialog |
|
||||||
|
| Zeiten bearbeiten und löschen | ✅ | Klick auf Buchung |
|
||||||
|
| Monatsexport Excel/PDF/CSV als Kundenvorlage mit Logo und Footer | ✅ (Footer-Inhalt siehe Hinweis) | Buchung → Export |
|
||||||
|
| Menü je Rolle (Dashboard/Buchung, Projekte, Team, Kunden, Reports, Einstellungen) | ✅ | Seitenleiste / mobile Leiste |
|
||||||
|
| Projekte: User nur zugewiesene; andere alle inkl. Töpfe, bearbeiten/anlegen/entfernen | ✅ | Projekte |
|
||||||
|
| Projekt wählen → Tasks darunter | ✅ | Projekte |
|
||||||
|
| Projektanlage: Name, Code automatisch, Start/Ende, Budget, Tags, Notizen, Typ billable/non-billable/Festpreis, Stunde/Tag | ✅ | Neues Projekt |
|
||||||
|
| Budgettopf: Laufzeit, Budget €, Stundensatz, Bezeichnung; n Töpfe; Balken Stunden/Tage/Budget | ✅ | Projekte |
|
||||||
|
| Hinweis beim Buchen, wann Budget endet und wieviel offen ist | ✅ | Buchungsdialog, Buchung |
|
||||||
|
| Topf voll oder abgelaufen → nicht buchbar | ✅ serverseitig erzwungen | |
|
||||||
|
| Nur auf Topf buchen, dessen Laufzeit die Leistung enthält | ✅ | |
|
||||||
|
| Töpfe bearbeiten ab Vorgesetzter | ✅ | Projekte |
|
||||||
|
| Festpreis: virtueller Stundensatz intern | ✅ | Projekt, Projektreport |
|
||||||
|
| Report: Gesamt aller Töpfe und einzelne Töpfe | ✅ | Projektreport → Budgettöpfe |
|
||||||
|
| Projekte Usern zuweisen | ✅ | Projekt → Zuweisen / Formular |
|
||||||
|
| Kunden über Projekten | ✅ | Kunden, Projektformular (+ Kunde) |
|
||||||
|
| Team: Hierarchie Vorgesetzte/Mitarbeiter, lokale User anlegen/bearbeiten/löschen, Externe | ✅ Organigramm + Liste | Team |
|
||||||
|
| Kunden anlegen/bearbeiten, wer hat welche Projekte mit welchen Mitarbeitern, Umsatz erbracht/offen, animiert | ✅ | Kunden |
|
||||||
|
| Reports wie Harvest (Report.png), interaktiv, Export Excel/PDF/CSV | ✅ | Reports → Zeitreport |
|
||||||
|
| Report für einzelne Kunden / Projekt wie Report-Kunde.png | ✅ | Reports → Kunden-/Projektreport |
|
||||||
|
| Einstellungen (nur Admin): SSO-Credentials, Userverwaltung, Kopf/Fuß der Exporte | ✅ inkl. PDF-/Excel-Vorschau | Einstellungen |
|
||||||
|
| Arbeitsstunden pro Woche (40) | ✅ | Einstellungen → Allgemein |
|
||||||
|
| Projektbezeichnungen nach Muster | ✅ | Einstellungen → Allgemein |
|
||||||
|
| Themes wie CRM (perinco, Pixel Art, Outdoor, Cyberpunk, Rainy, Snow, Aura – jeweils hell/dunkel, am Benutzer gespeichert) | ✅ | Kopfleiste |
|
||||||
|
| Lokaler Admin mit vorgegebenem Passwort, später änderbar | ✅ | Mein Konto |
|
||||||
|
| Mobil: eigene Ansicht bei Handy | ✅ untere Navigation, Tagesstreifen, Bottom-Sheets, schwebender Plus-Knopf | |
|
||||||
|
| SSO, anfangs normaler Login; AD-User auslesen, Rollen intern | ✅ vorbereitet, noch nicht gegen echten Tenant getestet | Einstellungen → SSO |
|
||||||
|
|
||||||
|
### Offene Punkte
|
||||||
|
|
||||||
|
- **Wordvorlage** lag nicht im Ordner. Kopf nutzt das perinco-Logo; die Fußzeilen (Adresse, Registergericht, Bank) bitte in *Einstellungen → Export* eintragen.
|
||||||
|
- **SSO** ist implementiert, braucht aber eine App-Registrierung in Entra ID zum Test.
|
||||||
|
- Nicht umgesetzt, weil nicht gefordert: Rechnungsmodul („Uninvoiced amount“ aus Harvest), gespeicherte Reports.
|
||||||
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 274 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
# perinco Pulse - Deployment auf 31.70.144.60
|
||||||
|
#
|
||||||
|
# Baut lokal und laedt dist/ plus Backend-Quellen hoch.
|
||||||
|
#
|
||||||
|
# Aufruf aus dem Projektverzeichnis:
|
||||||
|
# $env:PULSE_SSH_PASSWORD = '...'
|
||||||
|
# powershell -File deploy/deploy.ps1
|
||||||
|
# powershell -File deploy/deploy.ps1 -SkipBuild
|
||||||
|
# powershell -File deploy/deploy.ps1 -Deps # bei neuen Backend-Paketen
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$ServerHost = '31.70.144.60',
|
||||||
|
[int]$Port = 22,
|
||||||
|
[string]$User = 'root',
|
||||||
|
[string]$Password = $env:PULSE_SSH_PASSWORD,
|
||||||
|
[string]$RemotePath = '/var/www/pulse',
|
||||||
|
[switch]$SkipBuild,
|
||||||
|
[switch]$Deps
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if (-not $Password) { throw 'Kein Passwort. Setze $env:PULSE_SSH_PASSWORD oder uebergib -Password.' }
|
||||||
|
|
||||||
|
$plink = 'C:\Program Files\PuTTY\plink.exe'
|
||||||
|
$pscp = 'C:\Program Files\PuTTY\pscp.exe'
|
||||||
|
foreach ($tool in @($plink, $pscp)) { if (-not (Test-Path $tool)) { throw "Nicht gefunden: $tool" } }
|
||||||
|
|
||||||
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $projectRoot
|
||||||
|
|
||||||
|
if (-not $SkipBuild) {
|
||||||
|
Write-Host '==> Frontend bauen' -ForegroundColor Green
|
||||||
|
npm run build
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Build fehlgeschlagen - es wird nichts hochgeladen.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = "$User@$ServerHost"
|
||||||
|
|
||||||
|
Write-Host '==> dist/ hochladen' -ForegroundColor Green
|
||||||
|
& $plink -ssh -batch -P $Port -pw $Password $target "rm -rf $RemotePath/dist.new && mkdir -p $RemotePath/dist.new"
|
||||||
|
& $pscp -r -batch -P $Port -pw $Password 'dist\*' "${target}:$RemotePath/dist.new/"
|
||||||
|
|
||||||
|
Write-Host '==> Backend hochladen' -ForegroundColor Green
|
||||||
|
& $pscp -r -batch -P $Port -pw $Password 'server\src' "${target}:$RemotePath/server/"
|
||||||
|
& $pscp -r -batch -P $Port -pw $Password 'server\assets' "${target}:$RemotePath/server/"
|
||||||
|
& $pscp -batch -P $Port -pw $Password 'server\package.json' 'server\package-lock.json' "${target}:$RemotePath/server/"
|
||||||
|
|
||||||
|
# dist atomar tauschen, damit niemand eine halb hochgeladene Version sieht.
|
||||||
|
$remote = "cd $RemotePath && rm -rf dist.old && (mv dist dist.old 2>/dev/null; true) && mv dist.new dist && rm -rf dist.old"
|
||||||
|
if ($Deps) { $remote = "$remote && cd $RemotePath/server && npm ci --omit=dev --no-audit --no-fund" }
|
||||||
|
$remote = "$remote && chown -R pulse:pulse $RemotePath/server && systemctl restart perinco-pulse-backend && sleep 3 && systemctl is-active perinco-pulse-backend && curl -s http://127.0.0.1:5010/api/health"
|
||||||
|
|
||||||
|
Write-Host '==> Tauschen, neu starten, pruefen' -ForegroundColor Green
|
||||||
|
& $plink -ssh -batch -P $Port -pw $Password $target $remote
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host 'Fertig: https://pulse.perinco.de' -ForegroundColor Green
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=perinco Pulse Backend (Express + MariaDB)
|
||||||
|
After=network.target mariadb.service
|
||||||
|
Wants=mariadb.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Eigener Systembenutzer ohne Login - das Backend braucht keine Root-Rechte.
|
||||||
|
User=pulse
|
||||||
|
Group=pulse
|
||||||
|
WorkingDirectory=/var/www/pulse/server
|
||||||
|
EnvironmentFile=/var/www/pulse/server/.env
|
||||||
|
# Server laeuft auf UTC; "heute", Monatsgrenzen und Ablaufdaten gelten in Berlin.
|
||||||
|
Environment=TZ=Europe/Berlin
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
ExecStart=/usr/bin/node src/server.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# Sicherheitsnetz: ein Leck soll Apache, MariaDB und das ICON CRM nicht mitreissen.
|
||||||
|
MemoryMax=768M
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# perinco Pulse - Apache Vhost
|
||||||
|
# Ablage: /etc/apache2/sites-available/pulse.perinco.de.conf
|
||||||
|
# Aktivieren: a2ensite pulse.perinco.de && systemctl reload apache2
|
||||||
|
#
|
||||||
|
# Reihenfolge beim Aufsetzen:
|
||||||
|
# 1. DNS A-Record pulse.perinco.de -> 31.70.144.60
|
||||||
|
# 2. Diese Datei aktivieren (HTTP-Teil), Apache neu laden
|
||||||
|
# 3. certbot --apache -d pulse.perinco.de (legt den :443-Block selbst an)
|
||||||
|
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName pulse.perinco.de
|
||||||
|
DocumentRoot /var/www/pulse/dist
|
||||||
|
|
||||||
|
<Directory /var/www/pulse/dist>
|
||||||
|
Options -Indexes +FollowSymLinks
|
||||||
|
AllowOverride None
|
||||||
|
Require all granted
|
||||||
|
FallbackResource /index.html
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
# Gehashte Build-Dateien duerfen dauerhaft im Cache liegen, index.html nie.
|
||||||
|
<IfModule mod_expires.c>
|
||||||
|
ExpiresActive On
|
||||||
|
<FilesMatch "\.(js|css|woff2|png|jpg|svg|ico)$">
|
||||||
|
ExpiresDefault "access plus 1 year"
|
||||||
|
</FilesMatch>
|
||||||
|
</IfModule>
|
||||||
|
<FilesMatch "index\.html$">
|
||||||
|
Header set Cache-Control "no-cache, must-revalidate"
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
Header always set X-Content-Type-Options "nosniff"
|
||||||
|
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Header always set X-Frame-Options "SAMEORIGIN"
|
||||||
|
|
||||||
|
# API lokal auf 127.0.0.1:5010, von aussen nur hierueber erreichbar.
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyPass /api http://127.0.0.1:5010/api
|
||||||
|
ProxyPassReverse /api http://127.0.0.1:5010/api
|
||||||
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
|
|
||||||
|
ErrorLog ${APACHE_LOG_DIR}/pulse_error.log
|
||||||
|
CustomLog ${APACHE_LOG_DIR}/pulse_access.log combined
|
||||||
|
</VirtualHost>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#6ba04a" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<title>Pulse · perinco</title>
|
||||||
|
<script>
|
||||||
|
// Setzt Design und Modus vor dem ersten Frame - ohne das blitzt beim Laden
|
||||||
|
// die helle perinco-Variante auf, weil das Bundle erst danach laeuft.
|
||||||
|
//
|
||||||
|
// Die verbindliche Wahl steht am Benutzer in der Datenbank; hier liegt nur
|
||||||
|
// die zuletzt benutzte als Zwischenspeicher, denn vor dem ersten Frame ist
|
||||||
|
// noch niemand angemeldet. Melden sich zwei Benutzer an demselben Rechner
|
||||||
|
// an, sieht der zweite fuer einen Moment das Design des ersten und dann
|
||||||
|
// sein eigenes - das ist der Preis dafuer, dass es gar nicht blitzt.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var mode = localStorage.getItem('pulse_theme');
|
||||||
|
var dark =
|
||||||
|
mode === 'dark' ||
|
||||||
|
(mode !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
if (dark) document.documentElement.classList.add('dark');
|
||||||
|
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
||||||
|
|
||||||
|
var palette = localStorage.getItem('pulse_palette');
|
||||||
|
// Whitelist, damit ein manipulierter Eintrag keine Klasse setzt.
|
||||||
|
if (['pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura'].indexOf(palette) !== -1) {
|
||||||
|
document.documentElement.classList.add('theme-' + palette);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* localStorage gesperrt - dann eben im hellen perinco-Design starten */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<!--
|
||||||
|
Nunito Sans traegt perinco und Pixel Art (Fliesstext), Cabin das Outdoor-
|
||||||
|
Design, Silkscreen die Ueberschriften von Pixel Art, Chakra Petch das
|
||||||
|
Cyberpunk-Design. Alle in einer Anfrage: ein Nachladen beim Umschalten
|
||||||
|
wuerde die Schrift sichtbar springen lassen.
|
||||||
|
-->
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,300;0,6..12,400;0,6..12,600;0,6..12,700;0,6..12,800&family=Cabin:wght@400;500;600;700&family=Silkscreen:wght@400;700&family=Chakra+Petch:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body class="h-full">
|
||||||
|
<div id="root" class="h-full"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "perinco-pulse",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"tailwindcss": "^4.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 158 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 80" width="800" height="80">
|
||||||
|
<!--
|
||||||
|
Aura, hintere Wolken. Eine Kachel, die sich nahtlos wiederholt: keine Wolke
|
||||||
|
beruehrt den linken oder rechten Rand. index.css schiebt die Kachel langsam
|
||||||
|
nach rechts, genau um ihre Breite je Umlauf - deshalb kein Ruck.
|
||||||
|
-->
|
||||||
|
<defs>
|
||||||
|
<filter id="weich" x="-20%" y="-50%" width="140%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="3" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g fill="#ffffff" opacity="0.55" filter="url(#weich)">
|
||||||
|
<ellipse cx="110" cy="24" rx="70" ry="12" />
|
||||||
|
<ellipse cx="140" cy="18" rx="40" ry="11" />
|
||||||
|
<ellipse cx="430" cy="46" rx="80" ry="12" />
|
||||||
|
<ellipse cx="460" cy="38" rx="42" ry="12" />
|
||||||
|
<ellipse cx="650" cy="20" rx="60" ry="10" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 788 B |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 80" width="800" height="80">
|
||||||
|
<!--
|
||||||
|
Aura, vordere Wolken: groesser, deutlicher, flache Unterkante. Nahtlose
|
||||||
|
Kachel wie aura-clouds-far.svg, zieht aber doppelt so schnell - das gibt
|
||||||
|
Tiefe.
|
||||||
|
-->
|
||||||
|
<g fill="#ffffff" opacity="0.92">
|
||||||
|
<path d="M60 58 C 56 46 70 38 84 42 C 90 28 116 24 128 38 C 138 30 160 34 162 48 C 176 48 184 54 180 60 Z" />
|
||||||
|
<path d="M330 34 C 328 24 340 18 350 22 C 356 10 378 8 388 20 C 396 14 414 18 414 30 C 426 30 432 36 428 40 Z" />
|
||||||
|
<path d="M560 62 C 556 50 572 42 586 46 C 594 30 624 26 638 42 C 650 34 676 38 678 54 C 694 54 704 60 698 66 Z" />
|
||||||
|
</g>
|
||||||
|
<!-- Schattenkante unten, damit die Wolken auf hellem Himmel Form haben -->
|
||||||
|
<g fill="#c9dcef" opacity="0.6">
|
||||||
|
<path d="M70 58 C 100 54 150 54 180 60 Z" />
|
||||||
|
<path d="M340 38 C 370 35 400 35 428 40 Z" />
|
||||||
|
<path d="M572 62 C 610 58 660 58 698 66 Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 933 B |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<!--
|
||||||
|
Aura, dunkel ("Mond"): der Vollmond an derselben Stelle wie die Sonne im
|
||||||
|
hellen Modus - weicher Hof, blasse Scheibe, ein paar Krater.
|
||||||
|
-->
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="hof" cx="50" cy="50" r="50" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0.3" stop-color="#dfe4ff" stop-opacity="0.45" />
|
||||||
|
<stop offset="0.65" stop-color="#b9c2ff" stop-opacity="0.12" />
|
||||||
|
<stop offset="1" stop-color="#b9c2ff" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="scheibe" cx="44" cy="44" r="22" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#fbfbff" />
|
||||||
|
<stop offset="1" stop-color="#d7dcef" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="50" cy="50" r="50" fill="url(#hof)" />
|
||||||
|
<circle cx="50" cy="50" r="18" fill="url(#scheibe)" />
|
||||||
|
<g fill="#b8bfd8" opacity="0.55">
|
||||||
|
<circle cx="44" cy="46" r="3.5" />
|
||||||
|
<circle cx="55" cy="55" r="2.5" />
|
||||||
|
<circle cx="52" cy="41" r="1.8" />
|
||||||
|
<circle cx="43" cy="57" r="1.5" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 900" width="260" height="900" preserveAspectRatio="xMidYMax slice">
|
||||||
|
<!--
|
||||||
|
Aura, Seitenleiste: sanfte Huegel in Pastell, von unten hereinwachsend.
|
||||||
|
Eine Datei fuer beide Modi - der Schleier des Mond-Modus toent sie zur
|
||||||
|
Nacht ab (index.css).
|
||||||
|
-->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="dunst" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ffd9a0" stop-opacity="0" />
|
||||||
|
<stop offset="1" stop-color="#ffd9a0" stop-opacity="0.45" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="weich" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="14" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect y="520" width="260" height="380" fill="url(#dunst)" filter="url(#weich)" />
|
||||||
|
<path d="M0 730 C 60 690 120 700 170 720 C 210 736 240 716 260 706 L260 900 L0 900 Z" fill="#c7b8e8" opacity="0.5" />
|
||||||
|
<path d="M0 780 C 50 760 110 772 160 764 C 200 758 236 770 260 762 L260 900 L0 900 Z" fill="#f2b98a" opacity="0.55" />
|
||||||
|
<path d="M0 830 C 70 812 130 826 190 818 C 220 814 244 820 260 816 L260 900 L0 900 Z" fill="#9fcfa8" opacity="0.55" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 80" width="1600" height="80" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<!-- Aura, dunkel: Sterne hinter den Wolken. Nur im Mond-Modus eingesetzt. -->
|
||||||
|
<g fill="#ffffff">
|
||||||
|
<circle cx="60" cy="14" r="1" opacity="0.7" /><circle cx="140" cy="52" r="0.8" opacity="0.5" />
|
||||||
|
<circle cx="230" cy="24" r="1.2" opacity="0.8" /><circle cx="310" cy="62" r="0.8" opacity="0.5" />
|
||||||
|
<circle cx="390" cy="10" r="1" opacity="0.6" /><circle cx="470" cy="40" r="1.3" opacity="0.9" />
|
||||||
|
<circle cx="560" cy="18" r="0.8" opacity="0.5" /><circle cx="640" cy="58" r="1" opacity="0.7" />
|
||||||
|
<circle cx="720" cy="30" r="1.2" opacity="0.8" /><circle cx="810" cy="12" r="0.8" opacity="0.5" />
|
||||||
|
<circle cx="890" cy="48" r="1" opacity="0.7" /><circle cx="970" cy="22" r="1.3" opacity="0.9" />
|
||||||
|
<circle cx="1050" cy="64" r="0.8" opacity="0.5" /><circle cx="1130" cy="16" r="1" opacity="0.6" />
|
||||||
|
<circle cx="1210" cy="42" r="1.2" opacity="0.8" /><circle cx="1290" cy="8" r="0.8" opacity="0.5" />
|
||||||
|
<circle cx="1370" cy="54" r="1" opacity="0.7" /><circle cx="1450" cy="26" r="1.2" opacity="0.8" />
|
||||||
|
<circle cx="1530" cy="60" r="0.8" opacity="0.5" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<!--
|
||||||
|
Aura, hell ("Sunshine"): die Sonne. Sie sitzt in der Ecke oben rechts der
|
||||||
|
Leiste und ist nur zur Haelfte zu sehen (Position in index.css). Von ihrem
|
||||||
|
Mittelpunkt gehen die Sonnenstrahlen aus (components/AuraWeather.tsx) -
|
||||||
|
wer die Groesse oder Lage aendert, muss dort SUN_* nachziehen.
|
||||||
|
-->
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="schein" cx="50" cy="50" r="50" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0.3" stop-color="#ffd76a" stop-opacity="0.9" />
|
||||||
|
<stop offset="0.6" stop-color="#ffc94a" stop-opacity="0.35" />
|
||||||
|
<stop offset="1" stop-color="#ffc94a" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="kern" cx="45" cy="45" r="22" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#fff6c9" />
|
||||||
|
<stop offset="0.7" stop-color="#ffd24d" />
|
||||||
|
<stop offset="1" stop-color="#ffb81f" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="50" cy="50" r="50" fill="url(#schein)" />
|
||||||
|
<circle cx="50" cy="50" r="20" fill="url(#kern)" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,93 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 80" width="1600" height="80" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<!--
|
||||||
|
Cyberpunk, obere Leiste: Neonroehren hinter Regen.
|
||||||
|
|
||||||
|
Die Roehren sind doppelt gezeichnet - einmal breit und unscharf (der Schein),
|
||||||
|
einmal duenn und hell (das Glas). Genau daraus besteht der Neoneffekt; eine
|
||||||
|
einzelne farbige Linie sieht nur nach Linie aus.
|
||||||
|
|
||||||
|
Der Regen faellt schraeg und ist bewusst blass: er soll Bewegung andeuten,
|
||||||
|
nicht die Schrift der Leiste stoeren. Die Deckung darueber kommt aus dem
|
||||||
|
Theme (index.css), damit dieselbe Grafik im hellen Modus blass bleibt.
|
||||||
|
-->
|
||||||
|
<defs>
|
||||||
|
<filter id="schein" x="-60%" y="-200%" width="220%" height="500%">
|
||||||
|
<feGaussianBlur stdDeviation="7" />
|
||||||
|
</filter>
|
||||||
|
<filter id="weiterSchein" x="-80%" y="-300%" width="260%" height="700%">
|
||||||
|
<feGaussianBlur stdDeviation="18" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Bewusst KEINE deckende Nachtflaeche: die Grafik ist durchsichtig und zeigt
|
||||||
|
nur Licht. Ueber der dunklen Flaeche des Dunkelmodus ergibt das leuchtendes
|
||||||
|
Neon, ueber der weissen des hellen Modus blasse farbige Linien. Mit einer
|
||||||
|
dunklen Grundflaeche wurde daraus im hellen Modus ein grauer Schmutzstreifen.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Ferne Fensterreihen: die Stadt im Hintergrund -->
|
||||||
|
<g fill="#4a6fa8" opacity="0.35">
|
||||||
|
<rect x="120" y="18" width="3" height="5" /><rect x="130" y="30" width="3" height="4" />
|
||||||
|
<rect x="148" y="12" width="3" height="6" /><rect x="160" y="26" width="3" height="5" />
|
||||||
|
<rect x="420" y="22" width="3" height="5" /><rect x="432" y="14" width="3" height="4" />
|
||||||
|
<rect x="446" y="34" width="3" height="5" /><rect x="458" y="20" width="3" height="6" />
|
||||||
|
<rect x="980" y="16" width="3" height="5" /><rect x="992" y="28" width="3" height="4" />
|
||||||
|
<rect x="1006" y="20" width="3" height="6" /><rect x="1018" y="34" width="3" height="4" />
|
||||||
|
<rect x="1320" y="24" width="3" height="5" /><rect x="1332" y="14" width="3" height="6" />
|
||||||
|
<rect x="1346" y="30" width="3" height="4" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Waagerechte Neonroehren -->
|
||||||
|
<g>
|
||||||
|
<!-- Magenta, lang -->
|
||||||
|
<rect x="180" y="26" width="300" height="8" fill="#c026d3" opacity="0.5" filter="url(#weiterSchein)" />
|
||||||
|
<rect x="180" y="28" width="300" height="4" fill="#e879f9" opacity="0.85" filter="url(#schein)" />
|
||||||
|
<rect x="180" y="29" width="300" height="2" fill="#fbe3ff" opacity="0.95" />
|
||||||
|
|
||||||
|
<!-- Cyan, kurz -->
|
||||||
|
<rect x="620" y="50" width="180" height="8" fill="#0891b2" opacity="0.5" filter="url(#weiterSchein)" />
|
||||||
|
<rect x="620" y="52" width="180" height="4" fill="#22d3ee" opacity="0.85" filter="url(#schein)" />
|
||||||
|
<rect x="620" y="53" width="180" height="2" fill="#d6feff" opacity="0.95" />
|
||||||
|
|
||||||
|
<!-- Violett, mittig -->
|
||||||
|
<rect x="900" y="20" width="240" height="8" fill="#7326d6" opacity="0.5" filter="url(#weiterSchein)" />
|
||||||
|
<rect x="900" y="22" width="240" height="4" fill="#a24cff" opacity="0.85" filter="url(#schein)" />
|
||||||
|
<rect x="900" y="23" width="240" height="2" fill="#ecdcff" opacity="0.95" />
|
||||||
|
|
||||||
|
<!-- Cyan, rechts -->
|
||||||
|
<rect x="1260" y="46" width="220" height="8" fill="#0891b2" opacity="0.45" filter="url(#weiterSchein)" />
|
||||||
|
<rect x="1260" y="48" width="220" height="4" fill="#22d3ee" opacity="0.8" filter="url(#schein)" />
|
||||||
|
<rect x="1260" y="49" width="220" height="2" fill="#d6feff" opacity="0.9" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Senkrechte Roehren: setzen Takt in die Breite -->
|
||||||
|
<g>
|
||||||
|
<rect x="540" y="0" width="6" height="80" fill="#c026d3" opacity="0.35" filter="url(#weiterSchein)" />
|
||||||
|
<rect x="542" y="0" width="2" height="80" fill="#f0a6ff" opacity="0.7" filter="url(#schein)" />
|
||||||
|
<rect x="1180" y="0" width="6" height="80" fill="#0891b2" opacity="0.35" filter="url(#weiterSchein)" />
|
||||||
|
<rect x="1182" y="0" width="2" height="80" fill="#7ce9f7" opacity="0.7" filter="url(#schein)" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Regen: schraege Striche, unterschiedlich lang und blass -->
|
||||||
|
<g stroke="#a5c8ff" stroke-linecap="round">
|
||||||
|
<g stroke-width="1.2" opacity="0.3">
|
||||||
|
<path d="M40 -6 l-8 26" /><path d="M96 4 l-8 26" /><path d="M152 -8 l-8 26" />
|
||||||
|
<path d="M214 10 l-8 26" /><path d="M268 -4 l-8 26" /><path d="M330 16 l-8 26" />
|
||||||
|
<path d="M392 2 l-8 26" /><path d="M452 20 l-8 26" /><path d="M512 -2 l-8 26" />
|
||||||
|
<path d="M574 14 l-8 26" /><path d="M636 4 l-8 26" /><path d="M698 22 l-8 26" />
|
||||||
|
<path d="M760 -6 l-8 26" /><path d="M822 12 l-8 26" /><path d="M884 0 l-8 26" />
|
||||||
|
<path d="M946 18 l-8 26" /><path d="M1008 6 l-8 26" /><path d="M1070 24 l-8 26" />
|
||||||
|
<path d="M1132 -4 l-8 26" /><path d="M1194 14 l-8 26" /><path d="M1256 2 l-8 26" />
|
||||||
|
<path d="M1318 20 l-8 26" /><path d="M1380 8 l-8 26" /><path d="M1442 26 l-8 26" />
|
||||||
|
<path d="M1504 -2 l-8 26" /><path d="M1566 16 l-8 26" />
|
||||||
|
</g>
|
||||||
|
<g stroke-width="1.6" opacity="0.22">
|
||||||
|
<path d="M70 34 l-10 34" /><path d="M186 40 l-10 34" /><path d="M302 30 l-10 34" />
|
||||||
|
<path d="M418 44 l-10 34" /><path d="M534 36 l-10 34" /><path d="M650 46 l-10 34" />
|
||||||
|
<path d="M766 32 l-10 34" /><path d="M882 42 l-10 34" /><path d="M998 34 l-10 34" />
|
||||||
|
<path d="M1114 48 l-10 34" /><path d="M1230 36 l-10 34" /><path d="M1346 44 l-10 34" />
|
||||||
|
<path d="M1462 32 l-10 34" /><path d="M1578 42 l-10 34" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.2 KiB |
@@ -0,0 +1,106 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 900" width="260" height="900" preserveAspectRatio="xMidYMax slice">
|
||||||
|
<!--
|
||||||
|
Cyberpunk, Seitenleiste: Blick durch ein Fenster in eine regnerische Gasse.
|
||||||
|
|
||||||
|
Aufbau von hinten nach vorn: Fensterreihen der Haeuser, senkrechte
|
||||||
|
Neonroehren, eine Pfuetze mit Spiegelung, der Regen davor. Die Roehren sind
|
||||||
|
wieder doppelt gezeichnet - breit und unscharf als Schein, duenn und hell
|
||||||
|
als Glas.
|
||||||
|
|
||||||
|
Die obere Haelfte bleibt ruhig: dort stehen die Menuepunkte. Die Gasse steht
|
||||||
|
in den unteren rund 350 px, wo die Leiste frei ist.
|
||||||
|
-->
|
||||||
|
<defs>
|
||||||
|
<filter id="schein" x="-100%" y="-40%" width="300%" height="180%">
|
||||||
|
<feGaussianBlur stdDeviation="6" />
|
||||||
|
</filter>
|
||||||
|
<filter id="weiterSchein" x="-200%" y="-60%" width="500%" height="220%">
|
||||||
|
<feGaussianBlur stdDeviation="16" />
|
||||||
|
</filter>
|
||||||
|
<!--
|
||||||
|
Kein Schwarz: ein farbiger Dunst statt einer dunklen Flaeche. Ueber der
|
||||||
|
dunklen Flaeche des Dunkelmodus wirkt er wie Tiefe, ueber der weissen des
|
||||||
|
hellen Modus wie ein kuehler Schleier - grau wird er in keinem von beiden.
|
||||||
|
-->
|
||||||
|
<linearGradient id="tiefe" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#3a4a80" stop-opacity="0" />
|
||||||
|
<stop offset="0.4" stop-color="#33407a" stop-opacity="0.16" />
|
||||||
|
<stop offset="1" stop-color="#2b3a72" stop-opacity="0.3" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="pfuetze" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#22d3ee" stop-opacity="0.35" />
|
||||||
|
<stop offset="1" stop-color="#22d3ee" stop-opacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Die Gasse wird nach unten dichter -->
|
||||||
|
<rect y="450" width="260" height="450" fill="url(#tiefe)" />
|
||||||
|
|
||||||
|
<!-- Fensterreihen: warm und kalt gemischt, wie bewohnte Haeuser -->
|
||||||
|
<g opacity="0.5">
|
||||||
|
<g fill="#f0b356">
|
||||||
|
<rect x="18" y="612" width="5" height="7" /><rect x="34" y="648" width="5" height="7" />
|
||||||
|
<rect x="18" y="700" width="5" height="7" /><rect x="50" y="676" width="5" height="7" />
|
||||||
|
<rect x="208" y="624" width="5" height="7" /><rect x="224" y="668" width="5" height="7" />
|
||||||
|
<rect x="240" y="708" width="5" height="7" />
|
||||||
|
</g>
|
||||||
|
<g fill="#7cc4ef">
|
||||||
|
<rect x="34" y="588" width="5" height="7" /><rect x="18" y="664" width="5" height="7" />
|
||||||
|
<rect x="50" y="628" width="5" height="7" /><rect x="224" y="596" width="5" height="7" />
|
||||||
|
<rect x="208" y="688" width="5" height="7" /><rect x="240" y="640" width="5" height="7" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Senkrechte Neonroehren links und rechts, wie Schilder an den Haeusern -->
|
||||||
|
<g>
|
||||||
|
<rect x="26" y="520" width="10" height="180" fill="#c026d3" opacity="0.4" filter="url(#weiterSchein)" />
|
||||||
|
<rect x="29" y="520" width="4" height="180" fill="#e879f9" opacity="0.8" filter="url(#schein)" />
|
||||||
|
<rect x="30" y="520" width="2" height="180" fill="#fbe3ff" opacity="0.9" />
|
||||||
|
|
||||||
|
<rect x="222" y="580" width="10" height="230" fill="#0891b2" opacity="0.4" filter="url(#weiterSchein)" />
|
||||||
|
<rect x="225" y="580" width="4" height="230" fill="#22d3ee" opacity="0.8" filter="url(#schein)" />
|
||||||
|
<rect x="226" y="580" width="2" height="230" fill="#d6feff" opacity="0.9" />
|
||||||
|
|
||||||
|
<rect x="126" y="700" width="8" height="120" fill="#7326d6" opacity="0.35" filter="url(#weiterSchein)" />
|
||||||
|
<rect x="128" y="700" width="4" height="120" fill="#a24cff" opacity="0.75" filter="url(#schein)" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Waagerechtes Schild, quer zur Gasse -->
|
||||||
|
<g>
|
||||||
|
<rect x="60" y="756" width="140" height="8" fill="#c026d3" opacity="0.4" filter="url(#weiterSchein)" />
|
||||||
|
<rect x="60" y="758" width="140" height="4" fill="#f0a6ff" opacity="0.8" filter="url(#schein)" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Nasser Boden mit Spiegelung: die Roehren strahlen nach unten aus -->
|
||||||
|
<rect y="828" width="260" height="72" fill="#243060" opacity="0.3" />
|
||||||
|
<g>
|
||||||
|
<rect x="26" y="828" width="10" height="60" fill="url(#pfuetze)" filter="url(#schein)" />
|
||||||
|
<rect x="222" y="828" width="10" height="60" fill="url(#pfuetze)" filter="url(#schein)" />
|
||||||
|
<rect x="126" y="828" width="8" height="46" fill="url(#pfuetze)" filter="url(#schein)" />
|
||||||
|
</g>
|
||||||
|
<!-- Zwei Lichtstreifen als nasser Glanz -->
|
||||||
|
<g fill="#7ce9f7" opacity="0.16">
|
||||||
|
<rect x="0" y="846" width="260" height="2" />
|
||||||
|
<rect x="0" y="872" width="260" height="1.5" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Regen ueber die ganze Hoehe, oben blasser -->
|
||||||
|
<g stroke="#a5c8ff" stroke-linecap="round">
|
||||||
|
<g stroke-width="1.2" opacity="0.12">
|
||||||
|
<path d="M20 60 l-6 40" /><path d="M78 20 l-6 40" /><path d="M140 90 l-6 40" />
|
||||||
|
<path d="M198 40 l-6 40" /><path d="M246 110 l-6 40" /><path d="M46 150 l-6 40" />
|
||||||
|
<path d="M112 130 l-6 40" /><path d="M176 170 l-6 40" /><path d="M228 200 l-6 40" />
|
||||||
|
</g>
|
||||||
|
<g stroke-width="1.3" opacity="0.2">
|
||||||
|
<path d="M32 300 l-7 46" /><path d="M96 340 l-7 46" /><path d="M158 310 l-7 46" />
|
||||||
|
<path d="M214 360 l-7 46" /><path d="M62 420 l-7 46" /><path d="M128 400 l-7 46" />
|
||||||
|
<path d="M190 440 l-7 46" /><path d="M244 410 l-7 46" />
|
||||||
|
</g>
|
||||||
|
<g stroke-width="1.6" opacity="0.3">
|
||||||
|
<path d="M24 520 l-9 56" /><path d="M88 560 l-9 56" /><path d="M150 530 l-9 56" />
|
||||||
|
<path d="M206 580 l-9 56" /><path d="M54 640 l-9 56" /><path d="M118 620 l-9 56" />
|
||||||
|
<path d="M182 660 l-9 56" /><path d="M240 630 l-9 56" /><path d="M40 740 l-9 56" />
|
||||||
|
<path d="M104 760 l-9 56" /><path d="M168 730 l-9 56" /><path d="M232 770 l-9 56" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 80" width="1600" height="80" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<!--
|
||||||
|
Outdoor, obere Leiste: blauer Himmel mit Schleierwolken.
|
||||||
|
|
||||||
|
Die Leiste ist nur 80 px hoch und traegt Text - deshalb keine kraeftigen
|
||||||
|
Kontraste, sondern ein weicher Verlauf und Wolken aus unscharfen Ellipsen.
|
||||||
|
Der Schleier darueber kommt aus dem Theme (index.css), nicht aus dieser
|
||||||
|
Datei: so tont derselbe Himmel im Dunkelmodus zur Nacht ab.
|
||||||
|
-->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="himmel" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#5eb3e4" />
|
||||||
|
<stop offset="0.55" stop-color="#8fcdec" />
|
||||||
|
<stop offset="1" stop-color="#cfe8f5" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="weich" x="-30%" y="-120%" width="160%" height="340%">
|
||||||
|
<feGaussianBlur stdDeviation="9" />
|
||||||
|
</filter>
|
||||||
|
<filter id="sehrWeich" x="-40%" y="-200%" width="180%" height="500%">
|
||||||
|
<feGaussianBlur stdDeviation="20" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="1600" height="80" fill="url(#himmel)" />
|
||||||
|
|
||||||
|
<!-- Sonnenschein oben links, gerade so sichtbar -->
|
||||||
|
<ellipse cx="240" cy="4" rx="260" ry="60" fill="#fff6d8" opacity="0.5" filter="url(#sehrWeich)" />
|
||||||
|
|
||||||
|
<!-- Wolkenbaenke: je drei bis vier Ellipsen, damit die Kanten unregelmaessig sind -->
|
||||||
|
<g fill="#ffffff" filter="url(#weich)">
|
||||||
|
<g opacity="0.85">
|
||||||
|
<ellipse cx="150" cy="52" rx="120" ry="20" />
|
||||||
|
<ellipse cx="230" cy="44" rx="80" ry="16" />
|
||||||
|
<ellipse cx="86" cy="58" rx="70" ry="14" />
|
||||||
|
</g>
|
||||||
|
<g opacity="0.6">
|
||||||
|
<ellipse cx="520" cy="30" rx="150" ry="17" />
|
||||||
|
<ellipse cx="610" cy="24" rx="90" ry="13" />
|
||||||
|
</g>
|
||||||
|
<g opacity="0.8">
|
||||||
|
<ellipse cx="880" cy="58" rx="170" ry="22" />
|
||||||
|
<ellipse cx="960" cy="50" rx="110" ry="16" />
|
||||||
|
<ellipse cx="800" cy="62" rx="90" ry="15" />
|
||||||
|
</g>
|
||||||
|
<g opacity="0.55">
|
||||||
|
<ellipse cx="1240" cy="26" rx="140" ry="16" />
|
||||||
|
<ellipse cx="1330" cy="34" rx="100" ry="14" />
|
||||||
|
</g>
|
||||||
|
<g opacity="0.78">
|
||||||
|
<ellipse cx="1520" cy="56" rx="150" ry="20" />
|
||||||
|
<ellipse cx="1440" cy="62" rx="90" ry="14" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Dunstband am unteren Rand: laesst die Leiste sauber in den Inhalt uebergehen -->
|
||||||
|
<rect y="60" width="1600" height="20" fill="#ffffff" opacity="0.28" filter="url(#weich)" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,70 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 900" width="260" height="900" preserveAspectRatio="xMidYMax slice">
|
||||||
|
<!--
|
||||||
|
Outdoor, Seitenleiste: Wiese, die von unten hereinwaechst.
|
||||||
|
|
||||||
|
Der obere Teil ist absichtlich leer (durchsichtig): dort stehen die
|
||||||
|
Menuepunkte. Das Gras sitzt in den unteren rund 300 px, wo die Leiste
|
||||||
|
ohnehin frei ist, und wird nach oben hin blasser.
|
||||||
|
|
||||||
|
Gezeichnet mit quadratischen Bezierkurven statt Bildpunkten - die Halme
|
||||||
|
bleiben damit in jeder Hoehe scharf.
|
||||||
|
-->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="wiese" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7bbf5a" stop-opacity="0" />
|
||||||
|
<stop offset="0.45" stop-color="#6fb350" stop-opacity="0.55" />
|
||||||
|
<stop offset="1" stop-color="#4e8f39" stop-opacity="0.95" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="dunst" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#a8d68f" stop-opacity="0" />
|
||||||
|
<stop offset="1" stop-color="#a8d68f" stop-opacity="0.35" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="weich" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="14" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Gruener Schein, der den Uebergang traegt -->
|
||||||
|
<rect y="520" width="260" height="380" fill="url(#dunst)" filter="url(#weich)" />
|
||||||
|
|
||||||
|
<!-- Erdreich und Wiesenflaeche -->
|
||||||
|
<path d="M0 760 C 60 742 120 754 180 744 C 216 738 240 746 260 740 L260 900 L0 900 Z" fill="url(#wiese)" />
|
||||||
|
|
||||||
|
<!-- Hintere Halmreihe: hell, duenn, hoch -->
|
||||||
|
<g stroke="#8fca6e" stroke-width="2.4" stroke-linecap="round" fill="none" opacity="0.5">
|
||||||
|
<path d="M12 772 Q 6 720 20 686" />
|
||||||
|
<path d="M34 768 Q 40 716 30 676" />
|
||||||
|
<path d="M58 774 Q 52 726 66 692" />
|
||||||
|
<path d="M84 766 Q 92 714 82 680" />
|
||||||
|
<path d="M110 772 Q 104 722 118 690" />
|
||||||
|
<path d="M136 764 Q 144 712 134 678" />
|
||||||
|
<path d="M162 770 Q 156 720 170 688" />
|
||||||
|
<path d="M190 766 Q 198 716 188 684" />
|
||||||
|
<path d="M216 772 Q 210 724 224 694" />
|
||||||
|
<path d="M242 768 Q 250 718 240 686" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Vordere Halmreihe: dunkler, kraeftiger, kuerzer -->
|
||||||
|
<g stroke="#3f7d2e" stroke-width="3.6" stroke-linecap="round" fill="none" opacity="0.85">
|
||||||
|
<path d="M0 830 Q 8 786 24 758" />
|
||||||
|
<path d="M24 836 Q 18 792 6 764" />
|
||||||
|
<path d="M48 828 Q 58 784 74 756" />
|
||||||
|
<path d="M72 838 Q 66 794 54 766" />
|
||||||
|
<path d="M98 830 Q 108 786 124 760" />
|
||||||
|
<path d="M122 840 Q 116 796 104 768" />
|
||||||
|
<path d="M148 828 Q 158 784 174 758" />
|
||||||
|
<path d="M172 838 Q 166 794 154 764" />
|
||||||
|
<path d="M198 832 Q 208 788 224 760" />
|
||||||
|
<path d="M222 840 Q 216 796 204 768" />
|
||||||
|
<path d="M248 830 Q 258 786 260 764" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Ein paar Blueten, damit es nicht nach Rasenteppich aussieht -->
|
||||||
|
<g opacity="0.8">
|
||||||
|
<circle cx="42" cy="742" r="4" fill="#f7e07a" />
|
||||||
|
<circle cx="128" cy="726" r="3.4" fill="#f7f3e0" />
|
||||||
|
<circle cx="206" cy="748" r="3.8" fill="#f7e07a" />
|
||||||
|
<circle cx="88" cy="800" r="3" fill="#f7f3e0" />
|
||||||
|
<circle cx="186" cy="812" r="3.4" fill="#f7e07a" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,68 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 20" width="400" height="20" shape-rendering="crispEdges" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<!--
|
||||||
|
Pixel Art, obere Leiste: 8-bit-Himmel mit Wolken und Huegelkante.
|
||||||
|
|
||||||
|
Der viewBox ist absichtlich winzig (400 x 20): eine Einheit ist ein
|
||||||
|
Bildpunkt, und die Grafik wird von der Leiste hochskaliert. Zusammen mit
|
||||||
|
shape-rendering="crispEdges" bleiben die Kanten hart - genau das, was ein
|
||||||
|
Pixelbild ausmacht. Ein feiner Verlauf waere hier ein Stilbruch, deshalb
|
||||||
|
liegen die Himmelstoene in Baendern uebereinander.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Himmel in Stufen statt Verlauf -->
|
||||||
|
<rect width="400" height="6" fill="#7ec0e8" />
|
||||||
|
<rect y="6" width="400" height="5" fill="#96cfee" />
|
||||||
|
<rect y="11" width="400" height="4" fill="#b3dcf3" />
|
||||||
|
<rect y="15" width="400" height="5" fill="#cbe8f7" />
|
||||||
|
|
||||||
|
<!-- Sonne: ein Quadrat mit abgesetzter Ecke, wie in alten Spielen -->
|
||||||
|
<g fill="#ffe98a">
|
||||||
|
<rect x="26" y="2" width="8" height="8" />
|
||||||
|
<rect x="24" y="4" width="12" height="4" />
|
||||||
|
<rect x="28" y="0" width="4" height="12" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Wolken: Bloecke, keine Rundungen -->
|
||||||
|
<g fill="#ffffff">
|
||||||
|
<rect x="70" y="4" width="16" height="3" />
|
||||||
|
<rect x="74" y="2" width="9" height="3" />
|
||||||
|
<rect x="66" y="7" width="24" height="3" />
|
||||||
|
|
||||||
|
<rect x="150" y="8" width="20" height="3" />
|
||||||
|
<rect x="156" y="6" width="10" height="3" />
|
||||||
|
<rect x="146" y="11" width="28" height="2" />
|
||||||
|
|
||||||
|
<rect x="248" y="3" width="14" height="3" />
|
||||||
|
<rect x="252" y="1" width="8" height="3" />
|
||||||
|
<rect x="244" y="6" width="22" height="3" />
|
||||||
|
|
||||||
|
<rect x="330" y="7" width="18" height="3" />
|
||||||
|
<rect x="335" y="5" width="9" height="3" />
|
||||||
|
<rect x="326" y="10" width="26" height="2" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Vogelschwarm: je zwei Bildpunkte -->
|
||||||
|
<g fill="#4a5a66">
|
||||||
|
<rect x="200" y="4" width="2" height="1" />
|
||||||
|
<rect x="203" y="3" width="2" height="1" />
|
||||||
|
<rect x="290" y="6" width="2" height="1" />
|
||||||
|
<rect x="293" y="5" width="2" height="1" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Huegelkante am unteren Rand, treppenfoermig -->
|
||||||
|
<g fill="#5fa83f">
|
||||||
|
<rect y="17" width="400" height="3" />
|
||||||
|
<rect x="0" y="16" width="40" height="1" />
|
||||||
|
<rect x="52" y="15" width="34" height="2" />
|
||||||
|
<rect x="60" y="14" width="18" height="1" />
|
||||||
|
<rect x="120" y="16" width="46" height="1" />
|
||||||
|
<rect x="196" y="15" width="40" height="2" />
|
||||||
|
<rect x="206" y="14" width="20" height="1" />
|
||||||
|
<rect x="280" y="16" width="52" height="1" />
|
||||||
|
<rect x="348" y="15" width="36" height="2" />
|
||||||
|
</g>
|
||||||
|
<!-- Grasspitzen als hellere Kante -->
|
||||||
|
<g fill="#7ec957">
|
||||||
|
<rect x="0" y="17" width="400" height="1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,66 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 65 225" width="65" height="225" shape-rendering="crispEdges" preserveAspectRatio="xMidYMax slice">
|
||||||
|
<!--
|
||||||
|
Pixel Art, Seitenleiste: Steinmauer, von unten aufgemauert.
|
||||||
|
|
||||||
|
Der viewBox ist winzig (65 x 225, eine Einheit = ein Bildpunkt), damit die
|
||||||
|
Mauer beim Hochskalieren blockig bleibt; shape-rendering="crispEdges" haelt
|
||||||
|
die Kanten hart. Oben ist die Grafik leer - dort stehen die Menuepunkte.
|
||||||
|
|
||||||
|
Nach oben hin wird die Mauer **stufenweise** durchsichtig, Reihe um Reihe.
|
||||||
|
Ein weicher Verlauf waere hier ein Stilbruch: Pixelgrafik kennt keine
|
||||||
|
Zwischentoene, sie staffelt.
|
||||||
|
|
||||||
|
Jeder Stein ist 12 x 6 Punkte, versetzt gemauert, mit heller Kante oben und
|
||||||
|
dunkler unten - damit taeuscht Pixelgrafik Tiefe vor.
|
||||||
|
-->
|
||||||
|
<defs>
|
||||||
|
<g id="stein">
|
||||||
|
<rect width="12" height="6" fill="#8a8f7a" />
|
||||||
|
<rect width="12" height="1" fill="#a8ad96" />
|
||||||
|
<rect y="5" width="12" height="1" fill="#5f6553" />
|
||||||
|
<rect x="11" width="1" height="6" fill="#5f6553" />
|
||||||
|
</g>
|
||||||
|
<!-- Eine ganze Reihe; der Versatz kommt beim Aufruf -->
|
||||||
|
<g id="reihe">
|
||||||
|
<use href="#stein" x="0" /><use href="#stein" x="13" /><use href="#stein" x="26" />
|
||||||
|
<use href="#stein" x="39" /><use href="#stein" x="52" />
|
||||||
|
</g>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Reihen von unten nach oben, jede zweite um einen halben Stein versetzt -->
|
||||||
|
<use href="#reihe" y="219" opacity="1" />
|
||||||
|
<use href="#reihe" x="-6" y="212" opacity="1" />
|
||||||
|
<use href="#reihe" y="205" opacity="1" />
|
||||||
|
<use href="#reihe" x="-6" y="198" opacity="1" />
|
||||||
|
<use href="#reihe" y="191" opacity="0.9" />
|
||||||
|
<use href="#reihe" x="-6" y="184" opacity="0.8" />
|
||||||
|
<use href="#reihe" y="177" opacity="0.7" />
|
||||||
|
<use href="#reihe" x="-6" y="170" opacity="0.6" />
|
||||||
|
<use href="#reihe" y="163" opacity="0.5" />
|
||||||
|
<use href="#reihe" x="-6" y="156" opacity="0.4" />
|
||||||
|
<use href="#reihe" y="149" opacity="0.32" />
|
||||||
|
<use href="#reihe" x="-6" y="142" opacity="0.24" />
|
||||||
|
<use href="#reihe" y="135" opacity="0.18" />
|
||||||
|
<use href="#reihe" x="-6" y="128" opacity="0.13" />
|
||||||
|
<use href="#reihe" y="121" opacity="0.09" />
|
||||||
|
<use href="#reihe" x="-6" y="114" opacity="0.06" />
|
||||||
|
<use href="#reihe" y="107" opacity="0.03" />
|
||||||
|
|
||||||
|
<!-- Moos in den Fugen: sonst wirkt die Mauer steril -->
|
||||||
|
<g fill="#6f9c4a">
|
||||||
|
<rect x="4" y="218" width="3" height="1" />
|
||||||
|
<rect x="30" y="211" width="4" height="1" />
|
||||||
|
<rect x="17" y="204" width="2" height="1" />
|
||||||
|
<rect x="46" y="197" width="3" height="1" />
|
||||||
|
<rect x="9" y="190" width="4" height="1" opacity="0.9" />
|
||||||
|
<rect x="55" y="183" width="3" height="1" opacity="0.8" />
|
||||||
|
<rect x="24" y="176" width="2" height="1" opacity="0.7" />
|
||||||
|
<rect x="38" y="162" width="3" height="1" opacity="0.5" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Zwei ausgebrochene Steine, damit die Mauer nicht zu regelmaessig ist -->
|
||||||
|
<g fill="#5f6553">
|
||||||
|
<rect x="26" y="219" width="12" height="6" />
|
||||||
|
<rect x="7" y="170" width="12" height="6" opacity="0.6" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,46 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 80" width="1600" height="80" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<!--
|
||||||
|
Rainy, obere Leiste: eine Wolkendecke.
|
||||||
|
|
||||||
|
Die Datei hat zwei Aufgaben. Sie ist das Hintergrundbild der Leiste, und sie
|
||||||
|
ist die Maske fuer das Wetterleuchten (components/RainyWeather.tsx): der
|
||||||
|
Blitz leuchtet nur dort, wo hier deckend gezeichnet ist. Deshalb gibt es
|
||||||
|
keine Flaeche und keinen Himmel, nur Wolken auf durchsichtigem Grund - sonst
|
||||||
|
leuchtete beim Blitz die ganze Leiste statt der Wolken.
|
||||||
|
|
||||||
|
Drei Lagen von hinten nach vorn: hell und weich, mittel, dunkel und
|
||||||
|
tiefhaengend. Die Unterkanten sind flach wie bei echten Regenwolken.
|
||||||
|
-->
|
||||||
|
<defs>
|
||||||
|
<filter id="weich" x="-10%" y="-40%" width="120%" height="180%">
|
||||||
|
<feGaussianBlur stdDeviation="2.2" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Hintere Lage: hell, verschwommen -->
|
||||||
|
<g fill="#a9b6c6" opacity="0.55" filter="url(#weich)">
|
||||||
|
<ellipse cx="90" cy="22" rx="110" ry="20" />
|
||||||
|
<ellipse cx="330" cy="16" rx="140" ry="22" />
|
||||||
|
<ellipse cx="610" cy="24" rx="130" ry="20" />
|
||||||
|
<ellipse cx="880" cy="14" rx="150" ry="22" />
|
||||||
|
<ellipse cx="1150" cy="22" rx="130" ry="20" />
|
||||||
|
<ellipse cx="1410" cy="16" rx="150" ry="22" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Mittlere Lage -->
|
||||||
|
<g fill="#8394aa" opacity="0.7" filter="url(#weich)">
|
||||||
|
<path d="M150 44 C 150 30 170 22 186 26 C 194 10 222 6 238 18 C 252 6 282 10 288 28 C 306 26 318 36 314 46 Z" />
|
||||||
|
<path d="M500 42 C 498 28 520 20 536 26 C 546 8 580 4 596 20 C 612 8 646 14 650 30 C 670 28 684 38 678 46 Z" />
|
||||||
|
<path d="M760 40 C 760 26 780 18 796 24 C 806 8 836 4 850 18 C 866 8 894 12 898 28 C 914 28 924 36 920 44 Z" />
|
||||||
|
<path d="M1060 44 C 1058 30 1078 22 1094 28 C 1104 10 1138 6 1154 22 C 1170 10 1204 16 1208 32 C 1226 30 1238 40 1232 48 Z" />
|
||||||
|
<path d="M1360 42 C 1360 28 1380 20 1396 26 C 1406 10 1438 6 1452 20 C 1468 10 1498 14 1502 30 C 1518 30 1530 38 1524 46 Z" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Vordere Lage: dunkel, tief, flache Unterkante -->
|
||||||
|
<g fill="#5d6d82" opacity="0.85">
|
||||||
|
<path d="M300 56 C 296 42 314 32 332 38 C 340 20 374 16 390 32 C 402 20 436 22 440 40 C 460 38 474 48 468 58 Z" />
|
||||||
|
<path d="M640 60 C 636 46 656 36 674 42 C 684 24 720 20 736 36 C 750 24 786 28 790 46 C 810 44 824 54 818 62 Z" />
|
||||||
|
<path d="M960 54 C 956 40 976 30 994 36 C 1004 18 1038 14 1054 30 C 1068 18 1102 22 1106 40 C 1124 38 1138 48 1132 56 Z" />
|
||||||
|
<path d="M1220 60 C 1218 46 1236 38 1252 42 C 1262 26 1292 22 1306 36 C 1320 26 1350 30 1352 46 C 1368 46 1378 54 1374 62 Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,54 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 900" width="260" height="900" preserveAspectRatio="xMidYMax slice">
|
||||||
|
<!--
|
||||||
|
Rainy, Seitenleiste: nasse Huegel mit Pfuetzen, von unten hereinwachsend.
|
||||||
|
|
||||||
|
Wie bei den anderen Themes sitzt das Motiv unten - oben liegen die
|
||||||
|
Menuepunkte unter einem dichten Schleier (index.css). Der Regen selbst ist
|
||||||
|
nicht gezeichnet, er faellt lebendig ueber den Bildschirm; hier stehen nur
|
||||||
|
die Ringe, die er in den Pfuetzen hinterlaesst.
|
||||||
|
-->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="dunst" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#8fa1b6" stop-opacity="0" />
|
||||||
|
<stop offset="1" stop-color="#8fa1b6" stop-opacity="0.45" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="weich" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="12" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Nebel ueber dem Boden -->
|
||||||
|
<rect y="540" width="260" height="360" fill="url(#dunst)" filter="url(#weich)" />
|
||||||
|
|
||||||
|
<!-- Hintere Huegel -->
|
||||||
|
<path d="M0 740 C 50 700 100 712 140 724 C 180 736 220 706 260 712 L260 900 L0 900 Z" fill="#7d8ea3" opacity="0.45" />
|
||||||
|
|
||||||
|
<!-- Vordere Huegel -->
|
||||||
|
<path d="M0 792 C 60 770 110 786 160 778 C 200 772 236 782 260 776 L260 900 L0 900 Z" fill="#56667b" opacity="0.8" />
|
||||||
|
|
||||||
|
<!-- Pfuetzen: der Himmel spiegelt sich hell darin -->
|
||||||
|
<g fill="#b9c7d6" opacity="0.45">
|
||||||
|
<ellipse cx="62" cy="838" rx="42" ry="7" />
|
||||||
|
<ellipse cx="184" cy="858" rx="52" ry="8" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Ringe, die der Regen in den Pfuetzen zieht -->
|
||||||
|
<g fill="none" stroke="#eef3f8" stroke-width="1.2" opacity="0.4">
|
||||||
|
<ellipse cx="52" cy="838" rx="12" ry="2.4" />
|
||||||
|
<ellipse cx="52" cy="838" rx="22" ry="4" opacity="0.5" />
|
||||||
|
<ellipse cx="80" cy="838" rx="6" ry="1.4" />
|
||||||
|
<ellipse cx="170" cy="858" rx="16" ry="3" />
|
||||||
|
<ellipse cx="170" cy="858" rx="28" ry="5" opacity="0.45" />
|
||||||
|
<ellipse cx="206" cy="857" rx="8" ry="1.8" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Graeser am Pfuetzenrand, dunkel und nass -->
|
||||||
|
<g stroke="#3e4b5c" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.35">
|
||||||
|
<path d="M14 830 Q 10 808 20 794" />
|
||||||
|
<path d="M22 832 Q 28 810 22 796" />
|
||||||
|
<path d="M118 848 Q 112 824 124 810" />
|
||||||
|
<path d="M128 850 Q 134 826 128 812" />
|
||||||
|
<path d="M238 846 Q 232 824 244 810" />
|
||||||
|
<path d="M248 848 Q 254 826 250 812" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,79 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 80" width="1600" height="80" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<!--
|
||||||
|
Snow, obere Leiste: verschneite Bergkette mit Tannen.
|
||||||
|
|
||||||
|
Wie bei den anderen Themes ohne deckenden Himmel - die Flaeche kommt aus dem
|
||||||
|
Theme, hier steht nur Motiv. Die Berge sind blaugrau mit weissen Kuppen,
|
||||||
|
davor eine Reihe Tannen, deren Zweige Schnee tragen. Der Schneefall selbst
|
||||||
|
ist nicht gezeichnet, er faellt lebendig (components/SnowWeather.tsx).
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Ferne Berge -->
|
||||||
|
<path d="M0 80 L0 52 L90 30 L170 46 L260 18 L360 44 L450 26 L540 48 L640 22 L740 46 L830 28 L930 50 L1030 20 L1130 44 L1220 30 L1320 48 L1420 24 L1520 44 L1600 34 L1600 80 Z" fill="#9fb3c8" opacity="0.45" />
|
||||||
|
|
||||||
|
<!-- Schneekuppen der fernen Berge -->
|
||||||
|
<g fill="#ffffff" opacity="0.9">
|
||||||
|
<path d="M260 18 L236 28 L250 27 L262 32 L274 26 L286 29 Z" />
|
||||||
|
<path d="M640 22 L614 32 L630 31 L642 36 L654 30 L668 33 Z" />
|
||||||
|
<path d="M1030 20 L1004 30 L1020 29 L1032 34 L1044 28 L1058 31 Z" />
|
||||||
|
<path d="M1420 24 L1394 34 L1410 33 L1422 38 L1434 32 L1448 35 Z" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Nahe Huegel, verschneit -->
|
||||||
|
<path d="M0 80 L0 66 C 120 56 220 62 340 58 C 480 54 560 64 700 60 C 840 56 940 64 1080 58 C 1220 54 1340 64 1460 60 C 1520 58 1570 62 1600 60 L1600 80 Z" fill="#e8f0f8" opacity="0.9" />
|
||||||
|
|
||||||
|
<!-- Tannen in drei Etagen, jede mit Schneekappe -->
|
||||||
|
<g fill="#4d6478" opacity="0.85">
|
||||||
|
<rect x="442.8" y="66.0" width="2.4" height="3.0" />
|
||||||
|
<rect x="465.1" y="68.0" width="1.7" height="2.1" />
|
||||||
|
<rect x="774.7" y="64.0" width="2.6" height="3.4" />
|
||||||
|
<rect x="799.1" y="66.0" width="1.8" height="2.2" />
|
||||||
|
<rect x="1162.8" y="64.0" width="2.4" height="3.0" />
|
||||||
|
<rect x="1185.2" y="66.0" width="1.7" height="2.1" />
|
||||||
|
<rect x="1511.0" y="66.0" width="1.9" height="2.4" />
|
||||||
|
<path d="M444 28.0 L449.0 45.1 L439.0 45.1 Z" />
|
||||||
|
<path d="M444 38.6 L452.4 55.4 L435.6 55.4 Z" />
|
||||||
|
<path d="M444 48.9 L456.0 66.0 L432.0 66.0 Z" />
|
||||||
|
<path d="M466 42.0 L469.6 53.7 L462.4 53.7 Z" />
|
||||||
|
<path d="M466 49.3 L471.9 60.7 L460.1 60.7 Z" />
|
||||||
|
<path d="M466 56.3 L474.5 68.0 L457.5 68.0 Z" />
|
||||||
|
<path d="M776 22.0 L781.5 40.9 L770.5 40.9 Z" />
|
||||||
|
<path d="M776 33.8 L785.1 52.2 L766.9 52.2 Z" />
|
||||||
|
<path d="M776 45.1 L789.0 64.0 L763.0 64.0 Z" />
|
||||||
|
<path d="M800 38.0 L803.8 50.6 L796.2 50.6 Z" />
|
||||||
|
<path d="M800 45.8 L806.3 58.2 L793.7 58.2 Z" />
|
||||||
|
<path d="M800 53.4 L809.0 66.0 L791.0 66.0 Z" />
|
||||||
|
<path d="M1164 26.0 L1169.0 43.1 L1159.0 43.1 Z" />
|
||||||
|
<path d="M1164 36.6 L1172.4 53.4 L1155.6 53.4 Z" />
|
||||||
|
<path d="M1164 46.9 L1176.0 64.0 L1152.0 64.0 Z" />
|
||||||
|
<path d="M1186 40.0 L1189.6 51.7 L1182.4 51.7 Z" />
|
||||||
|
<path d="M1186 47.3 L1192.0 58.7 L1180.0 58.7 Z" />
|
||||||
|
<path d="M1186 54.3 L1194.5 66.0 L1177.5 66.0 Z" />
|
||||||
|
<path d="M1512 36.0 L1516.0 49.5 L1508.0 49.5 Z" />
|
||||||
|
<path d="M1512 44.4 L1518.7 57.6 L1505.3 57.6 Z" />
|
||||||
|
<path d="M1512 52.5 L1521.5 66.0 L1502.5 66.0 Z" />
|
||||||
|
</g>
|
||||||
|
<g fill="#ffffff" opacity="0.95">
|
||||||
|
<path d="M444 28.0 L446.3 35.7 Q444 33.6 441.7 35.7 Z" />
|
||||||
|
<path d="M444 38.6 L447.8 46.2 Q444 44.2 440.2 46.2 Z" />
|
||||||
|
<path d="M444 48.9 L449.4 56.6 Q444 54.5 438.6 56.6 Z" />
|
||||||
|
<path d="M466 42.0 L467.6 47.3 Q466 45.9 464.4 47.3 Z" />
|
||||||
|
<path d="M466 49.3 L468.7 54.4 Q466 53.1 463.3 54.4 Z" />
|
||||||
|
<path d="M466 56.3 L469.8 61.6 Q466 60.2 462.2 61.6 Z" />
|
||||||
|
<path d="M776 22.0 L778.5 30.5 Q776 28.2 773.5 30.5 Z" />
|
||||||
|
<path d="M776 33.8 L780.1 42.1 Q776 39.9 771.9 42.1 Z" />
|
||||||
|
<path d="M776 45.1 L781.9 53.6 Q776 51.3 770.1 53.6 Z" />
|
||||||
|
<path d="M800 38.0 L801.7 43.7 Q800 42.2 798.3 43.7 Z" />
|
||||||
|
<path d="M800 45.8 L802.8 51.4 Q800 49.9 797.2 51.4 Z" />
|
||||||
|
<path d="M800 53.4 L804.0 59.1 Q800 57.6 796.0 59.1 Z" />
|
||||||
|
<path d="M1164 26.0 L1166.3 33.7 Q1164 31.6 1161.7 33.7 Z" />
|
||||||
|
<path d="M1164 36.6 L1167.8 44.2 Q1164 42.2 1160.2 44.2 Z" />
|
||||||
|
<path d="M1164 46.9 L1169.4 54.6 Q1164 52.5 1158.6 54.6 Z" />
|
||||||
|
<path d="M1186 40.0 L1187.6 45.3 Q1186 43.9 1184.4 45.3 Z" />
|
||||||
|
<path d="M1186 47.3 L1188.7 52.4 Q1186 51.1 1183.3 52.4 Z" />
|
||||||
|
<path d="M1186 54.3 L1189.8 59.6 Q1186 58.2 1182.2 59.6 Z" />
|
||||||
|
<path d="M1512 36.0 L1513.8 42.1 Q1512 40.5 1510.2 42.1 Z" />
|
||||||
|
<path d="M1512 44.4 L1515.0 50.3 Q1512 48.8 1509.0 50.3 Z" />
|
||||||
|
<path d="M1512 52.5 L1516.3 58.6 Q1512 57.0 1507.7 58.6 Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,65 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 900" width="260" height="900" preserveAspectRatio="xMidYMax slice">
|
||||||
|
<!--
|
||||||
|
Snow, Seitenleiste: verschneiter Waldrand, von unten hereinwachsend.
|
||||||
|
|
||||||
|
Das Motiv sitzt unten, oben liegt der Schleier ueber den Menuepunkten
|
||||||
|
(index.css). Die Schneeverwehung ganz unten ist bewusst flach und hell: dort
|
||||||
|
stehen Name und Rolle des Benutzers, und darueber haeuft sich zusaetzlich
|
||||||
|
der fallende Schnee an.
|
||||||
|
-->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="dunst" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#c9d8e8" stop-opacity="0" />
|
||||||
|
<stop offset="1" stop-color="#c9d8e8" stop-opacity="0.5" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="weich" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="12" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect y="540" width="260" height="360" fill="url(#dunst)" filter="url(#weich)" />
|
||||||
|
|
||||||
|
<!-- Tannen hinten: blass -->
|
||||||
|
<g fill="#8aa0b5" opacity="0.4">
|
||||||
|
<rect x="43.2" y="770.0" width="5.6" height="8.0" />
|
||||||
|
<rect x="180.9" y="760.0" width="6.2" height="8.8" />
|
||||||
|
<path d="M46 670.0 L57.8 715.0 L34.2 715.0 Z" />
|
||||||
|
<path d="M46 698.0 L65.6 742.0 L26.4 742.0 Z" />
|
||||||
|
<path d="M46 725.0 L74.0 770.0 L18.0 770.0 Z" />
|
||||||
|
<path d="M184 650.0 L197.0 699.5 L171.0 699.5 Z" />
|
||||||
|
<path d="M184 680.8 L205.7 729.2 L162.3 729.2 Z" />
|
||||||
|
<path d="M184 710.5 L215.0 760.0 L153.0 760.0 Z" />
|
||||||
|
</g>
|
||||||
|
<g fill="#ffffff" opacity="0.6">
|
||||||
|
<path d="M46 670.0 L51.3 690.2 Q46 684.9 40.7 690.2 Z" />
|
||||||
|
<path d="M46 698.0 L54.8 717.8 Q46 712.5 37.2 717.8 Z" />
|
||||||
|
<path d="M46 725.0 L58.6 745.2 Q46 739.9 33.4 745.2 Z" />
|
||||||
|
<path d="M184 650.0 L189.9 672.3 Q184 666.3 178.1 672.3 Z" />
|
||||||
|
<path d="M184 680.8 L193.8 702.6 Q184 696.8 174.2 702.6 Z" />
|
||||||
|
<path d="M184 710.5 L197.9 732.8 Q184 726.8 170.1 732.8 Z" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Tannen vorn, mit Schneelast -->
|
||||||
|
<g fill="#4d6478" opacity="0.65">
|
||||||
|
<rect x="100.5" y="800.0" width="7.0" height="9.6" />
|
||||||
|
<rect x="223.3" y="808.0" width="5.4" height="7.2" />
|
||||||
|
<path d="M104 680.0 L118.7 734.0 L89.3 734.0 Z" />
|
||||||
|
<path d="M104 713.6 L128.5 766.4 L79.5 766.4 Z" />
|
||||||
|
<path d="M104 746.0 L139.0 800.0 L69.0 800.0 Z" />
|
||||||
|
<path d="M226 718.0 L237.3 758.5 L214.7 758.5 Z" />
|
||||||
|
<path d="M226 743.2 L244.9 782.8 L207.1 782.8 Z" />
|
||||||
|
<path d="M226 767.5 L253.0 808.0 L199.0 808.0 Z" />
|
||||||
|
</g>
|
||||||
|
<g fill="#ffffff" opacity="0.9">
|
||||||
|
<path d="M104 680.0 L110.6 704.3 Q104 697.8 97.4 704.3 Z" />
|
||||||
|
<path d="M104 713.6 L115.0 737.4 Q104 731.0 93.0 737.4 Z" />
|
||||||
|
<path d="M104 746.0 L119.8 770.3 Q104 763.8 88.2 770.3 Z" />
|
||||||
|
<path d="M226 718.0 L231.1 736.2 Q226 731.4 220.9 736.2 Z" />
|
||||||
|
<path d="M226 743.2 L234.5 761.0 Q226 756.3 217.5 761.0 Z" />
|
||||||
|
<path d="M226 767.5 L238.2 785.7 Q226 780.9 213.8 785.7 Z" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Schneeverwehung -->
|
||||||
|
<path d="M0 800 C 50 784 110 796 160 788 C 200 782 236 792 260 786 L260 900 L0 900 Z" fill="#f2f7fc" opacity="0.8" />
|
||||||
|
<path d="M0 800 C 50 784 110 796 160 788 C 200 782 236 792 260 786" fill="none" stroke="#b9cadb" stroke-width="1.5" opacity="0.6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
# perinco Pulse Backend
|
||||||
|
# Als .env kopieren und Werte setzen. .env niemals einchecken.
|
||||||
|
|
||||||
|
PORT=5010
|
||||||
|
# Nur lokal binden - von aussen ausschliesslich ueber den Apache-Reverse-Proxy.
|
||||||
|
HOST=127.0.0.1
|
||||||
|
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=pulse
|
||||||
|
DB_PASSWORD=changeme
|
||||||
|
DB_NAME=perinco_pulse
|
||||||
|
|
||||||
|
# Signiert die Sitzungen. Langer Zufallswert:
|
||||||
|
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||||
|
JWT_SECRET=changeme-langer-zufallswert
|
||||||
|
JWT_EXPIRES_IN=12h
|
||||||
|
|
||||||
|
# Verschluesselt das SSO Client Secret in der Datenbank. Nie aendern, sonst ist
|
||||||
|
# das gespeicherte Secret nicht mehr lesbar (dann in den Einstellungen neu setzen).
|
||||||
|
SETTINGS_KEY=changeme-zweiter-zufallswert
|
||||||
|
|
||||||
|
# Wird beim ersten Start angelegt, falls noch kein Benutzer existiert.
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_NAME=Administrator
|
||||||
|
ADMIN_EMAIL=admin@perinco.de
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
|
# Nur fuer den lokalen Vite-Dev-Server noetig.
|
||||||
|
# CORS_ORIGINS=http://localhost:3110
|
||||||
|
After Width: | Height: | Size: 158 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "perinco-pulse-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "REST-Backend fuer perinco Pulse (Zeiterfassung, MariaDB perinco_pulse)",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "node --watch src/server.js",
|
||||||
|
"test": "node --test test/sso-filter.test.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mysql2": "^3.11.5",
|
||||||
|
"pdfkit": "^0.15.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: Number(process.env.DB_PORT || 3306),
|
||||||
|
user: process.env.DB_USER || 'pulse',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || 'perinco_pulse',
|
||||||
|
waitForConnections: true,
|
||||||
|
// Klein gehalten: dieselbe MariaDB bedient weitere Anwendungen.
|
||||||
|
connectionLimit: 8,
|
||||||
|
charset: 'utf8mb4',
|
||||||
|
dateStrings: true,
|
||||||
|
decimalNumbers: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Fuehrt schema.sql aus und legt bei leerer Benutzertabelle den Admin an. */
|
||||||
|
export async function initDatabase() {
|
||||||
|
const sql = await fs.readFile(path.join(__dirname, 'schema.sql'), 'utf8');
|
||||||
|
// Split an Semikolon am Zeilenende - so braucht es kein multipleStatements.
|
||||||
|
const statements = sql
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !line.trim().startsWith('--'))
|
||||||
|
.join('\n')
|
||||||
|
.split(/;\s*$/m)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
for (const statement of statements) await conn.query(statement);
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureAdminUser();
|
||||||
|
await ensureInternalCustomer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAdminUser() {
|
||||||
|
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users');
|
||||||
|
if (rows[0].n > 0) return;
|
||||||
|
|
||||||
|
const password = process.env.ADMIN_PASSWORD;
|
||||||
|
if (!password) throw new Error('ADMIN_PASSWORD fehlt - erster Administrator kann nicht angelegt werden.');
|
||||||
|
const hash = await bcrypt.hash(password, 12);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO pulse_users (username, email, name, password_hash, role)
|
||||||
|
VALUES (?, ?, ?, ?, 'admin')`,
|
||||||
|
[
|
||||||
|
process.env.ADMIN_USERNAME || 'admin',
|
||||||
|
(process.env.ADMIN_EMAIL || 'admin@perinco.de').toLowerCase(),
|
||||||
|
process.env.ADMIN_NAME || 'Administrator',
|
||||||
|
hash,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
console.log('[Pulse] Erster Administrator angelegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* perinco selbst ist der Kunde fuer alle internen Taetigkeiten ("perinco
|
||||||
|
* intern"). Wird einmalig angelegt, damit direkt nach der Installation ein
|
||||||
|
* Projekt fuer nicht abrechenbare Zeiten angelegt werden kann.
|
||||||
|
*/
|
||||||
|
async function ensureInternalCustomer() {
|
||||||
|
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_customers');
|
||||||
|
if (rows[0].n > 0) return;
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO pulse_customers (name, short_name, notes, color) VALUES (?, ?, ?, ?)`,
|
||||||
|
['perinco GmbH', 'PERINCO', 'Interne Tätigkeiten (nicht abrechenbar)', '#6ba04a']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kleiner Helfer fuer Transaktionen. */
|
||||||
|
export async function withTransaction(fn) {
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
const result = await fn(conn);
|
||||||
|
await conn.commit();
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gemeinsamer Unterbau fuer alle Exporte. Ein Export wird als neutrales
|
||||||
|
* Dokument beschrieben (Titel, Infoblock, Kennzahlen, Tabellen) und von hier
|
||||||
|
* aus als PDF, Excel oder CSV geschrieben - so sehen Stundenzettel und Reports
|
||||||
|
* in allen drei Formaten gleich aufgebaut aus und tragen denselben Kopf und Fuss
|
||||||
|
* aus den Einstellungen.
|
||||||
|
*
|
||||||
|
* doc = {
|
||||||
|
* title, subtitle, filename,
|
||||||
|
* info: [[Label, Wert], ...],
|
||||||
|
* kpis: [[Label, Wert], ...],
|
||||||
|
* tables: [{ title?, columns: [{ key, label, width?, align?, type? }], rows: [...], totals?: {...} }],
|
||||||
|
* signature: boolean,
|
||||||
|
* landscape: boolean,
|
||||||
|
* }
|
||||||
|
* type: 'hours' (Minuten -> 1,50), 'duration' (Minuten -> 1:30), 'money', 'date', 'text', 'percent'
|
||||||
|
*/
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DEFAULT_LOGO = path.join(__dirname, '..', '..', 'assets', 'logo.png');
|
||||||
|
|
||||||
|
export function hoursDe(minutes) {
|
||||||
|
return (minutes / 60).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
export function durationDe(minutes) {
|
||||||
|
const m = Math.round(minutes);
|
||||||
|
return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
export function moneyDe(value) {
|
||||||
|
return `${Number(value || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
|
||||||
|
}
|
||||||
|
export function dateDe(value) {
|
||||||
|
const [y, m, d] = String(value).slice(0, 10).split('-');
|
||||||
|
return `${d}.${m}.${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCell(value, type) {
|
||||||
|
if (value == null || value === '') return '';
|
||||||
|
switch (type) {
|
||||||
|
case 'hours':
|
||||||
|
return hoursDe(value);
|
||||||
|
case 'duration':
|
||||||
|
return durationDe(value);
|
||||||
|
case 'money':
|
||||||
|
return moneyDe(value);
|
||||||
|
case 'date':
|
||||||
|
return dateDe(value);
|
||||||
|
case 'percent':
|
||||||
|
return `${value} %`;
|
||||||
|
default:
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoBuffer(exportSettings) {
|
||||||
|
const logo = exportSettings.logo;
|
||||||
|
if (typeof logo === 'string' && logo.startsWith('data:image/')) {
|
||||||
|
const match = logo.match(/^data:image\/(png|jpe?g);base64,(.+)$/);
|
||||||
|
if (match) return Buffer.from(match[2], 'base64');
|
||||||
|
}
|
||||||
|
return fs.readFileSync(DEFAULT_LOGO);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ CSV -- */
|
||||||
|
|
||||||
|
export function renderCsv(doc) {
|
||||||
|
const escape = (v) => {
|
||||||
|
const s = String(v ?? '');
|
||||||
|
return /[;"\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
|
||||||
|
};
|
||||||
|
const lines = [];
|
||||||
|
doc.tables.forEach((table, index) => {
|
||||||
|
if (index > 0) lines.push('');
|
||||||
|
if (doc.tables.length > 1 && table.title) lines.push(escape(table.title));
|
||||||
|
lines.push(table.columns.map((c) => escape(c.label)).join(';'));
|
||||||
|
for (const row of table.rows) {
|
||||||
|
lines.push(table.columns.map((c) => escape(formatCell(row[c.key], c.type))).join(';'));
|
||||||
|
}
|
||||||
|
if (table.totals) lines.push(table.columns.map((c) => escape(formatCell(table.totals[c.key], c.type))).join(';'));
|
||||||
|
});
|
||||||
|
// BOM, damit Excel die Umlaute als UTF-8 liest.
|
||||||
|
return Buffer.from('' + lines.join('\r\n'), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Excel -- */
|
||||||
|
|
||||||
|
export async function renderXlsx(doc, settings) {
|
||||||
|
const ex = settings.export;
|
||||||
|
const accent = (ex.accent || '#6BA04A').replace('#', '').toUpperCase();
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
workbook.creator = settings.company_name || 'perinco';
|
||||||
|
workbook.created = new Date();
|
||||||
|
|
||||||
|
const sheet = workbook.addWorksheet(doc.sheetName || 'Stundennachweis', {
|
||||||
|
pageSetup: {
|
||||||
|
paperSize: 9,
|
||||||
|
orientation: doc.landscape ? 'landscape' : 'portrait',
|
||||||
|
fitToPage: true,
|
||||||
|
fitToWidth: 1,
|
||||||
|
fitToHeight: 0,
|
||||||
|
margins: { left: 0.5, right: 0.5, top: 0.6, bottom: 0.8, header: 0.3, footer: 0.3 },
|
||||||
|
},
|
||||||
|
headerFooter: {
|
||||||
|
oddFooter: `&L&8${xlEsc(ex.footer_left)}&C&8${xlEsc(ex.footer_center)}&R&8${xlEsc(ex.footer_right)} · Seite &P von &N`,
|
||||||
|
},
|
||||||
|
views: [{ showGridLines: false }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const widest = doc.tables.reduce((m, t) => Math.max(m, t.columns.length), 2);
|
||||||
|
// Spaltenbreiten aus der ersten (Haupt-)Tabelle.
|
||||||
|
sheet.columns = doc.tables[0].columns.map((c) => ({ width: c.xlsWidth || Math.max(10, (c.width || 80) / 5.5) }));
|
||||||
|
|
||||||
|
const logo = workbook.addImage({ buffer: logoBuffer(ex), extension: /^data:image\/jpe?g/.test(ex.logo || '') ? 'jpeg' : 'png' });
|
||||||
|
sheet.addImage(logo, { tl: { col: 0, row: 0 }, ext: { width: 170, height: 77 } });
|
||||||
|
sheet.getRow(1).height = 22;
|
||||||
|
sheet.getRow(2).height = 22;
|
||||||
|
sheet.getRow(3).height = 22;
|
||||||
|
const headerRight = sheet.getCell(1, widest);
|
||||||
|
headerRight.value = ex.header_right || '';
|
||||||
|
headerRight.alignment = { horizontal: 'right' };
|
||||||
|
headerRight.font = { size: 9, color: { argb: 'FF6E6E6E' } };
|
||||||
|
|
||||||
|
let row = 5;
|
||||||
|
const title = sheet.getCell(row, 1);
|
||||||
|
title.value = doc.title;
|
||||||
|
title.font = { size: 18, bold: true, color: { argb: `FF${accent}` } };
|
||||||
|
row++;
|
||||||
|
if (doc.subtitle) {
|
||||||
|
sheet.getCell(row, 1).value = doc.subtitle;
|
||||||
|
sheet.getCell(row, 1).font = { size: 11, color: { argb: 'FF6E6E6E' } };
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
row++;
|
||||||
|
|
||||||
|
for (const [label, value] of doc.info || []) {
|
||||||
|
sheet.getCell(row, 1).value = label;
|
||||||
|
sheet.getCell(row, 1).font = { bold: true, size: 10, color: { argb: 'FF6E6E6E' } };
|
||||||
|
sheet.getCell(row, 2).value = value;
|
||||||
|
sheet.getCell(row, 2).font = { size: 10 };
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
if (doc.kpis?.length) {
|
||||||
|
row++;
|
||||||
|
doc.kpis.forEach(([label, value], i) => {
|
||||||
|
const col = 1 + i * 2;
|
||||||
|
if (col > Math.max(widest, 8)) return;
|
||||||
|
sheet.getCell(row, col).value = label;
|
||||||
|
sheet.getCell(row, col).font = { size: 9, color: { argb: 'FF6E6E6E' } };
|
||||||
|
sheet.getCell(row + 1, col).value = value;
|
||||||
|
sheet.getCell(row + 1, col).font = { size: 14, bold: true };
|
||||||
|
});
|
||||||
|
row += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const table of doc.tables) {
|
||||||
|
row++;
|
||||||
|
if (table.title) {
|
||||||
|
sheet.getCell(row, 1).value = table.title;
|
||||||
|
sheet.getCell(row, 1).font = { bold: true, size: 12, color: { argb: `FF${accent}` } };
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
const headRow = sheet.getRow(row);
|
||||||
|
table.columns.forEach((c, i) => {
|
||||||
|
const cell = headRow.getCell(i + 1);
|
||||||
|
cell.value = c.label;
|
||||||
|
cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 10 };
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: `FF${accent}` } };
|
||||||
|
cell.alignment = { horizontal: c.align === 'right' ? 'right' : 'left', vertical: 'middle' };
|
||||||
|
});
|
||||||
|
headRow.height = 20;
|
||||||
|
row++;
|
||||||
|
const first = row;
|
||||||
|
table.rows.forEach((data, index) => {
|
||||||
|
const r = sheet.getRow(row);
|
||||||
|
table.columns.forEach((c, i) => writeXlsCell(r.getCell(i + 1), data[c.key], c, index % 2 === 1));
|
||||||
|
row++;
|
||||||
|
});
|
||||||
|
if (table.totals) {
|
||||||
|
const r = sheet.getRow(row);
|
||||||
|
table.columns.forEach((c, i) => {
|
||||||
|
const cell = r.getCell(i + 1);
|
||||||
|
if ((c.type === 'hours' || c.type === 'money') && table.rows.length && table.totals[c.key] != null) {
|
||||||
|
const letter = sheet.getColumn(i + 1).letter;
|
||||||
|
const value = c.type === 'hours' ? table.totals[c.key] / 60 : table.totals[c.key];
|
||||||
|
cell.value = { formula: `SUM(${letter}${first}:${letter}${row - 1})`, result: value };
|
||||||
|
cell.numFmt = c.type === 'money' ? '#,##0.00 "€"' : '#,##0.00';
|
||||||
|
} else {
|
||||||
|
cell.value = formatCell(table.totals[c.key], c.type);
|
||||||
|
}
|
||||||
|
cell.font = { bold: true };
|
||||||
|
cell.alignment = { horizontal: c.align === 'right' ? 'right' : 'left' };
|
||||||
|
cell.border = { top: { style: 'medium', color: { argb: `FF${accent}` } } };
|
||||||
|
});
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.signature && ex.show_signature) {
|
||||||
|
row += 3;
|
||||||
|
const half = Math.max(2, Math.floor(widest / 2));
|
||||||
|
for (const [col, label] of [
|
||||||
|
[1, ex.signature_left],
|
||||||
|
[half + 1, ex.signature_right],
|
||||||
|
]) {
|
||||||
|
const cell = sheet.getCell(row, col);
|
||||||
|
cell.border = { top: { style: 'thin', color: { argb: 'FF9A9A9A' } } };
|
||||||
|
sheet.getCell(row, col + 1).border = { top: { style: 'thin', color: { argb: 'FF9A9A9A' } } };
|
||||||
|
cell.value = label;
|
||||||
|
cell.font = { size: 9, color: { argb: 'FF6E6E6E' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
function xlEsc(value) {
|
||||||
|
return String(value || '').replaceAll('&', '&&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeXlsCell(cell, value, column, zebra) {
|
||||||
|
switch (column.type) {
|
||||||
|
case 'hours':
|
||||||
|
cell.value = value == null ? null : Math.round((value / 60) * 100) / 100;
|
||||||
|
cell.numFmt = '#,##0.00';
|
||||||
|
break;
|
||||||
|
case 'duration':
|
||||||
|
cell.value = value == null ? null : durationDe(value);
|
||||||
|
break;
|
||||||
|
case 'money':
|
||||||
|
cell.value = value == null ? null : Number(value);
|
||||||
|
cell.numFmt = '#,##0.00 "€"';
|
||||||
|
break;
|
||||||
|
case 'date':
|
||||||
|
cell.value = value ? new Date(`${String(value).slice(0, 10)}T00:00:00Z`) : null;
|
||||||
|
cell.numFmt = 'dd.mm.yyyy';
|
||||||
|
break;
|
||||||
|
case 'percent':
|
||||||
|
cell.value = value == null ? null : value / 100;
|
||||||
|
cell.numFmt = '0%';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
cell.value = value ?? '';
|
||||||
|
}
|
||||||
|
cell.font = { size: 10 };
|
||||||
|
cell.alignment = { horizontal: column.align === 'right' ? 'right' : 'left', vertical: 'top', wrapText: column.type === 'text' || !column.type };
|
||||||
|
if (zebra) cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF4F7F1' } };
|
||||||
|
cell.border = { bottom: { style: 'hair', color: { argb: 'FFDCDCDC' } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ PDF -- */
|
||||||
|
|
||||||
|
export function renderPdf(doc, settings) {
|
||||||
|
const ex = settings.export;
|
||||||
|
const accent = ex.accent || '#6BA04A';
|
||||||
|
const pdf = new PDFDocument({
|
||||||
|
size: 'A4',
|
||||||
|
layout: doc.landscape ? 'landscape' : 'portrait',
|
||||||
|
margins: { top: 40, bottom: 70, left: 42, right: 42 },
|
||||||
|
bufferPages: true,
|
||||||
|
info: { Title: `${doc.title} ${doc.subtitle || ''}`.trim(), Author: settings.company_name || 'perinco' },
|
||||||
|
});
|
||||||
|
const chunks = [];
|
||||||
|
pdf.on('data', (c) => chunks.push(c));
|
||||||
|
const done = new Promise((resolve) => pdf.on('end', () => resolve(Buffer.concat(chunks))));
|
||||||
|
|
||||||
|
const left = pdf.page.margins.left;
|
||||||
|
const width = pdf.page.width - left - pdf.page.margins.right;
|
||||||
|
const logo = logoBuffer(ex);
|
||||||
|
|
||||||
|
const drawHeader = () => {
|
||||||
|
pdf.image(logo, left, 30, { fit: [120, 46] });
|
||||||
|
pdf.fontSize(8).fillColor('#6E6E6E').font('Helvetica');
|
||||||
|
if (ex.subtitle) pdf.text(ex.subtitle, left, 34, { width, align: 'right' });
|
||||||
|
if (ex.header_right) pdf.text(ex.header_right, left, 46, { width, align: 'right' });
|
||||||
|
pdf.moveTo(left, 84).lineTo(left + width, 84).lineWidth(1.5).strokeColor(accent).stroke();
|
||||||
|
pdf.x = left;
|
||||||
|
pdf.y = 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
drawHeader();
|
||||||
|
pdf.on('pageAdded', drawHeader);
|
||||||
|
|
||||||
|
// Titel
|
||||||
|
pdf.font('Helvetica-Bold').fontSize(20).fillColor('#1F1F1F').text(doc.title, left, pdf.y);
|
||||||
|
if (doc.subtitle) pdf.font('Helvetica').fontSize(11).fillColor(accent).text(doc.subtitle);
|
||||||
|
pdf.moveDown(0.8);
|
||||||
|
|
||||||
|
// Infoblock zweispaltig
|
||||||
|
if (doc.info?.length) {
|
||||||
|
const colW = width / 2;
|
||||||
|
const startY = pdf.y;
|
||||||
|
let maxY = startY;
|
||||||
|
doc.info.forEach(([label, value], i) => {
|
||||||
|
const x = left + (i % 2) * colW;
|
||||||
|
const y = startY + Math.floor(i / 2) * 30;
|
||||||
|
pdf.font('Helvetica').fontSize(7.5).fillColor('#9A9A9A').text(label.toUpperCase(), x, y, { width: colW - 10, characterSpacing: 0.6 });
|
||||||
|
pdf.font('Helvetica-Bold').fontSize(10).fillColor('#1F1F1F').text(String(value ?? '–'), x, y + 10, { width: colW - 10, height: 14, ellipsis: true });
|
||||||
|
maxY = Math.max(maxY, y + 26);
|
||||||
|
});
|
||||||
|
pdf.y = maxY + 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kennzahlen als Kacheln
|
||||||
|
if (doc.kpis?.length) {
|
||||||
|
const gap = 8;
|
||||||
|
const boxW = (width - gap * (doc.kpis.length - 1)) / doc.kpis.length;
|
||||||
|
const y = pdf.y;
|
||||||
|
doc.kpis.forEach(([label, value], i) => {
|
||||||
|
const x = left + i * (boxW + gap);
|
||||||
|
pdf.roundedRect(x, y, boxW, 46, 6).fillColor('#F4F7F1').fill();
|
||||||
|
pdf.rect(x, y, 3, 46).fillColor(accent).fill();
|
||||||
|
pdf.font('Helvetica').fontSize(7.5).fillColor('#6E6E6E').text(label, x + 10, y + 8, { width: boxW - 16 });
|
||||||
|
pdf.font('Helvetica-Bold').fontSize(14).fillColor('#1F1F1F').text(String(value), x + 10, y + 21, { width: boxW - 16 });
|
||||||
|
});
|
||||||
|
pdf.y = y + 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const table of doc.tables) drawTable(pdf, table, { left, width, accent });
|
||||||
|
|
||||||
|
if (doc.signature && ex.show_signature) {
|
||||||
|
if (pdf.y + 80 > pdf.page.height - pdf.page.margins.bottom) pdf.addPage();
|
||||||
|
const y = pdf.y + 45;
|
||||||
|
const w = (width - 40) / 2;
|
||||||
|
for (const [x, label] of [
|
||||||
|
[left, ex.signature_left],
|
||||||
|
[left + w + 40, ex.signature_right],
|
||||||
|
]) {
|
||||||
|
pdf.moveTo(x, y).lineTo(x + w, y).lineWidth(0.6).strokeColor('#9A9A9A').stroke();
|
||||||
|
pdf.font('Helvetica').fontSize(8).fillColor('#6E6E6E').text(label || '', x, y + 4, { width: w });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuss auf jeder Seite, inkl. Seitenzahl.
|
||||||
|
const range = pdf.bufferedPageRange();
|
||||||
|
for (let i = 0; i < range.count; i++) {
|
||||||
|
pdf.switchToPage(range.start + i);
|
||||||
|
const bottom = pdf.page.height - 48;
|
||||||
|
const margin = pdf.page.margins.bottom;
|
||||||
|
pdf.page.margins.bottom = 0; // sonst bricht text() unten auf eine neue Seite um
|
||||||
|
pdf.moveTo(left, bottom - 8).lineTo(left + width, bottom - 8).lineWidth(0.5).strokeColor('#DCDCDC').stroke();
|
||||||
|
pdf.font('Helvetica').fontSize(7.5).fillColor('#6E6E6E');
|
||||||
|
const third = width / 3;
|
||||||
|
pdf.text(ex.footer_left || '', left, bottom, { width: third, align: 'left', lineGap: 1 });
|
||||||
|
pdf.text(ex.footer_center || '', left + third, bottom, { width: third, align: 'center', lineGap: 1 });
|
||||||
|
pdf.text(`${ex.footer_right || ''}${ex.footer_right ? '\n' : ''}Seite ${i + 1} von ${range.count}`, left + 2 * third, bottom, {
|
||||||
|
width: third,
|
||||||
|
align: 'right',
|
||||||
|
lineGap: 1,
|
||||||
|
});
|
||||||
|
pdf.page.margins.bottom = margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.end();
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTable(pdf, table, { left, width, accent }) {
|
||||||
|
const fixed = table.columns.reduce((s, c) => s + (c.width || 0), 0);
|
||||||
|
const flex = table.columns.filter((c) => !c.width).length;
|
||||||
|
const widths = table.columns.map((c) => c.width || Math.max(40, (width - fixed) / Math.max(1, flex)));
|
||||||
|
const scale = width / widths.reduce((a, b) => a + b, 0);
|
||||||
|
const cols = widths.map((w) => w * scale);
|
||||||
|
const bottomLimit = () => pdf.page.height - pdf.page.margins.bottom;
|
||||||
|
|
||||||
|
const header = () => {
|
||||||
|
const y = pdf.y;
|
||||||
|
pdf.rect(left, y, width, 18).fillColor(accent).fill();
|
||||||
|
let x = left;
|
||||||
|
table.columns.forEach((c, i) => {
|
||||||
|
pdf.font('Helvetica-Bold').fontSize(8).fillColor('#FFFFFF').text(c.label, x + 5, y + 5, {
|
||||||
|
width: cols[i] - 10,
|
||||||
|
align: c.align || 'left',
|
||||||
|
lineBreak: false,
|
||||||
|
ellipsis: true,
|
||||||
|
});
|
||||||
|
x += cols[i];
|
||||||
|
});
|
||||||
|
pdf.y = y + 18;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pdf.y + 60 > bottomLimit()) pdf.addPage();
|
||||||
|
if (table.title) {
|
||||||
|
pdf.moveDown(0.6);
|
||||||
|
pdf.font('Helvetica-Bold').fontSize(11).fillColor('#1F1F1F').text(table.title, left, pdf.y);
|
||||||
|
pdf.moveDown(0.3);
|
||||||
|
}
|
||||||
|
header();
|
||||||
|
|
||||||
|
const drawRow = (data, { zebra = false, bold = false } = {}) => {
|
||||||
|
const texts = table.columns.map((c) => formatCell(data[c.key], c.type));
|
||||||
|
pdf.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8.5);
|
||||||
|
const height = Math.max(16, ...texts.map((t, i) => pdf.heightOfString(t || ' ', { width: cols[i] - 10 }) + 8));
|
||||||
|
if (pdf.y + height > bottomLimit()) {
|
||||||
|
pdf.addPage();
|
||||||
|
header();
|
||||||
|
}
|
||||||
|
const y = pdf.y;
|
||||||
|
if (zebra) pdf.rect(left, y, width, height).fillColor('#F4F7F1').fill();
|
||||||
|
if (bold) {
|
||||||
|
pdf.moveTo(left, y).lineTo(left + width, y).lineWidth(1.2).strokeColor(accent).stroke();
|
||||||
|
}
|
||||||
|
let x = left;
|
||||||
|
texts.forEach((text, i) => {
|
||||||
|
pdf.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8.5).fillColor('#1F1F1F').text(text, x + 5, y + 4, {
|
||||||
|
width: cols[i] - 10,
|
||||||
|
align: table.columns[i].align || 'left',
|
||||||
|
});
|
||||||
|
x += cols[i];
|
||||||
|
});
|
||||||
|
if (!bold) pdf.moveTo(left, y + height).lineTo(left + width, y + height).lineWidth(0.3).strokeColor('#E4E4E4').stroke();
|
||||||
|
pdf.y = y + height;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (table.rows.length === 0) {
|
||||||
|
pdf.font('Helvetica-Oblique').fontSize(9).fillColor('#9A9A9A').text('Keine Buchungen im Zeitraum.', left + 5, pdf.y + 6);
|
||||||
|
pdf.y += 10;
|
||||||
|
}
|
||||||
|
table.rows.forEach((row, index) => drawRow(row, { zebra: index % 2 === 1 }));
|
||||||
|
if (table.totals) drawRow(table.totals, { bold: true });
|
||||||
|
pdf.x = left;
|
||||||
|
pdf.moveDown(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendDocument(res, doc, format, settings) {
|
||||||
|
const safe = doc.filename.replace(/[^\w.\-äöüÄÖÜß ]+/g, '_');
|
||||||
|
let buffer;
|
||||||
|
let type;
|
||||||
|
if (format === 'pdf') {
|
||||||
|
buffer = await renderPdf(doc, settings);
|
||||||
|
type = 'application/pdf';
|
||||||
|
} else if (format === 'xlsx') {
|
||||||
|
buffer = await renderXlsx(doc, settings);
|
||||||
|
type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||||
|
} else {
|
||||||
|
buffer = renderCsv(doc);
|
||||||
|
type = 'text/csv; charset=utf-8';
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', type);
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="${safe.replace(/[^\x20-\x7e]/g, '_')}.${format}"; filename*=UTF-8''${encodeURIComponent(safe)}.${format}`
|
||||||
|
);
|
||||||
|
res.send(buffer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { pool } from '../db.js';
|
||||||
|
import { hasRole } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wessen Zeiten darf der Benutzer sehen?
|
||||||
|
* management/admin: alle (null)
|
||||||
|
* lead: sich selbst und alle, die direkt oder indirekt unter ihm haengen
|
||||||
|
* user: nur sich selbst
|
||||||
|
*/
|
||||||
|
export async function visibleUserIds(user) {
|
||||||
|
if (hasRole(user, 'management')) return null;
|
||||||
|
if (user.role !== 'lead') return [user.id];
|
||||||
|
|
||||||
|
const [rows] = await pool.query('SELECT id, manager_id FROM pulse_users');
|
||||||
|
const children = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.manager_id == null) continue;
|
||||||
|
if (!children.has(row.manager_id)) children.set(row.manager_id, []);
|
||||||
|
children.get(row.manager_id).push(row.id);
|
||||||
|
}
|
||||||
|
const seen = new Set([user.id]);
|
||||||
|
const queue = [user.id];
|
||||||
|
while (queue.length) {
|
||||||
|
const current = queue.shift();
|
||||||
|
for (const child of children.get(current) || []) {
|
||||||
|
if (seen.has(child)) continue; // Schutz gegen Zyklen
|
||||||
|
seen.add(child);
|
||||||
|
queue.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Unterstellten (rekursiv) eines Vorgesetzten, ohne ihn selbst. */
|
||||||
|
export async function teamOf(leadId) {
|
||||||
|
const ids = await visibleUserIds({ id: leadId, role: 'lead' });
|
||||||
|
return ids.filter((id) => id !== leadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SQL-Fragment fuer "user_id IN (...)" - bei null ohne Einschraenkung. */
|
||||||
|
export function userFilter(ids, column = 'e.user_id') {
|
||||||
|
if (ids === null) return { sql: '1=1', params: [] };
|
||||||
|
if (ids.length === 0) return { sql: '1=0', params: [] };
|
||||||
|
return { sql: `${column} IN (${ids.map(() => '?').join(',')})`, params: ids };
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
* Datumsrechnung ohne Zeitzonenfallen: alles laeuft ueber 'YYYY-MM-DD' und
|
||||||
|
* UTC-Mitternacht, damit ein Tag nie durch die Sommerzeit verrutscht.
|
||||||
|
*/
|
||||||
|
export function parseDate(value) {
|
||||||
|
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
||||||
|
const date = new Date(`${value}T00:00:00Z`);
|
||||||
|
return Number.isNaN(date.getTime()) || iso(date) !== value ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iso(date) {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function today() {
|
||||||
|
const now = new Date();
|
||||||
|
// Lokales Datum des Servers (Europe/Berlin), nicht UTC.
|
||||||
|
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||||
|
return local.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(value, days) {
|
||||||
|
const date = parseDate(value);
|
||||||
|
date.setUTCDate(date.getUTCDate() + days);
|
||||||
|
return iso(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysBetween(from, to) {
|
||||||
|
return Math.round((parseDate(to) - parseDate(from)) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arbeitstage Mo-Fr im geschlossenen Intervall. */
|
||||||
|
export function workdays(from, to) {
|
||||||
|
let count = 0;
|
||||||
|
const end = parseDate(to);
|
||||||
|
for (const d = parseDate(from); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||||
|
const day = d.getUTCDay();
|
||||||
|
if (day !== 0 && day !== 6) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthRange(month) {
|
||||||
|
if (!/^\d{4}-\d{2}$/.test(month || '')) return null;
|
||||||
|
const [y, m] = month.split('-').map(Number);
|
||||||
|
if (m < 1 || m > 12) return null;
|
||||||
|
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||||
|
return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, '0')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoWeek(value) {
|
||||||
|
const date = parseDate(value);
|
||||||
|
const day = date.getUTCDay() || 7;
|
||||||
|
date.setUTCDate(date.getUTCDate() + 4 - day);
|
||||||
|
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||||
|
return { year: date.getUTCFullYear(), week: Math.ceil(((date - yearStart) / 86400000 + 1) / 7) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mondayOf(value) {
|
||||||
|
const date = parseDate(value);
|
||||||
|
const day = date.getUTCDay() || 7;
|
||||||
|
date.setUTCDate(date.getUTCDate() - day + 1);
|
||||||
|
return iso(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||||
|
|
||||||
|
export function formatDateDe(value) {
|
||||||
|
const [y, m, d] = String(value).slice(0, 10).split('-');
|
||||||
|
return `${d}.${m}.${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeitraum, in dem `value` liegt: day | week | month | quarter | year. */
|
||||||
|
export function periodRange(granularity, value) {
|
||||||
|
const [y, m] = value.split('-').map(Number);
|
||||||
|
switch (granularity) {
|
||||||
|
case 'day':
|
||||||
|
return { from: value, to: value };
|
||||||
|
case 'week': {
|
||||||
|
const from = mondayOf(value);
|
||||||
|
return { from, to: addDays(from, 6) };
|
||||||
|
}
|
||||||
|
case 'month':
|
||||||
|
return monthRange(value.slice(0, 7));
|
||||||
|
case 'quarter': {
|
||||||
|
const q = Math.floor((m - 1) / 3);
|
||||||
|
const startMonth = String(q * 3 + 1).padStart(2, '0');
|
||||||
|
const end = monthRange(`${y}-${String(q * 3 + 3).padStart(2, '0')}`);
|
||||||
|
return { from: `${y}-${startMonth}-01`, to: end.to };
|
||||||
|
}
|
||||||
|
case 'year':
|
||||||
|
return { from: `${y}-01-01`, to: `${y}-12-31` };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beginn des vorherigen Zeitraums (fuer Reihen und Vergleich). */
|
||||||
|
export function previousStart(granularity, from) {
|
||||||
|
const d = parseDate(from);
|
||||||
|
if (granularity === 'day') d.setUTCDate(d.getUTCDate() - 1);
|
||||||
|
if (granularity === 'week') d.setUTCDate(d.getUTCDate() - 7);
|
||||||
|
if (granularity === 'month') d.setUTCMonth(d.getUTCMonth() - 1);
|
||||||
|
if (granularity === 'quarter') d.setUTCMonth(d.getUTCMonth() - 3);
|
||||||
|
if (granularity === 'year') d.setUTCFullYear(d.getUTCFullYear() - 1);
|
||||||
|
return iso(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodLabel(granularity, from) {
|
||||||
|
const [y, m, d] = from.split('-');
|
||||||
|
if (granularity === 'day') return `${d}.${m}.`;
|
||||||
|
if (granularity === 'week') return `KW ${isoWeek(from).week}`;
|
||||||
|
if (granularity === 'month') return `${MONTHS_DE[Number(m) - 1].slice(0, 3)} ${y.slice(2)}`;
|
||||||
|
if (granularity === 'quarter') return `Q${Math.floor((Number(m) - 1) / 3) + 1} ${y.slice(2)}`;
|
||||||
|
return y;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { pool } from '../db.js';
|
||||||
|
import { displayName, hourlyRate } from './projects.js';
|
||||||
|
import { userFilter } from './access.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Buchungen fuer Auswertungen laden - mit allem, was Dashboard, Reports,
|
||||||
|
* Kundenansicht und Exporte brauchen, und mit dem Betrag je Buchung:
|
||||||
|
*
|
||||||
|
* amount abrechenbarer Betrag (Stunden x Stundensatz des Topfs);
|
||||||
|
* nur fuer Projekte vom Typ "billable"
|
||||||
|
* fixed_amount Festpreisprojekte: Stunden x virtueller Stundensatz
|
||||||
|
* (Topfbudget / alle auf dem Topf gebuchten Stunden). Nur
|
||||||
|
* intern, in Reports per Schalter zuschaltbar.
|
||||||
|
*/
|
||||||
|
export async function loadEntries(settings, { from, to, userIds = null, customerId, projectId, taskId } = {}) {
|
||||||
|
// Administratoren sind keine Mitarbeiter - ihre Zeiten zaehlen nirgends mit.
|
||||||
|
const where = ["u.role <> 'admin'"];
|
||||||
|
const params = [];
|
||||||
|
if (from) {
|
||||||
|
where.push('e.entry_date >= ?');
|
||||||
|
params.push(from);
|
||||||
|
}
|
||||||
|
if (to) {
|
||||||
|
where.push('e.entry_date <= ?');
|
||||||
|
params.push(to);
|
||||||
|
}
|
||||||
|
const users = userFilter(userIds);
|
||||||
|
where.push(users.sql);
|
||||||
|
params.push(...users.params);
|
||||||
|
if (customerId) {
|
||||||
|
where.push('p.customer_id = ?');
|
||||||
|
params.push(Number(customerId));
|
||||||
|
}
|
||||||
|
if (projectId) {
|
||||||
|
where.push('e.project_id = ?');
|
||||||
|
params.push(Number(projectId));
|
||||||
|
}
|
||||||
|
if (taskId) {
|
||||||
|
where.push('e.task_id = ?');
|
||||||
|
params.push(Number(taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT e.id, e.user_id, e.project_id, e.task_id, e.pot_id, e.entry_date, e.minutes, e.note, e.billable,
|
||||||
|
e.created_at, e.updated_at,
|
||||||
|
u.name AS user_name, u.color AS user_color, u.is_external, u.company AS user_company, u.weekly_hours,
|
||||||
|
u.manager_id,
|
||||||
|
p.name AS project_name, p.code AS project_code, p.type AS project_type, p.billing_unit,
|
||||||
|
c.id AS customer_id, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color,
|
||||||
|
t.name AS task_name,
|
||||||
|
b.label AS pot_label, b.rate AS pot_rate, b.budget_amount AS pot_amount
|
||||||
|
FROM pulse_time_entries e
|
||||||
|
JOIN pulse_users u ON u.id = e.user_id
|
||||||
|
JOIN pulse_projects p ON p.id = e.project_id
|
||||||
|
JOIN pulse_customers c ON c.id = p.customer_id
|
||||||
|
LEFT JOIN pulse_tasks t ON t.id = e.task_id
|
||||||
|
LEFT JOIN pulse_budget_pots b ON b.id = e.pot_id
|
||||||
|
WHERE ${where.join(' AND ')}
|
||||||
|
ORDER BY e.entry_date, e.id`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Virtueller Stundensatz braucht die Stunden des ganzen Topfs, nicht nur des Zeitraums.
|
||||||
|
const fixedPots = [...new Set(rows.filter((r) => r.project_type === 'fixed' && r.pot_id).map((r) => r.pot_id))];
|
||||||
|
const potMinutes = new Map();
|
||||||
|
if (fixedPots.length) {
|
||||||
|
const [usage] = await pool.query(
|
||||||
|
`SELECT pot_id, SUM(minutes) AS minutes FROM pulse_time_entries WHERE pot_id IN (${fixedPots.map(() => '?').join(',')}) GROUP BY pot_id`,
|
||||||
|
fixedPots
|
||||||
|
);
|
||||||
|
for (const u of usage) potMinutes.set(u.pot_id, Number(u.minutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((r) => {
|
||||||
|
const project = { name: r.project_name, code: r.project_code, billing_unit: r.billing_unit };
|
||||||
|
const hours = r.minutes / 60;
|
||||||
|
let amount = 0;
|
||||||
|
let fixedAmount = 0;
|
||||||
|
if (r.billable && r.project_type === 'billable') {
|
||||||
|
const rate = hourlyRate({ rate: r.pot_rate }, project, settings);
|
||||||
|
amount = rate ? hours * rate : 0;
|
||||||
|
}
|
||||||
|
if (r.billable && r.project_type === 'fixed' && r.pot_amount && potMinutes.get(r.pot_id)) {
|
||||||
|
fixedAmount = (Number(r.pot_amount) / (potMinutes.get(r.pot_id) / 60)) * hours;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
billable: Boolean(r.billable),
|
||||||
|
is_external: Boolean(r.is_external),
|
||||||
|
project_display: displayName(settings, project, { name: r.customer_name, short_name: r.customer_short }),
|
||||||
|
amount: Math.round(amount * 100) / 100,
|
||||||
|
fixed_amount: Math.round(fixedAmount * 100) / 100,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { pool } from '../db.js';
|
||||||
|
import { daysBetween, parseDate, today } from './dates.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fachlogik rund um Projekte und Budgettoepfe - an einer Stelle, weil Buchung,
|
||||||
|
* Projektansicht, Kunden und Reports dieselben Zahlen zeigen muessen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const round2 = (n) => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
export function parseTags(value) {
|
||||||
|
if (!value) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return Array.isArray(parsed) ? parsed.map(String) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anzeigename nach dem Muster aus den Einstellungen, z. B. "KUKA - PMO". */
|
||||||
|
export function displayName(settings, project, customer) {
|
||||||
|
const pattern = settings.project_name_pattern || '{KUNDE} - {PROJEKT}';
|
||||||
|
const kunde = customer?.short_name && customer.short_name !== customer.name ? prettyShort(customer) : customer?.name || '';
|
||||||
|
return pattern
|
||||||
|
.replaceAll('{KUNDE}', kunde)
|
||||||
|
.replaceAll('{KUNDENNAME}', customer?.name || '')
|
||||||
|
.replaceAll('{PROJEKT}', project.name)
|
||||||
|
.replaceAll('{CODE}', project.code || '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kuerzel in Grossbuchstaben wirken in Namen laut - "KUKA" bleibt, "PERINCO" wird "perinco". */
|
||||||
|
function prettyShort(customer) {
|
||||||
|
if (customer.short_name.toUpperCase() === 'PERINCO') return 'perinco';
|
||||||
|
return customer.short_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stundensatz je Stunde, auch wenn das Projekt nach Tagen abrechnet. */
|
||||||
|
export function hourlyRate(pot, project, settings) {
|
||||||
|
if (pot?.rate == null) return null;
|
||||||
|
const rate = Number(pot.rate);
|
||||||
|
return project.billing_unit === 'day' ? rate / Number(settings.hours_per_day || 8) : rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buchbares Volumen eines Topfs in Stunden (null = unbegrenzt). */
|
||||||
|
export function deriveBudgetHours(input, project, settings) {
|
||||||
|
const amount = input.budget_amount == null || input.budget_amount === '' ? null : Number(input.budget_amount);
|
||||||
|
const rate = input.rate == null || input.rate === '' ? null : Number(input.rate);
|
||||||
|
if (project.type === 'billable' && amount != null && rate) {
|
||||||
|
const perUnit = project.billing_unit === 'day' ? Number(settings.hours_per_day || 8) : 1;
|
||||||
|
return round2((amount / rate) * perUnit);
|
||||||
|
}
|
||||||
|
if (input.budget_hours == null || input.budget_hours === '') return null;
|
||||||
|
return round2(Number(input.budget_hours));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kennzahlen eines Topfs. `usedMinutes` sind alle Buchungen auf diesem Topf.
|
||||||
|
* status: upcoming | active | expired | exhausted
|
||||||
|
* earned: bereits erbrachter Umsatz, open: noch offener Umsatz
|
||||||
|
*/
|
||||||
|
export function potStats(pot, project, usedMinutes, settings) {
|
||||||
|
const now = today();
|
||||||
|
const usedHours = usedMinutes / 60;
|
||||||
|
const budgetHours = pot.budget_hours == null ? null : Number(pot.budget_hours);
|
||||||
|
const amount = pot.budget_amount == null ? null : Number(pot.budget_amount);
|
||||||
|
const rateHour = hourlyRate(pot, project, settings);
|
||||||
|
|
||||||
|
let status = 'active';
|
||||||
|
if (now < pot.valid_from) status = 'upcoming';
|
||||||
|
else if (now > pot.valid_to) status = 'expired';
|
||||||
|
else if (budgetHours != null && usedHours >= budgetHours - 1e-9) status = 'exhausted';
|
||||||
|
|
||||||
|
let earned = 0;
|
||||||
|
if (project.type === 'billable') {
|
||||||
|
earned = rateHour ? usedHours * rateHour : 0;
|
||||||
|
} else if (project.type === 'fixed' && amount) {
|
||||||
|
if (budgetHours) {
|
||||||
|
earned = amount * Math.min(1, usedHours / budgetHours);
|
||||||
|
} else {
|
||||||
|
// Ohne Stundenkontingent: Festpreis anteilig ueber die Laufzeit.
|
||||||
|
const total = Math.max(1, daysBetween(pot.valid_from, pot.valid_to) + 1);
|
||||||
|
const elapsed = now < pot.valid_from ? 0 : Math.min(total, daysBetween(pot.valid_from, now) + 1);
|
||||||
|
earned = amount * (elapsed / total);
|
||||||
|
}
|
||||||
|
if (status === 'expired') earned = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgetValue = project.type === 'billable' ? (amount ?? (budgetHours && rateHour ? budgetHours * rateHour : null)) : amount;
|
||||||
|
const open = budgetValue == null || status === 'expired' ? 0 : Math.max(0, budgetValue - earned);
|
||||||
|
const daysLeft = daysBetween(now, pot.valid_to);
|
||||||
|
|
||||||
|
return {
|
||||||
|
used_minutes: usedMinutes,
|
||||||
|
used_hours: round2(usedHours),
|
||||||
|
remaining_hours: budgetHours == null ? null : round2(Math.max(0, budgetHours - usedHours)),
|
||||||
|
percent_used: budgetHours ? Math.round((usedHours / budgetHours) * 1000) / 10 : null,
|
||||||
|
hourly_rate: rateHour == null ? null : round2(rateHour),
|
||||||
|
// Festpreis: Budget geteilt durch gebuchte Stunden - nur intern.
|
||||||
|
virtual_rate: project.type === 'fixed' && amount && usedHours > 0 ? round2(amount / usedHours) : null,
|
||||||
|
budget_value: budgetValue == null ? null : round2(budgetValue),
|
||||||
|
earned: round2(earned),
|
||||||
|
open: round2(open),
|
||||||
|
days_left: daysLeft,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist der Topf zu leer oder laeuft er bald ab? */
|
||||||
|
export function potWarning(pot, stats, settings) {
|
||||||
|
if (stats.status === 'expired') return `Budgettopf „${pot.label}“ ist abgelaufen.`;
|
||||||
|
if (stats.status === 'exhausted') return `Budgettopf „${pot.label}“ ist aufgebraucht.`;
|
||||||
|
const parts = [];
|
||||||
|
if (stats.percent_used != null && stats.percent_used >= Number(settings.budget_warn_percent || 80)) {
|
||||||
|
parts.push(`${Math.round(stats.percent_used)} % verbraucht`);
|
||||||
|
}
|
||||||
|
if (stats.status === 'active' && stats.days_left <= Number(settings.budget_warn_days || 14)) {
|
||||||
|
parts.push(stats.days_left === 0 ? 'läuft heute ab' : `läuft in ${stats.days_left} Tagen ab`);
|
||||||
|
}
|
||||||
|
return parts.length ? `Budgettopf „${pot.label}“: ${parts.join(', ')}.` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laufende Nummer je Kunde und Jahr, eingesetzt in das Codemuster. */
|
||||||
|
export async function generateCode(conn, settings, customer, startDate) {
|
||||||
|
const year = String(startDate).slice(0, 4);
|
||||||
|
const pattern = settings.project_code_pattern || '{KUERZEL}-{JAHR}-{NR}';
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
'SELECT COALESCE(MAX(seq), 0) AS n FROM pulse_projects WHERE customer_id = ? AND YEAR(start_date) = ?',
|
||||||
|
[customer.id, Number(year)]
|
||||||
|
);
|
||||||
|
let seq = Number(rows[0].n) + 1;
|
||||||
|
// Gegen Kollisionen, falls das Muster geaendert wurde.
|
||||||
|
for (let attempt = 0; attempt < 500; attempt++, seq++) {
|
||||||
|
const code = pattern
|
||||||
|
.replaceAll('{KUERZEL}', customer.short_name.toUpperCase())
|
||||||
|
.replaceAll('{JAHR}', year)
|
||||||
|
.replaceAll('{JJ}', year.slice(2))
|
||||||
|
.replaceAll('{NR}', String(seq).padStart(3, '0'))
|
||||||
|
.slice(0, 40);
|
||||||
|
const [exists] = await conn.query('SELECT id FROM pulse_projects WHERE code = ? LIMIT 1', [code]);
|
||||||
|
if (exists.length === 0) return { code, seq };
|
||||||
|
}
|
||||||
|
throw new Error('Projektcode konnte nicht erzeugt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was verbraucht Budget? Bei Billable-Projekten ist das Volumen aus Betrag und
|
||||||
|
* Satz abgeleitet - also verkaufte Stunden. Nicht abrechenbare Task-Stunden
|
||||||
|
* (z. B. Reisezeit) verbrauchen es nicht. Bei Festpreis und intern zaehlt alles.
|
||||||
|
*/
|
||||||
|
export function countsAgainstBudget(project) {
|
||||||
|
return project.type === 'billable' ? 'billable' : 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abrechenbar ist eine Buchung, wenn Projekt UND Task es sind. */
|
||||||
|
export function isBillable(project, task) {
|
||||||
|
if (project.type === 'non_billable') return false;
|
||||||
|
return task ? Boolean(task.billable) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laedt Projekte samt Kunde, Toepfen (mit Kennzahlen), Tasks und Mitgliedern.
|
||||||
|
* `where` schraenkt pulse_projects (Alias p) ein.
|
||||||
|
*/
|
||||||
|
export async function loadProjects(settings, { where = '1=1', params = [] } = {}) {
|
||||||
|
const [projects] = await pool.query(
|
||||||
|
`SELECT p.*, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color,
|
||||||
|
u.name AS owner_name
|
||||||
|
FROM pulse_projects p
|
||||||
|
JOIN pulse_customers c ON c.id = p.customer_id
|
||||||
|
LEFT JOIN pulse_users u ON u.id = p.owner_id
|
||||||
|
WHERE ${where}
|
||||||
|
ORDER BY c.name, p.name`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
if (projects.length === 0) return [];
|
||||||
|
const ids = projects.map((p) => p.id);
|
||||||
|
const inList = ids.map(() => '?').join(',');
|
||||||
|
|
||||||
|
const [[pots], [tasks], [members], [potUsage], [projectUsage], [taskUsage]] = await Promise.all([
|
||||||
|
pool.query(`SELECT * FROM pulse_budget_pots WHERE project_id IN (${inList}) ORDER BY valid_from, id`, ids),
|
||||||
|
pool.query(`SELECT * FROM pulse_tasks WHERE project_id IN (${inList}) ORDER BY name`, ids),
|
||||||
|
pool.query(
|
||||||
|
`SELECT m.project_id, u.id, u.name, u.color, u.is_external
|
||||||
|
FROM pulse_project_members m JOIN pulse_users u ON u.id = m.user_id
|
||||||
|
WHERE m.project_id IN (${inList}) AND u.role <> 'admin' ORDER BY u.name`,
|
||||||
|
ids
|
||||||
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT pot_id, SUM(minutes) AS minutes, SUM(CASE WHEN billable = 1 THEN minutes ELSE 0 END) AS billable_minutes
|
||||||
|
FROM pulse_time_entries
|
||||||
|
WHERE project_id IN (${inList}) AND pot_id IS NOT NULL GROUP BY pot_id`,
|
||||||
|
ids
|
||||||
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT project_id, SUM(minutes) AS minutes, SUM(CASE WHEN billable = 1 THEN minutes ELSE 0 END) AS billable_minutes,
|
||||||
|
MAX(entry_date) AS last_entry
|
||||||
|
FROM pulse_time_entries WHERE project_id IN (${inList}) GROUP BY project_id`,
|
||||||
|
ids
|
||||||
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT task_id, SUM(minutes) AS minutes FROM pulse_time_entries
|
||||||
|
WHERE project_id IN (${inList}) AND task_id IS NOT NULL GROUP BY task_id`,
|
||||||
|
ids
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const potMinutes = new Map(potUsage.map((r) => [r.pot_id, r]));
|
||||||
|
const projectMinutes = new Map(projectUsage.map((r) => [r.project_id, r]));
|
||||||
|
const taskMinutes = new Map(taskUsage.map((r) => [r.task_id, Number(r.minutes)]));
|
||||||
|
|
||||||
|
return projects.map((p) => {
|
||||||
|
const project = { ...p, tags: parseTags(p.tags), is_active: Boolean(p.is_active) };
|
||||||
|
const customer = { id: p.customer_id, name: p.customer_name, short_name: p.customer_short };
|
||||||
|
const projectPots = pots
|
||||||
|
.filter((pot) => pot.project_id === p.id)
|
||||||
|
.map((pot) => {
|
||||||
|
const usage = potMinutes.get(pot.id);
|
||||||
|
const booked = Number(usage?.minutes || 0);
|
||||||
|
const counted = countsAgainstBudget(project) === 'billable' ? Number(usage?.billable_minutes || 0) : booked;
|
||||||
|
const stats = potStats(pot, project, counted, settings);
|
||||||
|
return { ...pot, ...stats, booked_minutes: booked, warning: potWarning(pot, stats, settings) };
|
||||||
|
});
|
||||||
|
const usage = projectMinutes.get(p.id);
|
||||||
|
const totalBudgetHours = projectPots.every((pot) => pot.budget_hours != null) && projectPots.length
|
||||||
|
? round2(projectPots.reduce((s, pot) => s + Number(pot.budget_hours), 0))
|
||||||
|
: null;
|
||||||
|
const usedMinutes = Number(usage?.minutes || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
display_name: displayName(settings, project, customer),
|
||||||
|
customer: { id: p.customer_id, name: p.customer_name, short_name: p.customer_short, color: p.customer_color },
|
||||||
|
pots: projectPots,
|
||||||
|
tasks: tasks
|
||||||
|
.filter((t) => t.project_id === p.id)
|
||||||
|
.map((t) => ({ ...t, billable: Boolean(t.billable), is_active: Boolean(t.is_active), used_minutes: taskMinutes.get(t.id) || 0 })),
|
||||||
|
members: members
|
||||||
|
.filter((m) => m.project_id === p.id)
|
||||||
|
.map((m) => ({ id: m.id, name: m.name, color: m.color, is_external: Boolean(m.is_external) })),
|
||||||
|
used_minutes: usedMinutes,
|
||||||
|
billable_minutes: Number(usage?.billable_minutes || 0),
|
||||||
|
// Was gegen das Budget zaehlt (bei Billable nur abrechenbare Stunden).
|
||||||
|
budget_used_minutes: projectPots.reduce((s, pot) => s + pot.used_minutes, 0),
|
||||||
|
last_entry: usage?.last_entry || null,
|
||||||
|
budget_hours: totalBudgetHours,
|
||||||
|
remaining_hours:
|
||||||
|
totalBudgetHours == null ? null : round2(Math.max(0, totalBudgetHours - projectPots.reduce((s, pot) => s + pot.used_minutes, 0) / 60)),
|
||||||
|
budget_value: round2(projectPots.reduce((s, pot) => s + (pot.budget_value || 0), 0)),
|
||||||
|
earned: round2(projectPots.reduce((s, pot) => s + pot.earned, 0)),
|
||||||
|
open: round2(projectPots.reduce((s, pot) => s + pot.open, 0)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Betraege weg fuer die Rolle "user" - Mitarbeiter sehen Stunden, keine Preise. */
|
||||||
|
export function stripAmounts(project) {
|
||||||
|
const clean = { ...project, budget_value: null, earned: null, open: null };
|
||||||
|
clean.pots = project.pots.map((pot) => ({
|
||||||
|
...pot,
|
||||||
|
budget_amount: null,
|
||||||
|
rate: null,
|
||||||
|
hourly_rate: null,
|
||||||
|
virtual_rate: null,
|
||||||
|
budget_value: null,
|
||||||
|
earned: null,
|
||||||
|
open: null,
|
||||||
|
}));
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.status = 422;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Findet den Topf fuer eine Buchung.
|
||||||
|
*
|
||||||
|
* Regeln (aus dem Anforderungscall):
|
||||||
|
* - gebucht wird nur auf einen Topf, dessen Laufzeit das Leistungsdatum enthaelt
|
||||||
|
* - ist das Volumen erreicht oder das Ablaufdatum ueberschritten: nicht buchbar
|
||||||
|
* - ueberlappen mehrere Toepfe, zuerst der, der am fruehesten endet
|
||||||
|
* - Projekte ohne Topf (z. B. intern, unbegrenzt) sind frei buchbar
|
||||||
|
*/
|
||||||
|
export async function resolvePot(conn, project, date, minutes, excludeEntryId = null, billable = true) {
|
||||||
|
const onlyBillable = countsAgainstBudget(project) === 'billable';
|
||||||
|
// Nicht abrechenbare Stunden in einem Billable-Projekt verbrauchen kein Volumen.
|
||||||
|
const needed = onlyBillable && !billable ? 0 : minutes;
|
||||||
|
if (date < project.start_date) {
|
||||||
|
throw new BookingError(`Das Projekt beginnt erst am ${de(project.start_date)}.`);
|
||||||
|
}
|
||||||
|
if (project.end_date && date > project.end_date) {
|
||||||
|
throw new BookingError(`Das Projekt ist am ${de(project.end_date)} beendet.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [pots] = await conn.query(
|
||||||
|
'SELECT * FROM pulse_budget_pots WHERE project_id = ? ORDER BY valid_to, id FOR UPDATE',
|
||||||
|
[project.id]
|
||||||
|
);
|
||||||
|
if (pots.length === 0) return null;
|
||||||
|
|
||||||
|
const covering = pots.filter((pot) => pot.valid_from <= date && pot.valid_to >= date);
|
||||||
|
if (covering.length === 0) {
|
||||||
|
throw new BookingError(
|
||||||
|
`Kein Budgettopf deckt den ${de(date)} ab. Buchbar ist nur innerhalb der Topf-Laufzeiten ` +
|
||||||
|
`(${pots.map((p) => `${p.label}: ${de(p.valid_from)}–${de(p.valid_to)}`).join('; ')}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let lastShortage = null;
|
||||||
|
for (const pot of covering) {
|
||||||
|
if (pot.valid_to < today()) {
|
||||||
|
lastShortage = `Budgettopf „${pot.label}“ ist seit ${de(pot.valid_to)} abgelaufen.`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pot.budget_hours == null) return pot;
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT COALESCE(SUM(minutes), 0) AS used FROM pulse_time_entries
|
||||||
|
WHERE pot_id = ? AND id <> ? ${onlyBillable ? 'AND billable = 1' : ''}`,
|
||||||
|
[pot.id, excludeEntryId || 0]
|
||||||
|
);
|
||||||
|
const free = Number(pot.budget_hours) * 60 - Number(rows[0].used);
|
||||||
|
// Ein aufgebrauchter Topf ist fuer alles gesperrt, auch fuer Reisezeit.
|
||||||
|
if (free > 1e-9 && needed <= free + 1e-9) return pot;
|
||||||
|
lastShortage =
|
||||||
|
free <= 0
|
||||||
|
? `Budgettopf „${pot.label}“ ist aufgebraucht.`
|
||||||
|
: `Budgettopf „${pot.label}“ hat nur noch ${fmtMinutes(free)} h frei.`;
|
||||||
|
}
|
||||||
|
throw new BookingError(`${lastShortage} Buchung nicht möglich – bitte Projektverantwortlichen informieren.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach Aenderung an Toepfen: bestehende Buchungen neu zuordnen. Historische
|
||||||
|
* Buchungen werden nicht abgewiesen - sie landen im passenden Topf, auch wenn
|
||||||
|
* dieser dadurch ueberbucht ist (das zeigt die Projektansicht dann rot).
|
||||||
|
*/
|
||||||
|
export async function reassignEntries(conn, projectId) {
|
||||||
|
const [pots] = await conn.query('SELECT * FROM pulse_budget_pots WHERE project_id = ? ORDER BY valid_to, id', [projectId]);
|
||||||
|
const [[project]] = await conn.query('SELECT type FROM pulse_projects WHERE id = ?', [projectId]);
|
||||||
|
const onlyBillable = project && countsAgainstBudget(project) === 'billable';
|
||||||
|
const [entries] = await conn.query(
|
||||||
|
'SELECT id, entry_date, minutes, pot_id, billable FROM pulse_time_entries WHERE project_id = ? ORDER BY entry_date, id',
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
const used = new Map(pots.map((p) => [p.id, 0]));
|
||||||
|
for (const entry of entries) {
|
||||||
|
const needed = onlyBillable && !entry.billable ? 0 : entry.minutes;
|
||||||
|
const covering = pots.filter((p) => p.valid_from <= entry.entry_date && p.valid_to >= entry.entry_date);
|
||||||
|
const target =
|
||||||
|
covering.find((p) => p.budget_hours == null || used.get(p.id) + needed <= Number(p.budget_hours) * 60) ||
|
||||||
|
covering[0] ||
|
||||||
|
null;
|
||||||
|
if (target) used.set(target.id, used.get(target.id) + needed);
|
||||||
|
const targetId = target ? target.id : null;
|
||||||
|
if (targetId !== entry.pot_id) {
|
||||||
|
await conn.query('UPDATE pulse_time_entries SET pot_id = ? WHERE id = ?', [targetId, entry.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function de(value) {
|
||||||
|
const [y, m, d] = String(value).slice(0, 10).split('-');
|
||||||
|
return `${d}.${m}.${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtMinutes(minutes) {
|
||||||
|
const total = Math.max(0, Math.round(minutes));
|
||||||
|
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { parseDate };
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Einstellungen liegen als JSON je Schluessel in pulse_settings. Fehlt ein
|
||||||
|
* Schluessel, gilt der Standard von hier - so muss beim Hinzufuegen neuer
|
||||||
|
* Einstellungen nichts migriert werden.
|
||||||
|
*/
|
||||||
|
export const DEFAULTS = {
|
||||||
|
weekly_hours: 40,
|
||||||
|
hours_per_day: 8,
|
||||||
|
// Platzhalter: {KUERZEL} Kundenkuerzel, {JAHR} vierstellig, {NR} laufende
|
||||||
|
// Nummer je Kunde und Jahr (dreistellig).
|
||||||
|
project_code_pattern: '{KUERZEL}-{JAHR}-{NR}',
|
||||||
|
// Anzeigename der Projekte: {KUNDE} Kundenkuerzel oder -name, {PROJEKT} Name, {CODE}.
|
||||||
|
project_name_pattern: '{KUNDE} - {PROJEKT}',
|
||||||
|
budget_warn_percent: 80,
|
||||||
|
budget_warn_days: 14,
|
||||||
|
company_name: 'perinco GmbH',
|
||||||
|
export: {
|
||||||
|
title: 'Stundennachweis',
|
||||||
|
subtitle: 'Performance Intelligence Consulting',
|
||||||
|
accent: '#6BA04A',
|
||||||
|
logo: null, // Data-URL; null = perinco Logo
|
||||||
|
header_right: 'www.perinco.de',
|
||||||
|
footer_left: 'perinco GmbH',
|
||||||
|
footer_center: '',
|
||||||
|
footer_right: 'www.perinco.de',
|
||||||
|
show_signature: true,
|
||||||
|
signature_left: 'Datum, Unterschrift Berater',
|
||||||
|
signature_right: 'Datum, Unterschrift Kunde',
|
||||||
|
},
|
||||||
|
sso: {
|
||||||
|
enabled: false,
|
||||||
|
button_label: 'Mit Microsoft anmelden',
|
||||||
|
tenant_id: '',
|
||||||
|
client_id: '',
|
||||||
|
client_secret: '',
|
||||||
|
redirect_uri: 'https://pulse.perinco.de/api/auth/sso/callback',
|
||||||
|
// Uebernommen wird nur von Hand in der SSO-Verwaltung; Rollen vergibt Pulse.
|
||||||
|
local_login: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getSettings() {
|
||||||
|
const [rows] = await pool.query('SELECT k, v FROM pulse_settings');
|
||||||
|
const stored = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
stored[row.k] = JSON.parse(row.v);
|
||||||
|
} catch {
|
||||||
|
/* kaputter Wert - Standard gilt */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = {};
|
||||||
|
for (const [key, def] of Object.entries(DEFAULTS)) {
|
||||||
|
const value = stored[key];
|
||||||
|
if (def && typeof def === 'object' && !Array.isArray(def)) {
|
||||||
|
result[key] = { ...def, ...(value && typeof value === 'object' ? value : {}) };
|
||||||
|
} else {
|
||||||
|
result[key] = value ?? def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.sso.client_secret) result.sso.client_secret = decrypt(result.sso.client_secret);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSetting(key, value) {
|
||||||
|
if (!(key in DEFAULTS)) throw new Error(`Unbekannte Einstellung: ${key}`);
|
||||||
|
let stored = value;
|
||||||
|
if (key === 'sso' && value.client_secret) stored = { ...value, client_secret: encrypt(value.client_secret) };
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO pulse_settings (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)',
|
||||||
|
[key, JSON.stringify(stored)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Client Secret verschluesselt ablegen (AES-256-GCM, Schluessel aus SETTINGS_KEY). */
|
||||||
|
function key() {
|
||||||
|
const base = process.env.SETTINGS_KEY || process.env.JWT_SECRET || '';
|
||||||
|
return crypto.createHash('sha256').update(base).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
function encrypt(plain) {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
|
||||||
|
const data = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
||||||
|
return `enc:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${data.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(value) {
|
||||||
|
if (typeof value !== 'string' || !value.startsWith('enc:')) return value;
|
||||||
|
try {
|
||||||
|
const [, iv, tag, data] = value.split(':');
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(iv, 'base64'));
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||||
|
return Buffer.concat([decipher.update(Buffer.from(data, 'base64')), decipher.final()]).toString('utf8');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Microsoft Entra ID (M365).
|
||||||
|
*
|
||||||
|
* Anmeldung: OpenID Connect, Authorization Code Flow als vertrauliche App
|
||||||
|
* (Client Secret). Das id_token kommt direkt vom Token-Endpunkt ueber TLS -
|
||||||
|
* nach OIDC Core 3.1.3.7 reicht dann die Pruefung von aud/tid/exp.
|
||||||
|
*
|
||||||
|
* Verzeichnis: Client Credentials gegen Microsoft Graph (Anwendungsberechtigung
|
||||||
|
* User.Read.All). Angezeigt werden nur echte Mitarbeiterkonten - siehe
|
||||||
|
* isEligible(). Uebernommen wird von Hand in der SSO-Verwaltung; anmelden kann
|
||||||
|
* sich nur, wer uebernommen wurde. Rollen vergibt ausschliesslich Pulse, AD-
|
||||||
|
* Gruppen spielen keine Rolle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LOGIN = 'https://login.microsoftonline.com';
|
||||||
|
|
||||||
|
/** Erlaubte Maildomains fuer Mitarbeiterkonten. */
|
||||||
|
export const ALLOWED_DOMAINS = ['perinco.de', 'icon-experts.de'];
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Genau zwei Namensteile: vorname.nachname@domain. Bindestriche innerhalb eines
|
||||||
|
* Teils sind erlaubt (anna-lena.mueller), weitere Punkte nicht - damit fallen
|
||||||
|
* Admin-Konten wie adm.sascha.wolf@ und Funktionsadressen wie info@ heraus.
|
||||||
|
*/
|
||||||
|
const MAIL_PATTERN = new RegExp(
|
||||||
|
`^[a-z]+(?:-[a-z]+)*\\.[a-z]+(?:-[a-z]+)*@(?:${ALLOWED_DOMAINS.map((d) => d.replace('.', '\\.')).join('|')})$`
|
||||||
|
);
|
||||||
|
|
||||||
|
export function ssoConfigured(sso) {
|
||||||
|
return Boolean(sso.tenant_id && sso.client_id && sso.client_secret && sso.redirect_uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Primaere Mailadresse eines AD-Benutzers (mail, sonst UPN), klein geschrieben. */
|
||||||
|
export function adEmail(ad) {
|
||||||
|
return String(ad.mail || ad.userPrincipalName || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Technische Praefixe, die nie eine Person sind (adm.sascha.wolf, sa.hrworks, srv.pa). */
|
||||||
|
const TECHNICAL_PREFIXES = ['adm', 'admin', 'sa', 'srv', 'svc', 'service', 'test'];
|
||||||
|
|
||||||
|
/** "Mülln" -> "muelln", "René" -> "rene", "Grote-Aksu" -> "groteaksu". */
|
||||||
|
export function normalizeName(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, 'ae')
|
||||||
|
.replace(/ö/g, 'oe')
|
||||||
|
.replace(/ü/g, 'ue')
|
||||||
|
.replace(/ß/g, 'ss')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/[^a-z]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ist das ein echtes Mitarbeiterkonto?
|
||||||
|
* - Mail im Format vorname.nachname@perinco.de bzw. @icon-experts.de
|
||||||
|
* - kein technisches Praefix (sa., srv., adm. ...)
|
||||||
|
* - Nachname im AD passt zum Teil hinter dem Punkt - Dienstkonten wie
|
||||||
|
* sa.hrworks ("Service Account") oder srv.pa ("SRV Flow") fallen so heraus.
|
||||||
|
* Der Vorname wird bewusst nicht verglichen: Tippfehler im AD kommen vor.
|
||||||
|
* - kein Gast
|
||||||
|
* - Konto aktiv (freigegebene Postfaecher sind in Entra deaktiviert)
|
||||||
|
* - mindestens eine Lizenz (freigegebene Postfaecher und Dienstkonten haben keine)
|
||||||
|
*/
|
||||||
|
export function isEligible(ad) {
|
||||||
|
if (ad.userType === 'Guest') return false;
|
||||||
|
if (ad.accountEnabled === false) return false;
|
||||||
|
if (Array.isArray(ad.assignedLicenses) && ad.assignedLicenses.length === 0) return false;
|
||||||
|
const email = adEmail(ad);
|
||||||
|
if (!MAIL_PATTERN.test(email)) return false;
|
||||||
|
const [first, last] = email.split('@')[0].split('.');
|
||||||
|
if (TECHNICAL_PREFIXES.includes(first)) return false;
|
||||||
|
const surname = normalizeName(ad.surname);
|
||||||
|
return surname.length > 0 && normalizeName(last) === surname;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorizeUrl(sso, state) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: sso.client_id,
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: sso.redirect_uri,
|
||||||
|
response_mode: 'query',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
state,
|
||||||
|
prompt: 'select_account',
|
||||||
|
});
|
||||||
|
return `${LOGIN}/${encodeURIComponent(sso.tenant_id)}/oauth2/v2.0/authorize?${params}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tokenRequest(sso, body) {
|
||||||
|
const response = await fetch(`${LOGIN}/${encodeURIComponent(sso.tenant_id)}/oauth2/v2.0/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ client_id: sso.client_id, client_secret: sso.client_secret, ...body }),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(String(payload.error_description || payload.error || `HTTP ${response.status}`).split('\n')[0]);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCode(sso, code) {
|
||||||
|
const tokens = await tokenRequest(sso, {
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: sso.redirect_uri,
|
||||||
|
scope: 'openid profile email',
|
||||||
|
});
|
||||||
|
const [, body] = String(tokens.id_token || '').split('.');
|
||||||
|
if (!body) throw new Error('Kein id_token erhalten.');
|
||||||
|
const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
||||||
|
if (claims.aud !== sso.client_id) throw new Error('id_token gehört zu einer anderen Anwendung.');
|
||||||
|
if (claims.exp * 1000 < Date.now()) throw new Error('id_token ist abgelaufen.');
|
||||||
|
if (/^[0-9a-f-]{36}$/i.test(sso.tenant_id) && claims.tid !== sso.tenant_id) throw new Error('Falscher Mandant.');
|
||||||
|
return {
|
||||||
|
oid: claims.oid,
|
||||||
|
email: String(claims.email || claims.preferred_username || '').toLowerCase(),
|
||||||
|
name: claims.name || claims.preferred_username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function graphToken(sso) {
|
||||||
|
const tokens = await tokenRequest(sso, {
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
scope: 'https://graph.microsoft.com/.default',
|
||||||
|
});
|
||||||
|
return tokens.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT = 'id,displayName,givenName,surname,mail,userPrincipalName,jobTitle,department,accountEnabled,userType,assignedLicenses';
|
||||||
|
|
||||||
|
async function graphGetAll(token, url, maxPages = 100) {
|
||||||
|
const items = [];
|
||||||
|
let next = url;
|
||||||
|
let pages = 0;
|
||||||
|
while (next && pages++ < maxPages) {
|
||||||
|
const response = await fetch(next, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(payload.error?.message || `Graph HTTP ${response.status}`);
|
||||||
|
items.push(...(payload.value || []));
|
||||||
|
next = payload['@odata.nextLink'];
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testConnection(sso) {
|
||||||
|
const token = await graphToken(sso);
|
||||||
|
const users = await graphGetAll(token, `https://graph.microsoft.com/v1.0/users?$select=${SELECT}&$top=50`, 1);
|
||||||
|
const eligible = users.filter(isEligible);
|
||||||
|
return { sample: eligible.slice(0, 5).map((u) => u.displayName), checked: users.length, eligible: eligible.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Verzeichnis-Zwischenspeicher. Die SSO-Verwaltung filtert und sucht im
|
||||||
|
* Browser; Graph wird hoechstens alle 5 Minuten (oder auf Knopfdruck) gefragt.
|
||||||
|
* Der Schluessel haengt an Tenant und Client, damit ein Wechsel der
|
||||||
|
* Zugangsdaten nie alte Daten zeigt.
|
||||||
|
*/
|
||||||
|
const CACHE_MS = 5 * 60 * 1000;
|
||||||
|
let cache = { key: null, at: 0, users: null, raw: null };
|
||||||
|
|
||||||
|
function mapUser(ad) {
|
||||||
|
return {
|
||||||
|
id: ad.id,
|
||||||
|
name: ad.displayName || adEmail(ad),
|
||||||
|
given_name: ad.givenName || null,
|
||||||
|
surname: ad.surname || null,
|
||||||
|
email: adEmail(ad),
|
||||||
|
job_title: ad.jobTitle || null,
|
||||||
|
department: ad.department || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle AD-Konten (roh) - fuer den Abgleich, der auch Deaktivierungen sehen muss. */
|
||||||
|
async function fetchAll(sso, refresh) {
|
||||||
|
const key = `${sso.tenant_id}|${sso.client_id}`;
|
||||||
|
if (!refresh && cache.key === key && cache.raw && Date.now() - cache.at < CACHE_MS) return cache.raw;
|
||||||
|
const token = await graphToken(sso);
|
||||||
|
const raw = await graphGetAll(token, `https://graph.microsoft.com/v1.0/users?$select=${SELECT}&$top=999`);
|
||||||
|
cache = { key, at: Date.now(), raw, users: raw.filter(isEligible).map(mapUser).sort((a, b) => a.name.localeCompare(b.name, 'de')) };
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gefilterte Mitarbeiterkonten aus dem AD. */
|
||||||
|
export async function listDirectory(sso, { refresh = false } = {}) {
|
||||||
|
await fetchAll(sso, refresh);
|
||||||
|
return { users: cache.users, fetched_at: new Date(cache.at).toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebernimmt AD-Konten nach Pulse. Existiert schon ein Benutzer mit derselben
|
||||||
|
* Objekt-ID oder Mailadresse (z. B. ein vorher lokal angelegtes Konto), wird er
|
||||||
|
* verknuepft statt doppelt angelegt. Die Rolle wird in beiden Faellen gesetzt.
|
||||||
|
*/
|
||||||
|
export async function importUsers(sso, adIds, { role, managerId }) {
|
||||||
|
const { users } = await listDirectory(sso);
|
||||||
|
const byId = new Map(users.map((u) => [u.id, u]));
|
||||||
|
const result = { created: [], linked: [], skipped: [] };
|
||||||
|
|
||||||
|
for (const adId of adIds) {
|
||||||
|
const ad = byId.get(adId);
|
||||||
|
if (!ad) {
|
||||||
|
result.skipped.push(adId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [rows] = await pool.query('SELECT id FROM pulse_users WHERE external_id = ? OR email = ? LIMIT 1', [ad.id, ad.email]);
|
||||||
|
if (rows[0]) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE pulse_users
|
||||||
|
SET external_id = ?, source = 'sso', name = ?, email = ?, job_title = COALESCE(?, job_title),
|
||||||
|
role = ?, manager_id = COALESCE(?, manager_id), is_active = 1, is_external = 0
|
||||||
|
WHERE id = ?`,
|
||||||
|
[ad.id, ad.name, ad.email, ad.job_title, role, managerId ?? null, rows[0].id]
|
||||||
|
);
|
||||||
|
result.linked.push(rows[0].id);
|
||||||
|
} else {
|
||||||
|
const [insert] = await pool.query(
|
||||||
|
`INSERT INTO pulse_users (username, email, name, role, manager_id, source, external_id, job_title)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'sso', ?, ?)`,
|
||||||
|
[await freeUsername(ad.email), ad.email, ad.name, role, managerId ?? null, ad.id, ad.job_title]
|
||||||
|
);
|
||||||
|
result.created.push(insert.insertId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abgleich der uebernommenen Benutzer: Name, Mail, Jobtitel aus dem AD.
|
||||||
|
* Wer im AD geloescht, deaktiviert oder nicht mehr zulaessig ist, wird in Pulse
|
||||||
|
* deaktiviert. Neue Konten legt der Abgleich nie an - das geschieht nur in der
|
||||||
|
* SSO-Verwaltung.
|
||||||
|
*/
|
||||||
|
export async function syncUsers(sso) {
|
||||||
|
const raw = await fetchAll(sso, true);
|
||||||
|
const byId = new Map(raw.map((u) => [u.id, u]));
|
||||||
|
const [linked] = await pool.query("SELECT id, external_id, is_active FROM pulse_users WHERE source = 'sso' AND external_id IS NOT NULL");
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
let deactivated = 0;
|
||||||
|
for (const user of linked) {
|
||||||
|
const ad = byId.get(user.external_id);
|
||||||
|
if (!ad || !isEligible(ad)) {
|
||||||
|
if (user.is_active) {
|
||||||
|
await pool.query('UPDATE pulse_users SET is_active = 0 WHERE id = ?', [user.id]);
|
||||||
|
deactivated++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await pool.query('UPDATE pulse_users SET name = ?, email = ?, job_title = COALESCE(?, job_title) WHERE id = ?', [
|
||||||
|
ad.displayName || adEmail(ad),
|
||||||
|
adEmail(ad),
|
||||||
|
ad.jobTitle || null,
|
||||||
|
user.id,
|
||||||
|
]);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
return { total: raw.length, eligible: cache.users.length, linked: linked.length, updated, deactivated };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function freeUsername(email) {
|
||||||
|
const base = email.split('@')[0].replace(/[^a-z0-9._-]/gi, '').toLowerCase().slice(0, 60) || 'user';
|
||||||
|
let candidate = base;
|
||||||
|
for (let i = 2; i < 1000; i++) {
|
||||||
|
const [rows] = await pool.query('SELECT id FROM pulse_users WHERE username = ? LIMIT 1', [candidate]);
|
||||||
|
if (rows.length === 0) return candidate;
|
||||||
|
candidate = `${base}${i}`;
|
||||||
|
}
|
||||||
|
return `${base}-${crypto.randomBytes(3).toString('hex')}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
|
||||||
|
export const ROLES = ['user', 'lead', 'management', 'admin'];
|
||||||
|
const RANK = { user: 0, lead: 1, management: 2, admin: 3 };
|
||||||
|
|
||||||
|
function secret() {
|
||||||
|
const value = process.env.JWT_SECRET;
|
||||||
|
if (!value || value.length < 16) throw new Error('JWT_SECRET fehlt oder ist zu kurz (min. 16 Zeichen).');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signToken(user) {
|
||||||
|
return jwt.sign({ sub: user.id, role: user.role }, secret(), {
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || '12h',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signShort(payload, expiresIn = '10m') {
|
||||||
|
return jwt.sign(payload, secret(), { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyShort(token) {
|
||||||
|
return jwt.verify(token, secret());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USER_COLUMNS = `id, username, email, name, role, manager_id, job_title, is_external, company,
|
||||||
|
source, external_id, weekly_hours, color, theme, theme_mode, is_active, last_login_at, created_at, updated_at`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft das Bearer-Token und laedt den Benutzer bei jeder Anfrage frisch aus
|
||||||
|
* der Datenbank - ein deaktiviertes Konto ist sofort draussen und eine
|
||||||
|
* geaenderte Rolle gilt sofort.
|
||||||
|
*/
|
||||||
|
export async function requireAuth(req, res, next) {
|
||||||
|
const header = req.headers.authorization || '';
|
||||||
|
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
|
if (!token) return res.status(401).json({ error: 'Nicht angemeldet.' });
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = jwt.verify(token, secret());
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte neu anmelden.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(`SELECT ${USER_COLUMNS} FROM pulse_users WHERE id = ? LIMIT 1`, [payload.sub]);
|
||||||
|
const user = rows[0];
|
||||||
|
if (!user) return res.status(401).json({ error: 'Benutzer existiert nicht mehr.' });
|
||||||
|
if (!user.is_active) return res.status(401).json({ error: 'Benutzerkonto ist deaktiviert.' });
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRole(user, minimum) {
|
||||||
|
return RANK[user?.role] >= RANK[minimum];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mindestrolle: lead < management < admin. */
|
||||||
|
export function requireRole(minimum) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!hasRole(req.user, minimum)) {
|
||||||
|
return res.status(403).json({ error: 'Für diese Aktion fehlen die Berechtigungen.' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicUser(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
email: row.email,
|
||||||
|
name: row.name,
|
||||||
|
role: row.role,
|
||||||
|
manager_id: row.manager_id ?? null,
|
||||||
|
job_title: row.job_title ?? null,
|
||||||
|
is_external: Boolean(row.is_external),
|
||||||
|
company: row.company ?? null,
|
||||||
|
source: row.source,
|
||||||
|
weekly_hours: row.weekly_hours == null ? null : Number(row.weekly_hours),
|
||||||
|
color: row.color ?? null,
|
||||||
|
theme: row.theme ?? null,
|
||||||
|
theme_mode: row.theme_mode ?? null,
|
||||||
|
is_active: Boolean(row.is_active),
|
||||||
|
last_login_at: row.last_login_at ?? null,
|
||||||
|
created_at: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
import { publicUser, requireAuth, signShort, signToken, verifyShort } from '../middleware/auth.js';
|
||||||
|
import { getSettings } from '../lib/settings.js';
|
||||||
|
import { authorizeUrl, exchangeCode, ssoConfigured } from '../lib/sso.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Bremse gegen Passwortraten: 10 Fehlversuche je IP und Kennung in 15 Minuten.
|
||||||
|
* Im Speicher reicht - es gibt genau eine Backend-Instanz.
|
||||||
|
*/
|
||||||
|
const attempts = new Map();
|
||||||
|
const WINDOW = 15 * 60000;
|
||||||
|
function isLimited(key) {
|
||||||
|
const now = Date.now();
|
||||||
|
const recent = (attempts.get(key) || []).filter((t) => now - t < WINDOW);
|
||||||
|
if (recent.length) attempts.set(key, recent);
|
||||||
|
else attempts.delete(key);
|
||||||
|
return recent.length >= 10;
|
||||||
|
}
|
||||||
|
function recordFailure(key) {
|
||||||
|
attempts.set(key, [...(attempts.get(key) || []), Date.now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oeffentlich: was zeigt der Anmeldeschirm?
|
||||||
|
router.get('/config', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { sso } = await getSettings();
|
||||||
|
const ssoOn = Boolean(sso.enabled && ssoConfigured(sso));
|
||||||
|
res.json({ sso_enabled: ssoOn, sso_label: sso.button_label, local_login: ssoOn ? sso.local_login !== false : true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/login', async (req, res, next) => {
|
||||||
|
const identifier = String(req.body?.identifier || '').trim();
|
||||||
|
const password = String(req.body?.password || '');
|
||||||
|
if (!identifier || !password) {
|
||||||
|
return res.status(400).json({ error: 'Benutzername und Passwort sind erforderlich.' });
|
||||||
|
}
|
||||||
|
const key = `${req.ip}|${identifier.toLowerCase()}`;
|
||||||
|
if (isLimited(key)) {
|
||||||
|
return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM pulse_users WHERE username = ? OR email = ? LIMIT 1', [
|
||||||
|
identifier,
|
||||||
|
identifier.toLowerCase(),
|
||||||
|
]);
|
||||||
|
const user = rows[0];
|
||||||
|
// Identische Meldung fuer "gibt es nicht" und "falsches Passwort".
|
||||||
|
const ok = user?.password_hash ? await bcrypt.compare(password, user.password_hash) : false;
|
||||||
|
if (!ok) {
|
||||||
|
recordFailure(key);
|
||||||
|
return res.status(401).json({ error: 'Benutzername oder Passwort ist falsch.' });
|
||||||
|
}
|
||||||
|
if (!user.is_active) return res.status(403).json({ error: 'Dieses Benutzerkonto ist deaktiviert.' });
|
||||||
|
|
||||||
|
await pool.query('UPDATE pulse_users SET last_login_at = NOW() WHERE id = ?', [user.id]);
|
||||||
|
res.json({ token: signToken(user), user: publicUser(user) });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/me', requireAuth, (req, res) => res.json({ user: publicUser(req.user) }));
|
||||||
|
|
||||||
|
// Eigene Darstellung: Design, Modus, Farbe.
|
||||||
|
router.patch('/me', requireAuth, async (req, res, next) => {
|
||||||
|
const updates = {};
|
||||||
|
if (req.body?.theme !== undefined) {
|
||||||
|
const theme = req.body.theme;
|
||||||
|
if (theme !== null && !['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura'].includes(theme)) {
|
||||||
|
return res.status(400).json({ error: 'Unbekanntes Design.' });
|
||||||
|
}
|
||||||
|
updates.theme = theme === 'perinco' ? null : theme;
|
||||||
|
}
|
||||||
|
if (req.body?.theme_mode !== undefined) {
|
||||||
|
const mode = req.body.theme_mode;
|
||||||
|
if (mode !== null && mode !== 'light' && mode !== 'dark') return res.status(400).json({ error: 'Unbekannter Modus.' });
|
||||||
|
updates.theme_mode = mode;
|
||||||
|
}
|
||||||
|
if (req.body?.color !== undefined) {
|
||||||
|
const color = req.body.color;
|
||||||
|
if (color !== null && !/^#[0-9a-f]{6}$/i.test(color)) return res.status(400).json({ error: 'Ungültige Farbe.' });
|
||||||
|
updates.color = color;
|
||||||
|
}
|
||||||
|
const keys = Object.keys(updates);
|
||||||
|
if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
|
||||||
|
try {
|
||||||
|
await pool.query(`UPDATE pulse_users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
|
||||||
|
...keys.map((k) => updates[k]),
|
||||||
|
req.user.id,
|
||||||
|
]);
|
||||||
|
res.json({ user: publicUser({ ...req.user, ...updates }) });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/change-password', requireAuth, async (req, res, next) => {
|
||||||
|
const current = String(req.body?.current_password || '');
|
||||||
|
const wanted = String(req.body?.new_password || '');
|
||||||
|
if (wanted.length < 8) return res.status(400).json({ error: 'Das neue Passwort muss mindestens 8 Zeichen haben.' });
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT password_hash FROM pulse_users WHERE id = ?', [req.user.id]);
|
||||||
|
const hash = rows[0]?.password_hash;
|
||||||
|
if (!hash) return res.status(400).json({ error: 'Dieses Konto meldet sich über SSO an und hat kein Passwort.' });
|
||||||
|
if (!(await bcrypt.compare(current, hash))) return res.status(400).json({ error: 'Das aktuelle Passwort ist falsch.' });
|
||||||
|
await pool.query('UPDATE pulse_users SET password_hash = ? WHERE id = ?', [await bcrypt.hash(wanted, 12), req.user.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- SSO -- */
|
||||||
|
|
||||||
|
router.get('/sso/start', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { sso } = await getSettings();
|
||||||
|
if (!sso.enabled || !ssoConfigured(sso)) {
|
||||||
|
return res.redirect('/?sso_error=' + encodeURIComponent('SSO ist nicht eingerichtet.'));
|
||||||
|
}
|
||||||
|
const state = signShort({ n: crypto.randomBytes(12).toString('hex'), t: 'sso' });
|
||||||
|
res.redirect(authorizeUrl(sso, state));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/sso/callback', async (req, res) => {
|
||||||
|
const back = (message) => res.redirect('/?sso_error=' + encodeURIComponent(message));
|
||||||
|
try {
|
||||||
|
if (req.query.error) return back(String(req.query.error_description || req.query.error).split('\n')[0]);
|
||||||
|
try {
|
||||||
|
const state = verifyShort(String(req.query.state || ''));
|
||||||
|
if (state.t !== 'sso') throw new Error('state');
|
||||||
|
} catch {
|
||||||
|
return back('Anmeldung abgelaufen. Bitte erneut versuchen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sso } = await getSettings();
|
||||||
|
if (!sso.enabled || !ssoConfigured(sso)) return back('SSO ist nicht eingerichtet.');
|
||||||
|
const identity = await exchangeCode(sso, String(req.query.code || ''));
|
||||||
|
|
||||||
|
// Nur wer in der SSO-Verwaltung uebernommen wurde, kommt herein - verknuepft
|
||||||
|
// wird ueber die Objekt-ID, nicht ueber die Mailadresse.
|
||||||
|
const [rows] = await pool.query("SELECT * FROM pulse_users WHERE external_id = ? AND source = 'sso' LIMIT 1", [identity.oid]);
|
||||||
|
const user = rows[0];
|
||||||
|
if (!user) {
|
||||||
|
return back(`Für ${identity.email || 'dieses Konto'} ist Pulse noch nicht freigeschaltet. Bitte an einen Manager oder Administrator wenden.`);
|
||||||
|
}
|
||||||
|
if (!user.is_active) return back('Dieses Benutzerkonto ist deaktiviert.');
|
||||||
|
|
||||||
|
await pool.query('UPDATE pulse_users SET last_login_at = NOW() WHERE id = ?', [user.id]);
|
||||||
|
// Token im Fragment: landet weder im Server-Log noch im Referer.
|
||||||
|
res.redirect(`/#sso_token=${encodeURIComponent(signToken(user))}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Pulse] SSO-Anmeldung fehlgeschlagen:', err.message);
|
||||||
|
back('Anmeldung über Microsoft fehlgeschlagen: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
import { getSettings } from '../lib/settings.js';
|
||||||
|
import { loadProjects } from '../lib/projects.js';
|
||||||
|
import { loadEntries } from '../lib/entries.js';
|
||||||
|
import { addDays, today } from '../lib/dates.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
|
||||||
|
|
||||||
|
function normalize(body, creating) {
|
||||||
|
const errors = [];
|
||||||
|
const out = {};
|
||||||
|
if (creating || body.name !== undefined) {
|
||||||
|
out.name = str(body.name, 190);
|
||||||
|
if (!out.name) errors.push('Kundenname fehlt.');
|
||||||
|
}
|
||||||
|
if (creating || body.short_name !== undefined) {
|
||||||
|
out.short_name = str(body.short_name, 16)?.toUpperCase().replace(/[^A-Z0-9ÄÖÜ_-]/g, '') || null;
|
||||||
|
if (!out.short_name) errors.push('Kürzel fehlt (z. B. KUKA) - es steht im Projektcode.');
|
||||||
|
}
|
||||||
|
for (const [key, max] of [
|
||||||
|
['contact_person', 160],
|
||||||
|
['email', 190],
|
||||||
|
['phone', 60],
|
||||||
|
['street', 190],
|
||||||
|
['zip', 20],
|
||||||
|
['city', 120],
|
||||||
|
['country', 80],
|
||||||
|
['notes', 5000],
|
||||||
|
]) {
|
||||||
|
if (body[key] !== undefined) out[key] = str(body[key], max);
|
||||||
|
}
|
||||||
|
if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null;
|
||||||
|
if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0;
|
||||||
|
return { out, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT c.*, (SELECT COUNT(*) FROM pulse_projects p WHERE p.customer_id = c.id) AS project_count
|
||||||
|
FROM pulse_customers c ORDER BY c.name`
|
||||||
|
);
|
||||||
|
res.json(rows.map((r) => ({ ...r, is_active: Boolean(r.is_active), project_count: Number(r.project_count) })));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebersicht: welche Kunden haben welche Projekte mit welchen Mitarbeitern,
|
||||||
|
* wie viel Umsatz ist erbracht und wie viel noch offen - plus die letzten 12
|
||||||
|
* Monate Umsatz fuer die Verlaufslinie.
|
||||||
|
*/
|
||||||
|
router.get('/overview', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const [customers] = await pool.query('SELECT * FROM pulse_customers ORDER BY name');
|
||||||
|
const projects = await loadProjects(settings);
|
||||||
|
const from = `${addDays(today(), -365).slice(0, 7)}-01`;
|
||||||
|
const entries = await loadEntries(settings, { from });
|
||||||
|
|
||||||
|
const months = [];
|
||||||
|
const cursor = new Date(`${from}T00:00:00Z`);
|
||||||
|
for (let i = 0; i < 13; i++) {
|
||||||
|
months.push(cursor.toISOString().slice(0, 7));
|
||||||
|
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = customers.map((customer) => {
|
||||||
|
const own = projects.filter((p) => p.customer_id === customer.id);
|
||||||
|
const memberMap = new Map();
|
||||||
|
for (const project of own) for (const m of project.members) memberMap.set(m.id, m);
|
||||||
|
const monthly = months.map((month) =>
|
||||||
|
entries
|
||||||
|
.filter((e) => e.customer_id === customer.id && e.entry_date.startsWith(month))
|
||||||
|
.reduce((sum, e) => sum + e.amount + e.fixed_amount, 0)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...customer,
|
||||||
|
is_active: Boolean(customer.is_active),
|
||||||
|
projects: own.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
code: p.code,
|
||||||
|
name: p.name,
|
||||||
|
display_name: p.display_name,
|
||||||
|
type: p.type,
|
||||||
|
is_active: p.is_active,
|
||||||
|
used_minutes: p.used_minutes,
|
||||||
|
budget_hours: p.budget_hours,
|
||||||
|
earned: p.earned,
|
||||||
|
open: p.open,
|
||||||
|
budget_value: p.budget_value,
|
||||||
|
members: p.members,
|
||||||
|
end_date: p.end_date,
|
||||||
|
})),
|
||||||
|
members: [...memberMap.values()],
|
||||||
|
used_minutes: own.reduce((s, p) => s + p.used_minutes, 0),
|
||||||
|
earned: Math.round(own.reduce((s, p) => s + p.earned, 0) * 100) / 100,
|
||||||
|
open: Math.round(own.reduce((s, p) => s + p.open, 0) * 100) / 100,
|
||||||
|
monthly: monthly.map((v) => Math.round(v * 100) / 100),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.json({ months, customers: result });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
const { out, errors } = normalize(req.body || {}, true);
|
||||||
|
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
||||||
|
try {
|
||||||
|
const keys = Object.keys(out);
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`INSERT INTO pulse_customers (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
|
||||||
|
keys.map((k) => out[k])
|
||||||
|
);
|
||||||
|
const [[row]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [result.insertId]);
|
||||||
|
res.status(201).json(row);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Dieses Kürzel ist schon vergeben.' });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
const { out, errors } = normalize(req.body || {}, false);
|
||||||
|
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
||||||
|
try {
|
||||||
|
const keys = Object.keys(out);
|
||||||
|
if (keys.length) {
|
||||||
|
const [result] = await pool.query(`UPDATE pulse_customers SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
|
||||||
|
...keys.map((k) => out[k]),
|
||||||
|
Number(req.params.id),
|
||||||
|
]);
|
||||||
|
if (!result.affectedRows) return res.status(404).json({ error: 'Kunde nicht gefunden.' });
|
||||||
|
}
|
||||||
|
const [[row]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [Number(req.params.id)]);
|
||||||
|
res.json(row);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Dieses Kürzel ist schon vergeben.' });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_projects WHERE customer_id = ?', [Number(req.params.id)]);
|
||||||
|
if (Number(n) > 0) {
|
||||||
|
return res.status(409).json({ error: `Dem Kunden sind ${n} Projekte zugeordnet. Bitte deaktivieren statt löschen.` });
|
||||||
|
}
|
||||||
|
await pool.query('DELETE FROM pulse_customers WHERE id = ?', [Number(req.params.id)]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
import { visibleUserIds, userFilter } from '../lib/access.js';
|
||||||
|
import { getSettings } from '../lib/settings.js';
|
||||||
|
import { loadEntries } from '../lib/entries.js';
|
||||||
|
import { addDays, periodLabel, periodRange, previousStart, today, workdays } from '../lib/dates.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const SERIES_LENGTH = { day: 14, week: 12, month: 12, quarter: 8, year: 5 };
|
||||||
|
|
||||||
|
export function capacityMinutes(user, settings, from, to) {
|
||||||
|
const weekly = user.weekly_hours == null ? Number(settings.weekly_hours) : Number(user.weekly_hours);
|
||||||
|
return Math.round((weekly / 5) * workdays(from, to) * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sum(entries) {
|
||||||
|
let minutes = 0;
|
||||||
|
let billable = 0;
|
||||||
|
let amount = 0;
|
||||||
|
for (const e of entries) {
|
||||||
|
minutes += e.minutes;
|
||||||
|
if (e.billable) billable += e.minutes;
|
||||||
|
amount += e.amount;
|
||||||
|
}
|
||||||
|
return { minutes, billable_minutes: billable, non_billable_minutes: minutes - billable, amount: Math.round(amount * 100) / 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard fuer Fuehrungsrollen. Vorgesetzte sehen ihr Team (rekursiv),
|
||||||
|
* Management und Administratoren alle.
|
||||||
|
*
|
||||||
|
* ?granularity day|week|month|quarter|year - Diagramm und Kennzahlen
|
||||||
|
* ?rank days|week|month|quarter|year - Zeitraum der Mitarbeiterliste
|
||||||
|
*/
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const granularity = SERIES_LENGTH[req.query.granularity] ? req.query.granularity : 'week';
|
||||||
|
const rank = ['days', 'week', 'month', 'quarter', 'year'].includes(req.query.rank) ? req.query.rank : 'week';
|
||||||
|
const settings = await getSettings();
|
||||||
|
const userIds = await visibleUserIds(req.user);
|
||||||
|
const now = today();
|
||||||
|
|
||||||
|
// Diagramm: die letzten N Zeitraeume bis einschliesslich des aktuellen.
|
||||||
|
const buckets = [];
|
||||||
|
let start = periodRange(granularity, now).from;
|
||||||
|
for (let i = 0; i < SERIES_LENGTH[granularity]; i++) {
|
||||||
|
buckets.unshift({ ...periodRange(granularity, start), label: periodLabel(granularity, start) });
|
||||||
|
start = previousStart(granularity, start);
|
||||||
|
}
|
||||||
|
const rankRange = rank === 'days' ? { from: addDays(now, -6), to: now } : periodRange(rank, now);
|
||||||
|
const earliest = [buckets[0].from, rankRange.from].sort()[0];
|
||||||
|
const entries = await loadEntries(settings, { from: earliest, to: buckets[buckets.length - 1].to, userIds });
|
||||||
|
|
||||||
|
const series = buckets.map((b) => ({
|
||||||
|
from: b.from,
|
||||||
|
to: b.to,
|
||||||
|
label: b.label,
|
||||||
|
...sum(entries.filter((e) => e.entry_date >= b.from && e.entry_date <= b.to)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const filter = userFilter(userIds, 'id');
|
||||||
|
const [users] = await pool.query(
|
||||||
|
`SELECT id, name, color, role, weekly_hours, is_external, company, job_title FROM pulse_users
|
||||||
|
WHERE is_active = 1 AND role <> 'admin' AND ${filter.sql} ORDER BY name`,
|
||||||
|
filter.params
|
||||||
|
);
|
||||||
|
|
||||||
|
const current = buckets[buckets.length - 1];
|
||||||
|
const previous = buckets[buckets.length - 2];
|
||||||
|
// Kapazitaet nur bis heute - sonst sieht die Auslastung am Montag schlecht aus.
|
||||||
|
const capTo = current.to > now ? now : current.to;
|
||||||
|
const capacity = users.reduce((s, u) => s + capacityMinutes(u, settings, current.from, capTo), 0);
|
||||||
|
const kpis = {
|
||||||
|
current: { ...series[series.length - 1], capacity_minutes: capacity },
|
||||||
|
previous: series[series.length - 2] ?? null,
|
||||||
|
period_label: current.label,
|
||||||
|
previous_label: previous?.label ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rankTo = rankRange.to > now ? now : rankRange.to;
|
||||||
|
const ranking = users.map((u) => {
|
||||||
|
const own = entries.filter((e) => e.user_id === u.id && e.entry_date >= rankRange.from && e.entry_date <= rankRange.to);
|
||||||
|
const totals = sum(own);
|
||||||
|
return {
|
||||||
|
user_id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
color: u.color,
|
||||||
|
job_title: u.job_title,
|
||||||
|
is_external: Boolean(u.is_external),
|
||||||
|
...totals,
|
||||||
|
entries: own.length,
|
||||||
|
capacity_minutes: capacityMinutes(u, settings, rankRange.from, rankTo),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Projekte im aktuellen Zeitraum - fuer die Verteilung.
|
||||||
|
const byProject = new Map();
|
||||||
|
for (const e of entries.filter((e) => e.entry_date >= current.from && e.entry_date <= current.to)) {
|
||||||
|
const item = byProject.get(e.project_id) || {
|
||||||
|
project_id: e.project_id,
|
||||||
|
name: e.project_display,
|
||||||
|
customer_color: e.customer_color,
|
||||||
|
minutes: 0,
|
||||||
|
billable_minutes: 0,
|
||||||
|
};
|
||||||
|
item.minutes += e.minutes;
|
||||||
|
if (e.billable) item.billable_minutes += e.minutes;
|
||||||
|
byProject.set(e.project_id, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentFilter = userFilter(userIds, 'e.user_id');
|
||||||
|
const [recentIds] = await pool.query(
|
||||||
|
`SELECT e.id, e.entry_date FROM pulse_time_entries e WHERE ${recentFilter.sql} ORDER BY e.created_at DESC, e.id DESC LIMIT 12`,
|
||||||
|
recentFilter.params
|
||||||
|
);
|
||||||
|
let recent = [];
|
||||||
|
if (recentIds.length) {
|
||||||
|
const dates = recentIds.map((r) => r.entry_date).sort();
|
||||||
|
const pool_ = await loadEntries(settings, { from: dates[0], to: dates[dates.length - 1], userIds });
|
||||||
|
const byId = new Map(pool_.map((e) => [e.id, e]));
|
||||||
|
recent = recentIds
|
||||||
|
.map((r) => byId.get(r.id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
user_id: e.user_id,
|
||||||
|
user_name: e.user_name,
|
||||||
|
user_color: e.user_color,
|
||||||
|
project_display: e.project_display,
|
||||||
|
customer_color: e.customer_color,
|
||||||
|
task_name: e.task_name,
|
||||||
|
note: e.note,
|
||||||
|
entry_date: e.entry_date,
|
||||||
|
minutes: e.minutes,
|
||||||
|
billable: e.billable,
|
||||||
|
created_at: e.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
granularity,
|
||||||
|
rank,
|
||||||
|
rank_range: rankRange,
|
||||||
|
scope: userIds === null ? 'all' : 'team',
|
||||||
|
team_size: users.length,
|
||||||
|
series,
|
||||||
|
kpis,
|
||||||
|
ranking,
|
||||||
|
projects: [...byProject.values()].sort((a, b) => b.minutes - a.minutes).slice(0, 8),
|
||||||
|
recent,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
import { getSettings } from '../lib/settings.js';
|
||||||
|
import { ALLOWED_DOMAINS, importUsers, listDirectory, ssoConfigured, syncUsers } from '../lib/sso.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* SSO-Verwaltung: AD-Konten ansehen und nach Pulse uebernehmen.
|
||||||
|
* Zugriff ab Rolle Management (in server.js). Die Zugangsdaten selbst bleiben
|
||||||
|
* Administratoren vorbehalten (routes/settings.js).
|
||||||
|
*/
|
||||||
|
const router = express.Router();
|
||||||
|
const IMPORT_ROLES = ['user', 'lead', 'management'];
|
||||||
|
|
||||||
|
router.get('/ad-users', async (req, res) => {
|
||||||
|
const { sso } = await getSettings();
|
||||||
|
if (!ssoConfigured(sso)) {
|
||||||
|
return res.json({ configured: false, domains: ALLOWED_DOMAINS, users: [], fetched_at: null });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { users, fetched_at } = await listDirectory(sso, { refresh: req.query.refresh === '1' });
|
||||||
|
const [pulseUsers] = await pool.query('SELECT id, external_id, email, role, is_active, source FROM pulse_users');
|
||||||
|
const byExternal = new Map(pulseUsers.filter((u) => u.external_id).map((u) => [u.external_id, u]));
|
||||||
|
const byEmail = new Map(pulseUsers.filter((u) => u.email).map((u) => [u.email.toLowerCase(), u]));
|
||||||
|
res.json({
|
||||||
|
configured: true,
|
||||||
|
domains: ALLOWED_DOMAINS,
|
||||||
|
fetched_at,
|
||||||
|
users: users.map((ad) => {
|
||||||
|
const linked = byExternal.get(ad.id);
|
||||||
|
const sameMail = !linked ? byEmail.get(ad.email) : null;
|
||||||
|
return {
|
||||||
|
...ad,
|
||||||
|
pulse_user_id: linked?.id ?? null,
|
||||||
|
pulse_role: linked?.role ?? null,
|
||||||
|
pulse_active: linked ? Boolean(linked.is_active) : null,
|
||||||
|
// Lokales Konto mit derselben Mail - wird beim Uebernehmen verknuepft.
|
||||||
|
local_match_id: sameMail?.id ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: `Microsoft Graph nicht erreichbar: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/import', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { sso } = await getSettings();
|
||||||
|
if (!ssoConfigured(sso)) return res.status(400).json({ error: 'SSO ist noch nicht eingerichtet.' });
|
||||||
|
const ids = Array.isArray(req.body?.ad_ids) ? [...new Set(req.body.ad_ids.map(String))].slice(0, 500) : [];
|
||||||
|
const role = req.body?.role;
|
||||||
|
if (!ids.length) return res.status(400).json({ error: 'Keine Benutzer ausgewählt.' });
|
||||||
|
if (!IMPORT_ROLES.includes(role)) return res.status(400).json({ error: 'Ungültige Rolle.' });
|
||||||
|
|
||||||
|
let managerId = req.body?.manager_id ? Number(req.body.manager_id) : null;
|
||||||
|
if (managerId) {
|
||||||
|
const [[manager]] = await pool.query('SELECT id, role, is_active FROM pulse_users WHERE id = ?', [managerId]);
|
||||||
|
if (!manager || !manager.is_active || !['lead', 'management'].includes(manager.role)) {
|
||||||
|
return res.status(400).json({ error: 'Der Vorgesetzte muss ein aktiver Vorgesetzter oder Manager sein.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ein Administrator in der Auswahl darf nicht herabgestuft werden.
|
||||||
|
const [admins] = await pool.query(
|
||||||
|
`SELECT external_id FROM pulse_users WHERE role = 'admin' AND external_id IN (${ids.map(() => '?').join(',')})`,
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
if (admins.length) return res.status(400).json({ error: 'Administratoren können hier nicht geändert werden.' });
|
||||||
|
|
||||||
|
const result = await importUsers(sso, ids, { role, managerId });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Benutzername oder E-Mail ist schon vergeben.' });
|
||||||
|
if (/Graph|AADSTS|HTTP/.test(err.message)) return res.status(502).json({ error: `Microsoft Graph: ${err.message}` });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/sync', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sso } = await getSettings();
|
||||||
|
if (!ssoConfigured(sso)) return res.status(400).json({ error: 'SSO ist noch nicht eingerichtet.' });
|
||||||
|
res.json(await syncUsers(sso));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: `Abgleich fehlgeschlagen: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { pool, withTransaction } from '../db.js';
|
||||||
|
import { hasRole } from '../middleware/auth.js';
|
||||||
|
import { visibleUserIds } from '../lib/access.js';
|
||||||
|
import { getSettings } from '../lib/settings.js';
|
||||||
|
import { loadEntries } from '../lib/entries.js';
|
||||||
|
import { parseDate } from '../lib/dates.js';
|
||||||
|
import { BookingError, isBillable, loadProjects, resolvePot } from '../lib/projects.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function publicEntry(entry, showAmounts) {
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
user_id: entry.user_id,
|
||||||
|
user_name: entry.user_name,
|
||||||
|
project_id: entry.project_id,
|
||||||
|
project_name: entry.project_name,
|
||||||
|
project_code: entry.project_code,
|
||||||
|
project_display: entry.project_display,
|
||||||
|
project_type: entry.project_type,
|
||||||
|
customer_id: entry.customer_id,
|
||||||
|
customer_name: entry.customer_name,
|
||||||
|
customer_color: entry.customer_color,
|
||||||
|
task_id: entry.task_id,
|
||||||
|
task_name: entry.task_name,
|
||||||
|
pot_id: entry.pot_id,
|
||||||
|
pot_label: entry.pot_label,
|
||||||
|
entry_date: entry.entry_date,
|
||||||
|
minutes: entry.minutes,
|
||||||
|
note: entry.note,
|
||||||
|
billable: entry.billable,
|
||||||
|
amount: showAmounts ? entry.amount : null,
|
||||||
|
created_at: entry.created_at,
|
||||||
|
updated_at: entry.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { from, to } = req.query;
|
||||||
|
if (!parseDate(from) || !parseDate(to)) return res.status(400).json({ error: 'Zeitraum von/bis fehlt.' });
|
||||||
|
let userIds = [req.user.id];
|
||||||
|
if (req.query.user_id && Number(req.query.user_id) !== req.user.id) {
|
||||||
|
const visible = await visibleUserIds(req.user);
|
||||||
|
const wanted = Number(req.query.user_id);
|
||||||
|
if (visible !== null && !visible.includes(wanted)) return res.status(403).json({ error: 'Keine Einsicht in diese Zeiten.' });
|
||||||
|
userIds = [wanted];
|
||||||
|
}
|
||||||
|
const settings = await getSettings();
|
||||||
|
const entries = await loadEntries(settings, { from, to, userIds });
|
||||||
|
res.json(entries.map((e) => publicEntry(e, hasRole(req.user, 'lead'))));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function validate(conn, user, body, existing) {
|
||||||
|
const projectId = Number(body.project_id ?? existing?.project_id);
|
||||||
|
const date = body.entry_date ?? existing?.entry_date;
|
||||||
|
const minutes = Number(body.minutes ?? existing?.minutes);
|
||||||
|
const taskId = body.task_id !== undefined ? (body.task_id ? Number(body.task_id) : null) : existing?.task_id ?? null;
|
||||||
|
const note = body.note !== undefined ? String(body.note || '').trim().slice(0, 1000) || null : existing?.note ?? null;
|
||||||
|
|
||||||
|
if (user.role === 'admin' && !existing) throw new BookingError('Administratoren buchen keine Zeiten.');
|
||||||
|
if (!parseDate(date)) throw new BookingError('Datum fehlt.');
|
||||||
|
if (!Number.isInteger(minutes) || minutes < 1) throw new BookingError('Bitte eine Zeit größer 0:00 eingeben.');
|
||||||
|
if (minutes > 24 * 60) throw new BookingError('Mehr als 24 Stunden an einem Tag gehen nicht.');
|
||||||
|
|
||||||
|
const [projects] = await conn.query('SELECT * FROM pulse_projects WHERE id = ? LIMIT 1', [projectId]);
|
||||||
|
const project = projects[0];
|
||||||
|
if (!project) throw new BookingError('Projekt nicht gefunden.');
|
||||||
|
if (!project.is_active) throw new BookingError('Das Projekt ist archiviert und nicht mehr buchbar.');
|
||||||
|
|
||||||
|
// Gebucht wird nur auf zugewiesene Projekte - fuer jede Rolle.
|
||||||
|
const [member] = await conn.query('SELECT 1 FROM pulse_project_members WHERE project_id = ? AND user_id = ?', [
|
||||||
|
project.id,
|
||||||
|
existing?.user_id ?? user.id,
|
||||||
|
]);
|
||||||
|
if (!member.length) throw new BookingError('Dieses Projekt ist dir nicht zugewiesen.');
|
||||||
|
|
||||||
|
let task = null;
|
||||||
|
if (taskId) {
|
||||||
|
const [tasks] = await conn.query('SELECT * FROM pulse_tasks WHERE id = ? AND project_id = ?', [taskId, project.id]);
|
||||||
|
task = tasks[0];
|
||||||
|
if (!task) throw new BookingError('Der Task gehört nicht zu diesem Projekt.');
|
||||||
|
if (!task.is_active && task.id !== existing?.task_id) throw new BookingError('Der Task ist nicht mehr aktiv.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [[day]] = await conn.query(
|
||||||
|
'SELECT COALESCE(SUM(minutes), 0) AS total FROM pulse_time_entries WHERE user_id = ? AND entry_date = ? AND id <> ?',
|
||||||
|
[existing?.user_id ?? user.id, date, existing?.id || 0]
|
||||||
|
);
|
||||||
|
if (Number(day.total) + minutes > 24 * 60) throw new BookingError('An diesem Tag wären es mehr als 24 Stunden.');
|
||||||
|
|
||||||
|
const billable = isBillable(project, task);
|
||||||
|
const pot = await resolvePot(conn, project, date, minutes, existing?.id, billable);
|
||||||
|
return { project, task, pot, date, minutes, note, billable: billable ? 1 : 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function respond(req, res, id, status) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const [[row]] = await pool.query('SELECT entry_date, user_id, project_id, pot_id FROM pulse_time_entries WHERE id = ?', [id]);
|
||||||
|
const entries = await loadEntries(settings, { from: row.entry_date, to: row.entry_date, userIds: [row.user_id] });
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
// Hinweis zum Topf gleich mitliefern, damit der Dialog warnen kann.
|
||||||
|
let warning = null;
|
||||||
|
if (row.pot_id) {
|
||||||
|
const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [row.project_id] });
|
||||||
|
warning = project?.pots.find((p) => p.id === row.pot_id)?.warning ?? null;
|
||||||
|
}
|
||||||
|
res.status(status).json({ entry: publicEntry(entry, hasRole(req.user, 'lead')), warning });
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const id = await withTransaction(async (conn) => {
|
||||||
|
const v = await validate(conn, req.user, req.body || {}, null);
|
||||||
|
const [result] = await conn.query(
|
||||||
|
`INSERT INTO pulse_time_entries (user_id, project_id, task_id, pot_id, entry_date, minutes, note, billable)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[req.user.id, v.project.id, v.task?.id ?? null, v.pot?.id ?? null, v.date, v.minutes, v.note, v.billable]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
});
|
||||||
|
await respond(req, res, id, 201);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BookingError) return res.status(422).json({ error: err.message });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadOwned(req) {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM pulse_time_entries WHERE id = ? LIMIT 1', [Number(req.params.id)]);
|
||||||
|
const entry = rows[0];
|
||||||
|
if (!entry) return { error: [404, 'Buchung nicht gefunden.'] };
|
||||||
|
// Eigene Buchungen; Administratoren duerfen korrigieren.
|
||||||
|
if (entry.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
return { error: [403, 'Nur eigene Buchungen können geändert werden.'] };
|
||||||
|
}
|
||||||
|
return { entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { entry, error } = await loadOwned(req);
|
||||||
|
if (error) return res.status(error[0]).json({ error: error[1] });
|
||||||
|
await withTransaction(async (conn) => {
|
||||||
|
const v = await validate(conn, req.user, req.body || {}, entry);
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE pulse_time_entries SET project_id = ?, task_id = ?, pot_id = ?, entry_date = ?, minutes = ?, note = ?, billable = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[v.project.id, v.task?.id ?? null, v.pot?.id ?? null, v.date, v.minutes, v.note, v.billable, entry.id]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await respond(req, res, entry.id, 200);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BookingError) return res.status(422).json({ error: err.message });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { entry, error } = await loadOwned(req);
|
||||||
|
if (error) return res.status(error[0]).json({ error: error[1] });
|
||||||
|
await pool.query('DELETE FROM pulse_time_entries WHERE id = ?', [entry.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
import { hasRole } from '../middleware/auth.js';
|
||||||
|
import { visibleUserIds } from '../lib/access.js';
|
||||||
|
import { getSettings } from '../lib/settings.js';
|
||||||
|
import { loadEntries } from '../lib/entries.js';
|
||||||
|
import { MONTHS_DE, formatDateDe, monthRange, parseDate } from '../lib/dates.js';
|
||||||
|
import { dateDe, durationDe, hoursDe, moneyDe, sendDocument } from '../export/render.js';
|
||||||
|
import { buildTimeReport } from './reports.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const FORMATS = ['pdf', 'xlsx', 'csv'];
|
||||||
|
const WEEKDAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stundennachweis eines Monats - die Vorlage fuer Kunden und Rechnungsstellung.
|
||||||
|
*
|
||||||
|
* ?month=2026-09 &format=pdf|xlsx|csv
|
||||||
|
* ?user_id=<id>|all eigener Nachweis (Standard), eines Teammitglieds oder aller sichtbaren
|
||||||
|
* ?customer_id / ?project_id auf einen Kunden bzw. ein Projekt einschraenken
|
||||||
|
* ?amounts=1 Satz und Betrag mit ausgeben (ab Rolle Vorgesetzter)
|
||||||
|
*/
|
||||||
|
router.get('/timesheet', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const format = FORMATS.includes(req.query.format) ? req.query.format : 'pdf';
|
||||||
|
const range = monthRange(String(req.query.month || ''));
|
||||||
|
if (!range) return res.status(400).json({ error: 'Monat fehlt (JJJJ-MM).' });
|
||||||
|
|
||||||
|
let userIds = [req.user.id];
|
||||||
|
const wanted = req.query.user_id;
|
||||||
|
if (wanted && String(wanted) !== String(req.user.id)) {
|
||||||
|
const visible = await visibleUserIds(req.user);
|
||||||
|
if (wanted === 'all') {
|
||||||
|
if (!hasRole(req.user, 'lead')) return res.status(403).json({ error: 'Keine Einsicht in fremde Zeiten.' });
|
||||||
|
userIds = visible;
|
||||||
|
} else {
|
||||||
|
if (visible !== null && !visible.includes(Number(wanted))) {
|
||||||
|
return res.status(403).json({ error: 'Keine Einsicht in diese Zeiten.' });
|
||||||
|
}
|
||||||
|
userIds = [Number(wanted)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const showAmounts = req.query.amounts === '1' && hasRole(req.user, 'lead');
|
||||||
|
const settings = await getSettings();
|
||||||
|
const entries = await loadEntries(settings, {
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
userIds,
|
||||||
|
customerId: req.query.customer_id,
|
||||||
|
projectId: req.query.project_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [y, m] = req.query.month.split('-');
|
||||||
|
const monthName = `${MONTHS_DE[Number(m) - 1]} ${y}`;
|
||||||
|
const multiUser = userIds === null || userIds.length > 1;
|
||||||
|
|
||||||
|
let personName = 'Alle Mitarbeiter';
|
||||||
|
let company = '';
|
||||||
|
if (!multiUser) {
|
||||||
|
const [[person]] = await pool.query('SELECT name, company, is_external FROM pulse_users WHERE id = ?', [userIds[0]]);
|
||||||
|
personName = person?.name || '';
|
||||||
|
company = person?.is_external ? person.company || '' : settings.company_name;
|
||||||
|
}
|
||||||
|
let customerName = 'Alle Kunden';
|
||||||
|
let projectName = [...new Set(entries.map((e) => e.project_display))].join(', ') || '–';
|
||||||
|
if (req.query.customer_id) {
|
||||||
|
const [[c]] = await pool.query('SELECT name FROM pulse_customers WHERE id = ?', [Number(req.query.customer_id)]);
|
||||||
|
customerName = c?.name || '–';
|
||||||
|
} else {
|
||||||
|
const names = [...new Set(entries.map((e) => e.customer_name))];
|
||||||
|
if (names.length === 1) customerName = names[0];
|
||||||
|
}
|
||||||
|
if (projectName.length > 120) projectName = `${new Set(entries.map((e) => e.project_id)).size} Projekte`;
|
||||||
|
|
||||||
|
const total = entries.reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const days = new Set(entries.map((e) => e.entry_date)).size;
|
||||||
|
const amount = entries.reduce((s, e) => s + e.amount, 0);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'entry_date', label: 'Datum', type: 'date', width: 58, xlsWidth: 12 },
|
||||||
|
{ key: 'weekday', label: 'Tag', width: 26, xlsWidth: 6 },
|
||||||
|
...(multiUser ? [{ key: 'user_name', label: 'Mitarbeiter', width: 90, xlsWidth: 22 }] : []),
|
||||||
|
{ key: 'project_display', label: 'Projekt', width: 110, xlsWidth: 28 },
|
||||||
|
{ key: 'task_name', label: 'Task', width: 80, xlsWidth: 20 },
|
||||||
|
{ key: 'note', label: 'Tätigkeit', type: 'text', xlsWidth: 44 },
|
||||||
|
{ key: 'duration', label: 'Dauer', align: 'right', width: 38, xlsWidth: 9 },
|
||||||
|
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 46, xlsWidth: 10 },
|
||||||
|
...(showAmounts
|
||||||
|
? [
|
||||||
|
{ key: 'rate', label: 'Satz/h', type: 'money', align: 'right', width: 56, xlsWidth: 12 },
|
||||||
|
{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 66, xlsWidth: 14 },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
const rows = entries.map((e) => ({
|
||||||
|
...e,
|
||||||
|
weekday: WEEKDAYS[new Date(`${e.entry_date}T00:00:00Z`).getUTCDay()],
|
||||||
|
duration: durationDe(e.minutes),
|
||||||
|
rate: e.amount && e.minutes ? Math.round((e.amount / (e.minutes / 60)) * 100) / 100 : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Zusammenfassung je Projekt und Task.
|
||||||
|
const summary = new Map();
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = `${e.project_id}|${e.task_name || ''}`;
|
||||||
|
const item = summary.get(key) || { project_display: e.project_display, task_name: e.task_name || '–', minutes: 0, amount: 0 };
|
||||||
|
item.minutes += e.minutes;
|
||||||
|
item.amount += e.amount;
|
||||||
|
summary.set(key, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
title: settings.export.title || 'Stundennachweis',
|
||||||
|
subtitle: monthName,
|
||||||
|
filename: `Stundennachweis ${y}-${m} ${multiUser ? customerName : personName}`,
|
||||||
|
landscape: showAmounts || multiUser,
|
||||||
|
info: [
|
||||||
|
[multiUser ? 'Mitarbeiter' : 'Berater', personName + (company && !multiUser ? ` (${company})` : '')],
|
||||||
|
['Kunde', customerName],
|
||||||
|
['Zeitraum', `${formatDateDe(range.from)} – ${formatDateDe(range.to)}`],
|
||||||
|
['Projekt', projectName],
|
||||||
|
],
|
||||||
|
kpis: [
|
||||||
|
['Gesamtstunden', hoursDe(total)],
|
||||||
|
['Dauer (h:mm)', durationDe(total)],
|
||||||
|
['Tage mit Leistung', String(days)],
|
||||||
|
...(showAmounts ? [['Betrag', moneyDe(amount)]] : [['davon abrechenbar', hoursDe(billable)]]),
|
||||||
|
],
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
totals: { entry_date: null, weekday: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Zusammenfassung nach Projekt und Task',
|
||||||
|
columns: [
|
||||||
|
{ key: 'project_display', label: 'Projekt', xlsWidth: 28 },
|
||||||
|
{ key: 'task_name', label: 'Task', width: 150 },
|
||||||
|
{ key: 'duration', label: 'Dauer', align: 'right', width: 60 },
|
||||||
|
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 60 },
|
||||||
|
...(showAmounts ? [{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 80 }] : []),
|
||||||
|
],
|
||||||
|
rows: [...summary.values()].map((s) => ({ ...s, duration: durationDe(s.minutes) })),
|
||||||
|
totals: { project_display: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
signature: true,
|
||||||
|
};
|
||||||
|
// CSV: nur die Buchungen - die Zusammenfassung stoert beim Weiterverarbeiten.
|
||||||
|
if (format === 'csv') doc.tables = [doc.tables[0]];
|
||||||
|
await sendDocument(res, doc, format, settings);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const GROUPS = {
|
||||||
|
users: { title: 'Mitarbeiter', label: 'Mitarbeiter' },
|
||||||
|
projects: { title: 'Projekte', label: 'Projekt' },
|
||||||
|
clients: { title: 'Kunden', label: 'Kunde' },
|
||||||
|
tasks: { title: 'Tasks', label: 'Task' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Export des Zeitreports in der gewaehlten Aufteilung oder als Detailliste. */
|
||||||
|
router.get('/report', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!hasRole(req.user, 'lead')) return res.status(403).json({ error: 'Keine Berechtigung.' });
|
||||||
|
const format = FORMATS.includes(req.query.format) ? req.query.format : 'pdf';
|
||||||
|
if (!parseDate(req.query.from) || !parseDate(req.query.to)) return res.status(400).json({ error: 'Zeitraum fehlt.' });
|
||||||
|
const settings = await getSettings();
|
||||||
|
const report = await buildTimeReport(req.user, req.query);
|
||||||
|
const s = report.summary;
|
||||||
|
const groupKey = GROUPS[req.query.group] ? req.query.group : req.query.group === 'detail' ? 'detail' : 'users';
|
||||||
|
|
||||||
|
let customerName = null;
|
||||||
|
if (req.query.customer_id) {
|
||||||
|
const [[c]] = await pool.query('SELECT name FROM pulse_customers WHERE id = ?', [Number(req.query.customer_id)]);
|
||||||
|
customerName = c?.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
let table;
|
||||||
|
if (groupKey === 'detail') {
|
||||||
|
table = {
|
||||||
|
columns: [
|
||||||
|
{ key: 'entry_date', label: 'Datum', type: 'date', width: 58, xlsWidth: 12 },
|
||||||
|
{ key: 'user_name', label: 'Mitarbeiter', width: 90, xlsWidth: 22 },
|
||||||
|
{ key: 'project_display', label: 'Projekt', width: 120, xlsWidth: 28 },
|
||||||
|
{ key: 'task_name', label: 'Task', width: 80, xlsWidth: 20 },
|
||||||
|
{ key: 'note', label: 'Tätigkeit', type: 'text', xlsWidth: 40 },
|
||||||
|
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 48, xlsWidth: 10 },
|
||||||
|
{ key: 'billable_label', label: 'Abr.', width: 30, xlsWidth: 6 },
|
||||||
|
{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 66, xlsWidth: 14 },
|
||||||
|
],
|
||||||
|
rows: report.entries.map((e) => ({
|
||||||
|
...e,
|
||||||
|
billable_label: e.billable ? 'ja' : 'nein',
|
||||||
|
amount: e.amount + (report.include_fixed ? e.fixed_amount : 0),
|
||||||
|
})),
|
||||||
|
totals: { user_name: 'Summe', minutes: s.minutes, amount: s.amount },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const rows = report[groupKey].map((row) => ({
|
||||||
|
...row,
|
||||||
|
billable_percent: row.minutes ? Math.round((row.billable_minutes / row.minutes) * 100) : 0,
|
||||||
|
}));
|
||||||
|
table = {
|
||||||
|
columns: [
|
||||||
|
{ key: 'name', label: GROUPS[groupKey].label, xlsWidth: 36 },
|
||||||
|
...(groupKey === 'projects' ? [{ key: 'customer_name', label: 'Kunde', width: 110, xlsWidth: 24 }] : []),
|
||||||
|
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 60, xlsWidth: 12 },
|
||||||
|
...(groupKey === 'users' ? [{ key: 'utilization', label: 'Auslastung', type: 'percent', align: 'right', width: 60, xlsWidth: 12 }] : []),
|
||||||
|
{ key: 'billable_minutes', label: 'Abrechenbar', type: 'hours', align: 'right', width: 66, xlsWidth: 13 },
|
||||||
|
{ key: 'billable_percent', label: 'Anteil', type: 'percent', align: 'right', width: 46, xlsWidth: 9 },
|
||||||
|
{ key: 'non_billable_minutes', label: 'Nicht abr.', type: 'hours', align: 'right', width: 60, xlsWidth: 12 },
|
||||||
|
{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 80, xlsWidth: 15 },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: {
|
||||||
|
name: 'Summe',
|
||||||
|
minutes: s.minutes,
|
||||||
|
billable_minutes: s.billable_minutes,
|
||||||
|
non_billable_minutes: s.non_billable_minutes,
|
||||||
|
amount: s.amount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = groupKey === 'detail' ? 'Zeitreport – Detail' : `Zeitreport – ${GROUPS[groupKey].title}`;
|
||||||
|
await sendDocument(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
subtitle: `${dateDe(report.range.from)} – ${dateDe(report.range.to)}`,
|
||||||
|
filename: `Zeitreport ${report.range.from} bis ${report.range.to}${customerName ? ` ${customerName}` : ''}`,
|
||||||
|
landscape: true,
|
||||||
|
sheetName: 'Zeitreport',
|
||||||
|
info: [
|
||||||
|
['Kunde', customerName || 'Alle Kunden'],
|
||||||
|
['Festpreisprojekte', report.include_fixed ? 'im Betrag enthalten' : 'nicht im Betrag enthalten'],
|
||||||
|
],
|
||||||
|
kpis: [
|
||||||
|
['Gesamtstunden', hoursDe(s.minutes)],
|
||||||
|
['Abrechenbar', `${hoursDe(s.billable_minutes)} (${s.minutes ? Math.round((s.billable_minutes / s.minutes) * 100) : 0} %)`],
|
||||||
|
['Nicht abrechenbar', hoursDe(s.non_billable_minutes)],
|
||||||
|
['Abrechenbarer Betrag', moneyDe(s.amount)],
|
||||||
|
],
|
||||||
|
tables: [table],
|
||||||
|
},
|
||||||
|
format,
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { pool, withTransaction } from '../db.js';
|
||||||
|
import { hasRole, requireRole } from '../middleware/auth.js';
|
||||||
|
import { getSettings } from '../lib/settings.js';
|
||||||
|
import { parseDate } from '../lib/dates.js';
|
||||||
|
import {
|
||||||
|
deriveBudgetHours,
|
||||||
|
generateCode,
|
||||||
|
loadProjects,
|
||||||
|
reassignEntries,
|
||||||
|
stripAmounts,
|
||||||
|
} from '../lib/projects.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const TYPES = ['billable', 'non_billable', 'fixed'];
|
||||||
|
|
||||||
|
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
|
||||||
|
const num = (v) => (v === '' || v == null ? null : Number(v));
|
||||||
|
|
||||||
|
function validatePot(input, project, settings) {
|
||||||
|
const errors = [];
|
||||||
|
const pot = {
|
||||||
|
label: str(input.label, 160),
|
||||||
|
valid_from: input.valid_from,
|
||||||
|
valid_to: input.valid_to,
|
||||||
|
budget_amount: num(input.budget_amount),
|
||||||
|
rate: num(input.rate),
|
||||||
|
notes: str(input.notes, 500),
|
||||||
|
};
|
||||||
|
if (!pot.label) errors.push('Budgettopf: Bezeichnung fehlt.');
|
||||||
|
if (!parseDate(pot.valid_from) || !parseDate(pot.valid_to)) errors.push(`Budgettopf „${pot.label || ''}“: Laufzeit von/bis fehlt.`);
|
||||||
|
else if (pot.valid_from > pot.valid_to) errors.push(`Budgettopf „${pot.label}“: Laufzeit endet vor dem Beginn.`);
|
||||||
|
for (const key of ['budget_amount', 'rate']) {
|
||||||
|
if (pot[key] != null && !(Number.isFinite(pot[key]) && pot[key] >= 0)) errors.push(`Budgettopf „${pot.label}“: ungültiger Betrag.`);
|
||||||
|
}
|
||||||
|
if (project.type === 'billable') {
|
||||||
|
if (!pot.rate) errors.push(`Budgettopf „${pot.label}“: Stundensatz/Tagessatz fehlt.`);
|
||||||
|
if (pot.budget_amount == null && num(input.budget_hours) == null) {
|
||||||
|
errors.push(`Budgettopf „${pot.label}“: Budget in Euro oder Stunden angeben.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (project.type === 'fixed' && !pot.budget_amount) errors.push(`Budgettopf „${pot.label}“: Festpreis fehlt.`);
|
||||||
|
if (project.type === 'non_billable') pot.rate = null;
|
||||||
|
pot.budget_hours = deriveBudgetHours({ ...input, budget_amount: pot.budget_amount, rate: pot.rate }, project, settings);
|
||||||
|
if (pot.budget_hours != null && !(pot.budget_hours > 0)) errors.push(`Budgettopf „${pot.label}“: Volumen muss größer 0 sein.`);
|
||||||
|
return { pot, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProject(body, { creating }) {
|
||||||
|
const errors = [];
|
||||||
|
const out = {};
|
||||||
|
if (creating || body.customer_id !== undefined) {
|
||||||
|
out.customer_id = Number(body.customer_id) || null;
|
||||||
|
if (!out.customer_id) errors.push('Kunde fehlt.');
|
||||||
|
}
|
||||||
|
if (creating || body.name !== undefined) {
|
||||||
|
out.name = str(body.name, 190);
|
||||||
|
if (!out.name) errors.push('Projektname fehlt.');
|
||||||
|
}
|
||||||
|
if (creating || body.type !== undefined) {
|
||||||
|
out.type = body.type || 'billable';
|
||||||
|
if (!TYPES.includes(out.type)) errors.push('Unbekannter Projekttyp.');
|
||||||
|
}
|
||||||
|
if (creating || body.billing_unit !== undefined) out.billing_unit = body.billing_unit === 'day' ? 'day' : 'hour';
|
||||||
|
if (creating || body.start_date !== undefined) {
|
||||||
|
out.start_date = body.start_date;
|
||||||
|
if (!parseDate(out.start_date)) errors.push('Startdatum fehlt.');
|
||||||
|
}
|
||||||
|
if (body.end_date !== undefined) {
|
||||||
|
out.end_date = body.end_date || null;
|
||||||
|
if (out.end_date && !parseDate(out.end_date)) errors.push('Enddatum ist ungültig.');
|
||||||
|
if (out.end_date && out.start_date && out.end_date < out.start_date) errors.push('Enddatum liegt vor dem Startdatum.');
|
||||||
|
}
|
||||||
|
if (body.tags !== undefined) {
|
||||||
|
const tags = Array.isArray(body.tags) ? body.tags : String(body.tags || '').split(',');
|
||||||
|
out.tags = JSON.stringify([...new Set(tags.map((t) => String(t).trim()).filter(Boolean))].slice(0, 20));
|
||||||
|
}
|
||||||
|
if (body.notes !== undefined) out.notes = str(body.notes, 5000);
|
||||||
|
if (body.owner_id !== undefined) out.owner_id = Number(body.owner_id) || null;
|
||||||
|
if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0;
|
||||||
|
return { out, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findProject(id) {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM pulse_projects WHERE id = ? LIMIT 1', [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function respondProject(req, res, id, status = 200) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [id] });
|
||||||
|
res.status(status).json(hasRole(req.user, 'lead') ? project : stripAmounts(project));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMembers(conn, projectId, userIds) {
|
||||||
|
let ids = [...new Set((userIds || []).map(Number).filter(Boolean))];
|
||||||
|
// Administratoren buchen keine Zeiten und werden deshalb nie zugewiesen.
|
||||||
|
if (ids.length) {
|
||||||
|
const [admins] = await conn.query(
|
||||||
|
`SELECT id FROM pulse_users WHERE role = 'admin' AND id IN (${ids.map(() => '?').join(',')})`,
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
const blocked = new Set(admins.map((a) => a.id));
|
||||||
|
ids = ids.filter((id) => !blocked.has(id));
|
||||||
|
}
|
||||||
|
await conn.query('DELETE FROM pulse_project_members WHERE project_id = ?', [projectId]);
|
||||||
|
if (ids.length) {
|
||||||
|
await conn.query('INSERT INTO pulse_project_members (project_id, user_id) VALUES ?', [ids.map((id) => [projectId, id])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abrechenbar-Merker aller Buchungen nach Aenderung von Projekttyp oder Task. */
|
||||||
|
async function refreshBillable(conn, projectId) {
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE pulse_time_entries e
|
||||||
|
JOIN pulse_projects p ON p.id = e.project_id
|
||||||
|
LEFT JOIN pulse_tasks t ON t.id = e.task_id
|
||||||
|
SET e.billable = CASE WHEN p.type = 'non_billable' THEN 0 WHEN t.id IS NULL THEN 1 ELSE t.billable END
|
||||||
|
WHERE e.project_id = ?`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste. Mitarbeiter sehen nur zugewiesene Projekte, ohne Betraege.
|
||||||
|
// ?scope=mine liefert auch fuer Fuehrungsrollen nur die eigenen (Buchungsdialog).
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const mineOnly = req.user.role === 'user' || req.query.scope === 'mine';
|
||||||
|
const projects = await loadProjects(
|
||||||
|
settings,
|
||||||
|
mineOnly
|
||||||
|
? { where: 'p.id IN (SELECT project_id FROM pulse_project_members WHERE user_id = ?)', params: [req.user.id] }
|
||||||
|
: {}
|
||||||
|
);
|
||||||
|
res.json(hasRole(req.user, 'lead') ? projects : projects.map(stripAmounts));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (req.user.role === 'user') {
|
||||||
|
const [rows] = await pool.query('SELECT 1 FROM pulse_project_members WHERE project_id = ? AND user_id = ?', [id, req.user.id]);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
|
||||||
|
}
|
||||||
|
if (!(await findProject(id))) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
|
||||||
|
await respondProject(req, res, id);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', requireRole('lead'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const body = req.body || {};
|
||||||
|
const { out, errors } = validateProject(body, { creating: true });
|
||||||
|
const pots = [];
|
||||||
|
const potInputs = Array.isArray(body.pots) ? body.pots : [];
|
||||||
|
if (potInputs.length === 0) errors.push('Mindestens ein Budgettopf ist erforderlich.');
|
||||||
|
for (const input of potInputs) {
|
||||||
|
const result = validatePot(input, out, settings);
|
||||||
|
errors.push(...result.errors);
|
||||||
|
pots.push(result.pot);
|
||||||
|
}
|
||||||
|
const [customers] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [out.customer_id || 0]);
|
||||||
|
if (out.customer_id && !customers[0]) errors.push('Kunde nicht gefunden.');
|
||||||
|
if (errors.length) return res.status(400).json({ error: [...new Set(errors)].join(' ') });
|
||||||
|
|
||||||
|
const id = await withTransaction(async (conn) => {
|
||||||
|
const { code, seq } = await generateCode(conn, settings, customers[0], out.start_date);
|
||||||
|
const fields = { ...out, code, seq, created_by: req.user.id, owner_id: out.owner_id ?? (req.user.role === 'admin' ? null : req.user.id) };
|
||||||
|
const keys = Object.keys(fields);
|
||||||
|
const [result] = await conn.query(
|
||||||
|
`INSERT INTO pulse_projects (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
|
||||||
|
keys.map((k) => fields[k])
|
||||||
|
);
|
||||||
|
const projectId = result.insertId;
|
||||||
|
for (const pot of pots) {
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[projectId, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tasks = (Array.isArray(body.tasks) ? body.tasks : []).filter((t) => str(t?.name, 160));
|
||||||
|
for (const task of tasks) {
|
||||||
|
await conn.query('INSERT INTO pulse_tasks (project_id, name, billable) VALUES (?, ?, ?)', [
|
||||||
|
projectId,
|
||||||
|
str(task.name, 160),
|
||||||
|
out.type === 'non_billable' ? 0 : task.billable === false ? 0 : 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await setMembers(conn, projectId, body.member_ids);
|
||||||
|
return projectId;
|
||||||
|
});
|
||||||
|
await respondProject(req, res, id, 201);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', requireRole('lead'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const project = await findProject(Number(req.params.id));
|
||||||
|
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
|
||||||
|
const body = req.body || {};
|
||||||
|
const { out, errors } = validateProject(
|
||||||
|
{ ...body, start_date: body.start_date ?? project.start_date },
|
||||||
|
{ creating: false }
|
||||||
|
);
|
||||||
|
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
||||||
|
|
||||||
|
await withTransaction(async (conn) => {
|
||||||
|
const keys = Object.keys(out);
|
||||||
|
if (keys.length) {
|
||||||
|
await conn.query(`UPDATE pulse_projects SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
|
||||||
|
...keys.map((k) => out[k]),
|
||||||
|
project.id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (Array.isArray(body.member_ids)) await setMembers(conn, project.id, body.member_ids);
|
||||||
|
if (out.type && out.type !== project.type) {
|
||||||
|
if (out.type === 'non_billable') await conn.query('UPDATE pulse_budget_pots SET rate = NULL WHERE project_id = ?', [project.id]);
|
||||||
|
await refreshBillable(conn, project.id);
|
||||||
|
await reassignEntries(conn, project.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await respondProject(req, res, project.id);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', requireRole('lead'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const project = await findProject(Number(req.params.id));
|
||||||
|
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
|
||||||
|
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE project_id = ?', [project.id]);
|
||||||
|
if (Number(n) > 0) {
|
||||||
|
return res.status(409).json({ error: `Auf dem Projekt sind ${n} Buchungen. Bitte archivieren statt löschen.` });
|
||||||
|
}
|
||||||
|
await pool.query('DELETE FROM pulse_projects WHERE id = ?', [project.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/members', requireRole('lead'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const project = await findProject(Number(req.params.id));
|
||||||
|
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
|
||||||
|
await withTransaction((conn) => setMembers(conn, project.id, req.body?.user_ids));
|
||||||
|
await respondProject(req, res, project.id);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------ Budgettoepfe -- */
|
||||||
|
|
||||||
|
router.post('/:id/pots', requireRole('lead'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const project = await findProject(Number(req.params.id));
|
||||||
|
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
|
||||||
|
const { pot, errors } = validatePot(req.body || {}, project, await getSettings());
|
||||||
|
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
||||||
|
await withTransaction(async (conn) => {
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[project.id, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes]
|
||||||
|
);
|
||||||
|
await reassignEntries(conn, project.id);
|
||||||
|
});
|
||||||
|
await respondProject(req, res, project.id, 201);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/pots/:potId', requireRole('lead'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const project = await findProject(Number(req.params.id));
|
||||||
|
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
|
||||||
|
const [existing] = await pool.query('SELECT * FROM pulse_budget_pots WHERE id = ? AND project_id = ?', [
|
||||||
|
Number(req.params.potId),
|
||||||
|
project.id,
|
||||||
|
]);
|
||||||
|
if (!existing[0]) return res.status(404).json({ error: 'Budgettopf nicht gefunden.' });
|
||||||
|
const { pot, errors } = validatePot({ ...existing[0], ...req.body }, project, await getSettings());
|
||||||
|
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
||||||
|
await withTransaction(async (conn) => {
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE pulse_budget_pots SET label = ?, valid_from = ?, valid_to = ?, budget_amount = ?, rate = ?, budget_hours = ?, notes = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes, existing[0].id]
|
||||||
|
);
|
||||||
|
await reassignEntries(conn, project.id);
|
||||||
|
});
|
||||||
|
await respondProject(req, res, project.id);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id/pots/:potId', requireRole('lead'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const projectId = Number(req.params.id);
|
||||||
|
const potId = Number(req.params.potId);
|
||||||
|
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE pot_id = ?', [potId]);
|
||||||
|
if (Number(n) > 0) {
|
||||||
|
return res.status(409).json({ error: `Auf dem Topf sind ${n} Buchungen - er kann nicht gelöscht werden.` });
|
||||||
|
}
|
||||||
|
const [[{ pots }]] = await pool.query('SELECT COUNT(*) AS pots FROM pulse_budget_pots WHERE project_id = ?', [projectId]);
|
||||||
|
if (Number(pots) <= 1) return res.status(400).json({ error: 'Ein Projekt braucht mindestens einen Budgettopf.' });
|
||||||
|
await pool.query('DELETE FROM pulse_budget_pots WHERE id = ? AND project_id = ?', [potId, projectId]);
|
||||||
|
await respondProject(req, res, projectId);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Tasks -- */
|
||||||
|
|
||||||
|
router.post('/:id/tasks', requireRole('lead'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const project = await findProject(Number(req.params.id));
|
||||||
|
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
|
||||||
|
const name = str(req.body?.name, 160);
|
||||||
|
if (!name) return res.status(400).json({ error: 'Taskname fehlt.' });
|
||||||
|
await pool.query('INSERT INTO pulse_tasks (project_id, name, billable) VALUES (?, ?, ?)', [
|
||||||
|
project.id,
|
||||||
|
name,
|
||||||
|
project.type === 'non_billable' ? 0 : req.body?.billable === false ? 0 : 1,
|
||||||
|
]);
|
||||||
|
await respondProject(req, res, project.id, 201);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/tasks/:taskId', requireRole('lead'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const projectId = Number(req.params.id);
|
||||||
|
const [rows] = await pool.query('SELECT * FROM pulse_tasks WHERE id = ? AND project_id = ?', [Number(req.params.taskId), projectId]);
|
||||||
|
if (!rows[0]) return res.status(404).json({ error: 'Task nicht gefunden.' });
|
||||||
|
const name = req.body?.name !== undefined ? str(req.body.name, 160) : rows[0].name;
|
||||||
|
if (!name) return res.status(400).json({ error: 'Taskname fehlt.' });
|
||||||
|
const billable = req.body?.billable !== undefined ? (req.body.billable ? 1 : 0) : rows[0].billable;
|
||||||
|
const active = req.body?.is_active !== undefined ? (req.body.is_active ? 1 : 0) : rows[0].is_active;
|
||||||
|
await withTransaction(async (conn) => {
|
||||||
|
await conn.query('UPDATE pulse_tasks SET name = ?, billable = ?, is_active = ? WHERE id = ?', [name, billable, active, rows[0].id]);
|
||||||
|
if (billable !== rows[0].billable) {
|
||||||
|
await refreshBillable(conn, projectId);
|
||||||
|
await reassignEntries(conn, projectId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await respondProject(req, res, projectId);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id/tasks/:taskId', requireRole('lead'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const projectId = Number(req.params.id);
|
||||||
|
const taskId = Number(req.params.taskId);
|
||||||
|
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE task_id = ?', [taskId]);
|
||||||
|
if (Number(n) > 0) {
|
||||||
|
await pool.query('UPDATE pulse_tasks SET is_active = 0 WHERE id = ? AND project_id = ?', [taskId, projectId]);
|
||||||
|
} else {
|
||||||
|
await pool.query('DELETE FROM pulse_tasks WHERE id = ? AND project_id = ?', [taskId, projectId]);
|
||||||
|
}
|
||||||
|
await respondProject(req, res, projectId);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
import { teamOf, userFilter, visibleUserIds } from '../lib/access.js';
|
||||||
|
import { getSettings } from '../lib/settings.js';
|
||||||
|
import { loadEntries } from '../lib/entries.js';
|
||||||
|
import { loadProjects } from '../lib/projects.js';
|
||||||
|
import { addDays, mondayOf, parseDate, today, isoWeek } from '../lib/dates.js';
|
||||||
|
import { capacityMinutes } from './dashboard.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const r2 = (n) => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
/** Welche Benutzer fliessen ein: Sicht des Anfragenden, optional auf ein Team verengt. */
|
||||||
|
export async function reportUserIds(viewer, teamLeadId) {
|
||||||
|
const visible = await visibleUserIds(viewer);
|
||||||
|
if (!teamLeadId) return visible;
|
||||||
|
const team = [Number(teamLeadId), ...(await teamOf(Number(teamLeadId)))];
|
||||||
|
return visible === null ? team : team.filter((id) => visible.includes(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function group(entries, keyFn, labelFn, includeFixed) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = keyFn(e);
|
||||||
|
const item = map.get(key) || { key, ...labelFn(e), minutes: 0, billable_minutes: 0, amount: 0 };
|
||||||
|
item.minutes += e.minutes;
|
||||||
|
if (e.billable) item.billable_minutes += e.minutes;
|
||||||
|
item.amount += e.amount + (includeFixed ? e.fixed_amount : 0);
|
||||||
|
map.set(key, item);
|
||||||
|
}
|
||||||
|
return [...map.values()]
|
||||||
|
.map((i) => ({ ...i, amount: r2(i.amount), non_billable_minutes: i.minutes - i.billable_minutes }))
|
||||||
|
.sort((a, b) => b.minutes - a.minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeitreport wie in Harvest: Summen plus Aufteilung nach Kunde, Projekt, Task, Mitarbeiter. */
|
||||||
|
export async function buildTimeReport(viewer, query) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const from = query.from;
|
||||||
|
const to = query.to;
|
||||||
|
const includeFixed = query.include_fixed === '1' || query.include_fixed === 'true';
|
||||||
|
const userIds = await reportUserIds(viewer, query.team);
|
||||||
|
const entries = await loadEntries(settings, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
userIds,
|
||||||
|
customerId: query.customer_id,
|
||||||
|
projectId: query.project_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filter = userFilter(userIds, 'id');
|
||||||
|
const [users] = await pool.query(
|
||||||
|
`SELECT id, name, color, weekly_hours, is_external, company, is_active FROM pulse_users WHERE role <> 'admin' AND ${filter.sql} ORDER BY name`,
|
||||||
|
filter.params
|
||||||
|
);
|
||||||
|
const capTo = to > today() ? today() : to;
|
||||||
|
|
||||||
|
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const amount = entries.reduce((s, e) => s + e.amount, 0);
|
||||||
|
const fixedAmount = entries.reduce((s, e) => s + e.fixed_amount, 0);
|
||||||
|
|
||||||
|
const userRows = group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color, is_external: e.is_external }), includeFixed);
|
||||||
|
// Mitarbeiter ohne Buchung trotzdem zeigen (nur aktive, nicht bei Kunden-/Projektfilter).
|
||||||
|
if (!query.customer_id && !query.project_id) {
|
||||||
|
for (const u of users) {
|
||||||
|
if (u.is_active && !userRows.some((row) => row.user_id === u.id)) {
|
||||||
|
userRows.push({ key: u.id, user_id: u.id, name: u.name, color: u.color, is_external: Boolean(u.is_external), minutes: 0, billable_minutes: 0, non_billable_minutes: 0, amount: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const byId = new Map(users.map((u) => [u.id, u]));
|
||||||
|
for (const row of userRows) {
|
||||||
|
const u = byId.get(row.user_id);
|
||||||
|
row.capacity_minutes = u && from <= capTo ? capacityMinutes(u, settings, from, capTo) : 0;
|
||||||
|
row.utilization = row.capacity_minutes ? Math.round((row.minutes / row.capacity_minutes) * 100) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
range: { from, to },
|
||||||
|
include_fixed: includeFixed,
|
||||||
|
summary: {
|
||||||
|
minutes,
|
||||||
|
billable_minutes: billable,
|
||||||
|
non_billable_minutes: minutes - billable,
|
||||||
|
amount: r2(amount + (includeFixed ? fixedAmount : 0)),
|
||||||
|
amount_without_fixed: r2(amount),
|
||||||
|
fixed_amount: r2(fixedAmount),
|
||||||
|
entries: entries.length,
|
||||||
|
},
|
||||||
|
clients: group(entries, (e) => e.customer_id, (e) => ({ customer_id: e.customer_id, name: e.customer_name, color: e.customer_color }), includeFixed),
|
||||||
|
projects: group(
|
||||||
|
entries,
|
||||||
|
(e) => e.project_id,
|
||||||
|
(e) => ({ project_id: e.project_id, name: e.project_display, code: e.project_code, type: e.project_type, customer_name: e.customer_name, color: e.customer_color }),
|
||||||
|
includeFixed
|
||||||
|
),
|
||||||
|
tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed),
|
||||||
|
users: userRows.sort((a, b) => a.name.localeCompare(b.name, 'de')),
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/time', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!parseDate(req.query.from) || !parseDate(req.query.to)) return res.status(400).json({ error: 'Zeitraum fehlt.' });
|
||||||
|
const report = await buildTimeReport(req.user, req.query);
|
||||||
|
delete report.entries;
|
||||||
|
res.json(report);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Projektreport (Harvest-Projektseite): Verlauf, Budget, Tasks, Team, Toepfe. */
|
||||||
|
router.get('/project/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [Number(req.params.id)] });
|
||||||
|
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
|
||||||
|
const userIds = await visibleUserIds(req.user);
|
||||||
|
const entries = await loadEntries(settings, { projectId: project.id, userIds, from: req.query.from, to: req.query.to });
|
||||||
|
res.json(buildProjectDetail(project, entries));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildProjectDetail(project, entries) {
|
||||||
|
const includeFixed = project.type === 'fixed';
|
||||||
|
// Wochenreihe vom Projektstart bis max(Ende, heute) - hoechstens 104 Wochen.
|
||||||
|
const end = [project.end_date || today(), today(), ...project.pots.map((p) => p.valid_to)].sort().pop();
|
||||||
|
const lastMonday = mondayOf(end);
|
||||||
|
let start = [mondayOf(project.start_date), addDays(lastMonday, -103 * 7)].sort().pop();
|
||||||
|
const weeks = [];
|
||||||
|
while (start <= lastMonday) {
|
||||||
|
weeks.push(start);
|
||||||
|
start = addDays(start, 7);
|
||||||
|
}
|
||||||
|
let cumulative = entries.filter((e) => e.entry_date < weeks[0]).reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const weekly = weeks.map((monday) => {
|
||||||
|
const sunday = addDays(monday, 6);
|
||||||
|
const own = entries.filter((e) => e.entry_date >= monday && e.entry_date <= sunday);
|
||||||
|
const minutes = own.reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const billable = own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
|
||||||
|
cumulative += minutes;
|
||||||
|
return {
|
||||||
|
week_start: monday,
|
||||||
|
label: `KW ${isoWeek(monday).week}`,
|
||||||
|
minutes,
|
||||||
|
billable_minutes: billable,
|
||||||
|
cumulative_minutes: monday <= today() ? cumulative : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
totals: {
|
||||||
|
minutes,
|
||||||
|
billable_minutes: billable,
|
||||||
|
non_billable_minutes: minutes - billable,
|
||||||
|
amount: r2(entries.reduce((s, e) => s + e.amount + e.fixed_amount, 0)),
|
||||||
|
},
|
||||||
|
weekly,
|
||||||
|
tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed),
|
||||||
|
team: group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), includeFixed),
|
||||||
|
pots: project.pots.map((pot) => {
|
||||||
|
const own = entries.filter((e) => e.pot_id === pot.id);
|
||||||
|
return {
|
||||||
|
...pot,
|
||||||
|
tasks: group(own, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed),
|
||||||
|
team: group(own, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), includeFixed),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kundenreport: alle Projekte eines Kunden mit Budget, Verlauf und Team. */
|
||||||
|
router.get('/customer/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const [[customer]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [Number(req.params.id)]);
|
||||||
|
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden.' });
|
||||||
|
const projects = await loadProjects(settings, { where: 'p.customer_id = ?', params: [customer.id] });
|
||||||
|
const userIds = await visibleUserIds(req.user);
|
||||||
|
const entries = await loadEntries(settings, { customerId: customer.id, userIds, from: req.query.from, to: req.query.to });
|
||||||
|
|
||||||
|
// Monatsreihe ueber die ganze Kundenbeziehung, hoechstens 24 Monate.
|
||||||
|
const months = [...new Set(entries.map((e) => e.entry_date.slice(0, 7)))].sort();
|
||||||
|
const first = months[0] || today().slice(0, 7);
|
||||||
|
const series = [];
|
||||||
|
const cursor = new Date(`${first}-01T00:00:00Z`);
|
||||||
|
const last = today().slice(0, 7);
|
||||||
|
while (cursor.toISOString().slice(0, 7) <= last && series.length < 36) {
|
||||||
|
const month = cursor.toISOString().slice(0, 7);
|
||||||
|
const own = entries.filter((e) => e.entry_date.startsWith(month));
|
||||||
|
series.push({
|
||||||
|
month,
|
||||||
|
minutes: own.reduce((s, e) => s + e.minutes, 0),
|
||||||
|
billable_minutes: own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0),
|
||||||
|
amount: r2(own.reduce((s, e) => s + e.amount + e.fixed_amount, 0)),
|
||||||
|
});
|
||||||
|
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
|
||||||
|
res.json({
|
||||||
|
customer,
|
||||||
|
totals: {
|
||||||
|
minutes,
|
||||||
|
billable_minutes: billable,
|
||||||
|
non_billable_minutes: minutes - billable,
|
||||||
|
amount: r2(entries.reduce((s, e) => s + e.amount + e.fixed_amount, 0)),
|
||||||
|
earned: r2(projects.reduce((s, p) => s + p.earned, 0)),
|
||||||
|
open: r2(projects.reduce((s, p) => s + p.open, 0)),
|
||||||
|
budget_value: r2(projects.reduce((s, p) => s + p.budget_value, 0)),
|
||||||
|
},
|
||||||
|
series: series.slice(-24),
|
||||||
|
projects: projects.map((p) => {
|
||||||
|
const own = entries.filter((e) => e.project_id === p.id);
|
||||||
|
return { ...buildProjectDetail(p, own), weekly: undefined };
|
||||||
|
}),
|
||||||
|
team: group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), true),
|
||||||
|
tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), true),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { requireRole, ROLES } from '../middleware/auth.js';
|
||||||
|
import { DEFAULTS, getSettings, saveSetting } from '../lib/settings.js';
|
||||||
|
import { ssoConfigured, testConnection } from '../lib/sso.js';
|
||||||
|
import { sendDocument } from '../export/render.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Fuer alle Angemeldeten: was die Oberflaeche zum Rechnen braucht.
|
||||||
|
router.get('/public', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const s = await getSettings();
|
||||||
|
res.json({
|
||||||
|
weekly_hours: Number(s.weekly_hours),
|
||||||
|
hours_per_day: Number(s.hours_per_day),
|
||||||
|
budget_warn_percent: Number(s.budget_warn_percent),
|
||||||
|
budget_warn_days: Number(s.budget_warn_days),
|
||||||
|
company_name: s.company_name,
|
||||||
|
project_code_pattern: s.project_code_pattern,
|
||||||
|
project_name_pattern: s.project_name_pattern,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', requireRole('admin'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const s = await getSettings();
|
||||||
|
res.json({ ...s, sso: { ...s.sso, client_secret: '', client_secret_set: Boolean(s.sso.client_secret), configured: ssoConfigured(s.sso) } });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = (v, max) => String(v ?? '').slice(0, max);
|
||||||
|
|
||||||
|
router.put('/', requireRole('admin'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = req.body || {};
|
||||||
|
const current = await getSettings();
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
const numbers = {
|
||||||
|
weekly_hours: [1, 80],
|
||||||
|
hours_per_day: [1, 24],
|
||||||
|
budget_warn_percent: [1, 100],
|
||||||
|
budget_warn_days: [0, 365],
|
||||||
|
};
|
||||||
|
for (const [key, [min, max]] of Object.entries(numbers)) {
|
||||||
|
if (body[key] === undefined) continue;
|
||||||
|
const value = Number(body[key]);
|
||||||
|
if (!(value >= min && value <= max)) errors.push(`${key}: Wert zwischen ${min} und ${max}.`);
|
||||||
|
else await saveSetting(key, value);
|
||||||
|
}
|
||||||
|
if (body.company_name !== undefined) await saveSetting('company_name', text(body.company_name, 160));
|
||||||
|
if (body.project_code_pattern !== undefined) {
|
||||||
|
const pattern = text(body.project_code_pattern, 60).trim();
|
||||||
|
if (!pattern.includes('{NR}')) errors.push('Das Codemuster braucht {NR}, sonst sind Codes nicht eindeutig.');
|
||||||
|
else await saveSetting('project_code_pattern', pattern);
|
||||||
|
}
|
||||||
|
if (body.project_name_pattern !== undefined) {
|
||||||
|
const pattern = text(body.project_name_pattern, 60).trim();
|
||||||
|
if (!pattern.includes('{PROJEKT}')) errors.push('Das Namensmuster braucht {PROJEKT}.');
|
||||||
|
else await saveSetting('project_name_pattern', pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.export) {
|
||||||
|
const next_ = { ...current.export };
|
||||||
|
for (const key of Object.keys(DEFAULTS.export)) {
|
||||||
|
if (body.export[key] === undefined) continue;
|
||||||
|
if (key === 'show_signature') next_[key] = Boolean(body.export[key]);
|
||||||
|
else if (key === 'logo') {
|
||||||
|
const logo = body.export.logo;
|
||||||
|
if (logo === null || logo === '') next_.logo = null;
|
||||||
|
else if (/^data:image\/(png|jpe?g);base64,/.test(logo) && logo.length < 2_500_000) next_.logo = logo;
|
||||||
|
else errors.push('Logo: PNG oder JPG bis ca. 1,8 MB.');
|
||||||
|
} else if (key === 'accent') {
|
||||||
|
if (/^#[0-9a-f]{6}$/i.test(body.export.accent)) next_.accent = body.export.accent;
|
||||||
|
else errors.push('Akzentfarbe ist ungültig.');
|
||||||
|
} else next_[key] = text(body.export[key], 300);
|
||||||
|
}
|
||||||
|
await saveSetting('export', next_);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.sso) {
|
||||||
|
const next_ = { ...current.sso };
|
||||||
|
for (const key of ['tenant_id', 'client_id', 'redirect_uri', 'button_label']) {
|
||||||
|
if (body.sso[key] !== undefined) next_[key] = text(body.sso[key], 300).trim();
|
||||||
|
}
|
||||||
|
for (const key of ['enabled', 'local_login']) {
|
||||||
|
if (body.sso[key] !== undefined) next_[key] = Boolean(body.sso[key]);
|
||||||
|
}
|
||||||
|
// Leer = unveraendert lassen; das Secret wird nie ausgeliefert.
|
||||||
|
if (body.sso.client_secret) next_.client_secret = text(body.sso.client_secret, 500).trim();
|
||||||
|
if (body.sso.clear_secret) next_.client_secret = '';
|
||||||
|
if (next_.enabled && !ssoConfigured(next_)) errors.push('SSO kann erst aktiviert werden, wenn Tenant, Client-ID, Secret und Redirect-URI gesetzt sind.');
|
||||||
|
if (next_.enabled && !ssoConfigured(next_)) next_.enabled = false;
|
||||||
|
if (!next_.enabled) next_.local_login = true; // ohne SSO braucht es die normale Anmeldung
|
||||||
|
await saveSetting('sso', next_);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/sso/test', requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sso } = await getSettings();
|
||||||
|
if (!ssoConfigured(sso)) return res.status(400).json({ error: 'Tenant, Client-ID und Secret fehlen.' });
|
||||||
|
const result = await testConnection(sso);
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: `Verbindung fehlgeschlagen: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Beispiel-Stundennachweis mit Platzhalterdaten - zeigt Kopf und Fuss. */
|
||||||
|
router.get('/export-preview', requireRole('admin'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const format = ['pdf', 'xlsx', 'csv'].includes(req.query.format) ? req.query.format : 'pdf';
|
||||||
|
const rows = [
|
||||||
|
['2026-09-01', 'Di', 'Projektleitung', 'Jour fixe und Statusbericht', 150],
|
||||||
|
['2026-09-02', 'Mi', 'Konzeption', 'Workshop Anforderungen', 420],
|
||||||
|
['2026-09-03', 'Do', 'Konzeption', 'Dokumentation Prozessmodell', 390],
|
||||||
|
['2026-09-04', 'Fr', 'Projektleitung', '', 240],
|
||||||
|
].map(([entry_date, weekday, task_name, note, minutes]) => ({
|
||||||
|
entry_date,
|
||||||
|
weekday,
|
||||||
|
project_display: 'Musterkunde - Beispielprojekt',
|
||||||
|
task_name,
|
||||||
|
note,
|
||||||
|
minutes,
|
||||||
|
duration: `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`,
|
||||||
|
}));
|
||||||
|
const total = rows.reduce((s, r) => s + r.minutes, 0);
|
||||||
|
await sendDocument(
|
||||||
|
res,
|
||||||
|
{
|
||||||
|
title: settings.export.title || 'Stundennachweis',
|
||||||
|
subtitle: 'Vorschau',
|
||||||
|
filename: 'Vorschau Stundennachweis',
|
||||||
|
info: [
|
||||||
|
['Berater', 'Max Mustermann'],
|
||||||
|
['Kunde', 'Musterkunde GmbH'],
|
||||||
|
['Zeitraum', '01.09.2026 – 30.09.2026'],
|
||||||
|
['Projekt', 'Musterkunde - Beispielprojekt'],
|
||||||
|
],
|
||||||
|
kpis: [
|
||||||
|
['Gesamtstunden', (total / 60).toLocaleString('de-DE', { minimumFractionDigits: 2 })],
|
||||||
|
['Tage mit Leistung', '4'],
|
||||||
|
],
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
columns: [
|
||||||
|
{ key: 'entry_date', label: 'Datum', type: 'date', width: 58 },
|
||||||
|
{ key: 'weekday', label: 'Tag', width: 26 },
|
||||||
|
{ key: 'project_display', label: 'Projekt', width: 120 },
|
||||||
|
{ key: 'task_name', label: 'Task', width: 80 },
|
||||||
|
{ key: 'note', label: 'Tätigkeit', type: 'text' },
|
||||||
|
{ key: 'duration', label: 'Dauer', align: 'right', width: 38 },
|
||||||
|
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 46 },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: { weekday: 'Summe', minutes: total },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
signature: true,
|
||||||
|
},
|
||||||
|
format,
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
import { ROLES, USER_COLUMNS, publicUser, requireRole } from '../middleware/auth.js';
|
||||||
|
import { visibleUserIds } from '../lib/access.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wer darf wen verwalten?
|
||||||
|
* admin alle
|
||||||
|
* management alle ausser Administratoren, vergibt hoechstens "management"
|
||||||
|
* lead nur Mitarbeiter (Rolle user) im eigenen Team, vergibt nur "user"
|
||||||
|
*/
|
||||||
|
function assignableRoles(actor) {
|
||||||
|
if (actor.role === 'admin') return ROLES;
|
||||||
|
if (actor.role === 'management') return ['user', 'lead', 'management'];
|
||||||
|
return ['user'];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canManage(actor, target) {
|
||||||
|
if (actor.role === 'admin') return true;
|
||||||
|
if (actor.role === 'management') return target.role !== 'admin';
|
||||||
|
if (actor.role === 'lead') {
|
||||||
|
if (target.role !== 'user') return false;
|
||||||
|
const team = await visibleUserIds(actor);
|
||||||
|
return team.includes(target.id);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUser(id) {
|
||||||
|
const [rows] = await pool.query(`SELECT ${USER_COLUMNS} FROM pulse_users WHERE id = ? LIMIT 1`, [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verhindert, dass jemand (indirekt) sein eigener Vorgesetzter wird. */
|
||||||
|
async function createsCycle(userId, managerId) {
|
||||||
|
if (!managerId || !userId) return false;
|
||||||
|
const [rows] = await pool.query('SELECT id, manager_id FROM pulse_users');
|
||||||
|
const parent = new Map(rows.map((r) => [r.id, r.manager_id]));
|
||||||
|
let current = managerId;
|
||||||
|
for (let guard = 0; current && guard < 1000; guard++) {
|
||||||
|
if (current === userId) return true;
|
||||||
|
current = parent.get(current);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activeAdmins(excludeId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
"SELECT COUNT(*) AS n FROM pulse_users WHERE role = 'admin' AND is_active = 1 AND id <> ?",
|
||||||
|
[excludeId]
|
||||||
|
);
|
||||||
|
return Number(rows[0].n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(body, { creating }) {
|
||||||
|
const errors = [];
|
||||||
|
const out = {};
|
||||||
|
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
|
||||||
|
|
||||||
|
if (creating || body.name !== undefined) {
|
||||||
|
out.name = str(body.name, 160);
|
||||||
|
if (!out.name) errors.push('Name fehlt.');
|
||||||
|
}
|
||||||
|
if (creating || body.username !== undefined) {
|
||||||
|
out.username = str(body.username, 80);
|
||||||
|
if (!out.username || !/^[a-zA-Z0-9._@-]{2,80}$/.test(out.username)) errors.push('Benutzername: 2-80 Zeichen, keine Leerzeichen.');
|
||||||
|
}
|
||||||
|
if (body.email !== undefined) {
|
||||||
|
out.email = str(body.email, 190)?.toLowerCase() ?? null;
|
||||||
|
if (out.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(out.email)) errors.push('E-Mail-Adresse ist ungültig.');
|
||||||
|
}
|
||||||
|
if (body.role !== undefined || creating) {
|
||||||
|
out.role = body.role || 'user';
|
||||||
|
if (!ROLES.includes(out.role)) errors.push('Unbekannte Rolle.');
|
||||||
|
}
|
||||||
|
if (body.manager_id !== undefined) out.manager_id = body.manager_id ? Number(body.manager_id) : null;
|
||||||
|
if (body.job_title !== undefined) out.job_title = str(body.job_title, 120);
|
||||||
|
if (body.is_external !== undefined) out.is_external = body.is_external ? 1 : 0;
|
||||||
|
if (body.company !== undefined) out.company = str(body.company, 160);
|
||||||
|
if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null;
|
||||||
|
if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0;
|
||||||
|
if (body.weekly_hours !== undefined) {
|
||||||
|
out.weekly_hours = body.weekly_hours === '' || body.weekly_hours == null ? null : Number(body.weekly_hours);
|
||||||
|
if (out.weekly_hours != null && !(out.weekly_hours >= 0 && out.weekly_hours <= 80)) errors.push('Wochenstunden: 0-80.');
|
||||||
|
}
|
||||||
|
return { out, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verzeichnis fuer alle: Namen, Farben und Hierarchie (ohne Kontaktdaten).
|
||||||
|
router.get('/directory', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT id, name, color, role, manager_id, is_external, company, job_title, is_active FROM pulse_users ORDER BY name'
|
||||||
|
);
|
||||||
|
res.json(rows.map((r) => ({ ...r, is_external: Boolean(r.is_external), is_active: Boolean(r.is_active) })));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', requireRole('lead'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT ${USER_COLUMNS.split(',').map((c) => `u.${c.trim()}`).join(', ')},
|
||||||
|
(SELECT COUNT(*) FROM pulse_time_entries e WHERE e.user_id = u.id) AS entry_count,
|
||||||
|
(SELECT COUNT(*) FROM pulse_project_members m WHERE m.user_id = u.id) AS project_count
|
||||||
|
FROM pulse_users u ORDER BY u.name`
|
||||||
|
);
|
||||||
|
const team = await visibleUserIds(req.user);
|
||||||
|
const result = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
result.push({
|
||||||
|
...publicUser(row),
|
||||||
|
entry_count: Number(row.entry_count),
|
||||||
|
project_count: Number(row.project_count),
|
||||||
|
can_manage: await canManage(req.user, row),
|
||||||
|
in_team: team === null || team.includes(row.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ users: result, assignable_roles: assignableRoles(req.user) });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', requireRole('lead'), async (req, res, next) => {
|
||||||
|
const { out, errors } = normalize(req.body || {}, { creating: true });
|
||||||
|
const password = String(req.body?.password || '');
|
||||||
|
if (password.length < 8) errors.push('Passwort: mindestens 8 Zeichen.');
|
||||||
|
if (!assignableRoles(req.user).includes(out.role)) errors.push('Diese Rolle darfst du nicht vergeben.');
|
||||||
|
if (req.user.role === 'lead') {
|
||||||
|
const team = await visibleUserIds(req.user);
|
||||||
|
if (!out.manager_id || !team.includes(out.manager_id)) out.manager_id = req.user.id;
|
||||||
|
}
|
||||||
|
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hash = await bcrypt.hash(password, 12);
|
||||||
|
const fields = { ...out, password_hash: hash, source: 'local' };
|
||||||
|
const keys = Object.keys(fields);
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`INSERT INTO pulse_users (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
|
||||||
|
keys.map((k) => fields[k])
|
||||||
|
);
|
||||||
|
res.status(201).json(publicUser(await loadUser(result.insertId)));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Benutzername oder E-Mail ist schon vergeben.' });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', requireRole('lead'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const target = await loadUser(Number(req.params.id));
|
||||||
|
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden.' });
|
||||||
|
if (!(await canManage(req.user, target))) return res.status(403).json({ error: 'Diesen Benutzer darfst du nicht bearbeiten.' });
|
||||||
|
|
||||||
|
const { out, errors } = normalize(req.body || {}, { creating: false });
|
||||||
|
if (out.role && out.role !== target.role && !assignableRoles(req.user).includes(out.role)) {
|
||||||
|
errors.push('Diese Rolle darfst du nicht vergeben.');
|
||||||
|
}
|
||||||
|
// SSO-Benutzer: Stammdaten kommen aus dem AD.
|
||||||
|
if (target.source === 'sso') {
|
||||||
|
delete out.username;
|
||||||
|
delete out.email;
|
||||||
|
}
|
||||||
|
if (out.manager_id !== undefined && (await createsCycle(target.id, out.manager_id))) {
|
||||||
|
errors.push('Die Hierarchie darf keinen Kreis bilden.');
|
||||||
|
}
|
||||||
|
if (req.user.role === 'lead' && out.manager_id !== undefined) {
|
||||||
|
const team = await visibleUserIds(req.user);
|
||||||
|
if (!out.manager_id || !team.includes(out.manager_id)) errors.push('Vorgesetzter muss aus deinem Team kommen.');
|
||||||
|
}
|
||||||
|
if (target.id === req.user.id && (out.is_active === 0 || (out.role && out.role !== target.role))) {
|
||||||
|
errors.push('Die eigene Rolle und Aktivierung können nicht geändert werden.');
|
||||||
|
}
|
||||||
|
if (target.role === 'admin' && ((out.role && out.role !== 'admin') || out.is_active === 0) && (await activeAdmins(target.id)) === 0) {
|
||||||
|
errors.push('Der letzte aktive Administrator kann nicht herabgestuft oder deaktiviert werden.');
|
||||||
|
}
|
||||||
|
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
||||||
|
|
||||||
|
if (req.body?.password) {
|
||||||
|
if (String(req.body.password).length < 8) return res.status(400).json({ error: 'Passwort: mindestens 8 Zeichen.' });
|
||||||
|
out.password_hash = await bcrypt.hash(String(req.body.password), 12);
|
||||||
|
}
|
||||||
|
const keys = Object.keys(out);
|
||||||
|
if (keys.length) {
|
||||||
|
await pool.query(`UPDATE pulse_users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
|
||||||
|
...keys.map((k) => out[k]),
|
||||||
|
target.id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
res.json(publicUser(await loadUser(target.id)));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Benutzername oder E-Mail ist schon vergeben.' });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', requireRole('lead'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const target = await loadUser(Number(req.params.id));
|
||||||
|
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden.' });
|
||||||
|
if (target.id === req.user.id) return res.status(400).json({ error: 'Das eigene Konto kann nicht gelöscht werden.' });
|
||||||
|
if (!(await canManage(req.user, target))) return res.status(403).json({ error: 'Diesen Benutzer darfst du nicht löschen.' });
|
||||||
|
if (target.source !== 'local') {
|
||||||
|
return res.status(400).json({ error: 'SSO-Benutzer werden über das AD verwaltet - hier nur deaktivieren.' });
|
||||||
|
}
|
||||||
|
if (target.role === 'admin' && (await activeAdmins(target.id)) === 0) {
|
||||||
|
return res.status(400).json({ error: 'Der letzte Administrator kann nicht gelöscht werden.' });
|
||||||
|
}
|
||||||
|
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE user_id = ?', [target.id]);
|
||||||
|
if (Number(n) > 0) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: `Der Benutzer hat ${n} Buchungen. Damit Reports stimmen, bitte deaktivieren statt löschen.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await pool.query('UPDATE pulse_users SET manager_id = ? WHERE manager_id = ?', [target.manager_id, target.id]);
|
||||||
|
await pool.query('DELETE FROM pulse_users WHERE id = ?', [target.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
-- perinco Pulse - Datenbankschema (MariaDB)
|
||||||
|
-- Wird bei jedem Start ausgefuehrt; alle Statements sind idempotent.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pulse_users (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
username VARCHAR(80) NOT NULL,
|
||||||
|
email VARCHAR(190) NULL,
|
||||||
|
name VARCHAR(160) NOT NULL,
|
||||||
|
password_hash VARCHAR(100) NULL,
|
||||||
|
role ENUM('user','lead','management','admin') NOT NULL DEFAULT 'user',
|
||||||
|
manager_id INT UNSIGNED NULL,
|
||||||
|
job_title VARCHAR(120) NULL,
|
||||||
|
is_external TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
company VARCHAR(160) NULL,
|
||||||
|
source ENUM('local','sso') NOT NULL DEFAULT 'local',
|
||||||
|
external_id VARCHAR(64) NULL,
|
||||||
|
weekly_hours DECIMAL(5,2) NULL,
|
||||||
|
color VARCHAR(7) NULL,
|
||||||
|
theme VARCHAR(20) NULL,
|
||||||
|
theme_mode VARCHAR(10) NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
last_login_at DATETIME NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_pulse_users_username (username),
|
||||||
|
UNIQUE KEY uq_pulse_users_email (email),
|
||||||
|
UNIQUE KEY uq_pulse_users_external (external_id),
|
||||||
|
KEY idx_pulse_users_manager (manager_id),
|
||||||
|
CONSTRAINT fk_pulse_users_manager FOREIGN KEY (manager_id) REFERENCES pulse_users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pulse_customers (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
name VARCHAR(190) NOT NULL,
|
||||||
|
short_name VARCHAR(16) NOT NULL,
|
||||||
|
contact_person VARCHAR(160) NULL,
|
||||||
|
email VARCHAR(190) NULL,
|
||||||
|
phone VARCHAR(60) NULL,
|
||||||
|
street VARCHAR(190) NULL,
|
||||||
|
zip VARCHAR(20) NULL,
|
||||||
|
city VARCHAR(120) NULL,
|
||||||
|
country VARCHAR(80) NULL,
|
||||||
|
notes TEXT NULL,
|
||||||
|
color VARCHAR(7) NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_pulse_customers_short (short_name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pulse_projects (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
customer_id INT UNSIGNED NOT NULL,
|
||||||
|
code VARCHAR(40) NOT NULL,
|
||||||
|
seq INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
name VARCHAR(190) NOT NULL,
|
||||||
|
type ENUM('billable','non_billable','fixed') NOT NULL DEFAULT 'billable',
|
||||||
|
billing_unit ENUM('hour','day') NOT NULL DEFAULT 'hour',
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NULL,
|
||||||
|
tags TEXT NULL,
|
||||||
|
notes TEXT NULL,
|
||||||
|
owner_id INT UNSIGNED NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_by INT UNSIGNED NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_pulse_projects_code (code),
|
||||||
|
KEY idx_pulse_projects_customer (customer_id),
|
||||||
|
CONSTRAINT fk_pulse_projects_customer FOREIGN KEY (customer_id) REFERENCES pulse_customers (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_pulse_projects_owner FOREIGN KEY (owner_id) REFERENCES pulse_users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Budgettopf. rate ist immer in der Abrechnungseinheit des Projekts (Stunde
|
||||||
|
-- oder Tag). budget_hours ist das buchbare Volumen: bei Betrag + Satz
|
||||||
|
-- abgeleitet, sonst direkt eingegeben. NULL = unbegrenzt.
|
||||||
|
CREATE TABLE IF NOT EXISTS pulse_budget_pots (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
project_id INT UNSIGNED NOT NULL,
|
||||||
|
label VARCHAR(160) NOT NULL,
|
||||||
|
valid_from DATE NOT NULL,
|
||||||
|
valid_to DATE NOT NULL,
|
||||||
|
budget_amount DECIMAL(12,2) NULL,
|
||||||
|
rate DECIMAL(10,2) NULL,
|
||||||
|
budget_hours DECIMAL(10,2) NULL,
|
||||||
|
notes VARCHAR(500) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_pulse_pots_project (project_id, valid_from, valid_to),
|
||||||
|
CONSTRAINT fk_pulse_pots_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pulse_tasks (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
project_id INT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(160) NOT NULL,
|
||||||
|
billable TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_pulse_tasks_project (project_id),
|
||||||
|
CONSTRAINT fk_pulse_tasks_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pulse_project_members (
|
||||||
|
project_id INT UNSIGNED NOT NULL,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (project_id, user_id),
|
||||||
|
KEY idx_pulse_members_user (user_id),
|
||||||
|
CONSTRAINT fk_pulse_members_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_pulse_members_user FOREIGN KEY (user_id) REFERENCES pulse_users (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pulse_time_entries (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
project_id INT UNSIGNED NOT NULL,
|
||||||
|
task_id INT UNSIGNED NULL,
|
||||||
|
pot_id INT UNSIGNED NULL,
|
||||||
|
entry_date DATE NOT NULL,
|
||||||
|
minutes INT UNSIGNED NOT NULL,
|
||||||
|
note VARCHAR(1000) NULL,
|
||||||
|
billable TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_pulse_entries_user_date (user_id, entry_date),
|
||||||
|
KEY idx_pulse_entries_project_date (project_id, entry_date),
|
||||||
|
KEY idx_pulse_entries_pot (pot_id),
|
||||||
|
KEY idx_pulse_entries_date (entry_date),
|
||||||
|
CONSTRAINT fk_pulse_entries_user FOREIGN KEY (user_id) REFERENCES pulse_users (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_pulse_entries_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_pulse_entries_task FOREIGN KEY (task_id) REFERENCES pulse_tasks (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_pulse_entries_pot FOREIGN KEY (pot_id) REFERENCES pulse_budget_pots (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pulse_settings (
|
||||||
|
k VARCHAR(80) NOT NULL,
|
||||||
|
v MEDIUMTEXT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (k)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { initDatabase, pool } from './db.js';
|
||||||
|
import { requireAuth, requireRole } from './middleware/auth.js';
|
||||||
|
import authRoutes from './routes/auth.js';
|
||||||
|
import userRoutes from './routes/users.js';
|
||||||
|
import customerRoutes from './routes/customers.js';
|
||||||
|
import projectRoutes from './routes/projects.js';
|
||||||
|
import entryRoutes from './routes/entries.js';
|
||||||
|
import dashboardRoutes from './routes/dashboard.js';
|
||||||
|
import reportRoutes from './routes/reports.js';
|
||||||
|
import exportRoutes from './routes/exports.js';
|
||||||
|
import settingsRoutes from './routes/settings.js';
|
||||||
|
import directoryRoutes from './routes/directory.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = Number(process.env.PORT || 5010);
|
||||||
|
const HOST = process.env.HOST || '127.0.0.1';
|
||||||
|
|
||||||
|
// Apache steht davor - fuer req.ip (Login-Bremse) die echte Client-IP nehmen.
|
||||||
|
app.set('trust proxy', 'loopback');
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
// Logos kommen als Data-URL.
|
||||||
|
app.use(express.json({ limit: '4mb' }));
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: (process.env.CORS_ORIGINS || 'http://localhost:3110,http://127.0.0.1:3110').split(','),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get('/api/health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
res.json({ ok: true, db: 'up' });
|
||||||
|
} catch {
|
||||||
|
res.status(503).json({ ok: false, db: 'down' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/users', requireAuth, userRoutes);
|
||||||
|
app.use('/api/customers', requireAuth, requireRole('lead'), customerRoutes);
|
||||||
|
app.use('/api/projects', requireAuth, projectRoutes);
|
||||||
|
app.use('/api/entries', requireAuth, entryRoutes);
|
||||||
|
app.use('/api/dashboard', requireAuth, requireRole('lead'), dashboardRoutes);
|
||||||
|
app.use('/api/reports', requireAuth, requireRole('lead'), reportRoutes);
|
||||||
|
app.use('/api/exports', requireAuth, exportRoutes);
|
||||||
|
app.use('/api/settings', requireAuth, settingsRoutes);
|
||||||
|
app.use('/api/directory', requireAuth, requireRole('management'), directoryRoutes);
|
||||||
|
|
||||||
|
app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' }));
|
||||||
|
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (res.headersSent) return next(err);
|
||||||
|
if (err?.type === 'entity.parse.failed') return res.status(400).json({ error: 'Ungültige Anfrage.' });
|
||||||
|
if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'Die Anfrage ist zu groß.' });
|
||||||
|
if (err?.code === 'ER_ROW_IS_REFERENCED_2') {
|
||||||
|
return res.status(409).json({ error: 'Der Datensatz wird noch verwendet und kann nicht gelöscht werden.' });
|
||||||
|
}
|
||||||
|
console.error('[Pulse] Unbehandelter Fehler:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initDatabase();
|
||||||
|
app.listen(PORT, HOST, () => console.log(`[Pulse] Backend läuft auf http://${HOST}:${PORT}`));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Pulse] Start fehlgeschlagen:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { isEligible } from '../src/lib/sso.js';
|
||||||
|
|
||||||
|
const user = (mail, surname, extra = {}) => ({
|
||||||
|
mail,
|
||||||
|
surname,
|
||||||
|
userType: 'Member',
|
||||||
|
accountEnabled: true,
|
||||||
|
assignedLicenses: [{ skuId: 'x' }],
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mitarbeiterkonten beider Domains werden angezeigt', () => {
|
||||||
|
assert.equal(isEligible(user('sascha.wolf@perinco.de', 'Wolf')), true);
|
||||||
|
assert.equal(isEligible(user('Sascha.Wolf@PERINCO.de', 'Wolf')), true);
|
||||||
|
assert.equal(isEligible(user('kai.schwickert@icon-experts.de', 'Schwickert')), true);
|
||||||
|
assert.equal(isEligible(user('timo.grote-aksu@perinco.de', 'Grote-Aksu')), true);
|
||||||
|
assert.equal(isEligible(user('niklas.muelln@perinco.de', 'Mülln')), true);
|
||||||
|
assert.equal(isEligible(user('rene.kleinau@perinco.de', 'Kleinau', { givenName: 'René' })), true);
|
||||||
|
// Tippfehler im Vornamen (AD: "Chrisitan") schadet nicht.
|
||||||
|
assert.equal(isEligible(user('christian.brix@perinco.de', 'Brix', { givenName: 'Chrisitan' })), true);
|
||||||
|
assert.equal(isEligible(user('merle.schwegler@perinco.de', 'Schwegler', { givenName: 'Merle Sophie' })), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Admin-, Dienst- und Funktionskonten werden ignoriert', () => {
|
||||||
|
assert.equal(isEligible(user('adm.sascha.wolf@perinco.de', 'Wolf')), false);
|
||||||
|
assert.equal(isEligible(user('sa.hrworks@perinco.de', 'Account', { givenName: 'Service' })), false);
|
||||||
|
assert.equal(isEligible(user('srv.pa@perinco.de', 'Flow', { givenName: 'SRV' })), false);
|
||||||
|
assert.equal(isEligible(user('srv.flow@perinco.de', 'Flow')), false);
|
||||||
|
assert.equal(isEligible(user('info@perinco.de', 'Info')), false);
|
||||||
|
assert.equal(isEligible(user('max.muster@perinco.de', undefined)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fremde Domains und falsche Formate werden ignoriert', () => {
|
||||||
|
for (const mail of ['sascha.wolf@perinco.com', 'sascha.wolf@sub.perinco.de', 'sascha.wolf2@perinco.de', 'sascha..wolf@perinco.de', '']) {
|
||||||
|
assert.equal(isEligible(user(mail, 'Wolf')), false, mail);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Freigegebene Postfaecher, Gaeste und Konten ohne Lizenz werden ignoriert', () => {
|
||||||
|
assert.equal(isEligible(user('philip.daum@perinco.de', 'Daum', { accountEnabled: false })), false);
|
||||||
|
assert.equal(isEligible(user('lisa.gast@perinco.de', 'Gast', { userType: 'Guest' })), false);
|
||||||
|
assert.equal(isEligible(user('sven.krueger@perinco.de', 'Krüger', { assignedLicenses: [] })), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ohne mail zaehlt der UPN', () => {
|
||||||
|
assert.equal(isEligible({ userPrincipalName: 'jonas.korn@perinco.de', surname: 'Korn', accountEnabled: true, assignedLicenses: [{}] }), true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Sidebar } from './components/layout/Sidebar';
|
||||||
|
import { Header } from './components/layout/Header';
|
||||||
|
import { MobileNav } from './components/layout/MobileNav';
|
||||||
|
import { LoginScreen } from './components/layout/LoginScreen';
|
||||||
|
import { PulseMark } from './components/layout/PulseMark';
|
||||||
|
import { AccountDialog } from './components/settings/AccountDialog';
|
||||||
|
import { Dashboard } from './components/dashboard/Dashboard';
|
||||||
|
import { Booking } from './components/booking/Booking';
|
||||||
|
import { Projects } from './components/projects/Projects';
|
||||||
|
import { Team } from './components/team/Team';
|
||||||
|
import { Customers } from './components/customers/Customers';
|
||||||
|
import { Reports } from './components/reports/Reports';
|
||||||
|
import { Settings } from './components/settings/Settings';
|
||||||
|
import { useAuth } from './lib/auth';
|
||||||
|
import { useIsMobile, usePersistentState } from './lib/hooks';
|
||||||
|
import { RouterContext, useHashRoute } from './lib/router';
|
||||||
|
import { homeFor, VIEW_LABELS, viewsFor, type ViewKey } from './types';
|
||||||
|
|
||||||
|
const SUBTITLES: Record<ViewKey, string> = {
|
||||||
|
dashboard: 'Gebuchte Zeiten, Auslastung und letzte Buchungen',
|
||||||
|
buchung: 'Zeiten erfassen, bearbeiten und exportieren',
|
||||||
|
projekte: 'Projekte, Budgettöpfe und Tasks',
|
||||||
|
team: 'Hierarchie, Benutzer und externe Mitarbeiter',
|
||||||
|
kunden: 'Kundenstamm, Projekte und Umsatz',
|
||||||
|
reports: 'Auswertungen nach Kunde, Projekt, Task und Mitarbeiter',
|
||||||
|
einstellungen: 'Arbeitszeit, Export, SSO und Benutzerverwaltung',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Splash: React.FC = () => (
|
||||||
|
<div className="flex h-[100dvh] w-screen flex-col items-center justify-center gap-6 bg-surface">
|
||||||
|
<img src="/logo.png" alt="perinco" className="logo-adaptive h-10 w-auto" />
|
||||||
|
<PulseMark size={44} />
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.2em] text-ink-400">Lade Pulse…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
const { route, navigate } = useHashRoute();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [collapsed, setCollapsed] = usePersistentState('pulse_sidebar_collapsed', false);
|
||||||
|
const [accountOpen, setAccountOpen] = useState(false);
|
||||||
|
|
||||||
|
const allowed = user ? viewsFor(user.role) : [];
|
||||||
|
const view: ViewKey | null = user ? (route.view && allowed.includes(route.view) ? route.view : homeFor(user.role)) : null;
|
||||||
|
|
||||||
|
// Unbekannte oder nicht erlaubte Adresse: still auf die Startseite.
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && view && route.view !== view) navigate(view, true);
|
||||||
|
}, [user, view, route.view, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view) document.title = `${VIEW_LABELS[view]} · Pulse`;
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
if (isLoading) return <Splash />;
|
||||||
|
if (!user) return <LoginScreen />;
|
||||||
|
if (!view) return <Splash />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouterContext.Provider value={{ route, navigate }}>
|
||||||
|
<div className="flex h-[100dvh] overflow-hidden bg-canvas text-ink-900">
|
||||||
|
<Sidebar current={view} onNavigate={(v) => navigate(v)} collapsed={collapsed} onAccount={() => setAccountOpen(true)} />
|
||||||
|
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<Header title={VIEW_LABELS[view]} subtitle={isMobile ? undefined : SUBTITLES[view]} onToggleSidebar={() => setCollapsed((c) => !c)} isMobile={isMobile} />
|
||||||
|
<div id="scroll-root" className="scrollbar-slim flex-1 overflow-y-auto overflow-x-hidden p-3 pb-28 sm:p-4 md:p-6 md:pb-6">
|
||||||
|
{view === 'dashboard' && <Dashboard />}
|
||||||
|
{view === 'buchung' && <Booking />}
|
||||||
|
{view === 'projekte' && <Projects />}
|
||||||
|
{view === 'team' && <Team />}
|
||||||
|
{view === 'kunden' && <Customers />}
|
||||||
|
{view === 'reports' && <Reports />}
|
||||||
|
{view === 'einstellungen' && <Settings />}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<MobileNav current={view} onNavigate={(v) => navigate(v)} onAccount={() => setAccountOpen(true)} />
|
||||||
|
{accountOpen && <AccountDialog onClose={() => setAccountOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
</RouterContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
type ToastKind = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
kind: ToastKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
addToast: (message: string, kind?: ToastKind) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
const STYLES: Record<ToastKind, string> = {
|
||||||
|
success: 'border-brand-200 bg-brand-50 text-brand-800',
|
||||||
|
error: 'border-red-200 bg-red-50 text-red-700',
|
||||||
|
info: 'border-ink-200 bg-surface text-ink-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICONS: Record<ToastKind, string> = {
|
||||||
|
success: 'M5 13l4 4L19 7',
|
||||||
|
error: 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z',
|
||||||
|
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const addToast = useCallback((message: string, kind: ToastKind = 'info') => {
|
||||||
|
const id = nextId++;
|
||||||
|
setToasts((prev) => [...prev, { id, message, kind }]);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, 4500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ addToast }), [addToast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<div className="pointer-events-none fixed bottom-24 left-4 right-4 z-[200] flex max-w-sm sm:bottom-6 sm:left-auto sm:right-6 sm:w-full flex-col gap-2">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`pointer-events-auto flex items-start gap-3 rounded-xl border px-4 py-3 shadow-lift ${STYLES[toast.kind]}`}
|
||||||
|
role={toast.kind === 'error' ? 'alert' : 'status'}
|
||||||
|
>
|
||||||
|
<svg className="mt-0.5 h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={ICONS[toast.kind]} />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm font-semibold leading-snug">{toast.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setToasts((prev) => prev.filter((t) => t.id !== toast.id))}
|
||||||
|
className="ml-auto shrink-0 opacity-50 transition hover:opacity-100"
|
||||||
|
aria-label="Meldung schließen"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useToast(): ToastContextValue {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) throw new Error('useToast muss innerhalb von <ToastProvider> verwendet werden.');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Avatar, Button, Card, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { Donut } from '../ui/charts';
|
||||||
|
import { EntryModal } from './EntryModal';
|
||||||
|
import { ExportDialog } from './ExportDialog';
|
||||||
|
import { WeekView } from './WeekView';
|
||||||
|
import { MonthView } from './MonthView';
|
||||||
|
import { YearView } from './YearView';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { entriesApi, projectsApi, usersApi } from '../../lib/api';
|
||||||
|
import { useIsMobile, useLoader, usePersistentState, useCountUp } from '../../lib/hooks';
|
||||||
|
import {
|
||||||
|
MONTHS,
|
||||||
|
addDays,
|
||||||
|
addMonths,
|
||||||
|
formatDate,
|
||||||
|
formatDuration,
|
||||||
|
isoWeek,
|
||||||
|
mondayOf,
|
||||||
|
monthEnd,
|
||||||
|
monthStart,
|
||||||
|
todayIso,
|
||||||
|
weekdayIndex,
|
||||||
|
} from '../../lib/format';
|
||||||
|
import { visibleTeam } from '../team/hierarchy';
|
||||||
|
import { atLeast, type DirectoryUser, type Project, type TimeEntry } from '../../types';
|
||||||
|
|
||||||
|
export type BookingMode = 'week' | 'month' | 'year';
|
||||||
|
|
||||||
|
function rangeFor(mode: BookingMode, anchor: string) {
|
||||||
|
if (mode === 'week') {
|
||||||
|
const from = mondayOf(anchor);
|
||||||
|
return { from, to: addDays(from, 6) };
|
||||||
|
}
|
||||||
|
if (mode === 'month') return { from: monthStart(anchor), to: monthEnd(anchor) };
|
||||||
|
return { from: `${anchor.slice(0, 4)}-01-01`, to: `${anchor.slice(0, 4)}-12-31` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arbeitstage Mo-Fr im Intervall, fuer das Soll. */
|
||||||
|
function workdays(from: string, to: string) {
|
||||||
|
let count = 0;
|
||||||
|
for (let d = from; d <= to; d = addDays(d, 1)) if (weekdayIndex(d) < 5) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Booking: React.FC = () => {
|
||||||
|
const { user, settings } = useAuth();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [mode, setMode] = usePersistentState<BookingMode>('pulse_booking_mode', 'week');
|
||||||
|
const [anchor, setAnchor] = useState(todayIso());
|
||||||
|
const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null);
|
||||||
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
|
const [viewUserId, setViewUserId] = useState<number>(user!.id);
|
||||||
|
const isLead = atLeast(user, 'lead');
|
||||||
|
const readOnly = viewUserId !== user!.id;
|
||||||
|
|
||||||
|
const range = rangeFor(mode, anchor);
|
||||||
|
const entries = useLoader(() => entriesApi.list(range.from, range.to, viewUserId), [range.from, range.to, viewUserId]);
|
||||||
|
const projects = useLoader(() => projectsApi.list('mine'), []);
|
||||||
|
const [team, setTeam] = useState<DirectoryUser[]>([]);
|
||||||
|
const [allProjects, setAllProjects] = useState<Project[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLead) return;
|
||||||
|
usersApi
|
||||||
|
.directory()
|
||||||
|
.then((list) => setTeam(visibleTeam(list, user!)))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [isLead, user]);
|
||||||
|
|
||||||
|
const list = entries.data ?? [];
|
||||||
|
const total = list.reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const billable = list.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const viewedUser = team.find((t) => t.id === viewUserId);
|
||||||
|
const weekly = (readOnly ? null : user!.weekly_hours) ?? settings.weekly_hours;
|
||||||
|
const today = todayIso();
|
||||||
|
// Soll bis heute (bzw. ganzer Zeitraum, wenn er vorbei ist).
|
||||||
|
const targetTo = range.to < today ? range.to : today < range.from ? range.from : today;
|
||||||
|
const target = today < range.from ? 0 : (weekly / 5) * workdays(range.from, targetTo) * 60;
|
||||||
|
|
||||||
|
const shift = (step: number) => {
|
||||||
|
if (mode === 'week') setAnchor(addDays(anchor, 7 * step));
|
||||||
|
else if (mode === 'month') setAnchor(addMonths(anchor, step));
|
||||||
|
else setAnchor(`${Number(anchor.slice(0, 4)) + step}-${anchor.slice(5)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const label =
|
||||||
|
mode === 'week'
|
||||||
|
? `KW ${isoWeek(range.from)} · ${formatDate(range.from).slice(0, 6)} – ${formatDate(range.to)}`
|
||||||
|
: mode === 'month'
|
||||||
|
? `${MONTHS[Number(anchor.slice(5, 7)) - 1]} ${anchor.slice(0, 4)}`
|
||||||
|
: anchor.slice(0, 4);
|
||||||
|
|
||||||
|
const openNew = (date?: string) => {
|
||||||
|
if (readOnly) return;
|
||||||
|
setModal({ date: date ?? (today >= range.from && today <= range.to ? today : range.from) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openExport = () => {
|
||||||
|
if (isLead && !allProjects) projectsApi.list().then(setAllProjects).catch(() => undefined);
|
||||||
|
setExportOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tastenkuerzel: N = neue Buchung, Pfeile = blaettern.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (modal || exportOpen) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
|
||||||
|
if (e.key === 'n' || e.key === '+') {
|
||||||
|
e.preventDefault();
|
||||||
|
openNew();
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowLeft') shift(-1);
|
||||||
|
if (e.key === 'ArrowRight') shift(1);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const warnings = useMemo(
|
||||||
|
() => (projects.data ?? []).flatMap((p) => p.pots.filter((pot) => pot.warning && pot.status !== 'expired' && pot.valid_to >= today).map((pot) => ({ project: p, pot }))),
|
||||||
|
[projects.data, today]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[1500px]">
|
||||||
|
{/* Werkzeugleiste */}
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
|
<div className="flex items-center rounded-xl border border-ink-200 bg-surface shadow-card">
|
||||||
|
<button onClick={() => shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Zurück">
|
||||||
|
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setAnchor(todayIso())} className="border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 hover:bg-ink-50">
|
||||||
|
Heute
|
||||||
|
</button>
|
||||||
|
<button onClick={() => shift(1)} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Weiter">
|
||||||
|
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h2 className="tabular min-w-0 flex-1 truncate text-base text-ink-900 sm:flex-none sm:text-lg">{label}</h2>
|
||||||
|
|
||||||
|
<div className="order-last flex w-full items-center gap-2 sm:order-none sm:ml-auto sm:w-auto">
|
||||||
|
<Segmented
|
||||||
|
value={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
className="flex-1 sm:flex-none"
|
||||||
|
options={[
|
||||||
|
{ value: 'week', label: 'Woche' },
|
||||||
|
{ value: 'month', label: 'Monat' },
|
||||||
|
{ value: 'year', label: 'Jahr' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{isLead && team.length > 1 && (
|
||||||
|
<select className={`${inputClass} w-auto max-w-[180px] py-2 text-xs font-bold`} value={viewUserId} onChange={(e) => setViewUserId(Number(e.target.value))} title="Zeiten ansehen von">
|
||||||
|
{team.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.id === user!.id ? 'Meine Zeiten' : t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" icon="download" onClick={openExport} className="py-2">
|
||||||
|
<span className="hidden sm:inline">Export</span>
|
||||||
|
</Button>
|
||||||
|
{!isMobile && !readOnly && (
|
||||||
|
<button
|
||||||
|
onClick={() => openNew()}
|
||||||
|
title="Zeit buchen (N)"
|
||||||
|
aria-label="Zeit buchen"
|
||||||
|
className="animate-glow flex h-11 w-11 items-center justify-center rounded-full bg-brand-500 text-white shadow-lift transition hover:scale-105 hover:bg-brand-600 active:scale-95"
|
||||||
|
>
|
||||||
|
<Icon name="plus" className="h-6 w-6" strokeWidth={2.6} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{readOnly && viewedUser && (
|
||||||
|
<div className="animate-fade mb-4 flex items-center gap-3 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm font-semibold text-sky-700">
|
||||||
|
<Avatar name={viewedUser.name} color={viewedUser.color} id={viewedUser.id} size={28} />
|
||||||
|
Du siehst die Zeiten von {viewedUser.name} (nur lesen).
|
||||||
|
<button className="ml-auto text-xs font-bold underline" onClick={() => setViewUserId(user!.id)}>
|
||||||
|
Zurück zu meinen Zeiten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readOnly && warnings.length > 0 && (
|
||||||
|
<div className="animate-fade mb-4 space-y-2">
|
||||||
|
{warnings.slice(0, 3).map(({ project, pot }) => (
|
||||||
|
<div key={pot.id} className="flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs font-semibold text-amber-700">
|
||||||
|
<Icon name="warning" className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
<b>{project.display_name}</b> – {pot.warning}
|
||||||
|
{pot.remaining_hours != null && ` Noch ${pot.remaining_hours.toLocaleString('de-DE')} h frei.`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SummaryStrip total={total} billable={billable} target={target} weekly={weekly} mode={mode} count={list.length} />
|
||||||
|
|
||||||
|
{entries.error && <ErrorBox message={entries.error} onRetry={entries.reload} />}
|
||||||
|
{entries.loading && !entries.data ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<div key={`${mode}-${range.from}`} className="animate-fade">
|
||||||
|
{mode === 'week' && (
|
||||||
|
<WeekView from={range.from} entries={list} readOnly={readOnly} dailyTarget={(weekly / 5) * 60} onAdd={openNew} onEdit={(entry) => !readOnly && setModal({ date: entry.entry_date, entry })} />
|
||||||
|
)}
|
||||||
|
{mode === 'month' && (
|
||||||
|
<MonthView
|
||||||
|
month={range.from}
|
||||||
|
entries={list}
|
||||||
|
dailyTarget={(weekly / 5) * 60}
|
||||||
|
onOpenDay={(date) => {
|
||||||
|
setAnchor(date);
|
||||||
|
setMode('week');
|
||||||
|
}}
|
||||||
|
onAdd={openNew}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{mode === 'year' && (
|
||||||
|
<YearView
|
||||||
|
year={anchor.slice(0, 4)}
|
||||||
|
entries={list}
|
||||||
|
weeklyTarget={weekly}
|
||||||
|
onOpenMonth={(month) => {
|
||||||
|
setAnchor(`${month}-01`);
|
||||||
|
setMode('month');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMobile && !readOnly && (
|
||||||
|
<button
|
||||||
|
onClick={() => openNew()}
|
||||||
|
aria-label="Zeit buchen"
|
||||||
|
className="animate-glow fixed bottom-24 right-5 z-40 flex h-16 w-16 items-center justify-center rounded-full bg-brand-500 text-white shadow-lift transition active:scale-95"
|
||||||
|
>
|
||||||
|
<Icon name="plus" className="h-8 w-8" strokeWidth={2.6} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modal && projects.data && (
|
||||||
|
<EntryModal projects={projects.data} date={modal.date} entry={modal.entry} onClose={() => setModal(null)} onSaved={() => { entries.reload(); projects.reload(); }} />
|
||||||
|
)}
|
||||||
|
{exportOpen && (
|
||||||
|
<ExportDialog
|
||||||
|
month={anchor.slice(0, 7)}
|
||||||
|
projects={isLead ? allProjects ?? projects.data ?? [] : projects.data ?? []}
|
||||||
|
team={team}
|
||||||
|
viewUserId={viewUserId}
|
||||||
|
onClose={() => setExportOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SummaryStrip: React.FC<{ total: number; billable: number; target: number; weekly: number; mode: BookingMode; count: number }> = ({
|
||||||
|
total,
|
||||||
|
billable,
|
||||||
|
target,
|
||||||
|
weekly,
|
||||||
|
mode,
|
||||||
|
count,
|
||||||
|
}) => {
|
||||||
|
const shown = useCountUp(total);
|
||||||
|
const percent = target > 0 ? Math.round((total / target) * 100) : 0;
|
||||||
|
const diff = total - target;
|
||||||
|
return (
|
||||||
|
<Card className="animate-rise mb-4 flex flex-wrap items-center gap-4 p-4 sm:gap-6 sm:p-5">
|
||||||
|
<Donut percent={Math.min(100, percent)} size={isFinite(percent) ? 76 : 76} stroke={9} track="var(--color-ink-100)" label="Soll" />
|
||||||
|
<div className="min-w-[120px]">
|
||||||
|
<p className="text-[11px] font-extrabold uppercase tracking-[0.14em] text-ink-400">
|
||||||
|
Gebucht {mode === 'week' ? 'diese Woche' : mode === 'month' ? 'im Monat' : 'im Jahr'}
|
||||||
|
</p>
|
||||||
|
<p className="tabular text-3xl font-extrabold text-ink-900">
|
||||||
|
{formatDuration(shown)}
|
||||||
|
<span className="ml-1 text-base text-ink-400">h</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid w-full grid-cols-3 gap-2 sm:w-auto sm:max-w-xl sm:flex-1 sm:gap-3">
|
||||||
|
<Mini label="Soll bis heute" value={`${formatDuration(target)} h`} hint={`${weekly} h/Woche`} />
|
||||||
|
<Mini label="Differenz" value={`${diff >= 0 ? '+' : '−'}${formatDuration(Math.abs(diff))} h`} tone={diff >= 0 ? 'good' : 'bad'} />
|
||||||
|
<Mini label="Billable" value={total ? `${Math.round((billable / total) * 100)} %` : '–'} hint={`${count} Buchungen`} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Mini: React.FC<{ label: string; value: string; hint?: string; tone?: 'good' | 'bad' }> = ({ label, value, hint, tone }) => (
|
||||||
|
<div className="min-w-0 rounded-xl bg-ink-50 px-3 py-2">
|
||||||
|
<p className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-400">{label}</p>
|
||||||
|
<p className={`tabular truncate text-sm font-extrabold sm:text-base ${tone === 'good' ? 'text-brand-700' : tone === 'bad' ? 'text-red-600' : 'text-ink-900'}`}>{value}</p>
|
||||||
|
{hint && <p className="truncate text-[10px] text-ink-400">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Modal, ConfirmDialog } from '../ui/Modal';
|
||||||
|
import { BudgetBar, Button, Field, inputClass } from '../ui/controls';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { entriesApi } from '../../lib/api';
|
||||||
|
import { formatDate, formatDuration, parseDuration } from '../../lib/format';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { checkPot } from './budget';
|
||||||
|
import { PROJECT_TYPE_LABELS, type Project, type TimeEntry } from '../../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projects: Project[];
|
||||||
|
date: string;
|
||||||
|
entry?: TimeEntry | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reihenfolge wie in der Auswahlliste: nach Kunde gruppiert, innerhalb in API-Reihenfolge. */
|
||||||
|
function inListOrder(projects: Project[]) {
|
||||||
|
const map = new Map<string, Project[]>();
|
||||||
|
for (const p of projects) map.set(p.customer.name, [...(map.get(p.customer.name) || []), p]);
|
||||||
|
return [...map.values()].flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorauswahl fuer neue Buchungen: das erste billable Projekt der Liste -
|
||||||
|
* bevorzugt eines, auf das am Datum auch gebucht werden kann (Topf-Laufzeit,
|
||||||
|
* Budget). Gibt es kein billable Projekt, bleibt nur ein einziges Projekt
|
||||||
|
* vorausgewaehlt.
|
||||||
|
*/
|
||||||
|
function defaultProject(projects: Project[], date: string, settings: { hours_per_day: number; budget_warn_percent: number; budget_warn_days: number }): number | '' {
|
||||||
|
const ordered = inListOrder(projects);
|
||||||
|
const billable = ordered.filter((p) => p.type === 'billable');
|
||||||
|
const bookableNow = billable.find(
|
||||||
|
(p) => checkPot(p, date, 0, settings.hours_per_day, settings.budget_warn_percent, settings.budget_warn_days).ok
|
||||||
|
);
|
||||||
|
if (bookableNow) return bookableNow.id;
|
||||||
|
if (billable.length) return billable[0].id;
|
||||||
|
return ordered.length === 1 ? ordered[0].id : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erster aktiver Task in Listenreihenfolge; ohne Tasks bleibt "Ohne Task". */
|
||||||
|
function firstTask(project: Project | undefined): number | '' {
|
||||||
|
return project?.tasks.find((t) => t.is_active)?.id ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buchungsdialog: oben Projekt, darunter Task, darunter Freitext mit der Zeit
|
||||||
|
* rechts daneben. Die Zeit versteht "1:23" und "1,5".
|
||||||
|
*/
|
||||||
|
export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry, onClose, onSaved }) => {
|
||||||
|
const { settings } = useAuth();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const bookable = useMemo(() => projects.filter((p) => p.is_active || p.id === entry?.project_id), [projects, entry]);
|
||||||
|
|
||||||
|
const [projectId, setProjectId] = useState<number | ''>(() => entry?.project_id ?? defaultProject(bookable, initialDate, settings));
|
||||||
|
// Neue Buchung: erster aktiver Task des vorausgewaehlten Projekts.
|
||||||
|
const [taskId, setTaskId] = useState<number | ''>(() =>
|
||||||
|
entry ? entry.task_id ?? '' : firstTask(bookable.find((p) => p.id === projectId))
|
||||||
|
);
|
||||||
|
const [note, setNote] = useState(entry?.note ?? '');
|
||||||
|
const [date, setDate] = useState(entry?.entry_date ?? initialDate);
|
||||||
|
const [timeText, setTimeText] = useState(entry ? formatDuration(entry.minutes) : '0:00');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const timeRef = useRef<HTMLInputElement>(null);
|
||||||
|
const projectRef = useRef<HTMLSelectElement>(null);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Schnell buchen: ist ein Projekt vorausgewaehlt, liegt der Fokus sofort auf
|
||||||
|
* der Zeit (Inhalt markiert, also direkt ueberschreiben). Sonst zuerst das
|
||||||
|
* Projekt waehlen. Kurz verzoegert, damit die Dialog-Animation nicht stoert.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (projectId) {
|
||||||
|
timeRef.current?.focus();
|
||||||
|
timeRef.current?.select();
|
||||||
|
} else {
|
||||||
|
projectRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, 60);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const project = bookable.find((p) => p.id === projectId) ?? null;
|
||||||
|
const tasks = project?.tasks.filter((t) => t.is_active || t.id === entry?.task_id) ?? [];
|
||||||
|
const minutes = parseDuration(timeText);
|
||||||
|
const validTime = minutes != null && minutes > 0 && minutes <= 24 * 60;
|
||||||
|
|
||||||
|
// Projekt gewechselt: passt der Task nicht mehr, den ersten Task des neuen Projekts nehmen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (taskId && tasks.some((t) => t.id === taskId)) return;
|
||||||
|
if (!taskId && entry && entry.project_id === projectId) return; // bewusst ohne Task gebucht
|
||||||
|
setTaskId(firstTask(project ?? undefined));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
|
const billable = project ? project.type !== 'non_billable' && (task ? task.billable : true) : true;
|
||||||
|
const check = project
|
||||||
|
? checkPot(
|
||||||
|
project,
|
||||||
|
date,
|
||||||
|
validTime ? minutes! : 0,
|
||||||
|
settings.hours_per_day,
|
||||||
|
settings.budget_warn_percent,
|
||||||
|
settings.budget_warn_days,
|
||||||
|
entry ? { potId: entry.pot_id, minutes: project.type === 'billable' && !entry.billable ? 0 : entry.minutes } : null,
|
||||||
|
billable
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Gruppiert nach Kunde fuer die Auswahl.
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const map = new Map<string, Project[]>();
|
||||||
|
for (const p of bookable) {
|
||||||
|
const key = p.customer.name;
|
||||||
|
map.set(key, [...(map.get(key) || []), p]);
|
||||||
|
}
|
||||||
|
return [...map.entries()];
|
||||||
|
}, [bookable]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!project) return addToast('Bitte ein Projekt wählen.', 'error');
|
||||||
|
if (!validTime) {
|
||||||
|
timeRef.current?.focus();
|
||||||
|
return addToast('Bitte eine Zeit eingeben, z. B. 1:30 oder 1,5.', 'error');
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const payload = { project_id: project.id, task_id: taskId || null, entry_date: date, minutes: minutes!, note };
|
||||||
|
const result = entry ? await entriesApi.update(entry.id, payload) : await entriesApi.create(payload);
|
||||||
|
addToast(`${formatDuration(minutes!)} h auf ${project.display_name} ${entry ? 'aktualisiert' : 'gebucht'}.`, 'success');
|
||||||
|
if (result.warning) addToast(result.warning, 'info');
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!entry) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await entriesApi.remove(entry.id);
|
||||||
|
addToast('Buchung gelöscht.', 'success');
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pot = check?.pot;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
title={entry ? 'Buchung bearbeiten' : 'Zeit buchen'}
|
||||||
|
subtitle={formatDate(date, 'long')}
|
||||||
|
onClose={onClose}
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
{entry ? (
|
||||||
|
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)}>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="hidden text-[11px] text-ink-400 sm:block">Enter im Zeitfeld bucht · sonst Strg + Enter</span>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button icon="check" busy={busy} onClick={save} disabled={!project || !validTime || check?.ok === false}>
|
||||||
|
{entry ? 'Speichern' : 'Buchen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{bookable.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm font-semibold text-amber-700">
|
||||||
|
Dir ist noch kein Projekt zugewiesen. Bitte deinen Vorgesetzten, dich einem Projekt zuzuordnen.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4" onKeyDown={onKeyDown}>
|
||||||
|
<Field label="Projekt" required>
|
||||||
|
<select className={inputClass} value={projectId} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : '')} ref={projectRef}>
|
||||||
|
<option value="">Projekt wählen…</option>
|
||||||
|
{groups.map(([customer, list]) => (
|
||||||
|
<optgroup key={customer} label={customer}>
|
||||||
|
{list.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.display_name} · {p.code}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Task">
|
||||||
|
<select className={inputClass} value={taskId} onChange={(e) => setTaskId(e.target.value ? Number(e.target.value) : '')} disabled={!project || tasks.length === 0}>
|
||||||
|
<option value="">{project && tasks.length === 0 ? 'Keine Tasks angelegt' : 'Ohne Task'}</option>
|
||||||
|
{tasks.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
{project?.type !== 'non_billable' && !t.billable ? ' (nicht abrechenbar)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[1fr_7.5rem] gap-3 sm:grid-cols-[1fr_9rem]">
|
||||||
|
<Field label="Beschreibung (optional)">
|
||||||
|
<textarea
|
||||||
|
className={`${inputClass} h-[92px] resize-none`}
|
||||||
|
value={note}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder="Was wurde gemacht?"
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Zeit" required>
|
||||||
|
<input
|
||||||
|
ref={timeRef}
|
||||||
|
inputMode="decimal"
|
||||||
|
className={`${inputClass} tabular h-[92px] !px-2 text-center !text-3xl font-extrabold ${timeText && !validTime && timeText !== '0:00' ? 'border-red-300 text-red-600' : ''}`}
|
||||||
|
value={timeText}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Im Zeitfeld reicht Enter zum Buchen.
|
||||||
|
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={(e) => setTimeText(e.target.value)}
|
||||||
|
onBlur={() => validTime && setTimeText(formatDuration(minutes!))}
|
||||||
|
aria-describedby="time-hint"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<p id="time-hint" className="-mt-2 text-right text-[11px] text-ink-400">
|
||||||
|
{validTime ? (
|
||||||
|
<span className="font-bold text-brand-700">
|
||||||
|
= {Math.floor(minutes! / 60)} Std. {minutes! % 60} Min. · {(minutes! / 60).toLocaleString('de-DE', { maximumFractionDigits: 2 })} h
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Format 1:30 oder 1,5'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Field label="Datum">
|
||||||
|
<input type="date" className={inputClass} value={date} onChange={(e) => e.target.value && setDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{project && (
|
||||||
|
<div
|
||||||
|
className={`animate-fade rounded-2xl border p-4 ${
|
||||||
|
check?.level === 'error' ? 'border-red-200 bg-red-50' : check?.level === 'warn' ? 'border-amber-200 bg-amber-50' : 'border-ink-200 bg-ink-50/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon
|
||||||
|
name={check?.level === 'error' ? 'warning' : check?.level === 'warn' ? 'warning' : 'pot'}
|
||||||
|
className={`mt-0.5 h-5 w-5 shrink-0 ${check?.level === 'error' ? 'text-red-600' : check?.level === 'warn' ? 'text-amber-600' : 'text-brand-600'}`}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-extrabold uppercase tracking-wider text-ink-500">
|
||||||
|
{PROJECT_TYPE_LABELS[project.type]} · {project.code}
|
||||||
|
</p>
|
||||||
|
<p className={`mt-1 text-sm font-semibold ${check?.level === 'error' ? 'text-red-700' : check?.level === 'warn' ? 'text-amber-700' : 'text-ink-700'}`}>
|
||||||
|
{check?.message ?? 'Freies Projekt ohne Budgettopf.'}
|
||||||
|
</p>
|
||||||
|
{pot && pot.budget_hours != null && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<BudgetBar percent={pot.percent_used} warnAt={settings.budget_warn_percent} />
|
||||||
|
<p className="tabular mt-1 flex justify-between text-[11px] font-bold text-ink-500">
|
||||||
|
<span>{formatDuration(pot.used_minutes)} h gebucht</span>
|
||||||
|
<span>{pot.budget_hours.toLocaleString('de-DE')} h Budget</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
{confirmDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Buchung löschen?"
|
||||||
|
message={`${formatDuration(entry!.minutes)} h auf ${entry!.project_display} am ${formatDate(entry!.entry_date)}.`}
|
||||||
|
onConfirm={remove}
|
||||||
|
onCancel={() => setConfirmDelete(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { Button, Field, inputClass, Toggle } from '../ui/controls';
|
||||||
|
import { Icon, type IconName } from '../ui/Icon';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { download, qs } from '../../lib/api';
|
||||||
|
import { MONTHS } from '../../lib/format';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { atLeast, type DirectoryUser, type Project } from '../../types';
|
||||||
|
|
||||||
|
const FORMATS: { value: 'pdf' | 'xlsx' | 'csv'; label: string; hint: string; icon: IconName }[] = [
|
||||||
|
{ value: 'pdf', label: 'PDF', hint: 'Stundenzettel mit Logo, Kopf und Fuß', icon: 'document' },
|
||||||
|
{ value: 'xlsx', label: 'Excel', hint: 'Zum Weiterrechnen, mit Summenformeln', icon: 'grid' },
|
||||||
|
{ value: 'csv', label: 'CSV', hint: 'Rohdaten, Semikolon, UTF-8', icon: 'list' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Monatsexport des Stundennachweises - Vorlage fuer Kunden und Rechnung. */
|
||||||
|
export const ExportDialog: React.FC<{
|
||||||
|
month: string;
|
||||||
|
projects: Project[];
|
||||||
|
team?: DirectoryUser[];
|
||||||
|
viewUserId?: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = ({ month: initialMonth, projects, team = [], viewUserId, onClose }) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [month, setMonth] = useState(initialMonth);
|
||||||
|
const [format, setFormat] = useState<'pdf' | 'xlsx' | 'csv'>('pdf');
|
||||||
|
const [customerId, setCustomerId] = useState<number | ''>('');
|
||||||
|
const [projectId, setProjectId] = useState<number | ''>('');
|
||||||
|
const [userId, setUserId] = useState<string>(String(viewUserId ?? user?.id ?? ''));
|
||||||
|
const [amounts, setAmounts] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const isLead = atLeast(user, 'lead');
|
||||||
|
|
||||||
|
const customers = useMemo(() => {
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
projects.forEach((p) => map.set(p.customer.id, p.customer.name));
|
||||||
|
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], 'de'));
|
||||||
|
}, [projects]);
|
||||||
|
const projectOptions = projects.filter((p) => !customerId || p.customer_id === customerId);
|
||||||
|
|
||||||
|
// Monate: 18 zurueck, 2 voraus.
|
||||||
|
const months = useMemo(() => {
|
||||||
|
const list: string[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
for (let i = -2; i < 18; i++) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
list.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
if (!list.includes(initialMonth)) list.push(initialMonth);
|
||||||
|
return list;
|
||||||
|
}, [initialMonth]);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await download(
|
||||||
|
`/exports/timesheet${qs({
|
||||||
|
month,
|
||||||
|
format,
|
||||||
|
user_id: userId,
|
||||||
|
customer_id: customerId || undefined,
|
||||||
|
project_id: projectId || undefined,
|
||||||
|
amounts: amounts ? 1 : undefined,
|
||||||
|
})}`,
|
||||||
|
`Stundennachweis-${month}.${format}`
|
||||||
|
);
|
||||||
|
addToast('Export erstellt.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Export fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Stundennachweis exportieren"
|
||||||
|
subtitle="Monatliche Vorlage für Kunden und Rechnungsstellung"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
<Button icon="download" busy={busy} onClick={run}>
|
||||||
|
Exportieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{FORMATS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.value}
|
||||||
|
onClick={() => setFormat(f.value)}
|
||||||
|
className={`flex flex-col items-center gap-1 rounded-2xl border p-3 text-center transition ${
|
||||||
|
format === f.value ? 'border-brand-400 bg-brand-50 text-brand-700 shadow-card' : 'border-ink-200 text-ink-600 hover:border-brand-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name={f.icon} className="h-6 w-6" />
|
||||||
|
<span className="text-sm font-extrabold">{f.label}</span>
|
||||||
|
<span className="hidden text-[10px] leading-tight text-ink-400 sm:block">{f.hint}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Monat">
|
||||||
|
<select className={inputClass} value={month} onChange={(e) => setMonth(e.target.value)}>
|
||||||
|
{months.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{MONTHS[Number(m.slice(5)) - 1]} {m.slice(0, 4)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{isLead && (
|
||||||
|
<Field label="Mitarbeiter">
|
||||||
|
<select className={inputClass} value={userId} onChange={(e) => setUserId(e.target.value)}>
|
||||||
|
<option value={String(user!.id)}>Ich ({user!.name})</option>
|
||||||
|
{team
|
||||||
|
.filter((t) => t.id !== user!.id)
|
||||||
|
.map((t) => (
|
||||||
|
<option key={t.id} value={String(t.id)}>
|
||||||
|
{t.name}
|
||||||
|
{t.is_external ? ' (extern)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="all">Alle meine Mitarbeiter</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="Kunde">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={customerId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomerId(e.target.value ? Number(e.target.value) : '');
|
||||||
|
setProjectId('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Alle Kunden</option>
|
||||||
|
{customers.map(([id, name]) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Projekt">
|
||||||
|
<select className={inputClass} value={projectId} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : '')}>
|
||||||
|
<option value="">Alle Projekte</option>
|
||||||
|
{projectOptions.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.display_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{isLead && (
|
||||||
|
<Toggle checked={amounts} onChange={setAmounts} label="Stundensätze und Beträge ausweisen" hint="Für die Rechnungsstellung. Im Kundennachweis meist ausgeschaltet." />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { WEEKDAYS_SHORT, addDays, colorFor, formatDuration, fromIso, mondayOf, monthEnd, todayIso } from '../../lib/format';
|
||||||
|
import { useIsMobile } from '../../lib/hooks';
|
||||||
|
import type { TimeEntry } from '../../types';
|
||||||
|
|
||||||
|
/** Monatskalender: Stunden je Tag als Fuellstand, Projekte als farbige Punkte. */
|
||||||
|
export const MonthView: React.FC<{
|
||||||
|
month: string;
|
||||||
|
entries: TimeEntry[];
|
||||||
|
dailyTarget: number;
|
||||||
|
onOpenDay: (date: string) => void;
|
||||||
|
onAdd: (date: string) => void;
|
||||||
|
readOnly: boolean;
|
||||||
|
}> = ({ month, entries, dailyTarget, onOpenDay }) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const today = todayIso();
|
||||||
|
const start = mondayOf(month);
|
||||||
|
const end = monthEnd(month);
|
||||||
|
const weeks = Math.ceil((((fromIso(end).getTime() - fromIso(start).getTime()) / 86400000) + 1) / 7);
|
||||||
|
const cells = Array.from({ length: weeks * 7 }, (_, i) => addDays(start, i));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-ink-200 bg-surface p-2 shadow-card sm:p-3">
|
||||||
|
<div className="mb-1 grid grid-cols-7 gap-1 sm:gap-2">
|
||||||
|
{WEEKDAYS_SHORT.map((d) => (
|
||||||
|
<p key={d} className="py-1 text-center text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||||
|
{d}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1 sm:gap-2">
|
||||||
|
{cells.map((date, i) => {
|
||||||
|
const inMonth = date.slice(0, 7) === month.slice(0, 7);
|
||||||
|
const list = entries.filter((e) => e.entry_date === date);
|
||||||
|
const minutes = list.reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const fill = dailyTarget ? Math.min(100, (minutes / dailyTarget) * 100) : 0;
|
||||||
|
const projects = [...new Map(list.map((e) => [e.project_id, e])).values()];
|
||||||
|
const weekend = i % 7 >= 5;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={date}
|
||||||
|
onClick={() => onOpenDay(date)}
|
||||||
|
title={list.map((e) => `${formatDuration(e.minutes)} ${e.project_display}`).join('\n') || 'Keine Buchungen'}
|
||||||
|
className={`animate-rise group relative flex min-h-[64px] flex-col overflow-hidden rounded-xl border p-1.5 text-left transition hover:border-brand-300 hover:shadow-card sm:min-h-[104px] sm:p-2.5 ${
|
||||||
|
!inMonth ? 'border-transparent opacity-35' : date === today ? 'border-brand-400' : weekend ? 'border-ink-100 bg-ink-50/60' : 'border-ink-200'
|
||||||
|
}`}
|
||||||
|
style={{ '--i': Math.floor(i / 7) } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{/* Fuellstand von unten */}
|
||||||
|
<span aria-hidden className="absolute inset-x-0 bottom-0 bg-brand-100 transition-[height] duration-700" style={{ height: `${fill}%`, opacity: 0.6 }} />
|
||||||
|
<span className={`relative text-xs font-extrabold sm:text-sm ${date === today ? 'text-brand-700' : 'text-ink-700'}`}>{fromIso(date).getDate()}</span>
|
||||||
|
{minutes > 0 && <span className="tabular relative mt-auto text-[11px] font-extrabold text-ink-900 sm:text-base">{formatDuration(minutes)}</span>}
|
||||||
|
{!isMobile && projects.length > 0 && (
|
||||||
|
<span className="relative mt-1 flex flex-wrap gap-1">
|
||||||
|
{projects.slice(0, 5).map((e) => (
|
||||||
|
<span key={e.project_id} className="h-1.5 w-4 rounded-full" style={{ background: colorFor(e.customer_id, e.customer_color) }} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { useIsMobile, useGrow } from '../../lib/hooks';
|
||||||
|
import { WEEKDAYS_LONG, WEEKDAYS_SHORT, addDays, colorFor, formatDuration, fromIso, todayIso } from '../../lib/format';
|
||||||
|
import type { TimeEntry } from '../../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
from: string;
|
||||||
|
entries: TimeEntry[];
|
||||||
|
readOnly: boolean;
|
||||||
|
dailyTarget: number;
|
||||||
|
onAdd: (date: string) => void;
|
||||||
|
onEdit: (entry: TimeEntry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wochenansicht wie ein moderner Stundenzettel: sieben Spalten, jede Buchung
|
||||||
|
* als Karte mit Kundenfarbe. Auf dem Handy ein Tagesstreifen mit Liste.
|
||||||
|
*/
|
||||||
|
export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget, onAdd, onEdit }) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const today = todayIso();
|
||||||
|
const days = Array.from({ length: 7 }, (_, i) => addDays(from, i));
|
||||||
|
const byDay = (date: string) => entries.filter((e) => e.entry_date === date);
|
||||||
|
const [selected, setSelected] = useState(() => (today >= from && today <= days[6] ? today : from));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(today >= from && today <= addDays(from, 6) ? today : from);
|
||||||
|
}, [from, today]);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
const list = byDay(selected);
|
||||||
|
const total = list.reduce((s, e) => s + e.minutes, 0);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 grid grid-cols-7 gap-1">
|
||||||
|
{days.map((date, i) => {
|
||||||
|
const minutes = byDay(date).reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const active = date === selected;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={date}
|
||||||
|
onClick={() => setSelected(date)}
|
||||||
|
className={`flex flex-col items-center rounded-2xl border py-2 transition ${
|
||||||
|
active ? 'border-brand-400 bg-brand-500 text-white shadow-card' : date === today ? 'border-brand-200 bg-brand-50 text-brand-700' : 'border-ink-200 bg-surface text-ink-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-bold uppercase">{WEEKDAYS_SHORT[i]}</span>
|
||||||
|
<span className="text-lg font-extrabold leading-tight">{fromIso(date).getDate()}</span>
|
||||||
|
<span className={`tabular text-[10px] font-bold ${active ? 'text-white/85' : minutes ? 'text-ink-500' : 'text-ink-300'}`}>{minutes ? formatDuration(minutes) : '–'}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 flex items-baseline justify-between px-1">
|
||||||
|
<p className="text-sm font-extrabold text-ink-900">{WEEKDAYS_LONG[days.indexOf(selected)]}</p>
|
||||||
|
<p className="tabular text-sm font-extrabold text-ink-600">{formatDuration(total)} h</p>
|
||||||
|
</div>
|
||||||
|
<div key={selected} className="space-y-2">
|
||||||
|
{list.map((entry, i) => (
|
||||||
|
<EntryCard key={entry.id} entry={entry} index={i} onClick={() => onEdit(entry)} readOnly={readOnly} large />
|
||||||
|
))}
|
||||||
|
{list.length === 0 && (
|
||||||
|
<button
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={() => onAdd(selected)}
|
||||||
|
className="flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-ink-200 py-10 text-sm font-bold text-ink-400"
|
||||||
|
>
|
||||||
|
<Icon name="plus" className="h-6 w-6" />
|
||||||
|
{readOnly ? 'Keine Buchungen' : 'Zeit für diesen Tag buchen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-7 gap-2 xl:gap-3">
|
||||||
|
{days.map((date, i) => {
|
||||||
|
const list = byDay(date);
|
||||||
|
const minutes = list.reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const weekend = i >= 5;
|
||||||
|
const isToday = date === today;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={date}
|
||||||
|
className={`animate-rise group flex min-h-[340px] min-w-0 flex-col rounded-2xl border ${
|
||||||
|
isToday ? 'border-brand-300 bg-brand-50/50' : weekend ? 'border-ink-200 bg-ink-50/60' : 'border-ink-200 bg-surface'
|
||||||
|
} shadow-card`}
|
||||||
|
style={{ '--i': i } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-1 border-b border-ink-100 px-3 pb-2 pt-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={`text-[11px] font-extrabold uppercase tracking-wider ${isToday ? 'text-brand-600' : 'text-ink-400'}`}>{WEEKDAYS_SHORT[i]}</p>
|
||||||
|
<p className={`text-2xl font-extrabold leading-none ${isToday ? 'text-brand-700' : 'text-ink-900'}`}>{fromIso(date).getDate()}.</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="tabular text-sm font-extrabold text-ink-800">{minutes ? formatDuration(minutes) : ''}</p>
|
||||||
|
{!weekend && <DayMeter minutes={minutes} target={dailyTarget} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2 p-2">
|
||||||
|
{list.map((entry, j) => (
|
||||||
|
<EntryCard key={entry.id} entry={entry} index={j} onClick={() => onEdit(entry)} readOnly={readOnly} />
|
||||||
|
))}
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
onClick={() => onAdd(date)}
|
||||||
|
className={`mt-auto flex items-center justify-center gap-1 rounded-xl border border-dashed border-ink-200 py-2 text-xs font-bold text-ink-400 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 ${
|
||||||
|
list.length ? 'opacity-0 group-hover:opacity-100 focus:opacity-100' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name="plus" className="h-3.5 w-3.5" strokeWidth={2.4} />
|
||||||
|
Buchen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DayMeter: React.FC<{ minutes: number; target: number }> = ({ minutes, target }) => {
|
||||||
|
const grown = useGrow(minutes);
|
||||||
|
const p = target ? Math.min(100, (minutes / target) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div className="mt-1 h-1 w-12 overflow-hidden rounded-full bg-ink-100" title={`${Math.round(p)} % vom Tagessoll`}>
|
||||||
|
<div className={`h-full rounded-full transition-[width] duration-700 ${p >= 100 ? 'bg-brand-500' : 'bg-brand-300'}`} style={{ width: grown ? `${p}%` : 0 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EntryCard: React.FC<{ entry: TimeEntry; index: number; onClick: () => void; readOnly: boolean; large?: boolean }> = ({
|
||||||
|
entry,
|
||||||
|
index,
|
||||||
|
onClick,
|
||||||
|
readOnly,
|
||||||
|
large,
|
||||||
|
}) => {
|
||||||
|
const color = colorFor(entry.customer_id, entry.customer_color);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={readOnly && !large}
|
||||||
|
className={`animate-rise relative w-full overflow-hidden rounded-xl border border-ink-200 bg-surface text-left transition ${
|
||||||
|
readOnly ? 'cursor-default' : 'hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift'
|
||||||
|
} ${large ? 'p-4 pl-5' : 'p-2.5 pl-3.5'}`}
|
||||||
|
style={{ '--i': index } as React.CSSProperties}
|
||||||
|
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `\n${entry.note}` : ''}`}
|
||||||
|
>
|
||||||
|
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className={`min-w-0 font-extrabold text-ink-900 ${large ? 'text-sm' : 'line-clamp-2 text-xs leading-snug'}`}>{entry.project_display}</p>
|
||||||
|
<span className={`tabular shrink-0 font-extrabold text-ink-800 ${large ? 'text-lg' : 'text-xs'}`}>{formatDuration(entry.minutes)}</span>
|
||||||
|
</div>
|
||||||
|
{entry.task_name && <p className={`truncate font-semibold text-ink-500 ${large ? 'text-xs' : 'text-[11px]'}`}>{entry.task_name}</p>}
|
||||||
|
{entry.note && <p className={`mt-0.5 text-ink-400 ${large ? 'text-xs' : 'line-clamp-2 text-[11px] leading-snug'}`}>{entry.note}</p>}
|
||||||
|
{!entry.billable && <span className="mt-1 inline-block rounded-full bg-ink-100 px-1.5 text-[9px] font-bold uppercase tracking-wide text-ink-500">non-billable</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { MONTHS, addDays, formatDuration, fromIso, mondayOf, todayIso, weekdayIndex } from '../../lib/format';
|
||||||
|
import { useGrow } from '../../lib/hooks';
|
||||||
|
import { BudgetBar, Card } from '../ui/controls';
|
||||||
|
import type { TimeEntry } from '../../types';
|
||||||
|
|
||||||
|
/** Jahresansicht: Monatskacheln mit Soll/Ist und eine Heatmap aller Tage. */
|
||||||
|
export const YearView: React.FC<{ year: string; entries: TimeEntry[]; weeklyTarget: number; onOpenMonth: (month: string) => void }> = ({
|
||||||
|
year,
|
||||||
|
entries,
|
||||||
|
weeklyTarget,
|
||||||
|
onOpenMonth,
|
||||||
|
}) => {
|
||||||
|
const today = todayIso();
|
||||||
|
const perDay = new Map<string, number>();
|
||||||
|
for (const e of entries) perDay.set(e.entry_date, (perDay.get(e.entry_date) || 0) + e.minutes);
|
||||||
|
const grown = useGrow(year);
|
||||||
|
|
||||||
|
const months = MONTHS.map((name, i) => {
|
||||||
|
const key = `${year}-${String(i + 1).padStart(2, '0')}`;
|
||||||
|
const list = entries.filter((e) => e.entry_date.startsWith(key));
|
||||||
|
const minutes = list.reduce((s, e) => s + e.minutes, 0);
|
||||||
|
const billable = list.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
|
||||||
|
let workdays = 0;
|
||||||
|
for (let d = `${key}-01`; d.startsWith(key); d = addDays(d, 1)) {
|
||||||
|
if (d > today) break;
|
||||||
|
if (weekdayIndex(d) < 5) workdays++;
|
||||||
|
}
|
||||||
|
const target = (weeklyTarget / 5) * workdays * 60;
|
||||||
|
return { key, name, minutes, billable, target };
|
||||||
|
});
|
||||||
|
const max = Math.max(1, ...months.map((m) => m.minutes));
|
||||||
|
|
||||||
|
// Heatmap: Wochen als Spalten, Mo-So als Zeilen.
|
||||||
|
const first = mondayOf(`${year}-01-01`);
|
||||||
|
const weeks: string[][] = [];
|
||||||
|
for (let d = first; d <= `${year}-12-31`; d = addDays(d, 7)) {
|
||||||
|
weeks.push(Array.from({ length: 7 }, (_, i) => addDays(d, i)));
|
||||||
|
}
|
||||||
|
const level = (minutes: number) => (minutes === 0 ? 0 : minutes < 180 ? 1 : minutes < 360 ? 2 : minutes < 480 ? 3 : 4);
|
||||||
|
const LEVELS = ['var(--color-ink-100)', 'var(--color-brand-200)', 'var(--color-brand-300)', 'var(--color-brand-500)', 'var(--color-brand-700)'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="animate-rise overflow-x-auto p-4">
|
||||||
|
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-[0.14em] text-ink-400">Buchungsdichte {year}</p>
|
||||||
|
<div className="flex gap-[3px]" style={{ minWidth: weeks.length * 14 }}>
|
||||||
|
{weeks.map((week, w) => (
|
||||||
|
<div key={week[0]} className="flex flex-col gap-[3px]">
|
||||||
|
{week.map((date) => {
|
||||||
|
const minutes = perDay.get(date) || 0;
|
||||||
|
const out = !date.startsWith(year);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={date}
|
||||||
|
title={`${fromIso(date).toLocaleDateString('de-DE')}: ${formatDuration(minutes)} h`}
|
||||||
|
className="h-[11px] w-[11px] rounded-[3px] transition-opacity duration-500"
|
||||||
|
style={{ background: out ? 'transparent' : LEVELS[level(minutes)], opacity: grown ? 1 : 0, transitionDelay: `${w * 8}ms` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-end gap-1 text-[10px] font-bold text-ink-400">
|
||||||
|
weniger
|
||||||
|
{LEVELS.map((c) => (
|
||||||
|
<span key={c} className="h-[11px] w-[11px] rounded-[3px]" style={{ background: c }} />
|
||||||
|
))}
|
||||||
|
mehr
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||||
|
{months.map((m, i) => (
|
||||||
|
<button
|
||||||
|
key={m.key}
|
||||||
|
onClick={() => onOpenMonth(m.key)}
|
||||||
|
className="animate-rise rounded-2xl border border-ink-200 bg-surface p-4 text-left shadow-card transition hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift"
|
||||||
|
style={{ '--i': i } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<p className={`text-xs font-extrabold uppercase tracking-wider ${m.key === today.slice(0, 7) ? 'text-brand-600' : 'text-ink-400'}`}>{m.name}</p>
|
||||||
|
<p className="tabular mt-1 text-2xl font-extrabold text-ink-900">{formatDuration(m.minutes)}</p>
|
||||||
|
<div className="mt-3 flex h-12 items-end gap-1">
|
||||||
|
<div className="flex-1 rounded-t-md bg-brand-500 transition-[height] duration-700" style={{ height: grown ? `${(m.billable / max) * 100}%` : 0 }} title="billable" />
|
||||||
|
<div className="flex-1 rounded-t-md bg-brand-200 transition-[height] duration-700" style={{ height: grown ? `${((m.minutes - m.billable) / max) * 100}%` : 0 }} title="non-billable" />
|
||||||
|
</div>
|
||||||
|
<BudgetBar className="mt-3" height="h-1.5" percent={m.target ? (m.minutes / m.target) * 100 : null} goal />
|
||||||
|
<p className="tabular mt-1 text-[10px] font-bold text-ink-400">Soll {m.target ? formatDuration(m.target) : '–'} h</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { BudgetPot, Project } from '../../types';
|
||||||
|
import { formatDate, formatDuration, todayIso } from '../../lib/format';
|
||||||
|
|
||||||
|
export interface PotCheck {
|
||||||
|
ok: boolean;
|
||||||
|
pot: BudgetPot | null;
|
||||||
|
level: 'ok' | 'warn' | 'error' | 'none';
|
||||||
|
message: string | null;
|
||||||
|
/** Freie Minuten im Topf (null = unbegrenzt). */
|
||||||
|
freeMinutes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spiegelt die Budgetregeln des Backends fuer die Vorschau im Buchungsdialog.
|
||||||
|
* Das Backend prueft verbindlich - hier geht es nur darum, VOR dem Speichern
|
||||||
|
* zu sagen, was passieren wird.
|
||||||
|
*/
|
||||||
|
export function checkPot(
|
||||||
|
project: Project,
|
||||||
|
date: string,
|
||||||
|
minutes: number,
|
||||||
|
hoursPerDay: number,
|
||||||
|
warnPercent: number,
|
||||||
|
warnDays: number,
|
||||||
|
editing?: { potId: number | null; minutes: number } | null,
|
||||||
|
billable = true
|
||||||
|
): PotCheck {
|
||||||
|
// Wie im Backend: nicht abrechenbare Stunden in Billable-Projekten verbrauchen kein Volumen.
|
||||||
|
const onlyBillable = project.type === 'billable';
|
||||||
|
if (onlyBillable && !billable) minutes = 0;
|
||||||
|
if (date < project.start_date) {
|
||||||
|
return { ok: false, pot: null, level: 'error', message: `Das Projekt beginnt erst am ${formatDate(project.start_date)}.`, freeMinutes: 0 };
|
||||||
|
}
|
||||||
|
if (project.end_date && date > project.end_date) {
|
||||||
|
return { ok: false, pot: null, level: 'error', message: `Das Projekt ist am ${formatDate(project.end_date)} beendet.`, freeMinutes: 0 };
|
||||||
|
}
|
||||||
|
if (project.pots.length === 0) return { ok: true, pot: null, level: 'none', message: null, freeMinutes: null };
|
||||||
|
|
||||||
|
const covering = project.pots.filter((p) => p.valid_from <= date && p.valid_to >= date).sort((a, b) => a.valid_to.localeCompare(b.valid_to));
|
||||||
|
if (covering.length === 0) {
|
||||||
|
const ranges = project.pots.map((p) => `${p.label}: ${formatDate(p.valid_from)}–${formatDate(p.valid_to)}`).join(' · ');
|
||||||
|
return { ok: false, pot: null, level: 'error', message: `Kein Budgettopf deckt dieses Datum ab. Laufzeiten: ${ranges}`, freeMinutes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = todayIso();
|
||||||
|
let last: PotCheck | null = null;
|
||||||
|
for (const pot of covering) {
|
||||||
|
if (pot.valid_to < today) {
|
||||||
|
last = { ok: false, pot, level: 'error', message: `Budgettopf „${pot.label}“ ist seit ${formatDate(pot.valid_to)} abgelaufen – nicht mehr buchbar.`, freeMinutes: 0 };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const credit = editing && editing.potId === pot.id ? editing.minutes : 0;
|
||||||
|
const free = pot.budget_hours == null ? null : pot.budget_hours * 60 - pot.used_minutes + credit;
|
||||||
|
if (free != null && (free <= 0 || minutes > free)) {
|
||||||
|
last = {
|
||||||
|
ok: false,
|
||||||
|
pot,
|
||||||
|
level: 'error',
|
||||||
|
message: free <= 0 ? `Budgettopf „${pot.label}“ ist aufgebraucht – nicht mehr buchbar.` : `Budgettopf „${pot.label}“ hat nur noch ${formatDuration(free)} h frei.`,
|
||||||
|
freeMinutes: Math.max(0, free),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const after = free == null ? null : free - minutes;
|
||||||
|
const percentAfter = pot.budget_hours ? ((pot.budget_hours * 60 - (after ?? 0)) / (pot.budget_hours * 60)) * 100 : null;
|
||||||
|
const hints: string[] = [];
|
||||||
|
if (after != null) {
|
||||||
|
const days = after / 60 / hoursPerDay;
|
||||||
|
hints.push(`Danach noch ${formatDuration(after)} h (${days.toLocaleString('de-DE', { maximumFractionDigits: 1 })} Tage) frei`);
|
||||||
|
} else {
|
||||||
|
hints.push('Unbegrenztes Volumen');
|
||||||
|
}
|
||||||
|
hints.push(`läuft bis ${formatDate(pot.valid_to)}${pot.days_left <= warnDays ? ` (noch ${Math.max(0, pot.days_left)} Tage)` : ''}`);
|
||||||
|
const warn = (percentAfter != null && percentAfter >= warnPercent) || pot.days_left <= warnDays;
|
||||||
|
return { ok: true, pot, level: warn ? 'warn' : 'ok', message: `${pot.label}: ${hints.join(', ')}.`, freeMinutes: free };
|
||||||
|
}
|
||||||
|
return last!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { Button, Field, Toggle, inputClass } from '../ui/controls';
|
||||||
|
import { customersApi } from '../../lib/api';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import type { Customer } from '../../types';
|
||||||
|
|
||||||
|
const COLORS = ['#6ba04a', '#0ea5e9', '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#eab308', '#6366f1', '#10b981', '#ec4899', '#64748b'];
|
||||||
|
|
||||||
|
export const CustomerForm: React.FC<{ customer?: Customer | null; onClose: () => void; onSaved: (customer: Customer) => void }> = ({
|
||||||
|
customer,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}) => {
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [form, setForm] = useState<Partial<Customer>>(
|
||||||
|
customer ?? { name: '', short_name: '', color: COLORS[Math.floor(Math.random() * COLORS.length)], is_active: true }
|
||||||
|
);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const set = (patch: Partial<Customer>) => setForm({ ...form, ...patch });
|
||||||
|
|
||||||
|
// Kuerzel aus dem Namen vorschlagen, solange es niemand angefasst hat.
|
||||||
|
const suggest = (name: string) =>
|
||||||
|
name
|
||||||
|
.replace(/\b(GmbH|AG|SE|KG|Co\.?|mbH|e\.V\.|Inc\.?|Ltd\.?)\b/gi, '')
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)[0]
|
||||||
|
?.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9ÄÖÜ]/g, '')
|
||||||
|
.slice(0, 10) ?? '';
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const saved = customer ? await customersApi.update(customer.id, form) : await customersApi.create(form);
|
||||||
|
addToast(customer ? 'Kunde gespeichert.' : `Kunde ${saved.name} angelegt.`, 'success');
|
||||||
|
onSaved(saved);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = (key: keyof Customer, label: string, props: React.InputHTMLAttributes<HTMLInputElement> = {}) => (
|
||||||
|
<Field label={label}>
|
||||||
|
<input className={inputClass} value={(form[key] as string) ?? ''} onChange={(e) => set({ [key]: e.target.value })} {...props} />
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={customer ? 'Kunde bearbeiten' : 'Neuer Kunde'}
|
||||||
|
subtitle={customer ? customer.name : 'Kunden stehen über den Projekten'}
|
||||||
|
onClose={onClose}
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button icon="check" busy={busy} onClick={save}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[1fr_10rem]">
|
||||||
|
<Field label="Name" required>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={form.name ?? ''}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => {
|
||||||
|
const name = e.target.value;
|
||||||
|
const auto = !customer && (form.short_name === '' || form.short_name === suggest(form.name ?? ''));
|
||||||
|
set({ name, ...(auto ? { short_name: suggest(name) } : {}) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Kürzel" required hint="Steht im Projektcode">
|
||||||
|
<input className={`${inputClass} uppercase`} maxLength={16} value={form.short_name ?? ''} onChange={(e) => set({ short_name: e.target.value.toUpperCase() })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{text('contact_person', 'Ansprechpartner')}
|
||||||
|
{text('email', 'E-Mail', { type: 'email' })}
|
||||||
|
{text('phone', 'Telefon', { type: 'tel' })}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[2fr_1fr_2fr_1fr]">
|
||||||
|
{text('street', 'Straße')}
|
||||||
|
{text('zip', 'PLZ')}
|
||||||
|
{text('city', 'Ort')}
|
||||||
|
{text('country', 'Land')}
|
||||||
|
</div>
|
||||||
|
<Field label="Notizen">
|
||||||
|
<textarea className={`${inputClass} h-20`} value={form.notes ?? ''} onChange={(e) => set({ notes: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<div>
|
||||||
|
<span className="field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500">Farbe</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set({ color: c })}
|
||||||
|
aria-label={`Farbe ${c}`}
|
||||||
|
className={`h-8 w-8 rounded-full transition hover:scale-110 ${form.color === c ? 'ring-2 ring-ink-800 ring-offset-2 ring-offset-surface' : ''}`}
|
||||||
|
style={{ background: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{customer && <Toggle checked={Boolean(form.is_active)} onChange={(v) => set({ is_active: v })} label="Aktiv" hint="Inaktive Kunden erscheinen nicht mehr bei neuen Projekten." />}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Avatar, AvatarStack, Badge, Button, Card, EmptyState, ErrorBox, IconButton, Panel, Segmented, Spinner, Stat, inputClass } from '../ui/controls';
|
||||||
|
import { ConfirmDialog } from '../ui/Modal';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { CountUp, SegmentRing, Sparkline } from '../ui/charts';
|
||||||
|
import { CustomerForm } from './CustomerForm';
|
||||||
|
import { customersApi, type CustomerOverview } from '../../lib/api';
|
||||||
|
import { useGrow, useLoader } from '../../lib/hooks';
|
||||||
|
import { MONTHS, colorFor, compactEuro, formatEuro, formatHours } from '../../lib/format';
|
||||||
|
import { useRouter } from '../../lib/router';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { TYPE_TONE } from '../projects/Projects';
|
||||||
|
import { PROJECT_TYPE_LABELS, type Customer } from '../../types';
|
||||||
|
|
||||||
|
export const Customers: React.FC = () => {
|
||||||
|
const { data, error, reload } = useLoader(() => customersApi.overview(), []);
|
||||||
|
const { navigate } = useRouter();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [status, setStatus] = useState<'active' | 'all'>('active');
|
||||||
|
const [form, setForm] = useState<{ customer: Customer | null } | null>(null);
|
||||||
|
const [remove, setRemove] = useState<CustomerOverview | null>(null);
|
||||||
|
const [expanded, setExpanded] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const customers = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return (data?.customers ?? [])
|
||||||
|
.filter((c) => (status === 'all' || c.is_active) && (!q || `${c.name} ${c.short_name} ${c.city ?? ''}`.toLowerCase().includes(q)))
|
||||||
|
.sort((a, b) => b.earned + b.open - (a.earned + a.open) || a.name.localeCompare(b.name, 'de'));
|
||||||
|
}, [data, search, status]);
|
||||||
|
|
||||||
|
if (error && !data) return <ErrorBox message={error} onRetry={reload} />;
|
||||||
|
if (!data) return <Spinner label="Lade Kunden…" />;
|
||||||
|
|
||||||
|
const all = data.customers;
|
||||||
|
const earned = all.reduce((s, c) => s + c.earned, 0);
|
||||||
|
const open = all.reduce((s, c) => s + c.open, 0);
|
||||||
|
const minutes = all.reduce((s, c) => s + c.used_minutes, 0);
|
||||||
|
const activeProjects = all.reduce((s, c) => s + c.projects.filter((p) => p.is_active).length, 0);
|
||||||
|
|
||||||
|
const doRemove = async () => {
|
||||||
|
const target = remove!;
|
||||||
|
setRemove(null);
|
||||||
|
try {
|
||||||
|
await customersApi.remove(target.id);
|
||||||
|
addToast(`${target.name} gelöscht.`, 'success');
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[1500px] space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<Stat index={0} label="Umsatz erbracht" icon="euro" value={earned} format={(v) => compactEuro(v)} hint="über alle Budgettöpfe" />
|
||||||
|
<Stat index={1} label="Umsatz offen" icon="pot" value={open} format={(v) => compactEuro(v)} hint="in laufenden Töpfen" accent="var(--color-brand-300)" />
|
||||||
|
<Stat index={2} label="Gebuchte Stunden" icon="clock" value={minutes / 60} format={(v) => Math.round(v).toLocaleString('de-DE')} accent="var(--color-sky-700)" />
|
||||||
|
<Stat index={3} label="Aktive Projekte" icon="folder" value={activeProjects} format={(v) => String(Math.round(v))} hint={`${all.filter((c) => c.is_active).length} aktive Kunden`} accent="var(--color-violet-700)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(300px,1fr)]">
|
||||||
|
<Panel index={4} title="Umsatz je Kunde" hint="Erbracht und noch offen – Balken wachsen beim Laden">
|
||||||
|
<RevenueBars customers={customers.filter((c) => c.earned + c.open > 0)} onOpen={(id) => navigate(`reports/kunde/${id}`)} />
|
||||||
|
</Panel>
|
||||||
|
<Panel index={5} title="Anteil am Umsatz" hint="Erbrachter Umsatz nach Kunde">
|
||||||
|
<ShareRing customers={all} total={earned} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||||
|
<div className="relative min-w-[200px] flex-1 sm:max-w-xs">
|
||||||
|
<Icon name="search" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-400" />
|
||||||
|
<input className={`${inputClass} pl-9`} placeholder="Kunde suchen…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Segmented size="sm" value={status} onChange={setStatus} options={[{ value: 'active', label: 'Aktiv' }, { value: 'all', label: 'Alle' }]} />
|
||||||
|
<Button icon="plus" className="ml-auto" onClick={() => setForm({ customer: null })}>
|
||||||
|
Neuer Kunde
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customers.length === 0 ? (
|
||||||
|
<EmptyState icon="building" title="Keine Kunden gefunden" action={<Button icon="plus" onClick={() => setForm({ customer: null })}>Neuer Kunde</Button>} />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
{customers.map((c, i) => (
|
||||||
|
<CustomerCard
|
||||||
|
key={c.id}
|
||||||
|
customer={c}
|
||||||
|
months={data.months}
|
||||||
|
index={i}
|
||||||
|
expanded={expanded === c.id}
|
||||||
|
onToggle={() => setExpanded(expanded === c.id ? null : c.id)}
|
||||||
|
onEdit={() => setForm({ customer: c })}
|
||||||
|
onDelete={() => setRemove(c)}
|
||||||
|
onReport={() => navigate(`reports/kunde/${c.id}`)}
|
||||||
|
onProject={(id) => navigate(`projekte/${id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form && <CustomerForm customer={form.customer} onClose={() => setForm(null)} onSaved={reload} />}
|
||||||
|
{remove && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`${remove.name} löschen?`}
|
||||||
|
message={remove.projects.length ? 'Der Kunde hat Projekte und kann nur deaktiviert werden.' : 'Der Kunde wird endgültig entfernt.'}
|
||||||
|
onConfirm={doRemove}
|
||||||
|
onCancel={() => setRemove(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RevenueBars: React.FC<{ customers: CustomerOverview[]; onOpen: (id: number) => void }> = ({ customers, onOpen }) => {
|
||||||
|
const grown = useGrow(customers.map((c) => c.id).join());
|
||||||
|
const max = Math.max(1, ...customers.map((c) => c.earned + c.open));
|
||||||
|
if (!customers.length) return <p className="py-10 text-center text-xs text-ink-400">Noch kein Umsatz erfasst.</p>;
|
||||||
|
return (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{customers.slice(0, 10).map((c, i) => {
|
||||||
|
const color = colorFor(c.id, c.color);
|
||||||
|
return (
|
||||||
|
<li key={c.id}>
|
||||||
|
<button onClick={() => onOpen(c.id)} className="group w-full text-left">
|
||||||
|
<div className="mb-1 flex items-baseline justify-between gap-3 text-sm">
|
||||||
|
<span className="flex min-w-0 items-center gap-2 font-bold text-ink-800 group-hover:text-brand-700">
|
||||||
|
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: color }} />
|
||||||
|
<span className="truncate">{c.name}</span>
|
||||||
|
</span>
|
||||||
|
<span className="tabular shrink-0 text-xs">
|
||||||
|
<b className="text-ink-900">{compactEuro(c.earned)}</b>
|
||||||
|
<span className="text-ink-400"> + {compactEuro(c.open)} offen</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-4 overflow-hidden rounded-full bg-ink-100">
|
||||||
|
<div
|
||||||
|
className="h-full transition-[width] duration-1000 ease-out"
|
||||||
|
style={{ width: grown ? `${(c.earned / max) * 100}%` : 0, background: color, transitionDelay: `${i * 70}ms` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full transition-[width] duration-1000 ease-out"
|
||||||
|
style={{
|
||||||
|
width: grown ? `${(c.open / max) * 100}%` : 0,
|
||||||
|
background: `repeating-linear-gradient(-45deg, ${color}55, ${color}55 5px, ${color}22 5px, ${color}22 10px)`,
|
||||||
|
transitionDelay: `${i * 70 + 200}ms`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ShareRing: React.FC<{ customers: CustomerOverview[]; total: number }> = ({ customers, total }) => {
|
||||||
|
const top = [...customers].filter((c) => c.earned > 0).sort((a, b) => b.earned - a.earned);
|
||||||
|
const segments = top.slice(0, 6).map((c) => ({ label: c.name, value: c.earned, color: colorFor(c.id, c.color) }));
|
||||||
|
const rest = top.slice(6).reduce((s, c) => s + c.earned, 0);
|
||||||
|
if (rest > 0) segments.push({ label: 'Weitere', value: rest, color: 'var(--color-ink-300)' });
|
||||||
|
if (!segments.length) return <p className="py-10 text-center text-xs text-ink-400">Noch kein Umsatz erfasst.</p>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<SegmentRing
|
||||||
|
segments={segments}
|
||||||
|
size={180}
|
||||||
|
stroke={22}
|
||||||
|
center={
|
||||||
|
<>
|
||||||
|
<CountUp value={total} format={(v) => compactEuro(v)} className="text-xl font-extrabold text-ink-900" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-ink-400">erbracht</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ul className="w-full space-y-1.5">
|
||||||
|
{segments.map((s) => (
|
||||||
|
<li key={s.label} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-sm" style={{ background: s.color }} />
|
||||||
|
<span className="min-w-0 flex-1 truncate font-bold text-ink-700">{s.label}</span>
|
||||||
|
<span className="tabular font-extrabold text-ink-900">{Math.round((s.value / (total || 1)) * 100)} %</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomerCard: React.FC<{
|
||||||
|
customer: CustomerOverview;
|
||||||
|
months: string[];
|
||||||
|
index: number;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onReport: () => void;
|
||||||
|
onProject: (id: number) => void;
|
||||||
|
}> = ({ customer: c, months, index, expanded, onToggle, onEdit, onDelete, onReport, onProject }) => {
|
||||||
|
const color = colorFor(c.id, c.color);
|
||||||
|
const total = c.earned + c.open;
|
||||||
|
const percent = total ? (c.earned / total) * 100 : 0;
|
||||||
|
const lastMonth = c.monthly[c.monthly.length - 1] ?? 0;
|
||||||
|
return (
|
||||||
|
<Card className={`animate-rise overflow-hidden transition hover:shadow-lift ${c.is_active ? '' : 'opacity-60'}`} style={{ '--i': Math.min(index, 10) } as React.CSSProperties}>
|
||||||
|
<div className="relative p-4">
|
||||||
|
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-sm font-extrabold text-white" style={{ background: color }}>
|
||||||
|
{c.short_name.slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="truncate text-base text-ink-900">{c.name}</h3>
|
||||||
|
<p className="truncate text-xs text-ink-400">
|
||||||
|
{c.short_name}
|
||||||
|
{c.city && ` · ${c.city}`}
|
||||||
|
{c.contact_person && ` · ${c.contact_person}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0">
|
||||||
|
<IconButton icon="chart" label="Kundenreport" onClick={onReport} />
|
||||||
|
<IconButton icon="edit" label="Bearbeiten" onClick={onEdit} />
|
||||||
|
{c.projects.length === 0 && <IconButton icon="trash" tone="danger" label="Löschen" onClick={onDelete} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-4">
|
||||||
|
<MiniRing percent={percent} color={color} />
|
||||||
|
<div className="grid flex-1 grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-400">Erbracht</p>
|
||||||
|
<CountUp value={c.earned} format={(v) => formatEuro(v)} className="text-base font-extrabold text-ink-900" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-400">Offen</p>
|
||||||
|
<CountUp value={c.open} format={(v) => formatEuro(v)} className="text-base font-extrabold text-ink-500" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-400">Letzte 12 Monate</p>
|
||||||
|
<p className="tabular text-[11px] text-ink-500">
|
||||||
|
{MONTHS[Number(months[months.length - 1].slice(5)) - 1]}: {compactEuro(lastMonth)} · {formatHours(c.used_minutes, 0)} h gesamt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Sparkline values={c.monthly} width={110} height={30} color={color} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={onToggle} className="flex w-full items-center justify-between border-t border-ink-100 bg-ink-50/60 px-4 py-2.5 text-xs font-bold text-ink-600 hover:bg-ink-100">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Icon name="folder" className="h-4 w-4" />
|
||||||
|
{c.projects.length} {c.projects.length === 1 ? 'Projekt' : 'Projekte'}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<AvatarStack people={c.members} max={6} size={22} />
|
||||||
|
<Icon name="chevronDown" className={`h-4 w-4 transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<ul className="animate-fade divide-y divide-ink-100">
|
||||||
|
{c.projects.length === 0 && <li className="px-4 py-3 text-xs text-ink-400">Noch keine Projekte.</li>}
|
||||||
|
{c.projects.map((p) => (
|
||||||
|
<li key={p.id} className="px-4 py-3">
|
||||||
|
<button onClick={() => onProject(p.id)} className="flex w-full items-center justify-between gap-2 text-left">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-bold text-ink-800 hover:text-brand-700">{p.display_name}</span>
|
||||||
|
<span className="tabular block text-[11px] text-ink-400">
|
||||||
|
{p.code} · {formatHours(p.used_minutes, 1)} h{p.budget_hours ? ` / ${p.budget_hours.toLocaleString('de-DE')} h` : ''} · {formatEuro(p.earned)} erbracht
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
|
||||||
|
</button>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{p.members.map((m) => (
|
||||||
|
<span key={m.id} className="flex items-center gap-1.5 rounded-full bg-ink-50 py-0.5 pl-0.5 pr-2 text-[11px] font-semibold text-ink-600">
|
||||||
|
<Avatar id={m.id} name={m.name} color={m.color} size={18} external={m.is_external} />
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MiniRing: React.FC<{ percent: number; color: string }> = ({ percent, color }) => {
|
||||||
|
const grown = useGrow(percent);
|
||||||
|
const size = 64;
|
||||||
|
const stroke = 8;
|
||||||
|
const r = (size - stroke) / 2;
|
||||||
|
const c = 2 * Math.PI * r;
|
||||||
|
return (
|
||||||
|
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size} className="-rotate-90">
|
||||||
|
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-ink-100)" strokeWidth={stroke} />
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={c}
|
||||||
|
strokeDashoffset={grown ? c * (1 - percent / 100) : c}
|
||||||
|
style={{ transition: 'stroke-dashoffset 1.2s cubic-bezier(0.22,1,0.36,1)' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="tabular absolute inset-0 flex items-center justify-center text-xs font-extrabold text-ink-800">{Math.round(percent)}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Avatar, Badge, ErrorBox, Panel, Segmented, Spinner, Stat } from '../ui/controls';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { BILLABLE, Donut, Legend, NON_BILLABLE, SegmentRing, SplitBar, StackedBars } from '../ui/charts';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { dashboardApi, type DashboardData, type Granularity, type RankPeriod } from '../../lib/api';
|
||||||
|
import { useLoader, usePersistentState } from '../../lib/hooks';
|
||||||
|
import { colorFor, compactEuro, formatDate, formatDuration, formatHours, relativeTime } from '../../lib/format';
|
||||||
|
import { useRouter } from '../../lib/router';
|
||||||
|
|
||||||
|
const GRANULARITY: { value: Granularity; label: string }[] = [
|
||||||
|
{ value: 'day', label: 'Tag' },
|
||||||
|
{ value: 'week', label: 'Woche' },
|
||||||
|
{ value: 'month', label: 'Monat' },
|
||||||
|
{ value: 'quarter', label: 'Quartal' },
|
||||||
|
{ value: 'year', label: 'Jahr' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PERIOD_NOUN: Record<Granularity, string> = { day: 'heute', week: 'diese Woche', month: 'diesen Monat', quarter: 'dieses Quartal', year: 'dieses Jahr' };
|
||||||
|
|
||||||
|
export const Dashboard: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [granularity, setGranularity] = usePersistentState<Granularity>('pulse_dash_granularity', 'week');
|
||||||
|
const [rank, setRank] = usePersistentState<RankPeriod>('pulse_dash_rank', 'month');
|
||||||
|
const { data, error, loading, reload } = useLoader(() => dashboardApi.get(granularity, rank), [granularity, rank]);
|
||||||
|
|
||||||
|
if (error && !data) return <ErrorBox message={error} onRetry={reload} />;
|
||||||
|
if (!data) return <Spinner label="Lade Dashboard…" />;
|
||||||
|
|
||||||
|
const cur = data.kpis.current;
|
||||||
|
const prev = data.kpis.previous;
|
||||||
|
const delta = (a: number, b: number | undefined) => (b ? ((a - b) / b) * 100 : null);
|
||||||
|
const share = cur.minutes ? (cur.billable_minutes / cur.minutes) * 100 : 0;
|
||||||
|
const utilization = cur.capacity_minutes ? (cur.minutes / cur.capacity_minutes) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`mx-auto max-w-[1500px] space-y-4 transition-opacity ${loading ? 'opacity-70' : ''}`}>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-ink-500">
|
||||||
|
Hallo <b className="text-ink-900">{user!.name.split(' ')[0]}</b> – so sieht {PERIOD_NOUN[granularity]} aus.
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<Badge tone={data.scope === 'all' ? 'brand' : 'sky'}>
|
||||||
|
<Icon name="team" className="h-3 w-3" />
|
||||||
|
{data.scope === 'all' ? 'Gesamte Organisation' : 'Mein Team'} · {data.team_size} {data.team_size === 1 ? 'Person' : 'Personen'}
|
||||||
|
</Badge>
|
||||||
|
<Badge>{data.kpis.period_label}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Segmented className="ml-auto w-full sm:w-auto" value={granularity} onChange={setGranularity} options={GRANULARITY} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<Stat index={0} label="Gebuchte Stunden" icon="clock" value={cur.minutes / 60} format={(v) => formatNumberDe(v, 1)} delta={delta(cur.minutes, prev?.minutes)} hint={prev ? `vs. ${data.kpis.previous_label}` : undefined} />
|
||||||
|
<Stat index={1} label="Billable" icon="check" value={cur.billable_minutes / 60} format={(v) => formatNumberDe(v, 1)} delta={delta(cur.billable_minutes, prev?.billable_minutes)} hint={`${Math.round(share)} % Anteil`} />
|
||||||
|
<Stat index={2} label="Billable Betrag" icon="euro" value={cur.amount} format={(v) => compactEuro(v)} delta={delta(cur.amount, prev?.amount)} accent="var(--color-sky-700)" />
|
||||||
|
<Stat index={3} label="Auslastung" icon="chart" value={utilization} format={(v) => `${Math.round(v)} %`} hint={`Kapazität ${formatNumberDe(cur.capacity_minutes / 60, 0)} h bis heute`} accent="var(--color-violet-700)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
|
||||||
|
<Panel
|
||||||
|
index={4}
|
||||||
|
title="Gebuchte Zeiten"
|
||||||
|
hint={`Billable und non-billable je ${GRANULARITY.find((g) => g.value === granularity)?.label}`}
|
||||||
|
action={<Legend items={[{ color: BILLABLE, label: 'Billable' }, { color: NON_BILLABLE, label: 'Non-billable' }]} />}
|
||||||
|
>
|
||||||
|
<StackedBars
|
||||||
|
points={data.series.map((s, i) => ({ key: s.from, label: s.label, a: s.billable_minutes, b: s.non_billable_minutes, highlight: i === data.series.length - 1 }))}
|
||||||
|
format={(m) => `${formatHours(m, 1)} h`}
|
||||||
|
axisFormat={(m) => formatNumberDe(m / 60, 0)}
|
||||||
|
height={260}
|
||||||
|
scale={60}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel index={5} title="Verteilung" hint={`Projekte ${PERIOD_NOUN[granularity]}`}>
|
||||||
|
<Distribution data={data} share={share} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,3fr)_minmax(320px,2fr)]">
|
||||||
|
<Ranking data={data} rank={rank} onRank={setRank} />
|
||||||
|
<RecentFeed data={data} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatNumberDe(value: number, digits: number) {
|
||||||
|
return value.toLocaleString('de-DE', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||||
|
}
|
||||||
|
|
||||||
|
const Distribution: React.FC<{ data: DashboardData; share: number }> = ({ data, share }) => {
|
||||||
|
const total = data.projects.reduce((s, p) => s + p.minutes, 0);
|
||||||
|
const segments = data.projects.map((p) => ({ label: p.name, value: p.minutes, color: colorFor(p.project_id, p.customer_color) }));
|
||||||
|
if (!total) return <p className="py-10 text-center text-xs font-semibold text-ink-400">Noch keine Buchungen in diesem Zeitraum.</p>;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-center gap-6">
|
||||||
|
<SegmentRing
|
||||||
|
segments={segments}
|
||||||
|
size={150}
|
||||||
|
center={
|
||||||
|
<>
|
||||||
|
<span className="tabular text-2xl font-extrabold text-ink-900">{formatNumberDe(total / 60, 0)}</span>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-ink-400">Stunden</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<Donut percent={share} size={84} stroke={10} label="billable" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-5 space-y-2">
|
||||||
|
{data.projects.slice(0, 6).map((p, i) => (
|
||||||
|
<li key={p.project_id} className="animate-rise flex items-center gap-2 text-xs" style={{ '--i': i } as React.CSSProperties}>
|
||||||
|
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: segments[i].color }} />
|
||||||
|
<span className="min-w-0 flex-1 truncate font-bold text-ink-700">{p.name}</span>
|
||||||
|
<span className="tabular font-extrabold text-ink-900">{formatHours(p.minutes, 1)} h</span>
|
||||||
|
<span className="tabular w-10 text-right text-ink-400">{Math.round((p.minutes / total) * 100)} %</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type RankFilter = 'all' | 'billable' | 'non_billable';
|
||||||
|
const ROW = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mitarbeiterliste. Die Zeilen sind absolut positioniert und gleiten beim
|
||||||
|
* Umsortieren oder Filtern an ihren neuen Platz - man sieht, wer aufsteigt.
|
||||||
|
*/
|
||||||
|
const Ranking: React.FC<{ data: DashboardData; rank: RankPeriod; onRank: (r: RankPeriod) => void }> = ({ data, rank, onRank }) => {
|
||||||
|
const [filter, setFilter] = usePersistentState<RankFilter>('pulse_dash_filter', 'all');
|
||||||
|
const [order, setOrder] = usePersistentState<'desc' | 'asc'>('pulse_dash_order', 'desc');
|
||||||
|
const { navigate } = useRouter();
|
||||||
|
|
||||||
|
const value = (r: DashboardData['ranking'][number]) => (filter === 'billable' ? r.billable_minutes : filter === 'non_billable' ? r.non_billable_minutes : r.minutes);
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => [...data.ranking].sort((a, b) => (order === 'desc' ? value(b) - value(a) : value(a) - value(b)) || a.name.localeCompare(b.name, 'de')),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[data.ranking, filter, order]
|
||||||
|
);
|
||||||
|
const positions = new Map(sorted.map((r, i) => [r.user_id, i]));
|
||||||
|
const max = Math.max(1, ...data.ranking.map((r) => (filter === 'all' ? r.minutes : value(r))));
|
||||||
|
const sum = sorted.reduce((s, r) => s + value(r), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
index={6}
|
||||||
|
title="Buchungen je Mitarbeiter"
|
||||||
|
hint={`${formatDate(data.rank_range.from)} – ${formatDate(data.rank_range.to)} · ${formatHours(sum, 1)} h gesamt`}
|
||||||
|
action={
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={rank}
|
||||||
|
onChange={(e) => onRank(e.target.value as RankPeriod)}
|
||||||
|
className="rounded-xl border border-ink-200 bg-surface px-3 py-1.5 text-xs font-bold text-ink-600 outline-none focus:border-brand-500"
|
||||||
|
>
|
||||||
|
<option value="days">Letzte 7 Tage</option>
|
||||||
|
<option value="week">Diese Woche</option>
|
||||||
|
<option value="month">Dieser Monat</option>
|
||||||
|
<option value="quarter">Dieses Quartal</option>
|
||||||
|
<option value="year">Dieses Jahr</option>
|
||||||
|
</select>
|
||||||
|
<Segmented
|
||||||
|
size="sm"
|
||||||
|
value={filter}
|
||||||
|
onChange={setFilter}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: 'Alle' },
|
||||||
|
{ value: 'billable', label: 'Billable' },
|
||||||
|
{ value: 'non_billable', label: 'Non-billable' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setOrder(order === 'desc' ? 'asc' : 'desc')}
|
||||||
|
className="flex items-center gap-1 rounded-xl border border-ink-200 px-2.5 py-1.5 text-xs font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
|
||||||
|
title="Sortierung umkehren"
|
||||||
|
>
|
||||||
|
<Icon name={order === 'desc' ? 'arrowDown' : 'arrowUp'} className="h-3.5 w-3.5" strokeWidth={2.4} />
|
||||||
|
{order === 'desc' ? 'Absteigend' : 'Aufsteigend'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data.ranking.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-xs text-ink-400">Keine Mitarbeiter im Team.</p>
|
||||||
|
) : (
|
||||||
|
<div className="relative" style={{ height: data.ranking.length * ROW }}>
|
||||||
|
{data.ranking.map((r) => {
|
||||||
|
const pos = positions.get(r.user_id) ?? 0;
|
||||||
|
const a = filter === 'non_billable' ? 0 : r.billable_minutes;
|
||||||
|
const b = filter === 'billable' ? 0 : r.non_billable_minutes;
|
||||||
|
const util = r.capacity_minutes ? Math.round((r.minutes / r.capacity_minutes) * 100) : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.user_id}
|
||||||
|
className="absolute inset-x-0 flex items-center gap-3 rounded-xl px-1 transition-transform duration-500 ease-out hover:bg-ink-50"
|
||||||
|
style={{ height: ROW, transform: `translateY(${pos * ROW}px)` }}
|
||||||
|
>
|
||||||
|
<span className="tabular w-5 shrink-0 text-center text-xs font-extrabold text-ink-300">{pos + 1}</span>
|
||||||
|
<Avatar id={r.user_id} name={r.name} color={r.color} size={34} external={r.is_external} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<button onClick={() => navigate('reports')} className="truncate text-left text-sm font-bold text-ink-800 hover:text-brand-700" title="Zu den Reports">
|
||||||
|
{r.name}
|
||||||
|
</button>
|
||||||
|
<span className="tabular shrink-0 text-sm font-extrabold text-ink-900">{formatHours(value(r), 2)} h</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<SplitBar a={a} b={b} max={max} className="flex-1" />
|
||||||
|
{util != null && (
|
||||||
|
<span
|
||||||
|
className={`tabular w-12 shrink-0 text-right text-[11px] font-bold ${util > 110 ? 'text-red-600' : util >= 80 ? 'text-brand-700' : 'text-ink-400'}`}
|
||||||
|
title="Auslastung (alle Stunden / Kapazität)"
|
||||||
|
>
|
||||||
|
{util} %
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RecentFeed: React.FC<{ data: DashboardData }> = ({ data }) => (
|
||||||
|
<Panel index={7} title="Letzte Buchungen" hint="Neueste Einträge zuerst">
|
||||||
|
{data.recent.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-xs text-ink-400">Noch keine Buchungen.</p>
|
||||||
|
) : (
|
||||||
|
<ol className="relative space-y-1 before:absolute before:bottom-3 before:left-[17px] before:top-3 before:w-px before:bg-ink-100">
|
||||||
|
{data.recent.map((e, i) => (
|
||||||
|
<li key={e.id} className="animate-rise relative flex gap-3 rounded-xl p-1.5 hover:bg-ink-50" style={{ '--i': i } as React.CSSProperties}>
|
||||||
|
<Avatar id={e.user_id} name={e.user_name} color={e.user_color} size={34} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<p className="truncate text-sm font-bold text-ink-800">{e.user_name}</p>
|
||||||
|
<span className={`tabular shrink-0 rounded-full px-2 py-0.5 text-xs font-extrabold ${e.billable ? 'bg-brand-50 text-brand-700' : 'bg-ink-100 text-ink-600'}`}>
|
||||||
|
{formatDuration(e.minutes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-xs text-ink-500">
|
||||||
|
<span className="mr-1 inline-block h-2 w-2 rounded-full align-middle" style={{ background: colorFor(e.project_display, e.customer_color) }} />
|
||||||
|
{e.project_display}
|
||||||
|
{e.task_name && <span className="text-ink-400"> · {e.task_name}</span>}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-ink-400">
|
||||||
|
für {formatDate(e.entry_date, 'day')} · {relativeTime(e.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sonnenstrahlen des Themes "Aura".
|
||||||
|
*
|
||||||
|
* Alle 16 bis 24 Sekunden fallen zwei oder drei Strahlen von der Sonne in der
|
||||||
|
* rechten oberen Ecke der Kopfleiste schräg über die Seite und verblassen
|
||||||
|
* wieder. Nachts bleibt es dunkel - dort steht der Mond, und Mondstrahlen über
|
||||||
|
* dem Bildschirm wären zu viel.
|
||||||
|
*
|
||||||
|
* Die Strahlen liegen über der Oberfläche, fangen keinen Klick ab und bleiben
|
||||||
|
* unter den Dialogen. Bei "Bewegung reduzieren" im Betriebssystem bleiben sie
|
||||||
|
* ganz aus; Himmel, Sonne und Wolken der Leiste sind reines CSS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GAP_MIN_MS = 16000;
|
||||||
|
const GAP_MAX_MS = 24000;
|
||||||
|
const DURATION_MS = 4200;
|
||||||
|
/** Ursprung: so weit von der rechten Kante bzw. Oberkante der Leiste entfernt. */
|
||||||
|
const ORIGIN_RIGHT = 20;
|
||||||
|
const ORIGIN_TOP = 10;
|
||||||
|
|
||||||
|
function random(min: number, max: number) {
|
||||||
|
return min + Math.random() * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefersReducedMotion() {
|
||||||
|
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuraSky: React.FC = () => {
|
||||||
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = layerRef.current;
|
||||||
|
if (!layer || prefersReducedMotion() || typeof layer.animate !== 'function') return;
|
||||||
|
|
||||||
|
let timer = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
const schedule = (delay = random(GAP_MIN_MS, GAP_MAX_MS)) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
timer = window.setTimeout(shine, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shine = () => {
|
||||||
|
/*
|
||||||
|
* Die Leiste wird bei jedem Durchgang neu vermessen: sie fehlt auf dem
|
||||||
|
* Anmeldeschirm und ändert ihre Breite mit der Seitenleiste. Im
|
||||||
|
* Dunkelmodus scheint nichts - dort steht der Mond.
|
||||||
|
*/
|
||||||
|
const header = document.querySelector<HTMLElement>('.app-header');
|
||||||
|
if (!header || document.hidden || document.documentElement.classList.contains('dark')) {
|
||||||
|
schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = header.getBoundingClientRect();
|
||||||
|
const originX = rect.right - ORIGIN_RIGHT;
|
||||||
|
const originY = rect.top + ORIGIN_TOP;
|
||||||
|
// Zielpunkt ist die linke untere Ecke: daraus folgen Winkel und Länge.
|
||||||
|
const dx = 0 - originX;
|
||||||
|
const dy = window.innerHeight - originY;
|
||||||
|
const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||||
|
const length = Math.hypot(dx, dy) * 1.15;
|
||||||
|
const count = Math.random() < 0.5 ? 2 : 3;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const ray = document.createElement('div');
|
||||||
|
const thickness = random(70, 150);
|
||||||
|
// Die Strahlen liegen leicht gefächert übereinander.
|
||||||
|
const rotation = angle + (i - (count - 1) / 2) * random(5, 8);
|
||||||
|
ray.className = 'aura-ray';
|
||||||
|
ray.style.left = `${originX}px`;
|
||||||
|
ray.style.top = `${originY - thickness / 2}px`;
|
||||||
|
ray.style.width = `${length}px`;
|
||||||
|
ray.style.height = `${thickness}px`;
|
||||||
|
layer.appendChild(ray);
|
||||||
|
|
||||||
|
// Leichtes Wandern im Winkel, damit es nicht wie ein Standbild wirkt.
|
||||||
|
const animation = ray.animate(
|
||||||
|
[
|
||||||
|
{ opacity: 0, transform: `rotate(${rotation - 1.5}deg)` },
|
||||||
|
{ opacity: random(0.55, 0.85), offset: 0.4 },
|
||||||
|
{ opacity: 0, transform: `rotate(${rotation + 1.5}deg)` },
|
||||||
|
],
|
||||||
|
{ duration: DURATION_MS + random(-600, 600), delay: i * random(250, 500), easing: 'ease-in-out', fill: 'both' }
|
||||||
|
);
|
||||||
|
animation.onfinish = () => ray.remove();
|
||||||
|
}
|
||||||
|
schedule();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Der erste Strahl kommt früher, damit man das Theme beim Umschalten erlebt.
|
||||||
|
schedule(random(2500, 4000));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
layer.replaceChildren();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div ref={layerRef} aria-hidden="true" className="pointer-events-none fixed inset-0 z-[90] overflow-hidden" />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTheme } from '../../lib/theme';
|
||||||
|
import { THEME_PALETTES, THEME_PALETTE_LABELS } from '../../types';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { PulseMark } from './PulseMark';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onToggleSidebar: () => void;
|
||||||
|
isMobile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header: React.FC<HeaderProps> = ({ title, subtitle, onToggleSidebar, isMobile }) => {
|
||||||
|
const { theme, preference, toggle, useSystem, palette, setPalette } = useTheme();
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="app-header relative flex shrink-0 items-center gap-3 border-b border-ink-200 bg-surface px-4 py-3 md:px-6 md:py-4">
|
||||||
|
{isMobile ? (
|
||||||
|
<PulseMark size={34} />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onToggleSidebar}
|
||||||
|
className="rounded-lg p-2 text-ink-500 transition hover:bg-ink-100 hover:text-ink-800"
|
||||||
|
title="Menü ein-/ausklappen"
|
||||||
|
aria-label="Menü ein-/ausklappen"
|
||||||
|
>
|
||||||
|
<Icon name="menu" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h1 className="truncate text-lg leading-tight text-ink-900">{title}</h1>
|
||||||
|
{subtitle && <p className="truncate text-xs font-semibold text-ink-400">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<label className="sr-only" htmlFor="theme-palette">
|
||||||
|
Design
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="theme-palette"
|
||||||
|
value={palette}
|
||||||
|
onChange={(event) => setPalette(event.target.value as typeof palette)}
|
||||||
|
title={`Design: ${THEME_PALETTE_LABELS[palette].hint}`}
|
||||||
|
className="max-w-[110px] rounded-xl border border-ink-200 bg-surface px-2.5 py-2 text-xs font-bold text-ink-600 outline-none transition hover:border-brand-300 hover:text-brand-700 focus:border-brand-500 sm:max-w-none sm:px-3"
|
||||||
|
>
|
||||||
|
{THEME_PALETTES.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{THEME_PALETTE_LABELS[option].name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
onDoubleClick={useSystem}
|
||||||
|
title={`${isDark ? 'Helles' : 'Dunkles'} Design${preference === null ? ' (folgt dem System)' : ' · Doppelklick: dem System folgen'}`}
|
||||||
|
aria-label={`${isDark ? 'Helles' : 'Dunkles'} Design einschalten`}
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-ink-200 px-2.5 py-2 text-xs font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700 sm:px-3"
|
||||||
|
>
|
||||||
|
<Icon name={isDark ? 'sun' : 'moon'} className="h-4 w-4" />
|
||||||
|
<span className="hidden lg:inline">{isDark ? 'Hell' : 'Dunkel'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { authApi } from '../../lib/api';
|
||||||
|
import { PulseMark } from './PulseMark';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Knoten des dekorativen Netzwerks. Jeder Knoten driftet auf einer eigenen
|
||||||
|
* Lissajous-Bahn, damit sich das Muster nie exakt wiederholt.
|
||||||
|
*/
|
||||||
|
const NETWORK_NODES = [
|
||||||
|
{ x: 80, y: 300, r: 9, ax: 10, ay: 7, sx: 0.055, sy: 0.041, px: 0.0, py: 1.9 },
|
||||||
|
{ x: 150, y: 210, r: 13, ax: 8, ay: 11, sx: 0.037, sy: 0.049, px: 1.2, py: 0.4 },
|
||||||
|
{ x: 240, y: 250, r: 10, ax: 12, ay: 8, sx: 0.045, sy: 0.033, px: 2.7, py: 3.3 },
|
||||||
|
{ x: 310, y: 150, r: 12, ax: 9, ay: 12, sx: 0.031, sy: 0.052, px: 4.1, py: 1.1 },
|
||||||
|
{ x: 250, y: 80, r: 7, ax: 13, ay: 9, sx: 0.048, sy: 0.036, px: 5.4, py: 2.6 },
|
||||||
|
{ x: 160, y: 340, r: 6, ax: 11, ay: 10, sx: 0.042, sy: 0.057, px: 0.8, py: 4.8 },
|
||||||
|
{ x: 360, y: 240, r: 8, ax: 7, ay: 13, sx: 0.058, sy: 0.029, px: 3.5, py: 0.2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Verbindungen zwischen den Knoten (Indizes in NETWORK_NODES). */
|
||||||
|
const NETWORK_EDGES: Array<[number, number]> = [
|
||||||
|
[0, 1],
|
||||||
|
[1, 2],
|
||||||
|
[2, 3],
|
||||||
|
[3, 4],
|
||||||
|
[4, 1],
|
||||||
|
[0, 5],
|
||||||
|
[5, 2],
|
||||||
|
[3, 6],
|
||||||
|
[6, 2],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Dekoratives Netzwerk-Muster - greift das Knoten-Motiv des perinco Logos auf. */
|
||||||
|
const NetworkPattern: React.FC = () => {
|
||||||
|
const circleRefs = useRef<Array<SVGCircleElement | null>>([]);
|
||||||
|
const lineRefs = useRef<Array<SVGLineElement | null>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
if (reduced.matches) return;
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const t = (now - start) / 1000;
|
||||||
|
const positions = NETWORK_NODES.map((node) => ({
|
||||||
|
x: node.x + Math.sin(t * node.sx * Math.PI * 2 + node.px) * node.ax,
|
||||||
|
y: node.y + Math.cos(t * node.sy * Math.PI * 2 + node.py) * node.ay,
|
||||||
|
}));
|
||||||
|
|
||||||
|
positions.forEach((pos, index) => {
|
||||||
|
const circle = circleRefs.current[index];
|
||||||
|
if (!circle) return;
|
||||||
|
circle.setAttribute('cx', pos.x.toFixed(2));
|
||||||
|
circle.setAttribute('cy', pos.y.toFixed(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
NETWORK_EDGES.forEach(([from, to], index) => {
|
||||||
|
const line = lineRefs.current[index];
|
||||||
|
if (!line) return;
|
||||||
|
line.setAttribute('x1', positions[from].x.toFixed(2));
|
||||||
|
line.setAttribute('y1', positions[from].y.toFixed(2));
|
||||||
|
line.setAttribute('x2', positions[to].x.toFixed(2));
|
||||||
|
line.setAttribute('y2', positions[to].y.toFixed(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute inset-0 h-full w-full opacity-20"
|
||||||
|
viewBox="0 0 400 400"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<g stroke="white" strokeWidth="1.2" strokeLinecap="round">
|
||||||
|
{NETWORK_EDGES.map(([from, to], index) => (
|
||||||
|
<line
|
||||||
|
key={`${from}-${to}`}
|
||||||
|
ref={(element) => {
|
||||||
|
lineRefs.current[index] = element;
|
||||||
|
}}
|
||||||
|
x1={NETWORK_NODES[from].x}
|
||||||
|
y1={NETWORK_NODES[from].y}
|
||||||
|
x2={NETWORK_NODES[to].x}
|
||||||
|
y2={NETWORK_NODES[to].y}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
<g fill="white">
|
||||||
|
{NETWORK_NODES.map((node, index) => (
|
||||||
|
<circle
|
||||||
|
key={`${node.x}-${node.y}`}
|
||||||
|
ref={(element) => {
|
||||||
|
circleRefs.current[index] = element;
|
||||||
|
}}
|
||||||
|
cx={node.x}
|
||||||
|
cy={node.y}
|
||||||
|
r={node.r}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LoginScreen: React.FC = () => {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [identifier, setIdentifier] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [config, setConfig] = useState<{ sso_enabled: boolean; sso_label: string; local_login: boolean } | null>(null);
|
||||||
|
const [showLocal, setShowLocal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
authApi
|
||||||
|
.config()
|
||||||
|
.then(setConfig)
|
||||||
|
.catch(() => setConfig({ sso_enabled: false, sso_label: '', local_login: true }));
|
||||||
|
// Fehler aus dem SSO-Rueckweg anzeigen und aus der Adresse entfernen.
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const ssoError = params.get('sso_error');
|
||||||
|
if (ssoError) {
|
||||||
|
setError(ssoError);
|
||||||
|
window.history.replaceState(null, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(identifier, password);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Anmeldung fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const localVisible = !config?.sso_enabled || config.local_login || showLocal;
|
||||||
|
const field =
|
||||||
|
'w-full rounded-xl border border-ink-200 bg-surface px-4 py-3 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] bg-surface">
|
||||||
|
<div className="relative hidden w-1/2 flex-col justify-between overflow-hidden bg-brand-500 p-12 lg:flex">
|
||||||
|
<NetworkPattern />
|
||||||
|
<img src="/logo.png" alt="perinco" className="relative h-12 w-auto self-start brightness-0 invert" />
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<div className="mb-6 flex items-center gap-3 text-white">
|
||||||
|
<svg viewBox="0 0 32 32" className="h-12 w-12" aria-hidden>
|
||||||
|
<path d="M2 17h6l3-7 5 14 4-10 2 3h8" fill="none" stroke="currentColor" strokeWidth={2.6} strokeLinecap="round" strokeLinejoin="round" className="draw-line" style={{ '--len': 60 } as React.CSSProperties} />
|
||||||
|
</svg>
|
||||||
|
<h1 className="text-5xl leading-none text-white">Pulse</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-light text-white/85">
|
||||||
|
Zeiten buchen, Budgets im Blick, Kunden sauber abrechnen – der Herzschlag deiner Projekte.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="relative text-xs font-semibold uppercase tracking-[0.2em] text-white/60">Performance Intelligence Consulting</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col justify-center px-6 py-12 sm:px-12 lg:w-1/2 lg:px-20">
|
||||||
|
<div className="mx-auto w-full max-w-sm">
|
||||||
|
<div className="mb-10 flex items-center justify-between lg:hidden">
|
||||||
|
<img src="/logo.png" alt="perinco" className="logo-adaptive h-10 w-auto" />
|
||||||
|
<span className="flex items-center gap-2 text-2xl font-extrabold text-ink-900">
|
||||||
|
<PulseMark size={32} />
|
||||||
|
Pulse
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl text-ink-900">Anmelden</h2>
|
||||||
|
<p className="mt-2 text-sm text-ink-500">
|
||||||
|
{config?.sso_enabled ? 'Mit deinem perinco-Konto oder als externer Benutzer.' : 'Mit Benutzername oder E-Mail-Adresse.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{config?.sso_enabled && (
|
||||||
|
<a
|
||||||
|
href="/api/auth/sso/start"
|
||||||
|
className="mt-8 flex w-full items-center justify-center gap-3 rounded-xl border border-ink-200 bg-surface px-4 py-3 text-sm font-bold text-ink-800 shadow-card transition hover:border-brand-300"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 21 21" className="h-5 w-5" aria-hidden>
|
||||||
|
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
|
||||||
|
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
|
||||||
|
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
|
||||||
|
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
|
||||||
|
</svg>
|
||||||
|
{config.sso_label || 'Mit Microsoft anmelden'}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config?.sso_enabled && localVisible && (
|
||||||
|
<div className="my-6 flex items-center gap-3 text-[11px] font-bold uppercase tracking-wider text-ink-300">
|
||||||
|
<span className="h-px flex-1 bg-ink-200" />
|
||||||
|
oder lokal
|
||||||
|
<span className="h-px flex-1 bg-ink-200" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{localVisible ? (
|
||||||
|
<form onSubmit={handleSubmit} className={`space-y-5 ${config?.sso_enabled ? '' : 'mt-8'}`}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="identifier" className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-ink-500">
|
||||||
|
Benutzername oder E-Mail
|
||||||
|
</label>
|
||||||
|
<input id="identifier" type="text" autoComplete="username" autoCapitalize="none" autoFocus required value={identifier} onChange={(e) => setIdentifier(e.target.value)} className={field} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-ink-500">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input id="password" type="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} className={field} />
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="animate-pop rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-brand-500 px-4 py-3 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSubmitting && <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />}
|
||||||
|
{isSubmitting ? 'Anmelden…' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">{error}</div>}
|
||||||
|
<button onClick={() => setShowLocal(true)} className="mt-6 text-xs font-bold text-ink-400 underline hover:text-ink-700">
|
||||||
|
Anmeldung für externe Benutzer & lokale Konten
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-10 text-xs text-ink-400">Probleme bei der Anmeldung? Wende dich an einen Administrator.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { Avatar } from '../ui/controls';
|
||||||
|
import { VIEW_ICONS } from './Sidebar';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { ROLE_LABELS, VIEW_LABELS, viewsFor, type ViewKey } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Untere Navigationsleiste auf dem Handy - mit dem Daumen erreichbar. Passen
|
||||||
|
* nicht alle Punkte hinein, sammelt "Mehr" den Rest samt Konto und Abmelden.
|
||||||
|
*/
|
||||||
|
export const MobileNav: React.FC<{ current: ViewKey; onNavigate: (view: ViewKey) => void; onAccount: () => void }> = ({
|
||||||
|
current,
|
||||||
|
onNavigate,
|
||||||
|
onAccount,
|
||||||
|
}) => {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
if (!user) return null;
|
||||||
|
const views = viewsFor(user.role);
|
||||||
|
const primary = views.length <= 4 ? views : views.slice(0, 4);
|
||||||
|
const rest = views.length <= 4 ? [] : views.slice(4);
|
||||||
|
const moreActive = rest.includes(current);
|
||||||
|
|
||||||
|
const go = (view: ViewKey) => {
|
||||||
|
setMoreOpen(false);
|
||||||
|
onNavigate(view);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{moreOpen && (
|
||||||
|
<div className="animate-fade fixed inset-0 z-40 bg-ink-950/40 backdrop-blur-sm md:hidden" onClick={() => setMoreOpen(false)}>
|
||||||
|
<div
|
||||||
|
className="animate-sheet absolute inset-x-0 bottom-0 rounded-t-3xl border-t border-ink-200 bg-surface px-4 pb-24 pt-3 shadow-lift"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-ink-200" />
|
||||||
|
<button onClick={() => { setMoreOpen(false); onAccount(); }} className="mb-3 flex w-full items-center gap-3 rounded-2xl bg-ink-50 p-3 text-left">
|
||||||
|
<Avatar name={user.name} color={user.color} id={user.id} size={40} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-bold text-ink-900">{user.name}</span>
|
||||||
|
<span className="block text-xs text-ink-400">{ROLE_LABELS[user.role]} · Mein Konto</span>
|
||||||
|
</span>
|
||||||
|
<Icon name="chevronRight" className="h-4 w-4 text-ink-400" />
|
||||||
|
</button>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{rest.map((view) => (
|
||||||
|
<button
|
||||||
|
key={view}
|
||||||
|
onClick={() => go(view)}
|
||||||
|
className={`flex flex-col items-center gap-1.5 rounded-2xl border p-3 text-xs font-bold ${
|
||||||
|
current === view ? 'border-brand-300 bg-brand-50 text-brand-700' : 'border-ink-200 text-ink-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name={VIEW_ICONS[view]} className="h-6 w-6" />
|
||||||
|
{VIEW_LABELS[view]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button onClick={logout} className="flex flex-col items-center gap-1.5 rounded-2xl border border-ink-200 p-3 text-xs font-bold text-red-600">
|
||||||
|
<Icon name="logout" className="h-6 w-6" />
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<nav className="safe-bottom fixed inset-x-0 bottom-0 z-50 border-t border-ink-200 bg-surface/95 backdrop-blur md:hidden">
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: `repeat(${primary.length + 1}, minmax(0, 1fr))` }}>
|
||||||
|
{primary.map((view) => {
|
||||||
|
const active = current === view;
|
||||||
|
return (
|
||||||
|
<button key={view} onClick={() => go(view)} className={`relative flex flex-col items-center gap-0.5 py-2.5 text-[10px] font-bold ${active ? 'text-brand-600' : 'text-ink-400'}`}>
|
||||||
|
{active && <span className="animate-pop absolute top-0 h-0.5 w-8 rounded-full bg-brand-500" />}
|
||||||
|
<Icon name={VIEW_ICONS[view]} className="h-6 w-6" strokeWidth={active ? 2.2 : 1.7} />
|
||||||
|
{VIEW_LABELS[view]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => (rest.length ? setMoreOpen((o) => !o) : onAccount())}
|
||||||
|
className={`flex flex-col items-center gap-0.5 py-2.5 text-[10px] font-bold ${moreActive || moreOpen ? 'text-brand-600' : 'text-ink-400'}`}
|
||||||
|
>
|
||||||
|
<Icon name={rest.length ? 'more' : 'user'} className="h-6 w-6" />
|
||||||
|
{rest.length ? 'Mehr' : 'Konto'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildmarke von Pulse: eine Herzschlag-Linie, deren Ausschlag die gruene
|
||||||
|
* Verbindung aus dem perinco Logo aufnimmt. `bare` ohne Kachel.
|
||||||
|
*/
|
||||||
|
export const PulseMark: React.FC<{ size?: number; bare?: boolean; animated?: boolean }> = ({ size = 32, bare, animated = true }) => {
|
||||||
|
const line = (
|
||||||
|
<svg viewBox="0 0 32 32" width={bare ? size : size * 0.62} height={bare ? size : size * 0.62} aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M2 17h6l3-7 5 14 4-10 2 3h8"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={animated ? 'draw-line' : undefined}
|
||||||
|
style={{ '--len': 60 } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
if (bare) return <span className="inline-flex text-brand-600">{line}</span>;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex shrink-0 items-center justify-center rounded-xl bg-brand-500 text-white shadow-card ${animated ? 'animate-glow' : ''}`}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Das Wetter des Themes "Rainy": Nieselregen ueber dem Bildschirm und
|
||||||
|
* Wetterleuchten in den Wolken der oberen Leiste.
|
||||||
|
*
|
||||||
|
* Beides liegt ueber der ganzen Oberflaeche, faengt aber keinen Klick ab
|
||||||
|
* (pointer-events: none) und bleibt unter den Dialogen (z-[100]) - ein Tropfen,
|
||||||
|
* der ueber ein Formular laeuft, ist Stimmung, einer, der den Speichern-Knopf
|
||||||
|
* verdeckt, waere ein Fehler.
|
||||||
|
*
|
||||||
|
* Bewusst sparsam: es sind nie mehr als eine Handvoll Tropfen gleichzeitig
|
||||||
|
* unterwegs, und es blitzt nur alle paar Sekunden. Ein CRM, in dem es schuettet,
|
||||||
|
* lenkt ab. Wer im Betriebssystem "Bewegung reduzieren" eingestellt hat, bekommt
|
||||||
|
* weder Regen noch Blitz; die Wolken in der Leiste bleiben als Grafik stehen.
|
||||||
|
*
|
||||||
|
* Der Regen ist ein Canvas und keine CSS-Animation: der Aufprall haengt davon
|
||||||
|
* ab, wo der Tropfen unten ankommt, und die Spritzer fliegen mit Schwerkraft -
|
||||||
|
* beides ist in Keyframes nicht sauber zu haben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Drop {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** Geschwindigkeit in px/s */
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
/** Restlebenszeit in s */
|
||||||
|
life: number;
|
||||||
|
maxLife: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Ripple {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
life: number;
|
||||||
|
maxLife: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abstand zwischen zwei neuen Tropfen in ms - daraus folgt "ein paar". */
|
||||||
|
const SPAWN_MIN_MS = 220;
|
||||||
|
const SPAWN_MAX_MS = 650;
|
||||||
|
/** Obergrenze, falls der Tab lange im Hintergrund war. */
|
||||||
|
const MAX_DROPS = 14;
|
||||||
|
const GRAVITY = 1400;
|
||||||
|
|
||||||
|
/** Pause zwischen zwei Blitzen in ms. */
|
||||||
|
const FLASH_MIN_MS = 7000;
|
||||||
|
const FLASH_MAX_MS = 18000;
|
||||||
|
|
||||||
|
function random(min: number, max: number) {
|
||||||
|
return min + Math.random() * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefersReducedMotion() {
|
||||||
|
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Farbe aus index.css (--rain-color), damit hell und dunkel passen. */
|
||||||
|
function rainColor() {
|
||||||
|
const value = getComputedStyle(document.documentElement).getPropertyValue('--rain-color').trim();
|
||||||
|
return value || 'rgb(120 140 165 / 0.5)';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RainyWeather: React.FC = () => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const flashRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// --- Regen -----------------------------------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas?.getContext('2d');
|
||||||
|
if (!canvas || !ctx || prefersReducedMotion()) return;
|
||||||
|
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
width = window.innerWidth;
|
||||||
|
height = window.innerHeight;
|
||||||
|
canvas.width = Math.round(width * dpr);
|
||||||
|
canvas.height = Math.round(height * dpr);
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
const drops: Drop[] = [];
|
||||||
|
const particles: Particle[] = [];
|
||||||
|
const ripples: Ripple[] = [];
|
||||||
|
let nextSpawn = 0;
|
||||||
|
let last = performance.now();
|
||||||
|
let frame = 0;
|
||||||
|
// Die Farbe haengt am Modus; sie wird gelegentlich nachgelesen statt in
|
||||||
|
// jedem Frame, getComputedStyle ist nicht umsonst.
|
||||||
|
let color = rainColor();
|
||||||
|
let colorReadAt = 0;
|
||||||
|
|
||||||
|
const spawn = () => {
|
||||||
|
if (drops.length >= MAX_DROPS) return;
|
||||||
|
// Leichter Wind von rechts: die Tropfen fallen etwas schraeg.
|
||||||
|
const vy = random(620, 900);
|
||||||
|
drops.push({
|
||||||
|
x: random(0, width + 60),
|
||||||
|
y: random(-80, -20),
|
||||||
|
vx: -vy * 0.08,
|
||||||
|
vy,
|
||||||
|
length: random(9, 17),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const splash = (x: number) => {
|
||||||
|
const count = Math.round(random(3, 6));
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const life = random(0.25, 0.45);
|
||||||
|
particles.push({
|
||||||
|
x,
|
||||||
|
y: height - 1,
|
||||||
|
vx: random(-110, 110),
|
||||||
|
vy: -random(110, 240),
|
||||||
|
life,
|
||||||
|
maxLife: life,
|
||||||
|
size: random(0.8, 1.6),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const life = random(0.3, 0.45);
|
||||||
|
ripples.push({ x, y: height - 1.5, life, maxLife: life, width: random(10, 18) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
// Nach einer Pause im Hintergrund nicht alles auf einmal nachholen.
|
||||||
|
const dt = Math.min((now - last) / 1000, 0.05);
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
if (now >= nextSpawn) {
|
||||||
|
spawn();
|
||||||
|
nextSpawn = now + random(SPAWN_MIN_MS, SPAWN_MAX_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - colorReadAt > 500) {
|
||||||
|
color = rainColor();
|
||||||
|
colorReadAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
|
||||||
|
// Tropfen
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
for (let i = drops.length - 1; i >= 0; i--) {
|
||||||
|
const drop = drops[i];
|
||||||
|
drop.x += drop.vx * dt;
|
||||||
|
drop.y += drop.vy * dt;
|
||||||
|
if (drop.y >= height) {
|
||||||
|
drops.splice(i, 1);
|
||||||
|
if (drop.x >= 0 && drop.x <= width) splash(drop.x);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const scale = drop.length / drop.vy;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(drop.x, drop.y);
|
||||||
|
ctx.lineTo(drop.x - drop.vx * scale, drop.y - drop.vy * scale);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spritzer
|
||||||
|
for (let i = particles.length - 1; i >= 0; i--) {
|
||||||
|
const p = particles[i];
|
||||||
|
p.life -= dt;
|
||||||
|
if (p.life <= 0) {
|
||||||
|
particles.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
p.vy += GRAVITY * dt;
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y = Math.min(p.y + p.vy * dt, height - 0.5);
|
||||||
|
ctx.globalAlpha = p.life / p.maxLife;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kleiner Ring am Boden, wo der Tropfen aufgekommen ist
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = ripples.length - 1; i >= 0; i--) {
|
||||||
|
const r = ripples[i];
|
||||||
|
r.life -= dt;
|
||||||
|
if (r.life <= 0) {
|
||||||
|
ripples.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const progress = 1 - r.life / r.maxLife;
|
||||||
|
ctx.globalAlpha = (1 - progress) * 0.8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(r.x, r.y, 2 + r.width * progress, 1 + 1.5 * progress, 0, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- Wetterleuchten --------------------------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
const flash = flashRef.current;
|
||||||
|
if (!flash || prefersReducedMotion() || typeof flash.animate !== 'function') return;
|
||||||
|
|
||||||
|
let timer = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
const schedule = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
timer = window.setTimeout(strike, random(FLASH_MIN_MS, FLASH_MAX_MS));
|
||||||
|
};
|
||||||
|
|
||||||
|
const strike = () => {
|
||||||
|
/*
|
||||||
|
* Die Leiste wird bei jedem Blitz neu vermessen statt einmal: sie ist
|
||||||
|
* nicht immer da (Anmeldeseite) und aendert ihre Breite mit der
|
||||||
|
* Seitenleiste. Das Overlay bekommt exakt ihre Masse, damit die Maske
|
||||||
|
* (dieselbe SVG wie das Hintergrundbild, gleich skaliert) auf den Wolken
|
||||||
|
* sitzt.
|
||||||
|
*/
|
||||||
|
const header = document.querySelector<HTMLElement>('.app-header');
|
||||||
|
if (!header || document.hidden) {
|
||||||
|
schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = header.getBoundingClientRect();
|
||||||
|
flash.style.left = `${rect.left}px`;
|
||||||
|
flash.style.top = `${rect.top}px`;
|
||||||
|
flash.style.width = `${rect.width}px`;
|
||||||
|
flash.style.height = `${rect.height}px`;
|
||||||
|
// Nur in der Mitte, wo der Schleier die Wolken durchlaesst - am Rand
|
||||||
|
// stuende der Blitz auf dem Titel bzw. den Schaltern.
|
||||||
|
flash.style.setProperty('--flash-x', `${random(28, 72)}%`);
|
||||||
|
|
||||||
|
// Kurz, zurueck, noch einmal heller, langes Ausklingen - so flackert
|
||||||
|
// Wetterleuchten.
|
||||||
|
const animation = flash.animate(
|
||||||
|
[
|
||||||
|
{ opacity: 0 },
|
||||||
|
{ opacity: 0.75, offset: 0.06 },
|
||||||
|
{ opacity: 0.15, offset: 0.14 },
|
||||||
|
{ opacity: 1, offset: 0.22 },
|
||||||
|
{ opacity: 0.35, offset: 0.45 },
|
||||||
|
{ opacity: 0 },
|
||||||
|
],
|
||||||
|
{ duration: 900, easing: 'ease-out' }
|
||||||
|
);
|
||||||
|
animation.onfinish = schedule;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Der erste Blitz kommt frueher, damit man das Theme beim Umschalten erlebt.
|
||||||
|
timer = window.setTimeout(strike, random(1500, 3500));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none fixed inset-0 z-[90] h-full w-full"
|
||||||
|
/>
|
||||||
|
<div ref={flashRef} aria-hidden="true" className="rainy-flash pointer-events-none fixed z-[90]" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Icon, type IconName } from '../ui/Icon';
|
||||||
|
import { Avatar } from '../ui/controls';
|
||||||
|
import { PulseMark } from './PulseMark';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { ROLE_LABELS, VIEW_LABELS, homeFor, viewsFor, type ViewKey } from '../../types';
|
||||||
|
|
||||||
|
export const VIEW_ICONS: Record<ViewKey, IconName> = {
|
||||||
|
dashboard: 'dashboard',
|
||||||
|
buchung: 'clock',
|
||||||
|
projekte: 'folder',
|
||||||
|
team: 'team',
|
||||||
|
kunden: 'building',
|
||||||
|
reports: 'chart',
|
||||||
|
einstellungen: 'settings',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
current: ViewKey;
|
||||||
|
onNavigate: (view: ViewKey) => void;
|
||||||
|
collapsed: boolean;
|
||||||
|
onAccount: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seitenleiste fuer Desktop/Tablet. Auf dem Handy uebernimmt die MobileNav. */
|
||||||
|
export const Sidebar: React.FC<SidebarProps> = ({ current, onNavigate, collapsed, onAccount }) => {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
if (!user) return null;
|
||||||
|
const views = viewsFor(user.role);
|
||||||
|
const main = views.filter((v) => v !== 'einstellungen');
|
||||||
|
const home = homeFor(user.role);
|
||||||
|
|
||||||
|
const item = (view: ViewKey) => {
|
||||||
|
const active = current === view;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={view}
|
||||||
|
onClick={() => onNavigate(view)}
|
||||||
|
title={collapsed ? VIEW_LABELS[view] : undefined}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={`group relative flex w-full items-center rounded-xl transition ${collapsed ? 'justify-center py-2.5' : 'gap-3 px-3 py-2.5'} ${
|
||||||
|
active ? 'bg-brand-500 text-white shadow-card' : 'text-ink-600 hover:bg-brand-50 hover:text-brand-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name={VIEW_ICONS[view]} className="h-5 w-5 shrink-0" strokeWidth={active ? 2.2 : 1.7} />
|
||||||
|
{!collapsed && <span className="truncate text-sm font-bold">{VIEW_LABELS[view]}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`app-sidebar hidden h-full shrink-0 flex-col overflow-hidden border-r border-ink-200 bg-surface transition-[width] duration-300 md:flex ${
|
||||||
|
collapsed ? 'w-[76px]' : 'w-64'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate(home)}
|
||||||
|
className={`flex shrink-0 items-center border-b border-ink-100 transition hover:bg-ink-50 ${collapsed ? 'justify-center py-5' : 'gap-3 px-5 py-4'}`}
|
||||||
|
title="Zur Startseite"
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<PulseMark size={34} />
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
|
<img src="/logo.png" alt="perinco" className="logo-adaptive h-9 w-auto" />
|
||||||
|
<span className="flex items-center gap-1.5 rounded-full bg-brand-50 px-2.5 py-1 text-xs font-extrabold tracking-wide text-brand-700">
|
||||||
|
<PulseMark size={14} bare />
|
||||||
|
Pulse
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav className="scrollbar-slim flex-1 space-y-1 overflow-y-auto px-3 py-4">
|
||||||
|
{!collapsed && <p className="mb-2 px-3 text-[10px] font-extrabold uppercase tracking-[0.18em] text-ink-400">Navigation</p>}
|
||||||
|
{main.map(item)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{views.includes('einstellungen') && <div className="shrink-0 border-t border-ink-100 px-3 py-3">{item('einstellungen')}</div>}
|
||||||
|
|
||||||
|
<div className={`shrink-0 border-t border-ink-100 ${collapsed ? 'flex flex-col items-center gap-2 p-3' : 'p-4'}`}>
|
||||||
|
<div className="flex w-full min-w-0 items-center justify-between gap-2">
|
||||||
|
<button onClick={onAccount} className="flex min-w-0 items-center gap-3 rounded-xl text-left transition hover:opacity-80" title="Mein Konto">
|
||||||
|
<Avatar name={user.name} color={user.color} id={user.id} size={36} external={user.is_external} />
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-bold text-ink-900">{user.name}</span>
|
||||||
|
<span className="block truncate text-xs text-ink-400">{ROLE_LABELS[user.role]}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{!collapsed && (
|
||||||
|
<button onClick={logout} title="Abmelden" className="shrink-0 rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600">
|
||||||
|
<Icon name="logout" className="h-4 w-4" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{collapsed && (
|
||||||
|
<button onClick={logout} title="Abmelden" className="rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600">
|
||||||
|
<Icon name="logout" className="h-4 w-4" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Das Wetter des Themes "Snow": Flocken fallen, sammeln sich am unteren Rand zu
|
||||||
|
* einem Haufen - und alle vier Minuten fährt ein Schneepflug durch und schiebt
|
||||||
|
* ihn zur Seite.
|
||||||
|
*
|
||||||
|
* Alles liegt über der Oberfläche, fängt aber keinen Klick ab
|
||||||
|
* (pointer-events: none) und bleibt unter den Dialogen (z-[90] gegen z-[100]).
|
||||||
|
* Wer im Betriebssystem "Bewegung reduzieren" eingestellt hat, bekommt gar
|
||||||
|
* keine Animation; die Winterlandschaft in den Leisten bleibt als Grafik stehen.
|
||||||
|
*
|
||||||
|
* Der Haufen ist ein Höhenfeld: je 3 px eine Spalte, in der die Höhe steht.
|
||||||
|
* Deshalb kann eine Flocke dort liegen bleiben, wo sie aufkommt, der Haufen
|
||||||
|
* fließt langsam flach (Glättung) und der Pflug kann ein Stück davon leer
|
||||||
|
* räumen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Breite einer Spalte des Höhenfelds in px. */
|
||||||
|
const COLUMN = 3;
|
||||||
|
/** Höchster Schnee in px - sonst wächst er irgendwann ins Bild. */
|
||||||
|
const MAX_PILE = 26;
|
||||||
|
/** Wie schnell der Haufen flach fließt. */
|
||||||
|
const SMOOTHING = 6;
|
||||||
|
/** Abstand zwischen zwei Flocken in ms. */
|
||||||
|
const SPAWN_MIN_MS = 70;
|
||||||
|
const SPAWN_MAX_MS = 160;
|
||||||
|
/** Obergrenze gleichzeitiger Flocken. */
|
||||||
|
const MAX_FLAKES = 70;
|
||||||
|
/** Pause zwischen zwei Durchfahrten des Pflugs. */
|
||||||
|
const PLOW_INTERVAL_MS = 240 * 1000;
|
||||||
|
/** Die erste Fahrt kommt früher, damit man das Theme beim Umschalten erlebt. */
|
||||||
|
const PLOW_FIRST_MS = 2500;
|
||||||
|
/** Fahrgeschwindigkeit in px/s. */
|
||||||
|
const PLOW_SPEED = 140;
|
||||||
|
/** Abstand Fahrzeugmitte zur Schaufel bzw. halbe Fahrzeuglänge. */
|
||||||
|
const PLOW_BLADE = 38;
|
||||||
|
const PLOW_HALF = 36;
|
||||||
|
|
||||||
|
interface Flake {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
radius: number;
|
||||||
|
/** Fallgeschwindigkeit in px/s */
|
||||||
|
vy: number;
|
||||||
|
/** Phase und Weite des Hin- und Herwehens */
|
||||||
|
phase: number;
|
||||||
|
sway: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Spray {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Plow {
|
||||||
|
x: number;
|
||||||
|
/** 1 = fährt nach rechts, -1 = nach links */
|
||||||
|
dir: 1 | -1;
|
||||||
|
/** Wie viel Schnee schon vor der Schaufel liegt */
|
||||||
|
pushed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function random(min: number, max: number) {
|
||||||
|
return min + Math.random() * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefersReducedMotion() {
|
||||||
|
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Farben aus index.css (--snow-*), damit hell und dunkel passen. */
|
||||||
|
function snowColors() {
|
||||||
|
const style = getComputedStyle(document.documentElement);
|
||||||
|
const read = (name: string, fallback: string) => style.getPropertyValue(name).trim() || fallback;
|
||||||
|
return {
|
||||||
|
flake: read('--snow-flake', '#ffffff'),
|
||||||
|
flakeEdge: read('--snow-flake-edge', 'rgb(120 145 170 / 0.35)'),
|
||||||
|
pile: read('--snow-pile', '#ffffff'),
|
||||||
|
pileShade: read('--snow-pile-shade', '#dbe6f1'),
|
||||||
|
pileEdge: read('--snow-pile-edge', 'rgb(120 145 170 / 0.5)'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnowWeather: React.FC = () => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas?.getContext('2d');
|
||||||
|
if (!canvas || !ctx || prefersReducedMotion()) return;
|
||||||
|
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
/** Höhe des Schnees je Spalte. */
|
||||||
|
let pile = new Float32Array(0);
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
width = window.innerWidth;
|
||||||
|
height = window.innerHeight;
|
||||||
|
canvas.width = Math.round(width * dpr);
|
||||||
|
canvas.height = Math.round(height * dpr);
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
// Vorhandenen Schnee auf die neue Breite umlegen statt wegzuwerfen.
|
||||||
|
const next = new Float32Array(Math.ceil(width / COLUMN) + 1);
|
||||||
|
if (pile.length > 0) {
|
||||||
|
for (let i = 0; i < next.length; i++) {
|
||||||
|
next[i] = pile[Math.min(pile.length - 1, Math.floor((i / next.length) * pile.length))];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pile = next;
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
const flakes: Flake[] = [];
|
||||||
|
const spray: Spray[] = [];
|
||||||
|
let nextSpawn = 0;
|
||||||
|
let last = performance.now();
|
||||||
|
let frame = 0;
|
||||||
|
let colors = snowColors();
|
||||||
|
let colorsReadAt = 0;
|
||||||
|
let plow: Plow | null = null;
|
||||||
|
let nextDir: 1 | -1 = 1;
|
||||||
|
let nextPlowAt = performance.now() + PLOW_FIRST_MS;
|
||||||
|
|
||||||
|
const spawn = () => {
|
||||||
|
if (flakes.length >= MAX_FLAKES) return;
|
||||||
|
const radius = random(1, 3);
|
||||||
|
flakes.push({
|
||||||
|
x: random(-20, width + 20),
|
||||||
|
y: -radius - random(0, 30),
|
||||||
|
radius,
|
||||||
|
// Große Flocken fallen etwas schneller.
|
||||||
|
vy: random(28, 45) + radius * 14,
|
||||||
|
phase: random(0, Math.PI * 2),
|
||||||
|
sway: random(8, 22),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Flocke bleibt liegen: verteilt ihr Volumen auf fünf Spalten. */
|
||||||
|
const land = (x: number, radius: number) => {
|
||||||
|
const center = Math.round(x / COLUMN);
|
||||||
|
const amount = radius * 5;
|
||||||
|
const add = (index: number, value: number) => {
|
||||||
|
if (index < 0 || index >= pile.length) return;
|
||||||
|
pile[index] = Math.min(MAX_PILE, pile[index] + value);
|
||||||
|
};
|
||||||
|
add(center, amount * 0.36);
|
||||||
|
add(center - 1, amount * 0.24);
|
||||||
|
add(center + 1, amount * 0.24);
|
||||||
|
add(center - 2, amount * 0.08);
|
||||||
|
add(center + 2, amount * 0.08);
|
||||||
|
};
|
||||||
|
|
||||||
|
let buffer = new Float32Array(0);
|
||||||
|
/** Der Haufen fließt langsam flach, sonst stehen dort Zacken. */
|
||||||
|
const smooth = (dt: number) => {
|
||||||
|
if (buffer.length !== pile.length) buffer = new Float32Array(pile.length);
|
||||||
|
const rate = Math.min(0.25, SMOOTHING * dt);
|
||||||
|
const last_ = pile.length - 1;
|
||||||
|
for (let i = 0; i <= last_; i++) {
|
||||||
|
const left = pile[i > 0 ? i - 1 : i];
|
||||||
|
const right = pile[i < last_ ? i + 1 : i];
|
||||||
|
buffer[i] = pile[i] + rate * (left + right - 2 * pile[i]);
|
||||||
|
}
|
||||||
|
pile.set(buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawPile = () => {
|
||||||
|
let highest = 0;
|
||||||
|
for (let i = 0; i < pile.length; i++) highest = Math.max(highest, pile[i]);
|
||||||
|
if (highest < 0.3) return;
|
||||||
|
|
||||||
|
const outline = () => {
|
||||||
|
ctx.moveTo(0, height - pile[0]);
|
||||||
|
for (let i = 1; i < pile.length; i++) {
|
||||||
|
const from = (i - 1) * COLUMN;
|
||||||
|
const to = i * COLUMN;
|
||||||
|
ctx.quadraticCurveTo(from, height - pile[i - 1], (from + to) / 2, height - (pile[i - 1] + pile[i]) / 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, height);
|
||||||
|
outline();
|
||||||
|
ctx.lineTo(width, height - pile[pile.length - 1]);
|
||||||
|
ctx.lineTo(width, height);
|
||||||
|
ctx.closePath();
|
||||||
|
const gradient = ctx.createLinearGradient(0, height - highest, 0, height);
|
||||||
|
gradient.addColorStop(0, colors.pile);
|
||||||
|
gradient.addColorStop(1, colors.pileShade);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
outline();
|
||||||
|
ctx.strokeStyle = colors.pileEdge;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Der Schneeberg, den die Schaufel vor sich herschiebt. */
|
||||||
|
const drawPushedHeap = (x: number, dir: number, pushed: number) => {
|
||||||
|
if (pushed < 1) return;
|
||||||
|
const heapHeight = Math.min(MAX_PILE + 8, 6 + Math.sqrt(pushed) * 1.2);
|
||||||
|
const heapWidth = heapHeight * 1.6;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x - dir * 2, height);
|
||||||
|
ctx.lineTo(x - dir * 2, height - heapHeight);
|
||||||
|
ctx.quadraticCurveTo(x + dir * heapWidth * 0.55, height - heapHeight * 1.05, x + dir * heapWidth, height);
|
||||||
|
ctx.closePath();
|
||||||
|
const gradient = ctx.createLinearGradient(0, height - heapHeight, 0, height);
|
||||||
|
gradient.addColorStop(0, colors.pile);
|
||||||
|
gradient.addColorStop(1, colors.pileShade);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = colors.pileEdge;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Der Pflug selbst: Kabine, Schaufel und blinkendes Rundumlicht. */
|
||||||
|
const drawPlow = (x: number, dir: number, seconds: number) => {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, height);
|
||||||
|
ctx.scale(dir, 1);
|
||||||
|
const blinkOn = Math.sin(seconds * 9) > 0;
|
||||||
|
|
||||||
|
if (blinkOn) {
|
||||||
|
const glow = ctx.createRadialGradient(-15, -41, 0, -15, -41, 16);
|
||||||
|
glow.addColorStop(0, 'rgb(255 170 40 / 0.7)');
|
||||||
|
glow.addColorStop(1, 'rgb(255 170 40 / 0)');
|
||||||
|
ctx.fillStyle = glow;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-15, -41, 16, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.strokeStyle = 'rgb(38 62 26 / 0.6)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
// Auspuff, Pritsche, Motorhaube, Kabine - in perinco-Grün.
|
||||||
|
ctx.fillStyle = '#5b6470';
|
||||||
|
ctx.fillRect(18, -15, 14, 4);
|
||||||
|
ctx.fillStyle = '#6ba04a';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(-PLOW_HALF, -21, 56, 14, 3);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(-4, -28, 24, 10, [5, 3, 0, 0]);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(-31, -38, 27, 19, [3, 3, 0, 0]);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Fensterscheibe, Kühlergrill, Schneekappe auf dem Dach
|
||||||
|
ctx.fillStyle = '#bfe3f5';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(-25, -35, 17, 10, 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#2b2f36';
|
||||||
|
for (let i = 0; i < 4; i++) ctx.fillRect(-30 + i * 12, -12, 5, 3);
|
||||||
|
ctx.fillStyle = colors.pile;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(-19, -38, 11, 2.5, 0, Math.PI, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Rundumlicht
|
||||||
|
ctx.fillStyle = blinkOn ? '#ffb020' : '#b86e00';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(-18, -43, 6, 5, [2, 2, 0, 0]);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Räder
|
||||||
|
for (const wheelX of [-22, 10]) {
|
||||||
|
ctx.fillStyle = '#2b2f36';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(wheelX, -6, 6, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#9aa3ad';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(wheelX, -6, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schaufel
|
||||||
|
ctx.fillStyle = '#c9d1d9';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(28, -24);
|
||||||
|
ctx.lineTo(33, -25);
|
||||||
|
ctx.lineTo(PLOW_BLADE, 0);
|
||||||
|
ctx.lineTo(29, 0);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fahrt starten, Strecke räumen, Schneestaub aufwirbeln. */
|
||||||
|
const movePlow = (now: number, dt: number) => {
|
||||||
|
if (!plow) {
|
||||||
|
if (now < nextPlowAt) return;
|
||||||
|
const dir = nextDir;
|
||||||
|
plow = { x: dir === 1 ? -PLOW_BLADE - 40 : width + PLOW_BLADE + 40, dir, pushed: 0 };
|
||||||
|
// Beim nächsten Mal aus der anderen Richtung.
|
||||||
|
nextDir = dir === 1 ? -1 : 1;
|
||||||
|
nextPlowAt = now + PLOW_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
plow.x += plow.dir * PLOW_SPEED * dt;
|
||||||
|
const blade = plow.x + plow.dir * PLOW_BLADE;
|
||||||
|
const from = plow.dir === 1 ? plow.x - PLOW_HALF - COLUMN * 4 : blade;
|
||||||
|
const to = plow.dir === 1 ? blade : plow.x + PLOW_HALF + COLUMN * 4;
|
||||||
|
const firstColumn = Math.max(0, Math.ceil(from / COLUMN));
|
||||||
|
const lastColumn = Math.min(pile.length - 1, Math.floor(to / COLUMN));
|
||||||
|
|
||||||
|
let cleared = 0;
|
||||||
|
for (let i = firstColumn; i <= lastColumn; i++) {
|
||||||
|
cleared += pile[i];
|
||||||
|
pile[i] = 0;
|
||||||
|
}
|
||||||
|
plow.pushed += cleared;
|
||||||
|
|
||||||
|
if (plow.pushed > 5 && blade > 0 && blade < width && Math.random() < dt * 14) {
|
||||||
|
spray.push({
|
||||||
|
x: blade,
|
||||||
|
y: height - Math.min(20, 2 + Math.sqrt(plow.pushed)),
|
||||||
|
vx: plow.dir * random(40, 120),
|
||||||
|
vy: -random(60, 140),
|
||||||
|
radius: random(1.2, 2.6),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const gone = plow.dir === 1 ? plow.x - PLOW_HALF > width + 60 : plow.x + PLOW_HALF < -60;
|
||||||
|
if (gone) plow = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawSpray = (dt: number) => {
|
||||||
|
ctx.fillStyle = colors.flake;
|
||||||
|
ctx.strokeStyle = colors.flakeEdge;
|
||||||
|
ctx.lineWidth = 0.6;
|
||||||
|
for (let i = spray.length - 1; i >= 0; i--) {
|
||||||
|
const particle = spray[i];
|
||||||
|
particle.vy += 600 * dt;
|
||||||
|
particle.x += particle.vx * dt;
|
||||||
|
particle.y += particle.vy * dt;
|
||||||
|
if (particle.y >= height) {
|
||||||
|
spray.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
// Nach einer Pause im Hintergrund nicht alles auf einmal nachholen.
|
||||||
|
const dt = Math.min((now - last) / 1000, 0.05);
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
// Die Farben hängen am Modus; getComputedStyle ist nicht umsonst.
|
||||||
|
if (now - colorsReadAt > 500) {
|
||||||
|
colors = snowColors();
|
||||||
|
colorsReadAt = now;
|
||||||
|
}
|
||||||
|
if (now >= nextSpawn) {
|
||||||
|
spawn();
|
||||||
|
nextSpawn = now + random(SPAWN_MIN_MS, SPAWN_MAX_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
ctx.fillStyle = colors.flake;
|
||||||
|
ctx.strokeStyle = colors.flakeEdge;
|
||||||
|
ctx.lineWidth = 0.6;
|
||||||
|
|
||||||
|
const seconds = now / 1000;
|
||||||
|
for (let i = flakes.length - 1; i >= 0; i--) {
|
||||||
|
const flake = flakes[i];
|
||||||
|
flake.y += flake.vy * dt;
|
||||||
|
const x = flake.x + Math.sin(seconds * 1.3 + flake.phase) * flake.sway;
|
||||||
|
const surface = height - (pile[Math.round(x / COLUMN)] ?? 0);
|
||||||
|
if (flake.y + flake.radius >= surface) {
|
||||||
|
flakes.splice(i, 1);
|
||||||
|
if (x >= 0 && x <= width) land(x, flake.radius);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, flake.y, flake.radius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
smooth(dt);
|
||||||
|
movePlow(now, dt);
|
||||||
|
drawPile();
|
||||||
|
if (plow) {
|
||||||
|
drawPushedHeap(plow.x + plow.dir * PLOW_BLADE, plow.dir, plow.pushed);
|
||||||
|
drawPlow(plow.x, plow.dir, seconds);
|
||||||
|
}
|
||||||
|
drawSpray(dt);
|
||||||
|
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none fixed inset-0 z-[90] h-full w-full" />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Avatar, Badge, BudgetBar, Button, Card, IconButton, inputClass } from '../ui/controls';
|
||||||
|
import { ConfirmDialog, Modal } from '../ui/Modal';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { PotFields, emptyPot, potPayload, type PotDraft } from './ProjectForm';
|
||||||
|
import { TYPE_TONE } from './Projects';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { projectsApi } from '../../lib/api';
|
||||||
|
import { formatDate, formatEuro, formatHours, formatNumber } from '../../lib/format';
|
||||||
|
import { useRouter } from '../../lib/router';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { PROJECT_TYPE_LABELS, type BudgetPot, type DirectoryUser, type Project } from '../../types';
|
||||||
|
|
||||||
|
const POT_STATUS: Record<BudgetPot['status'], { label: string; tone: 'brand' | 'neutral' | 'amber' | 'red' | 'sky' }> = {
|
||||||
|
active: { label: 'aktiv', tone: 'brand' },
|
||||||
|
upcoming: { label: 'geplant', tone: 'sky' },
|
||||||
|
expired: { label: 'abgelaufen', tone: 'neutral' },
|
||||||
|
exhausted: { label: 'aufgebraucht', tone: 'red' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
canEdit: boolean;
|
||||||
|
users: DirectoryUser[];
|
||||||
|
onChanged: (project: Project) => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aufgeklappte Projektansicht unter der Karte: Toepfe, Tasks, Team. */
|
||||||
|
export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onChanged, onEdit, onDeleted, onClose }) => {
|
||||||
|
const { settings } = useAuth();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const { navigate } = useRouter();
|
||||||
|
const [potDialog, setPotDialog] = useState<{ pot: BudgetPot | null } | null>(null);
|
||||||
|
const [membersOpen, setMembersOpen] = useState(false);
|
||||||
|
const [newTask, setNewTask] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState<{ title: string; message: string; action: () => Promise<unknown>; label?: string } | null>(null);
|
||||||
|
const hpd = settings.hours_per_day;
|
||||||
|
|
||||||
|
const run = async (action: () => Promise<Project | { ok: true }>, success?: string) => {
|
||||||
|
try {
|
||||||
|
const result = await action();
|
||||||
|
if ('id' in result) onChanged(result);
|
||||||
|
if (success) addToast(success, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Aktion fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTask = async () => {
|
||||||
|
if (!newTask.trim()) return;
|
||||||
|
await run(() => projectsApi.addTask(p.id, { name: newTask.trim(), billable: p.type !== 'non_billable' }));
|
||||||
|
setNewTask('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxTask = Math.max(1, ...p.tasks.map((t) => t.used_minutes));
|
||||||
|
const totalPotHours = p.pots.reduce((s, pot) => s + (pot.budget_hours ?? 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="animate-pop overflow-hidden border-brand-200">
|
||||||
|
<div className="flex flex-wrap items-start gap-3 border-b border-ink-100 bg-brand-50/40 p-4 sm:p-5">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge tone={TYPE_TONE[p.type]}>
|
||||||
|
{PROJECT_TYPE_LABELS[p.type]}
|
||||||
|
{p.type === 'billable' && ` · pro ${p.billing_unit === 'day' ? 'Tag' : 'Stunde'}`}
|
||||||
|
</Badge>
|
||||||
|
<span className="tabular text-xs font-bold text-ink-400">{p.code}</span>
|
||||||
|
{p.tags.map((t) => (
|
||||||
|
<Badge key={t}>
|
||||||
|
<Icon name="tag" className="h-3 w-3" />
|
||||||
|
{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-1 text-xl text-ink-900">{p.display_name}</h2>
|
||||||
|
<p className="mt-1 text-xs text-ink-500">
|
||||||
|
{p.customer.name} · {formatDate(p.start_date)} – {p.end_date ? formatDate(p.end_date) : 'offen'}
|
||||||
|
{p.owner_name && ` · verantwortlich: ${p.owner_name}`}
|
||||||
|
</p>
|
||||||
|
{p.notes && <p className="mt-2 whitespace-pre-line text-sm text-ink-600">{p.notes}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="secondary" icon="chart" onClick={() => navigate(`reports/projekt/${p.id}`)}>
|
||||||
|
Report
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" icon="edit" onClick={onEdit}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="archive"
|
||||||
|
onClick={() => run(() => projectsApi.update(p.id, { is_active: !p.is_active }), p.is_active ? 'Projekt archiviert.' : 'Projekt reaktiviert.')}
|
||||||
|
>
|
||||||
|
{p.is_active ? 'Archivieren' : 'Reaktivieren'}
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
icon="trash"
|
||||||
|
tone="danger"
|
||||||
|
label="Projekt löschen"
|
||||||
|
onClick={() =>
|
||||||
|
setConfirm({
|
||||||
|
title: 'Projekt löschen?',
|
||||||
|
message: 'Nur möglich, solange keine Zeiten gebucht sind. Sonst bitte archivieren.',
|
||||||
|
action: async () => {
|
||||||
|
await projectsApi.remove(p.id);
|
||||||
|
addToast('Projekt gelöscht.', 'success');
|
||||||
|
onDeleted();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<IconButton icon="close" label="Schließen" onClick={onClose} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-0 lg:grid-cols-2">
|
||||||
|
{/* Budgettoepfe */}
|
||||||
|
<section className="border-b border-ink-100 p-4 sm:p-5 lg:border-b-0 lg:border-r">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm text-ink-900">
|
||||||
|
<Icon name="pot" className="h-4 w-4 text-brand-600" />
|
||||||
|
Budgettöpfe
|
||||||
|
</h3>
|
||||||
|
{canEdit && (
|
||||||
|
<Button size="sm" variant="secondary" icon="plus" onClick={() => setPotDialog({ pot: null })}>
|
||||||
|
Topf
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.pots.length > 1 && (
|
||||||
|
<div className="mb-3 rounded-xl bg-ink-50 p-3">
|
||||||
|
<p className="text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Gesamt über alle Töpfe</p>
|
||||||
|
<div className="mt-1 flex items-baseline justify-between">
|
||||||
|
<span className="tabular text-sm font-extrabold text-ink-900">
|
||||||
|
{formatHours(p.used_minutes, 1)} h {totalPotHours ? `von ${formatNumber(totalPotHours, 0)} h` : ''}
|
||||||
|
</span>
|
||||||
|
{canEdit && p.budget_value ? (
|
||||||
|
<span className="tabular text-xs font-bold text-ink-500">
|
||||||
|
{formatEuro(p.earned)} erbracht · {formatEuro(p.open)} offen
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<BudgetBar className="mt-2" percent={totalPotHours ? (p.budget_used_minutes / 60 / totalPotHours) * 100 : null} warnAt={settings.budget_warn_percent} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{p.pots.map((pot, i) => (
|
||||||
|
<li key={pot.id} className="animate-rise rounded-xl border border-ink-200 p-3" style={{ '--i': i } as React.CSSProperties}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="flex flex-wrap items-center gap-2 text-sm font-extrabold text-ink-900">
|
||||||
|
{pot.label}
|
||||||
|
<Badge tone={POT_STATUS[pot.status].tone}>{POT_STATUS[pot.status].label}</Badge>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink-400">
|
||||||
|
{formatDate(pot.valid_from)} – {formatDate(pot.valid_to)}
|
||||||
|
{pot.status === 'active' && ` · noch ${pot.days_left} Tage`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex shrink-0">
|
||||||
|
<IconButton icon="edit" label="Topf bearbeiten" onClick={() => setPotDialog({ pot })} />
|
||||||
|
<IconButton
|
||||||
|
icon="trash"
|
||||||
|
tone="danger"
|
||||||
|
label="Topf löschen"
|
||||||
|
onClick={() =>
|
||||||
|
setConfirm({
|
||||||
|
title: `Topf „${pot.label}“ löschen?`,
|
||||||
|
message: 'Nur möglich, wenn noch nichts darauf gebucht ist.',
|
||||||
|
action: () => run(() => projectsApi.removePot(p.id, pot.id), 'Topf gelöscht.'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<BudgetBar className="mt-2" percent={pot.percent_used} warnAt={settings.budget_warn_percent} muted={pot.status === 'expired'} />
|
||||||
|
<div className="tabular mt-1.5 flex flex-wrap justify-between gap-x-3 text-[11px] font-bold text-ink-500">
|
||||||
|
<span>
|
||||||
|
{formatHours(pot.used_minutes, 1)} h / {formatNumber(pot.used_hours / hpd, 1)} T gebucht
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{pot.budget_hours != null
|
||||||
|
? `${formatNumber(pot.remaining_hours ?? 0, 1)} h / ${formatNumber((pot.remaining_hours ?? 0) / hpd, 1)} T frei von ${formatNumber(pot.budget_hours, 0)} h`
|
||||||
|
: 'unbegrenzt'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{canEdit && (pot.budget_value || pot.rate) && (
|
||||||
|
<div className="tabular mt-2 grid grid-cols-3 gap-2 text-[11px]">
|
||||||
|
<Kv label="Budget" value={formatEuro(pot.budget_value)} />
|
||||||
|
<Kv
|
||||||
|
label={p.type === 'fixed' ? 'virt. Stundensatz' : p.billing_unit === 'day' ? 'Tagessatz' : 'Stundensatz'}
|
||||||
|
value={p.type === 'fixed' ? (pot.virtual_rate ? formatEuro(pot.virtual_rate, 2) : '–') : formatEuro(pot.rate, 2)}
|
||||||
|
/>
|
||||||
|
<Kv label="Offen" value={formatEuro(pot.open)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pot.warning && pot.status !== 'expired' && (
|
||||||
|
<p className="mt-2 flex items-center gap-1.5 text-[11px] font-semibold text-amber-700">
|
||||||
|
<Icon name="warning" className="h-3.5 w-3.5" />
|
||||||
|
{pot.warning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Tasks + Team */}
|
||||||
|
<section className="p-4 sm:p-5">
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-sm text-ink-900">
|
||||||
|
<Icon name="list" className="h-4 w-4 text-brand-600" />
|
||||||
|
Tasks
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{p.tasks.length === 0 && <li className="text-xs text-ink-400">Noch keine Tasks – gebucht wird dann direkt aufs Projekt.</li>}
|
||||||
|
{p.tasks.map((t, i) => (
|
||||||
|
<li key={t.id} className={`animate-rise group flex items-center gap-3 rounded-xl px-2 py-1.5 hover:bg-ink-50 ${t.is_active ? '' : 'opacity-50'}`} style={{ '--i': i } as React.CSSProperties}>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-sm font-bold text-ink-800">
|
||||||
|
{t.name}
|
||||||
|
{!t.is_active && <span className="ml-1 text-[10px] text-ink-400">(inaktiv)</span>}
|
||||||
|
</span>
|
||||||
|
<span className="tabular shrink-0 text-xs font-extrabold text-ink-600">{formatHours(t.used_minutes, 1)} h</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-1 overflow-hidden rounded-full bg-ink-100">
|
||||||
|
<div className="h-full rounded-full bg-brand-400 transition-[width] duration-700" style={{ width: `${(t.used_minutes / maxTask) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{p.type !== 'non_billable' && (
|
||||||
|
<button
|
||||||
|
disabled={!canEdit}
|
||||||
|
onClick={() => run(() => projectsApi.updateTask(p.id, t.id, { billable: !t.billable }))}
|
||||||
|
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${t.billable ? 'bg-brand-50 text-brand-700' : 'bg-ink-100 text-ink-500'}`}
|
||||||
|
title={canEdit ? 'Abrechenbar umschalten' : undefined}
|
||||||
|
>
|
||||||
|
{t.billable ? 'billable' : 'non-billable'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex shrink-0 opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
|
||||||
|
<IconButton
|
||||||
|
icon="edit"
|
||||||
|
label="Umbenennen"
|
||||||
|
onClick={() => {
|
||||||
|
const name = window.prompt('Neuer Taskname', t.name);
|
||||||
|
if (name && name.trim() !== t.name) run(() => projectsApi.updateTask(p.id, t.id, { name: name.trim() }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!t.is_active ? (
|
||||||
|
<IconButton icon="refresh" label="Reaktivieren" onClick={() => run(() => projectsApi.updateTask(p.id, t.id, { is_active: true }))} />
|
||||||
|
) : (
|
||||||
|
<IconButton icon="trash" tone="danger" label="Entfernen" onClick={() => run(() => projectsApi.removeTask(p.id, t.id), t.used_minutes ? 'Task deaktiviert (hat Buchungen).' : 'Task entfernt.')} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<input className={`${inputClass} py-2`} value={newTask} placeholder="Neuer Task, z. B. Administration Server" onChange={(e) => setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTask()} />
|
||||||
|
<Button size="sm" icon="plus" onClick={addTask} disabled={!newTask.trim()}>
|
||||||
|
Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-3 mt-6 flex items-center justify-between">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm text-ink-900">
|
||||||
|
<Icon name="team" className="h-4 w-4 text-brand-600" />
|
||||||
|
Zugewiesen ({p.members.length})
|
||||||
|
</h3>
|
||||||
|
{canEdit && (
|
||||||
|
<Button size="sm" variant="secondary" icon="edit" onClick={() => setMembersOpen(true)}>
|
||||||
|
Zuweisen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{p.members.map((m) => (
|
||||||
|
<span key={m.id} className="flex items-center gap-2 rounded-full border border-ink-200 py-1 pl-1 pr-3 text-xs font-bold text-ink-700">
|
||||||
|
<Avatar id={m.id} name={m.name} color={m.color} size={22} external={m.is_external} />
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{p.members.length === 0 && <p className="text-xs text-ink-400">Noch niemand zugewiesen – niemand kann buchen.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{potDialog && <PotDialog project={p} pot={potDialog.pot} onClose={() => setPotDialog(null)} onSaved={onChanged} />}
|
||||||
|
{membersOpen && <MembersDialog project={p} users={users} onClose={() => setMembersOpen(false)} onSaved={onChanged} />}
|
||||||
|
{confirm && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={confirm.title}
|
||||||
|
message={confirm.message}
|
||||||
|
confirmLabel={confirm.label}
|
||||||
|
onCancel={() => setConfirm(null)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
const current = confirm;
|
||||||
|
setConfirm(null);
|
||||||
|
try {
|
||||||
|
await current.action();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Aktion fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Kv: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||||
|
<div className="rounded-lg bg-ink-50 px-2 py-1">
|
||||||
|
<p className="text-[9px] font-bold uppercase tracking-wider text-ink-400">{label}</p>
|
||||||
|
<p className="font-extrabold text-ink-800">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PotDialog: React.FC<{ project: Project; pot: BudgetPot | null; onClose: () => void; onSaved: (p: Project) => void }> = ({ project, pot, onClose, onSaved }) => {
|
||||||
|
const { settings } = useAuth();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [draft, setDraft] = useState<PotDraft>(() =>
|
||||||
|
pot
|
||||||
|
? {
|
||||||
|
label: pot.label,
|
||||||
|
valid_from: pot.valid_from,
|
||||||
|
valid_to: pot.valid_to,
|
||||||
|
budget_amount: pot.budget_amount == null ? '' : String(pot.budget_amount).replace('.', ','),
|
||||||
|
rate: pot.rate == null ? '' : String(pot.rate).replace('.', ','),
|
||||||
|
budget_hours: pot.budget_hours == null ? '' : String(pot.budget_hours).replace('.', ','),
|
||||||
|
budget_unit: 'hours',
|
||||||
|
notes: pot.notes ?? '',
|
||||||
|
}
|
||||||
|
: emptyPot(project.pots.length ? project.pots[project.pots.length - 1].valid_to : project.start_date, `Budget ${project.pots.length + 1}`)
|
||||||
|
);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const payload = potPayload(draft, settings.hours_per_day);
|
||||||
|
const saved = pot ? await projectsApi.updatePot(project.id, pot.id, payload) : await projectsApi.addPot(project.id, payload);
|
||||||
|
addToast(pot ? 'Topf gespeichert – Buchungen neu zugeordnet.' : 'Topf angelegt.', 'success');
|
||||||
|
onSaved(saved);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={pot ? 'Budgettopf bearbeiten' : 'Neuer Budgettopf'}
|
||||||
|
subtitle={project.display_name}
|
||||||
|
onClose={onClose}
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button icon="check" busy={busy} onClick={save}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PotFields draft={draft} onChange={setDraft} type={project.type} unit={project.billing_unit} />
|
||||||
|
<p className="mt-3 text-xs text-ink-400">Gebucht wird immer auf den Topf, dessen Laufzeit das Leistungsdatum enthält. Überlappen Töpfe, zuerst der, der früher endet.</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MembersDialog: React.FC<{ project: Project; users: DirectoryUser[]; onClose: () => void; onSaved: (p: Project) => void }> = ({ project, users, onClose, onSaved }) => {
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [ids, setIds] = useState<number[]>(project.members.map((m) => m.id));
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const list = users.filter((u) => u.role !== 'admin' && (u.is_active || ids.includes(u.id)) && (!filter || `${u.name} ${u.company ?? ''} ${u.job_title ?? ''}`.toLowerCase().includes(filter.toLowerCase())));
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
onSaved(await projectsApi.setMembers(project.id, ids));
|
||||||
|
addToast('Zuweisung gespeichert.', 'success');
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Mitarbeiter zuweisen"
|
||||||
|
subtitle={`${project.display_name} · ${ids.length} ausgewählt`}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button icon="check" busy={busy} onClick={save}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input className={`${inputClass} mb-3`} placeholder="Suchen…" value={filter} onChange={(e) => setFilter(e.target.value)} autoFocus />
|
||||||
|
<div className="space-y-1">
|
||||||
|
{list.map((u) => {
|
||||||
|
const checked = ids.includes(u.id);
|
||||||
|
return (
|
||||||
|
<label key={u.id} className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2 transition ${checked ? 'border-brand-300 bg-brand-50' : 'border-transparent hover:bg-ink-50'}`}>
|
||||||
|
<input type="checkbox" checked={checked} onChange={() => setIds(checked ? ids.filter((id) => id !== u.id) : [...ids, u.id])} />
|
||||||
|
<Avatar id={u.id} name={u.name} color={u.color} size={30} external={u.is_external} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-bold text-ink-800">{u.name}</span>
|
||||||
|
<span className="block truncate text-xs text-ink-400">{u.is_external ? `Extern · ${u.company ?? ''}` : u.job_title ?? ''}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { Avatar, Button, Field, IconButton, Segmented, inputClass } from '../ui/controls';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { projectsApi, type PotInput } from '../../lib/api';
|
||||||
|
import { addDays, addMonths, formatNumber, todayIso } from '../../lib/format';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { CustomerForm } from '../customers/CustomerForm';
|
||||||
|
import type { Customer, DirectoryUser, Project, ProjectType } from '../../types';
|
||||||
|
|
||||||
|
export interface PotDraft {
|
||||||
|
label: string;
|
||||||
|
valid_from: string;
|
||||||
|
valid_to: string;
|
||||||
|
budget_amount: string;
|
||||||
|
rate: string;
|
||||||
|
budget_hours: string;
|
||||||
|
budget_unit: 'hours' | 'days';
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyPot(start = todayIso(), label = 'Budget 1'): PotDraft {
|
||||||
|
return { label, valid_from: start, valid_to: addDays(addMonths(start, 12), -1), budget_amount: '', rate: '', budget_hours: '', budget_unit: 'hours', notes: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deutsche und englische Schreibweise: "48.000,50", "48000.5", "110,5". */
|
||||||
|
export function num(value: string): number | null {
|
||||||
|
const v = value.trim().replace(/\s|€/g, '');
|
||||||
|
if (v === '') return null;
|
||||||
|
if (v.includes(',')) return Number(v.replace(/\./g, '').replace(',', '.'));
|
||||||
|
if (/^\d{1,3}(\.\d{3})+$/.test(v)) return Number(v.replace(/\./g, ''));
|
||||||
|
return Number(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function potPayload(draft: PotDraft, hoursPerDay: number): PotInput {
|
||||||
|
const hours = num(draft.budget_hours);
|
||||||
|
return {
|
||||||
|
label: draft.label,
|
||||||
|
valid_from: draft.valid_from,
|
||||||
|
valid_to: draft.valid_to,
|
||||||
|
budget_amount: num(draft.budget_amount),
|
||||||
|
rate: num(draft.rate),
|
||||||
|
budget_hours: hours == null ? null : draft.budget_unit === 'days' ? hours * hoursPerDay : hours,
|
||||||
|
notes: draft.notes || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau des buchbaren Volumens - gleiche Rechnung wie im Backend. */
|
||||||
|
export function potVolume(draft: PotDraft, type: ProjectType, unit: 'hour' | 'day', hoursPerDay: number) {
|
||||||
|
const amount = num(draft.budget_amount);
|
||||||
|
const rate = num(draft.rate);
|
||||||
|
if (type === 'billable' && amount != null && rate) return (amount / rate) * (unit === 'day' ? hoursPerDay : 1);
|
||||||
|
const hours = num(draft.budget_hours);
|
||||||
|
if (hours == null) return null;
|
||||||
|
return draft.budget_unit === 'days' ? hours * hoursPerDay : hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Felder eines Budgettopfs - im Projektformular und im Topfdialog. */
|
||||||
|
export const PotFields: React.FC<{
|
||||||
|
draft: PotDraft;
|
||||||
|
onChange: (draft: PotDraft) => void;
|
||||||
|
type: ProjectType;
|
||||||
|
unit: 'hour' | 'day';
|
||||||
|
}> = ({ draft, onChange, type, unit }) => {
|
||||||
|
const { settings } = useAuth();
|
||||||
|
const set = (patch: Partial<PotDraft>) => onChange({ ...draft, ...patch });
|
||||||
|
const volume = potVolume(draft, type, unit, settings.hours_per_day);
|
||||||
|
const unitLabel = unit === 'day' ? 'Tagessatz' : 'Stundensatz';
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Field label="Bezeichnung" required className="sm:col-span-1">
|
||||||
|
<input className={inputClass} value={draft.label} onChange={(e) => set({ label: e.target.value })} placeholder="z. B. Bestellung 4500030022" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Laufzeit von" required>
|
||||||
|
<input type="date" className={inputClass} value={draft.valid_from} onChange={(e) => set({ valid_from: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Laufzeit bis" required>
|
||||||
|
<input type="date" className={inputClass} value={draft.valid_to} onChange={(e) => set({ valid_to: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
{type !== 'non_billable' && (
|
||||||
|
<Field label={type === 'fixed' ? 'Festpreis (€)' : 'Budget (€)'} required={type === 'fixed'}>
|
||||||
|
<input inputMode="decimal" className={inputClass} value={draft.budget_amount} onChange={(e) => set({ budget_amount: e.target.value })} placeholder="z. B. 48000" />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{type === 'billable' && (
|
||||||
|
<Field label={`${unitLabel} (€)`} required>
|
||||||
|
<input inputMode="decimal" className={inputClass} value={draft.rate} onChange={(e) => set({ rate: e.target.value })} placeholder={unit === 'day' ? 'z. B. 880' : 'z. B. 110'} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{(type !== 'billable' || !num(draft.budget_amount)) && (
|
||||||
|
<Field label={type === 'fixed' ? 'Kontingent (optional)' : type === 'billable' ? 'oder Kontingent' : 'Kontingent (leer = unbegrenzt)'}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input inputMode="decimal" className={inputClass} value={draft.budget_hours} onChange={(e) => set({ budget_hours: e.target.value })} placeholder="–" />
|
||||||
|
<select className={`${inputClass} w-24`} value={draft.budget_unit} onChange={(e) => set({ budget_unit: e.target.value as 'hours' | 'days' })}>
|
||||||
|
<option value="hours">Std.</option>
|
||||||
|
<option value="days">Tage</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="flex items-center gap-2 rounded-xl bg-brand-50 px-3 py-2 text-xs font-semibold text-brand-800">
|
||||||
|
<Icon name="pot" className="h-4 w-4 shrink-0" />
|
||||||
|
{volume == null
|
||||||
|
? 'Unbegrenzt buchbar innerhalb der Laufzeit.'
|
||||||
|
: `Buchbar: ${formatNumber(volume, 2)} Std. = ${formatNumber(volume / settings.hours_per_day, 1)} Tage – danach gesperrt, ebenso nach Ablauf.`}
|
||||||
|
{type === 'fixed' && ' Virtueller Stundensatz = Festpreis ÷ gebuchte Stunden (intern).'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project?: Project | null;
|
||||||
|
customers: Customer[];
|
||||||
|
users: DirectoryUser[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (project: Project) => void;
|
||||||
|
onCustomerCreated: (customer: Customer) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClose, onSaved, onCustomerCreated }) => {
|
||||||
|
const { user, settings } = useAuth();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const editing = Boolean(project);
|
||||||
|
const [customerId, setCustomerId] = useState<number | ''>(project?.customer_id ?? '');
|
||||||
|
const [name, setName] = useState(project?.name ?? '');
|
||||||
|
const [type, setType] = useState<ProjectType>(project?.type ?? 'billable');
|
||||||
|
const [unit, setUnit] = useState<'hour' | 'day'>(project?.billing_unit ?? 'hour');
|
||||||
|
const [start, setStart] = useState(project?.start_date ?? todayIso());
|
||||||
|
const [end, setEnd] = useState(project?.end_date ?? '');
|
||||||
|
const isAdmin = user!.role === 'admin';
|
||||||
|
const [ownerId, setOwnerId] = useState<number | ''>(project?.owner_id ?? (isAdmin ? '' : user!.id));
|
||||||
|
const [tags, setTags] = useState<string[]>(project?.tags ?? []);
|
||||||
|
const [tagText, setTagText] = useState('');
|
||||||
|
const [notes, setNotes] = useState(project?.notes ?? '');
|
||||||
|
const [members, setMembers] = useState<number[]>(project?.members.map((m) => m.id) ?? (isAdmin ? [] : [user!.id]));
|
||||||
|
const [memberFilter, setMemberFilter] = useState('');
|
||||||
|
const [tasks, setTasks] = useState<{ name: string; billable: boolean }[]>([{ name: '', billable: true }]);
|
||||||
|
const [pots, setPots] = useState<PotDraft[]>([emptyPot()]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [newCustomer, setNewCustomer] = useState(false);
|
||||||
|
|
||||||
|
const customer = customers.find((c) => c.id === customerId);
|
||||||
|
const codePreview = customer
|
||||||
|
? settings.project_code_pattern.replaceAll('{KUERZEL}', customer.short_name).replaceAll('{JAHR}', start.slice(0, 4)).replaceAll('{JJ}', start.slice(2, 4)).replaceAll('{NR}', '###')
|
||||||
|
: '–';
|
||||||
|
const namePreview = customer && name
|
||||||
|
? settings.project_name_pattern.replaceAll('{KUNDE}', customer.short_name === 'PERINCO' ? 'perinco' : customer.short_name).replaceAll('{KUNDENNAME}', customer.name).replaceAll('{PROJEKT}', name).replaceAll('{CODE}', project?.code ?? codePreview)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const activeUsers = useMemo(
|
||||||
|
() => users.filter((u) => u.is_active && u.role !== 'admin' && (!memberFilter || u.name.toLowerCase().includes(memberFilter.toLowerCase()))),
|
||||||
|
[users, memberFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addTag = () => {
|
||||||
|
const parts = tagText.split(',').map((t) => t.trim()).filter(Boolean);
|
||||||
|
if (parts.length) setTags([...new Set([...tags, ...parts])]);
|
||||||
|
setTagText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const base = {
|
||||||
|
customer_id: customerId,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
billing_unit: unit,
|
||||||
|
start_date: start,
|
||||||
|
end_date: end || null,
|
||||||
|
owner_id: ownerId || null,
|
||||||
|
tags,
|
||||||
|
notes,
|
||||||
|
member_ids: members,
|
||||||
|
};
|
||||||
|
const saved = editing
|
||||||
|
? await projectsApi.update(project!.id, base)
|
||||||
|
: await projectsApi.create({
|
||||||
|
...base,
|
||||||
|
tasks: tasks.filter((t) => t.name.trim()),
|
||||||
|
pots: pots.map((p) => potPayload(p, settings.hours_per_day)),
|
||||||
|
});
|
||||||
|
addToast(editing ? 'Projekt gespeichert.' : `Projekt ${saved.code} angelegt.`, 'success');
|
||||||
|
onSaved(saved);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
size="xl"
|
||||||
|
title={editing ? `Projekt bearbeiten` : 'Neues Projekt'}
|
||||||
|
subtitle={editing ? `${project!.code} · ${project!.display_name}` : 'Projektcode wird automatisch vergeben'}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button icon="check" busy={busy} onClick={save}>
|
||||||
|
{editing ? 'Speichern' : 'Projekt anlegen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Kunde" required>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select className={inputClass} value={customerId} onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : '')}>
|
||||||
|
<option value="">Kunde wählen…</option>
|
||||||
|
{customers
|
||||||
|
.filter((c) => c.is_active || c.id === customerId)
|
||||||
|
.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name} ({c.short_name})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<IconButton icon="plus" label="Neuen Kunden anlegen" type="button" onClick={() => setNewCustomer(true)} className="border border-ink-200" />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Projektname" required hint={namePreview ? <>Anzeige: <b className="text-ink-700">{namePreview}</b></> : `Muster: ${settings.project_name_pattern}`}>
|
||||||
|
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="z. B. PMO" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Projektcode">
|
||||||
|
<input className={`${inputClass} tabular`} value={project?.code ?? codePreview} disabled />
|
||||||
|
</Field>
|
||||||
|
<Field label="Projektverantwortlicher">
|
||||||
|
<select className={inputClass} value={ownerId} onChange={(e) => setOwnerId(e.target.value ? Number(e.target.value) : '')}>
|
||||||
|
<option value="">–</option>
|
||||||
|
{users
|
||||||
|
.filter((u) => u.is_active && (u.role === 'lead' || u.role === 'management'))
|
||||||
|
.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<span className="field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500">Projekttyp</span>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['billable', 'Billable', 'Abrechnung nach Stunden oder Tagen'],
|
||||||
|
['non_billable', 'Non-billable', 'z. B. perinco intern'],
|
||||||
|
['fixed', 'Festpreis', 'Fixes Budget, virtueller Stundensatz'],
|
||||||
|
] as const
|
||||||
|
).map(([value, label, hint]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setType(value)}
|
||||||
|
className={`rounded-xl border p-3 text-left transition ${type === value ? 'border-brand-400 bg-brand-50 shadow-card' : 'border-ink-200 hover:border-brand-200'}`}
|
||||||
|
>
|
||||||
|
<span className={`block text-sm font-extrabold ${type === value ? 'text-brand-700' : 'text-ink-800'}`}>{label}</span>
|
||||||
|
<span className="block text-[11px] text-ink-400">{hint}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{type === 'billable' && (
|
||||||
|
<Field label="Abrechnung">
|
||||||
|
<Segmented value={unit} onChange={setUnit} options={[{ value: 'hour', label: 'Preis pro Stunde' }, { value: 'day', label: 'Preis pro Tag' }]} className="w-full" />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Startdatum" required>
|
||||||
|
<input type="date" className={inputClass} value={start} onChange={(e) => setStart(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Enddatum">
|
||||||
|
<input type="date" className={inputClass} value={end} onChange={(e) => setEnd(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{!editing && (
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm text-ink-900">Budgettöpfe</h3>
|
||||||
|
<Button size="sm" variant="secondary" icon="plus" onClick={() => setPots([...pots, emptyPot(start, `Budget ${pots.length + 1}`)])}>
|
||||||
|
Topf hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{pots.map((pot, i) => (
|
||||||
|
<div key={i} className="relative rounded-2xl border border-ink-200 p-4">
|
||||||
|
{pots.length > 1 && (
|
||||||
|
<IconButton icon="trash" tone="danger" label="Topf entfernen" className="absolute right-2 top-2" onClick={() => setPots(pots.filter((_, j) => j !== i))} />
|
||||||
|
)}
|
||||||
|
<PotFields draft={pot} type={type} unit={unit} onChange={(next) => setPots(pots.map((p, j) => (j === i ? next : p)))} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!editing && (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-sm text-ink-900">Tasks</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tasks.map((task, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={task.name}
|
||||||
|
placeholder="z. B. Administration Server"
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = tasks.map((t, j) => (j === i ? { ...t, name: e.target.value } : t));
|
||||||
|
if (i === tasks.length - 1 && e.target.value) next.push({ name: '', billable: true });
|
||||||
|
setTasks(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{type !== 'non_billable' && (
|
||||||
|
<label className="flex shrink-0 items-center gap-1.5 text-xs font-bold text-ink-500">
|
||||||
|
<input type="checkbox" checked={task.billable} onChange={(e) => setTasks(tasks.map((t, j) => (j === i ? { ...t, billable: e.target.checked } : t)))} />
|
||||||
|
billable
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm text-ink-900">
|
||||||
|
Zugewiesene Mitarbeiter <span className="text-ink-400">({members.length})</span>
|
||||||
|
</h3>
|
||||||
|
<input className={`${inputClass} w-48 py-1.5`} placeholder="Suchen…" value={memberFilter} onChange={(e) => setMemberFilter(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="scrollbar-slim grid max-h-56 gap-1.5 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{activeUsers.map((u) => {
|
||||||
|
const checked = members.includes(u.id);
|
||||||
|
return (
|
||||||
|
<label key={u.id} className={`flex cursor-pointer items-center gap-2 rounded-xl border px-2.5 py-2 transition ${checked ? 'border-brand-300 bg-brand-50' : 'border-ink-200 hover:bg-ink-50'}`}>
|
||||||
|
<input type="checkbox" className="accent-[var(--color-brand-500)]" checked={checked} onChange={() => setMembers(checked ? members.filter((id) => id !== u.id) : [...members, u.id])} />
|
||||||
|
<Avatar id={u.id} name={u.name} color={u.color} size={24} external={u.is_external} />
|
||||||
|
<span className="min-w-0 truncate text-xs font-bold text-ink-700">{u.name}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Tags">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-ink-200 px-2 py-1.5">
|
||||||
|
{tags.map((t) => (
|
||||||
|
<span key={t} className="flex items-center gap-1 rounded-full bg-ink-100 px-2 py-0.5 text-xs font-bold text-ink-600">
|
||||||
|
{t}
|
||||||
|
<button type="button" onClick={() => setTags(tags.filter((x) => x !== t))} aria-label={`${t} entfernen`}>
|
||||||
|
<Icon name="close" className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
className="min-w-[80px] flex-1 bg-transparent px-1 py-1 text-sm outline-none"
|
||||||
|
value={tagText}
|
||||||
|
placeholder="Tag + Enter"
|
||||||
|
onChange={(e) => setTagText(e.target.value)}
|
||||||
|
onBlur={addTag}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Notizen">
|
||||||
|
<textarea className={`${inputClass} h-24 resize-y`} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="z. B. Rechnungen nur mit Belegnummer…" />
|
||||||
|
</Field>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
{newCustomer && (
|
||||||
|
<CustomerForm
|
||||||
|
onClose={() => setNewCustomer(false)}
|
||||||
|
onSaved={(c) => {
|
||||||
|
onCustomerCreated(c);
|
||||||
|
setCustomerId(c.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { AvatarStack, Badge, BudgetBar, Button, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { ProjectForm } from './ProjectForm';
|
||||||
|
import { ProjectDetail } from './ProjectDetail';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { customersApi, projectsApi, usersApi } from '../../lib/api';
|
||||||
|
import { useLoader, usePersistentState } from '../../lib/hooks';
|
||||||
|
import { colorFor, compactEuro, formatHours, formatNumber } from '../../lib/format';
|
||||||
|
import { useRouter } from '../../lib/router';
|
||||||
|
import { PROJECT_TYPE_LABELS, atLeast, type Customer, type DirectoryUser, type Project } from '../../types';
|
||||||
|
|
||||||
|
export const TYPE_TONE = { billable: 'brand', non_billable: 'neutral', fixed: 'violet' } as const;
|
||||||
|
|
||||||
|
export const Projects: React.FC = () => {
|
||||||
|
const { user, settings } = useAuth();
|
||||||
|
const { route, navigate } = useRouter();
|
||||||
|
const canEdit = atLeast(user, 'lead');
|
||||||
|
const projects = useLoader(() => projectsApi.list(), []);
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||||
|
const [users, setUsers] = useState<DirectoryUser[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [customerFilter, setCustomerFilter] = useState<number | ''>('');
|
||||||
|
const [status, setStatus] = usePersistentState<'active' | 'archived' | 'all'>('pulse_projects_status', 'active');
|
||||||
|
const [form, setForm] = useState<{ project: Project | null } | null>(null);
|
||||||
|
const detailRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const selectedId = route.params[0] ? Number(route.params[0]) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
customersApi.list().then(setCustomers).catch(() => undefined);
|
||||||
|
usersApi.directory().then(setUsers).catch(() => undefined);
|
||||||
|
}, [canEdit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedId) setTimeout(() => detailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 120);
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
const list = projects.data ?? [];
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return list.filter(
|
||||||
|
(p) =>
|
||||||
|
(status === 'all' || (status === 'active' ? p.is_active : !p.is_active)) &&
|
||||||
|
(!customerFilter || p.customer_id === customerFilter) &&
|
||||||
|
(!q || [p.display_name, p.code, p.customer.name, ...p.tags].some((s) => s.toLowerCase().includes(q)))
|
||||||
|
);
|
||||||
|
}, [list, search, customerFilter, status]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const map = new Map<number, { customer: Project['customer']; projects: Project[] }>();
|
||||||
|
for (const p of filtered) {
|
||||||
|
if (!map.has(p.customer_id)) map.set(p.customer_id, { customer: p.customer, projects: [] });
|
||||||
|
map.get(p.customer_id)!.projects.push(p);
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => a.customer.name.localeCompare(b.customer.name, 'de'));
|
||||||
|
}, [filtered]);
|
||||||
|
|
||||||
|
const selected = list.find((p) => p.id === selectedId) ?? null;
|
||||||
|
const customerOptions = useMemo(() => [...new Map(list.map((p) => [p.customer_id, p.customer])).values()].sort((a, b) => a.name.localeCompare(b.name, 'de')), [list]);
|
||||||
|
|
||||||
|
const replaceProject = (saved: Project) => {
|
||||||
|
projects.setData((current) => {
|
||||||
|
const rest = (current ?? []).filter((p) => p.id !== saved.id);
|
||||||
|
return [...rest, saved];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (projects.error && !projects.data) return <ErrorBox message={projects.error} onRetry={projects.reload} />;
|
||||||
|
if (!projects.data) return <Spinner label="Lade Projekte…" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[1500px]">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative min-w-[200px] flex-1 sm:max-w-xs">
|
||||||
|
<Icon name="search" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-400" />
|
||||||
|
<input className={`${inputClass} pl-9`} placeholder="Projekt, Code, Tag…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{customerOptions.length > 1 && (
|
||||||
|
<select className={`${inputClass} !w-auto max-w-[220px]`} value={customerFilter} onChange={(e) => setCustomerFilter(e.target.value ? Number(e.target.value) : '')}>
|
||||||
|
<option value="">Alle Kunden</option>
|
||||||
|
{customerOptions.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<Segmented
|
||||||
|
size="sm"
|
||||||
|
value={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
options={[
|
||||||
|
{ value: 'active', label: 'Aktiv' },
|
||||||
|
{ value: 'archived', label: 'Archiv' },
|
||||||
|
{ value: 'all', label: 'Alle' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<Button icon="plus" className="ml-auto" onClick={() => setForm({ project: null })}>
|
||||||
|
Neues Projekt
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="folder"
|
||||||
|
title={list.length ? 'Keine Projekte zum Filter' : canEdit ? 'Noch keine Projekte' : 'Dir ist noch kein Projekt zugewiesen'}
|
||||||
|
text={canEdit ? 'Lege ein Projekt mit Kunde und Budgettopf an und weise Mitarbeiter zu.' : 'Dein Vorgesetzter weist dir Projekte zu – danach kannst du darauf buchen.'}
|
||||||
|
action={canEdit && !list.length ? <Button icon="plus" onClick={() => setForm({ project: null })}>Neues Projekt</Button> : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{groups.map((group, gi) => (
|
||||||
|
<section key={group.customer.id}>
|
||||||
|
<div className="mb-2 flex items-center gap-2 px-1">
|
||||||
|
<span className="h-3 w-3 rounded-full" style={{ background: colorFor(group.customer.id, group.customer.color) }} />
|
||||||
|
<h2 className="text-sm text-ink-900">{group.customer.name}</h2>
|
||||||
|
<span className="text-xs font-bold text-ink-400">{group.projects.length} {group.projects.length === 1 ? 'Projekt' : 'Projekte'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{group.projects
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, 'de'))
|
||||||
|
.map((p, i) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={p.id}
|
||||||
|
project={p}
|
||||||
|
index={gi + i}
|
||||||
|
selected={p.id === selectedId}
|
||||||
|
showAmounts={canEdit}
|
||||||
|
hoursPerDay={settings.hours_per_day}
|
||||||
|
warnAt={settings.budget_warn_percent}
|
||||||
|
onClick={() => navigate(p.id === selectedId ? 'projekte' : `projekte/${p.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selected && selected.customer_id === group.customer.id && (
|
||||||
|
<div ref={detailRef} className="mt-3 scroll-mt-4">
|
||||||
|
<ProjectDetail
|
||||||
|
key={selected.id}
|
||||||
|
project={selected}
|
||||||
|
canEdit={canEdit}
|
||||||
|
users={users}
|
||||||
|
onChanged={replaceProject}
|
||||||
|
onEdit={() => setForm({ project: selected })}
|
||||||
|
onDeleted={() => {
|
||||||
|
projects.setData((cur) => (cur ?? []).filter((p) => p.id !== selected.id));
|
||||||
|
navigate('projekte');
|
||||||
|
}}
|
||||||
|
onClose={() => navigate('projekte')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form && (
|
||||||
|
<ProjectForm
|
||||||
|
project={form.project}
|
||||||
|
customers={customers}
|
||||||
|
users={users}
|
||||||
|
onClose={() => setForm(null)}
|
||||||
|
onSaved={(saved) => {
|
||||||
|
replaceProject(saved);
|
||||||
|
navigate(`projekte/${saved.id}`);
|
||||||
|
}}
|
||||||
|
onCustomerCreated={(c) => setCustomers((cur) => [...cur, c])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectCard: React.FC<{
|
||||||
|
project: Project;
|
||||||
|
index: number;
|
||||||
|
selected: boolean;
|
||||||
|
showAmounts: boolean;
|
||||||
|
hoursPerDay: number;
|
||||||
|
warnAt: number;
|
||||||
|
onClick: () => void;
|
||||||
|
}> = ({ project: p, index, selected, showAmounts, hoursPerDay, warnAt, onClick }) => {
|
||||||
|
const usedHours = p.used_minutes / 60;
|
||||||
|
const percent = p.budget_hours ? (p.budget_used_minutes / 60 / p.budget_hours) * 100 : null;
|
||||||
|
const warnings = p.pots.filter((pot) => pot.warning && pot.status !== 'expired');
|
||||||
|
const active = p.pots.filter((pot) => pot.status === 'active').length;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={`animate-rise group relative cursor-pointer overflow-hidden p-4 transition hover:-translate-y-0.5 hover:shadow-lift ${
|
||||||
|
selected ? 'ring-2 ring-brand-400' : ''
|
||||||
|
} ${p.is_active ? '' : 'opacity-70'}`}
|
||||||
|
style={{ '--i': index } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<button onClick={onClick} className="absolute inset-0 z-10" aria-label={`${p.display_name} öffnen`} />
|
||||||
|
<span aria-hidden className="absolute inset-x-0 top-0 h-1" style={{ background: colorFor(p.customer_id, p.customer.color) }} />
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="tabular text-[11px] font-bold text-ink-400">{p.code}</p>
|
||||||
|
<h3 className="truncate text-base text-ink-900">{p.display_name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
|
||||||
|
{!p.is_active && <Badge>Archiv</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-end justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="tabular text-2xl font-extrabold text-ink-900">
|
||||||
|
{formatHours(p.used_minutes, 1)}
|
||||||
|
<span className="text-sm text-ink-400"> h</span>
|
||||||
|
</p>
|
||||||
|
<p className="tabular text-[11px] font-semibold text-ink-400">
|
||||||
|
{formatNumber(usedHours / hoursPerDay, 1)} Tage gebucht
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
{p.budget_hours != null ? (
|
||||||
|
<>
|
||||||
|
<p className="tabular text-sm font-extrabold text-ink-800">{formatNumber(p.remaining_hours ?? 0, 1)} h frei</p>
|
||||||
|
<p className="tabular text-[11px] font-semibold text-ink-400">
|
||||||
|
von {formatNumber(p.budget_hours, 0)} h · {formatNumber((p.remaining_hours ?? 0) / hoursPerDay, 1)} Tage
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs font-bold text-ink-400">ohne Stundenlimit</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BudgetBar className="mt-2" percent={percent} warnAt={warnAt} />
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-2">
|
||||||
|
<AvatarStack people={p.members} max={5} size={24} />
|
||||||
|
<div className="flex items-center gap-2 text-[11px] font-bold text-ink-400">
|
||||||
|
{showAmounts && p.budget_value ? <span className="tabular">{compactEuro(p.open ?? 0)} offen</span> : null}
|
||||||
|
<span className="flex items-center gap-1" title={`${p.pots.length} Budgettöpfe, ${active} aktiv`}>
|
||||||
|
<Icon name="pot" className="h-3.5 w-3.5" />
|
||||||
|
{p.pots.length}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1" title="Tasks">
|
||||||
|
<Icon name="list" className="h-3.5 w-3.5" />
|
||||||
|
{p.tasks.filter((t) => t.is_active).length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<p className="mt-3 flex items-start gap-1.5 rounded-lg bg-amber-50 px-2 py-1.5 text-[11px] font-semibold text-amber-700">
|
||||||
|
<Icon name="warning" className="mt-px h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="line-clamp-2">{warnings[0].warning}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Badge, BudgetBar, Card, EmptyState, ErrorBox, Spinner, inputClass } from '../ui/controls';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { BILLABLE, CountUp, Legend, NON_BILLABLE, StackedBars } from '../ui/charts';
|
||||||
|
import { ReportTable } from './ReportTable';
|
||||||
|
import { ExportMenu } from './ExportMenu';
|
||||||
|
import { ReportKpi, ReportRow } from './ProjectReport';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { customersApi, qs, reportsApi, type CustomerReport as CustomerReportData } from '../../lib/api';
|
||||||
|
import { useLoader } from '../../lib/hooks';
|
||||||
|
import { MONTHS, colorFor, formatEuro, formatHours, formatNumber, todayIso } from '../../lib/format';
|
||||||
|
import { useRouter } from '../../lib/router';
|
||||||
|
import { TYPE_TONE } from '../projects/Projects';
|
||||||
|
import { PROJECT_TYPE_LABELS } from '../../types';
|
||||||
|
|
||||||
|
export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
|
||||||
|
const { navigate } = useRouter();
|
||||||
|
const customers = useLoader(() => customersApi.list(), []);
|
||||||
|
const report = useLoader(() => (id ? reportsApi.customer(id) : Promise.resolve(null)), [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<select className={`${inputClass} !w-auto min-w-[260px] max-w-full`} value={id ?? ''} onChange={(e) => navigate(e.target.value ? `reports/kunde/${e.target.value}` : 'reports/kunde')}>
|
||||||
|
<option value="">Kunde wählen…</option>
|
||||||
|
{(customers.data ?? []).map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{!id ? (
|
||||||
|
<EmptyState icon="building" title="Kunde auswählen" text="Alle Projekte eines Kunden mit Budget, Umsatz, Verlauf und Team." />
|
||||||
|
) : report.error ? (
|
||||||
|
<ErrorBox message={report.error} onRetry={report.reload} />
|
||||||
|
) : !report.data ? (
|
||||||
|
<Spinner label="Lade Kundenreport…" />
|
||||||
|
) : (
|
||||||
|
<Body key={id} data={report.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
|
||||||
|
const { settings } = useAuth();
|
||||||
|
const { navigate } = useRouter();
|
||||||
|
const [tab, setTab] = useState<'projects' | 'team' | 'tasks'>('projects');
|
||||||
|
const [months, setMonths] = useState(12);
|
||||||
|
const c = data.customer;
|
||||||
|
const t = data.totals;
|
||||||
|
const color = colorFor(c.id, c.color);
|
||||||
|
const month = todayIso().slice(0, 7);
|
||||||
|
const series = useMemo(() => data.series.slice(-months), [data.series, months]);
|
||||||
|
|
||||||
|
const exportOptions = [
|
||||||
|
{ label: 'PDF – Stundennachweis (Monat)', hint: 'Vorlage für den Kunden, alle Berater', path: `/exports/timesheet${qs({ month, format: 'pdf', user_id: 'all', customer_id: c.id })}`, filename: `${c.short_name}-${month}.pdf` },
|
||||||
|
{ label: 'Excel – Stundennachweis mit Beträgen', hint: 'Für die Rechnungsstellung', path: `/exports/timesheet${qs({ month, format: 'xlsx', user_id: 'all', customer_id: c.id, amounts: 1 })}`, filename: `${c.short_name}-${month}.xlsx` },
|
||||||
|
{ label: 'PDF – Projekte (Jahr)', path: `/exports/report${qs({ from: `${month.slice(0, 4)}-01-01`, to: todayIso(), customer_id: c.id, group: 'projects', format: 'pdf', include_fixed: 1 })}`, filename: `${c.short_name}-projekte.pdf` },
|
||||||
|
{ label: 'CSV – alle Buchungen (Jahr)', path: `/exports/report${qs({ from: `${month.slice(0, 4)}-01-01`, to: todayIso(), customer_id: c.id, group: 'detail', format: 'csv', include_fixed: 1 })}`, filename: `${c.short_name}.csv` },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="animate-rise flex flex-wrap items-center gap-4">
|
||||||
|
<span className="flex h-14 w-14 items-center justify-center rounded-2xl text-lg font-extrabold text-white shadow-card" style={{ background: color }}>
|
||||||
|
{c.short_name.slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="truncate text-2xl text-ink-900 sm:text-3xl">{c.name}</h2>
|
||||||
|
<p className="text-sm text-ink-500">
|
||||||
|
{data.projects.length} Projekte · {data.team.length} Mitarbeiter
|
||||||
|
{c.contact_person && ` · Ansprechpartner: ${c.contact_person}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ExportMenu options={exportOptions} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<ReportKpi index={1} title="Gesamtstunden">
|
||||||
|
<CountUp value={t.minutes / 60} format={(v) => formatNumber(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||||
|
<ReportRow label="Billable" value={formatHours(t.billable_minutes)} />
|
||||||
|
<ReportRow label="Non-billable" value={formatHours(t.non_billable_minutes)} />
|
||||||
|
</ReportKpi>
|
||||||
|
<ReportKpi index={2} title="Umsatz erbracht">
|
||||||
|
<CountUp value={t.earned} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||||
|
<ReportRow label="davon aus Buchungen" value={formatEuro(t.amount, 2)} />
|
||||||
|
</ReportKpi>
|
||||||
|
<ReportKpi index={3} title="Umsatz offen">
|
||||||
|
<CountUp value={t.open} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||||
|
<ReportRow label="in laufenden Töpfen" value={`${data.projects.reduce((s, p) => s + p.project.pots.filter((x) => x.status === 'active').length, 0)}`} />
|
||||||
|
</ReportKpi>
|
||||||
|
<ReportKpi index={4} title="Budget gesamt">
|
||||||
|
<CountUp value={t.budget_value} format={(v) => formatEuro(v, 0)} className="text-3xl font-extrabold text-ink-900" />
|
||||||
|
<BudgetBar className="mt-3" percent={t.budget_value ? (t.earned / t.budget_value) * 100 : null} warnAt={settings.budget_warn_percent} />
|
||||||
|
</ReportKpi>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="animate-rise p-4 sm:p-5" style={{ '--i': 5 } as React.CSSProperties}>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base text-ink-900">Stunden je Monat</h3>
|
||||||
|
<Legend className="mt-1" items={[{ color: BILLABLE, label: 'Billable' }, { color: NON_BILLABLE, label: 'Non-billable' }]} />
|
||||||
|
</div>
|
||||||
|
<select className="rounded-xl border border-ink-200 bg-surface px-3 py-1.5 text-xs font-bold text-ink-600" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
|
||||||
|
<option value={6}>6 Monate</option>
|
||||||
|
<option value={12}>12 Monate</option>
|
||||||
|
<option value={24}>24 Monate</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{series.length ? (
|
||||||
|
<StackedBars
|
||||||
|
scale={60}
|
||||||
|
points={series.map((m) => ({ key: m.month, label: `${MONTHS[Number(m.month.slice(5)) - 1].slice(0, 3)} ${m.month.slice(2, 4)}`, a: m.billable_minutes, b: m.minutes - m.billable_minutes }))}
|
||||||
|
format={(v) => `${formatHours(v, 1)} h`}
|
||||||
|
axisFormat={(v) => formatNumber(v / 60, 0)}
|
||||||
|
height={220}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="py-10 text-center text-xs text-ink-400">Noch keine Buchungen.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="animate-rise overflow-hidden" style={{ '--i': 6 } as React.CSSProperties}>
|
||||||
|
<div className="flex gap-1 border-b border-ink-100 px-4 pt-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['projects', 'Projekte'],
|
||||||
|
['team', 'Team'],
|
||||||
|
['tasks', 'Tasks'],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<button key={key} onClick={() => setTab(key)} className={`relative px-3 py-3 text-sm font-bold ${tab === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}>
|
||||||
|
{label}
|
||||||
|
{tab === key && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-[var(--color-brand-accent)]" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{tab === 'team' && <ReportTable rows={data.team} kind="team" label="Name" />}
|
||||||
|
{tab === 'tasks' && <ReportTable rows={data.tasks} kind="tasks" label="Task" />}
|
||||||
|
{tab === 'projects' && (
|
||||||
|
<div className="divide-y divide-ink-100">
|
||||||
|
{data.projects.length === 0 && <p className="p-6 text-center text-sm text-ink-400">Keine Projekte.</p>}
|
||||||
|
{data.projects.map(({ project: p, totals, pots }, i) => (
|
||||||
|
<button key={p.id} onClick={() => navigate(`reports/projekt/${p.id}`)} className="animate-rise block w-full p-4 text-left hover:bg-ink-50" style={{ '--i': i } as React.CSSProperties}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="tabular text-[11px] font-bold text-ink-400">{p.code}</p>
|
||||||
|
<p className="flex items-center gap-2 font-extrabold text-ink-900">
|
||||||
|
{p.display_name}
|
||||||
|
<Icon name="chevronRight" className="h-4 w-4 text-ink-300" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
|
||||||
|
{!p.is_active && <Badge>Archiv</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="tabular mt-2 grid grid-cols-2 gap-x-6 gap-y-1 text-xs sm:grid-cols-4">
|
||||||
|
<ReportRow label="Stunden" value={formatHours(totals.minutes)} />
|
||||||
|
<ReportRow label="Budget" value={p.budget_hours == null ? '∞' : `${formatNumber(p.budget_hours, 0)} h`} />
|
||||||
|
<ReportRow label="Erbracht" value={formatEuro(p.earned, 0)} />
|
||||||
|
<ReportRow label="Offen" value={formatEuro(p.open, 0)} />
|
||||||
|
</div>
|
||||||
|
<BudgetBar className="mt-2" percent={p.budget_hours ? (p.budget_used_minutes / 60 / p.budget_hours) * 100 : null} warnAt={settings.budget_warn_percent} />
|
||||||
|
{pots.length > 1 && (
|
||||||
|
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{pots.map((pot) => (
|
||||||
|
<div key={pot.id} className="rounded-xl border border-ink-200 p-2.5">
|
||||||
|
<p className="flex justify-between gap-2 text-xs font-bold text-ink-700">
|
||||||
|
<span className="truncate">{pot.label}</span>
|
||||||
|
<span className="tabular text-ink-500">{formatHours(pot.used_minutes, 1)} h</span>
|
||||||
|
</p>
|
||||||
|
<BudgetBar className="mt-1.5" height="h-1.5" percent={pot.percent_used} warnAt={settings.budget_warn_percent} muted={pot.status === 'expired'} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Button } from '../ui/controls';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { download } from '../../lib/api';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
|
||||||
|
export interface ExportOption {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
path: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIDTH = 264;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufklappmenue "Export".
|
||||||
|
*
|
||||||
|
* Die Liste haengt per Portal am <body> und wird fest am Knopf positioniert.
|
||||||
|
* Ohne das legen sich die Karten darunter darueber: ihre Einblend-Animation
|
||||||
|
* macht aus jeder Karte einen eigenen Stapelkontext, gegen den ein z-index
|
||||||
|
* innerhalb der Kopfzeile nicht ankommt.
|
||||||
|
*/
|
||||||
|
export const ExportMenu: React.FC<{ options: ExportOption[]; label?: string }> = ({ options, label = 'Export' }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const buttonRef = useRef<HTMLDivElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const place = useCallback(() => {
|
||||||
|
const rect = buttonRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
// Nach links ausgerichtet an der Knopfkante, notfalls am Fensterrand geklemmt.
|
||||||
|
const left = Math.max(8, Math.min(rect.right - WIDTH, window.innerWidth - WIDTH - 8));
|
||||||
|
setPos({ top: rect.bottom + 8, left });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
place();
|
||||||
|
const close = (event: MouseEvent) => {
|
||||||
|
if (buttonRef.current?.contains(event.target as Node) || menuRef.current?.contains(event.target as Node)) return;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (event: KeyboardEvent) => event.key === 'Escape' && setOpen(false);
|
||||||
|
document.addEventListener('mousedown', close);
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
// Beim Scrollen mitwandern (der Inhalt scrollt in einem eigenen Bereich).
|
||||||
|
window.addEventListener('resize', place);
|
||||||
|
document.addEventListener('scroll', place, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', close);
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
window.removeEventListener('resize', place);
|
||||||
|
document.removeEventListener('scroll', place, true);
|
||||||
|
};
|
||||||
|
}, [open, place]);
|
||||||
|
|
||||||
|
const run = async (option: ExportOption) => {
|
||||||
|
setBusy(option.label);
|
||||||
|
try {
|
||||||
|
await download(option.path, option.filename);
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Export fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={buttonRef} className="relative">
|
||||||
|
<Button size="sm" variant="secondary" icon="download" onClick={() => setOpen(!open)} aria-expanded={open} aria-haspopup="menu">
|
||||||
|
{label}
|
||||||
|
<Icon name="chevronDown" className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} strokeWidth={2.2} />
|
||||||
|
</Button>
|
||||||
|
{open &&
|
||||||
|
pos &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
role="menu"
|
||||||
|
style={{ top: pos.top, left: pos.left, width: WIDTH }}
|
||||||
|
className="animate-pop fixed z-[300] max-h-[70dvh] overflow-y-auto rounded-2xl border border-ink-200 bg-surface p-1.5 shadow-lift"
|
||||||
|
>
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.label}
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => run(o)}
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
className="flex w-full items-start gap-3 rounded-xl px-3 py-2 text-left hover:bg-ink-50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy === o.label ? (
|
||||||
|
<span className="mt-0.5 h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
|
||||||
|
) : (
|
||||||
|
<Icon
|
||||||
|
name={o.label.includes('PDF') ? 'document' : o.label.includes('Excel') ? 'grid' : 'list'}
|
||||||
|
className="mt-0.5 h-4 w-4 shrink-0 text-ink-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-bold text-ink-800">{o.label}</span>
|
||||||
|
{o.hint && <span className="block text-[11px] text-ink-400">{o.hint}</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Badge, BudgetBar, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { CountUp, ProgressChart } from '../ui/charts';
|
||||||
|
import { ReportTable } from './ReportTable';
|
||||||
|
import { ExportMenu } from './ExportMenu';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { projectsApi, qs, reportsApi, type ProjectDetail } from '../../lib/api';
|
||||||
|
import { useLoader } from '../../lib/hooks';
|
||||||
|
import { formatDate, formatEuro, formatHours, formatNumber, mondayOf, todayIso } from '../../lib/format';
|
||||||
|
import { useRouter } from '../../lib/router';
|
||||||
|
import { TYPE_TONE } from '../projects/Projects';
|
||||||
|
import { PROJECT_TYPE_LABELS } from '../../types';
|
||||||
|
|
||||||
|
export const ProjectReport: React.FC<{ id: number | null }> = ({ id }) => {
|
||||||
|
const { navigate } = useRouter();
|
||||||
|
const projects = useLoader(() => projectsApi.list(), []);
|
||||||
|
const report = useLoader(() => (id ? reportsApi.project(id) : Promise.resolve(null)), [id]);
|
||||||
|
|
||||||
|
const options = useMemo(() => [...(projects.data ?? [])].sort((a, b) => a.display_name.localeCompare(b.display_name, 'de')), [projects.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select className={`${inputClass} !w-auto min-w-[260px] max-w-full`} value={id ?? ''} onChange={(e) => navigate(e.target.value ? `reports/projekt/${e.target.value}` : 'reports/projekt')}>
|
||||||
|
<option value="">Projekt wählen…</option>
|
||||||
|
{options.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.display_name} · {p.code}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{!id ? (
|
||||||
|
<EmptyState icon="folder" title="Projekt auswählen" text="Verlauf, Budget, Tasks, Team und Budgettöpfe eines Projekts." />
|
||||||
|
) : report.error ? (
|
||||||
|
<ErrorBox message={report.error} onRetry={report.reload} />
|
||||||
|
) : !report.data ? (
|
||||||
|
<Spinner label="Lade Projektreport…" />
|
||||||
|
) : (
|
||||||
|
<ProjectReportBody key={id} detail={report.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectReportBody: React.FC<{ detail: ProjectDetail; compact?: boolean }> = ({ detail, compact }) => {
|
||||||
|
const { settings } = useAuth();
|
||||||
|
const { navigate } = useRouter();
|
||||||
|
const [chart, setChart] = useState<'progress' | 'weekly'>('progress');
|
||||||
|
const [tab, setTab] = useState<'tasks' | 'team' | 'pots'>('tasks');
|
||||||
|
const [showNotes, setShowNotes] = useState(false);
|
||||||
|
const p = detail.project;
|
||||||
|
const t = detail.totals;
|
||||||
|
const budgetHours = p.budget_hours;
|
||||||
|
const remaining = budgetHours != null ? Math.max(0, budgetHours - p.budget_used_minutes / 60) : null;
|
||||||
|
const remainingPct = budgetHours ? Math.round(((remaining ?? 0) / budgetHours) * 100) : null;
|
||||||
|
const thisWeek = mondayOf(todayIso());
|
||||||
|
const virtualRate = p.type === 'fixed' && p.used_minutes ? (p.budget_value ?? 0) / (p.used_minutes / 60) : null;
|
||||||
|
const month = todayIso().slice(0, 7);
|
||||||
|
|
||||||
|
const exportOptions = [
|
||||||
|
{ label: 'PDF – Stundennachweis (Monat)', hint: 'Alle Mitarbeiter, aktueller Monat', path: `/exports/timesheet${qs({ month, format: 'pdf', user_id: 'all', project_id: p.id })}`, filename: `${p.code}.pdf` },
|
||||||
|
{ label: 'Excel – Stundennachweis (Monat)', path: `/exports/timesheet${qs({ month, format: 'xlsx', user_id: 'all', project_id: p.id, amounts: 1 })}`, filename: `${p.code}.xlsx` },
|
||||||
|
{ label: 'CSV – alle Buchungen', path: `/exports/report${qs({ from: p.start_date, to: todayIso(), project_id: p.id, group: 'detail', format: 'csv', include_fixed: 1 })}`, filename: `${p.code}.csv` },
|
||||||
|
{ label: 'PDF – Tasks gesamt', path: `/exports/report${qs({ from: p.start_date, to: todayIso(), project_id: p.id, group: 'tasks', format: 'pdf', include_fixed: 1 })}`, filename: `${p.code}-tasks.pdf` },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!compact && (
|
||||||
|
<div className="animate-rise">
|
||||||
|
<button onClick={() => navigate(`reports/kunde/${p.customer_id}`)} className="text-sm font-bold text-ink-500 hover:text-brand-700">
|
||||||
|
{p.customer.name}
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<h2 className="text-2xl leading-tight text-ink-900 sm:text-3xl">
|
||||||
|
<span className="tabular text-ink-400">[{p.code}]</span> {p.display_name}
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => navigate(`projekte/${p.id}`)} className="flex items-center gap-1.5 rounded-xl border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-600 hover:border-brand-300 hover:text-brand-700">
|
||||||
|
<Icon name="edit" className="h-3.5 w-3.5" /> Projekt öffnen
|
||||||
|
</button>
|
||||||
|
<ExportMenu options={exportOptions} label="Aktionen" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-ink-500">
|
||||||
|
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Icon name="calendar" className="h-4 w-4" />
|
||||||
|
Start: {formatDate(p.start_date)}
|
||||||
|
{p.end_date && ` · Ende: ${formatDate(p.end_date)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{p.notes && (
|
||||||
|
<div className="mt-2 max-w-3xl text-sm text-ink-500">
|
||||||
|
<p className={showNotes ? 'whitespace-pre-line' : 'line-clamp-2'}>{p.notes}</p>
|
||||||
|
<button onClick={() => setShowNotes(!showNotes)} className="text-xs font-bold text-sky-700 underline">
|
||||||
|
{showNotes ? 'Weniger' : 'Mehr anzeigen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className="animate-rise p-4 sm:p-5" style={{ '--i': 1 } as React.CSSProperties}>
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Segmented
|
||||||
|
size="sm"
|
||||||
|
value={chart}
|
||||||
|
onChange={setChart}
|
||||||
|
options={[
|
||||||
|
{ value: 'progress', label: 'Projektverlauf' },
|
||||||
|
{ value: 'weekly', label: 'Stunden pro Woche' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-ink-400">Stunden · Start {formatDate(p.start_date)}</span>
|
||||||
|
</div>
|
||||||
|
<ProgressChart
|
||||||
|
mode={chart}
|
||||||
|
budget={budgetHours}
|
||||||
|
format={(v) => `${formatNumber(v, 0)} h`}
|
||||||
|
points={detail.weekly.map((w) => ({
|
||||||
|
label: w.label,
|
||||||
|
value: w.cumulative_minutes == null ? null : w.cumulative_minutes / 60,
|
||||||
|
bar: w.billable_minutes / 60,
|
||||||
|
barB: (w.minutes - w.billable_minutes) / 60,
|
||||||
|
current: w.week_start === thisWeek,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<Kpi index={2} title="Gesamtstunden">
|
||||||
|
<CountUp value={t.minutes / 60} format={(v) => formatNumber(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||||
|
<Row label="Billable" value={formatHours(t.billable_minutes)} />
|
||||||
|
<Row label="Non-billable" value={formatHours(t.non_billable_minutes)} />
|
||||||
|
</Kpi>
|
||||||
|
<Kpi index={3} title={`Budget verbleibend${remainingPct != null ? ` (${remainingPct} %)` : ''}`}>
|
||||||
|
<p className="tabular text-3xl font-extrabold text-ink-900">{remaining == null ? '∞' : formatNumber(remaining, 2)}</p>
|
||||||
|
<Row label="Gesamtbudget" value={budgetHours == null ? 'unbegrenzt' : `${formatNumber(budgetHours, 2)} h`} />
|
||||||
|
<BudgetBar className="mt-2" percent={budgetHours ? (p.budget_used_minutes / 60 / budgetHours) * 100 : null} warnAt={settings.budget_warn_percent} />
|
||||||
|
</Kpi>
|
||||||
|
<Kpi index={4} title={p.type === 'fixed' ? 'Virtueller Stundensatz' : 'Budget in Tagen'}>
|
||||||
|
{p.type === 'fixed' ? (
|
||||||
|
<>
|
||||||
|
<p className="tabular text-3xl font-extrabold text-ink-900">{virtualRate ? formatEuro(virtualRate, 2) : 'n/a'}</p>
|
||||||
|
<Row label="Festpreis ÷ Stunden" value="nur intern" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="tabular text-3xl font-extrabold text-ink-900">{remaining == null ? '∞' : formatNumber(remaining / settings.hours_per_day, 1)}</p>
|
||||||
|
<Row label="Tage frei von" value={budgetHours == null ? '–' : formatNumber(budgetHours / settings.hours_per_day, 1)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Kpi>
|
||||||
|
<Kpi index={5} title="Umsatz erbracht">
|
||||||
|
<CountUp value={p.earned ?? 0} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||||
|
<Row label="Budget gesamt" value={formatEuro(p.budget_value, 2)} />
|
||||||
|
</Kpi>
|
||||||
|
<Kpi index={6} title="Umsatz offen">
|
||||||
|
<CountUp value={p.open ?? 0} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||||
|
<Row label="Töpfe aktiv" value={`${p.pots.filter((x) => x.status === 'active').length} von ${p.pots.length}`} />
|
||||||
|
</Kpi>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="animate-rise overflow-hidden" style={{ '--i': 7 } as React.CSSProperties}>
|
||||||
|
<div className="flex gap-1 border-b border-ink-100 px-4 pt-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['tasks', 'Tasks'],
|
||||||
|
['team', 'Team'],
|
||||||
|
['pots', 'Budgettöpfe'],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<button key={key} onClick={() => setTab(key)} className={`relative px-3 py-3 text-sm font-bold ${tab === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}>
|
||||||
|
{label}
|
||||||
|
{tab === key && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-[var(--color-brand-accent)]" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{tab === 'tasks' && <ReportTable rows={detail.tasks} kind="tasks" label="Task" />}
|
||||||
|
{tab === 'team' && <ReportTable rows={detail.team} kind="team" label="Name" />}
|
||||||
|
{tab === 'pots' && <PotsBreakdown detail={detail} />}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Gesamtuebersicht aller Toepfe und darunter jeder Topf einzeln. */
|
||||||
|
const PotsBreakdown: React.FC<{ detail: ProjectDetail }> = ({ detail }) => {
|
||||||
|
const { settings } = useAuth();
|
||||||
|
const p = detail.project;
|
||||||
|
const totalBudget = p.pots.reduce((s, x) => s + (x.budget_hours ?? 0), 0);
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="rounded-2xl bg-ink-50 p-4">
|
||||||
|
<p className="text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Gesamt · {p.pots.length} Töpfe</p>
|
||||||
|
<div className="tabular mt-1 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||||
|
<Row label="Gebucht" value={`${formatHours(p.used_minutes)} h`} />
|
||||||
|
<Row label="Budget" value={totalBudget ? `${formatNumber(totalBudget, 2)} h` : 'unbegrenzt'} />
|
||||||
|
<Row label="Erbracht" value={formatEuro(p.earned, 2)} />
|
||||||
|
<Row label="Offen" value={formatEuro(p.open, 2)} />
|
||||||
|
</div>
|
||||||
|
<BudgetBar className="mt-3" percent={totalBudget ? (p.budget_used_minutes / 60 / totalBudget) * 100 : null} warnAt={settings.budget_warn_percent} />
|
||||||
|
</div>
|
||||||
|
{detail.pots.map((pot, i) => (
|
||||||
|
<div key={pot.id} className="animate-rise rounded-2xl border border-ink-200" style={{ '--i': i } as React.CSSProperties}>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 p-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-extrabold text-ink-900">{pot.label}</p>
|
||||||
|
<p className="text-xs text-ink-400">
|
||||||
|
{formatDate(pot.valid_from)} – {formatDate(pot.valid_to)} · {pot.status === 'active' ? 'aktiv' : pot.status === 'expired' ? 'abgelaufen' : pot.status === 'exhausted' ? 'aufgebraucht' : 'geplant'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="tabular flex flex-wrap gap-4 text-xs">
|
||||||
|
<Row label="Gebucht" value={`${formatHours(pot.used_minutes)} h`} />
|
||||||
|
<Row label="Frei" value={pot.remaining_hours == null ? '∞' : `${formatNumber(pot.remaining_hours, 2)} h`} />
|
||||||
|
<Row label="Erbracht" value={formatEuro(pot.earned, 2)} />
|
||||||
|
<Row label="Offen" value={formatEuro(pot.open, 2)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BudgetBar className="mx-4 mb-3" percent={pot.percent_used} warnAt={settings.budget_warn_percent} muted={pot.status === 'expired'} />
|
||||||
|
<div className="grid border-t border-ink-100 lg:grid-cols-2">
|
||||||
|
<ReportTable rows={pot.tasks} kind="tasks" label="Task" totals={false} />
|
||||||
|
<ReportTable rows={pot.team} kind="team" label="Team" totals={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Kpi: React.FC<{ title: string; index: number; children: React.ReactNode }> = ({ title, index, children }) => (
|
||||||
|
<Card className="animate-rise p-4" style={{ '--i': index } as React.CSSProperties}>
|
||||||
|
<p className="mb-1 text-sm font-semibold text-ink-500">{title}</p>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Row: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||||
|
<p className="tabular mt-1 flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="text-ink-500">{label}</span>
|
||||||
|
<b className="text-ink-900">{value}</b>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { Row as ReportRow, Kpi as ReportKpi };
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Avatar } from '../ui/controls';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { SplitBar } from '../ui/charts';
|
||||||
|
import { colorFor, formatEuro, formatHours } from '../../lib/format';
|
||||||
|
import type { GroupRow } from '../../lib/api';
|
||||||
|
|
||||||
|
type SortKey = 'name' | 'minutes' | 'billable_minutes' | 'amount' | 'utilization';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tabelle wie im Harvest-Report: Name, Stunden mit Doppelbalken, Auslastung,
|
||||||
|
* abrechenbare Stunden mit Anteil, Betrag. Spaltenkoepfe sortieren.
|
||||||
|
*/
|
||||||
|
export const ReportTable: React.FC<{
|
||||||
|
rows: GroupRow[];
|
||||||
|
label: string;
|
||||||
|
kind: 'clients' | 'projects' | 'tasks' | 'users' | 'team';
|
||||||
|
showAmounts?: boolean;
|
||||||
|
onRowClick?: (row: GroupRow) => void;
|
||||||
|
totals?: boolean;
|
||||||
|
}> = ({ rows, label, kind, showAmounts = true, onRowClick, totals = true }) => {
|
||||||
|
const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: kind === 'users' ? 'name' : 'minutes', dir: kind === 'users' ? 1 : -1 });
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
[...rows].sort((a, b) => {
|
||||||
|
const av = sort.key === 'name' ? a.name : (a[sort.key] ?? -1);
|
||||||
|
const bv = sort.key === 'name' ? b.name : (b[sort.key] ?? -1);
|
||||||
|
return (typeof av === 'string' ? av.localeCompare(bv as string, 'de') : (av as number) - (bv as number)) * sort.dir;
|
||||||
|
}),
|
||||||
|
[rows, sort]
|
||||||
|
);
|
||||||
|
const max = Math.max(1, ...rows.map((r) => r.minutes));
|
||||||
|
const showUtil = kind === 'users';
|
||||||
|
const sum = rows.reduce(
|
||||||
|
(s, r) => ({ minutes: s.minutes + r.minutes, billable: s.billable + r.billable_minutes, amount: s.amount + r.amount }),
|
||||||
|
{ minutes: 0, billable: 0, amount: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const head = (key: SortKey, text: string, align: 'left' | 'right' = 'right', className = '') => (
|
||||||
|
<th className={`px-3 py-3 ${align === 'right' ? 'text-right' : 'text-left'} ${className}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSort({ key, dir: sort.key === key ? ((sort.dir * -1) as 1 | -1) : key === 'name' ? 1 : -1 })}
|
||||||
|
className={`inline-flex items-center gap-1 font-extrabold uppercase tracking-wider hover:text-ink-700 ${sort.key === key ? 'text-ink-700' : ''}`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
{sort.key === key && <Icon name={sort.dir === 1 ? 'chevronUp' : 'chevronDown'} className="h-3 w-3" strokeWidth={2.5} />}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) return <p className="px-4 py-10 text-center text-sm text-ink-400">Keine Buchungen im Zeitraum.</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scrollbar-slim overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[720px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-ink-200 text-[11px] text-ink-400">
|
||||||
|
{head('name', label, 'left', 'pl-4')}
|
||||||
|
{head('minutes', 'Stunden')}
|
||||||
|
<th className="w-[22%] px-3 py-3" />
|
||||||
|
{showUtil && head('utilization', 'Auslastung')}
|
||||||
|
{head('billable_minutes', 'Billable Std.')}
|
||||||
|
{showAmounts && head('amount', 'Billable Betrag', 'right', 'pr-4')}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((r, i) => {
|
||||||
|
const share = r.minutes ? Math.round((r.billable_minutes / r.minutes) * 100) : 0;
|
||||||
|
const clickable = Boolean(onRowClick);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={String(r.key)}
|
||||||
|
onClick={() => onRowClick?.(r)}
|
||||||
|
className={`animate-rise border-b border-ink-100 last:border-0 ${clickable ? 'cursor-pointer hover:bg-brand-50/50' : 'hover:bg-ink-50'}`}
|
||||||
|
style={{ '--i': Math.min(i, 15) } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<td className="py-3 pl-4 pr-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
{kind === 'users' || kind === 'team' ? (
|
||||||
|
<Avatar id={r.user_id} name={r.name} color={r.color} size={28} external={r.is_external} />
|
||||||
|
) : kind !== 'tasks' ? (
|
||||||
|
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: colorFor(r.customer_id ?? r.project_id ?? r.name, r.color) }} />
|
||||||
|
) : null}
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className={`block truncate font-bold ${clickable ? 'text-ink-900 underline decoration-ink-200 underline-offset-4 group-hover:decoration-brand-400' : 'text-ink-800'}`}>{r.name}</span>
|
||||||
|
{r.customer_name && <span className="block truncate text-[11px] text-ink-400">{r.customer_name}{r.code ? ` · ${r.code}` : ''}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="tabular px-3 py-3 text-right font-extrabold text-ink-900">{formatHours(r.minutes)}</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<SplitBar a={r.billable_minutes} b={r.non_billable_minutes} max={max} delay={i * 30} />
|
||||||
|
</td>
|
||||||
|
{showUtil && (
|
||||||
|
<td className={`tabular px-3 py-3 text-right font-bold ${r.utilization == null ? 'text-ink-300' : r.utilization > 105 ? 'text-red-600' : r.utilization >= 75 ? 'text-brand-700' : 'text-ink-600'}`}>
|
||||||
|
{r.utilization == null ? '–' : `${r.utilization} %`}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="tabular px-3 py-3 text-right text-ink-700">
|
||||||
|
{formatHours(r.billable_minutes)} <span className="inline-block w-14 whitespace-nowrap text-ink-400">({share} %)</span>
|
||||||
|
</td>
|
||||||
|
{showAmounts && <td className="tabular py-3 pl-3 pr-4 text-right font-extrabold text-ink-900">{formatEuro(r.amount, 2)}</td>}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
{totals && (
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t-2 border-ink-200 bg-ink-50/60 font-extrabold text-ink-900">
|
||||||
|
<td className="py-3 pl-4 pr-3">Summe</td>
|
||||||
|
<td className="tabular px-3 py-3 text-right">{formatHours(sum.minutes)}</td>
|
||||||
|
<td />
|
||||||
|
{showUtil && <td />}
|
||||||
|
<td className="tabular px-3 py-3 text-right">
|
||||||
|
{formatHours(sum.billable)} <span className="inline-block w-14 whitespace-nowrap font-normal text-ink-400">({sum.minutes ? Math.round((sum.billable / sum.minutes) * 100) : 0} %)</span>
|
||||||
|
</td>
|
||||||
|
{showAmounts && <td className="tabular py-3 pl-3 pr-4 text-right">{formatEuro(sum.amount, 2)}</td>}
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Card, ErrorBox, IconButton, Spinner, inputClass } from '../ui/controls';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { CountUp, Donut, BILLABLE, NON_BILLABLE } from '../ui/charts';
|
||||||
|
import { ReportTable } from './ReportTable';
|
||||||
|
import { ExportMenu } from './ExportMenu';
|
||||||
|
import { CustomerReport } from './CustomerReport';
|
||||||
|
import { ProjectReport } from './ProjectReport';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { customersApi, qs, reportsApi, usersApi } from '../../lib/api';
|
||||||
|
import { useLoader, usePersistentState } from '../../lib/hooks';
|
||||||
|
import { MONTHS, addDays, addMonths, formatDate, formatEuro, formatHours, isoWeek, mondayOf, monthEnd, monthStart, todayIso } from '../../lib/format';
|
||||||
|
import { useRouter } from '../../lib/router';
|
||||||
|
import { atLeast, type Customer, type DirectoryUser } from '../../types';
|
||||||
|
|
||||||
|
type Period = 'week' | 'month' | 'quarter' | 'year' | 'custom';
|
||||||
|
type Tab = 'clients' | 'projects' | 'tasks' | 'users';
|
||||||
|
|
||||||
|
function periodRange(period: Period, anchor: string, custom: { from: string; to: string }) {
|
||||||
|
if (period === 'week') {
|
||||||
|
const from = mondayOf(anchor);
|
||||||
|
return { from, to: addDays(from, 6), label: `KW ${isoWeek(from)} · ${formatDate(from)} – ${formatDate(addDays(from, 6))}` };
|
||||||
|
}
|
||||||
|
if (period === 'month') return { from: monthStart(anchor), to: monthEnd(anchor), label: `${MONTHS[Number(anchor.slice(5, 7)) - 1]} ${anchor.slice(0, 4)}` };
|
||||||
|
if (period === 'quarter') {
|
||||||
|
const q = Math.floor((Number(anchor.slice(5, 7)) - 1) / 3);
|
||||||
|
const from = `${anchor.slice(0, 4)}-${String(q * 3 + 1).padStart(2, '0')}-01`;
|
||||||
|
return { from, to: monthEnd(addMonths(from, 2)), label: `Q${q + 1} ${anchor.slice(0, 4)}` };
|
||||||
|
}
|
||||||
|
if (period === 'year') return { from: `${anchor.slice(0, 4)}-01-01`, to: `${anchor.slice(0, 4)}-12-31`, label: anchor.slice(0, 4) };
|
||||||
|
return { ...custom, label: `${formatDate(custom.from)} – ${formatDate(custom.to)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Reports: React.FC = () => {
|
||||||
|
const { route, navigate } = useRouter();
|
||||||
|
const [kind, id] = route.params;
|
||||||
|
const view = kind === 'kunde' ? 'customer' : kind === 'projekt' ? 'project' : 'time';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[1500px]">
|
||||||
|
<div className="no-scrollbar mb-4 flex items-center gap-1 overflow-x-auto overflow-y-hidden border-b border-ink-200">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['time', 'Zeitreport', 'reports'],
|
||||||
|
['customer', 'Kundenreport', 'reports/kunde'],
|
||||||
|
['project', 'Projektreport', 'reports/projekt'],
|
||||||
|
] as const
|
||||||
|
).map(([key, label, path]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => navigate(path)}
|
||||||
|
className={`relative whitespace-nowrap px-4 py-2.5 text-sm font-bold transition ${view === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{view === key && <span className="animate-pop absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-brand-500" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{view === 'time' && <TimeReport />}
|
||||||
|
{view === 'customer' && <CustomerReport id={id ? Number(id) : null} />}
|
||||||
|
{view === 'project' && <ProjectReport id={id ? Number(id) : null} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TimeReport: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { navigate } = useRouter();
|
||||||
|
const [period, setPeriod] = usePersistentState<Period>('pulse_report_period', 'month');
|
||||||
|
const [anchor, setAnchor] = useState(todayIso());
|
||||||
|
const [custom, setCustom] = useState({ from: monthStart(todayIso()), to: todayIso() });
|
||||||
|
const [tab, setTab] = usePersistentState<Tab>('pulse_report_tab', 'users');
|
||||||
|
const [customerId, setCustomerId] = useState<number | ''>('');
|
||||||
|
const [team, setTeam] = useState<number | ''>('');
|
||||||
|
const [includeFixed, setIncludeFixed] = useState(false);
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||||
|
const [leads, setLeads] = useState<DirectoryUser[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
customersApi.list().then(setCustomers).catch(() => undefined);
|
||||||
|
usersApi
|
||||||
|
.directory()
|
||||||
|
.then((list) => setLeads(list.filter((u) => (u.role === 'lead' || u.role === 'management') && u.is_active)))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const range = periodRange(period, anchor, custom);
|
||||||
|
const report = useLoader(
|
||||||
|
() => reportsApi.time({ from: range.from, to: range.to, customer_id: customerId || null, team: team || null, include_fixed: includeFixed }),
|
||||||
|
[range.from, range.to, customerId, team, includeFixed]
|
||||||
|
);
|
||||||
|
|
||||||
|
const shift = (step: number) => {
|
||||||
|
if (period === 'week') setAnchor(addDays(anchor, 7 * step));
|
||||||
|
if (period === 'month') setAnchor(addMonths(anchor, step));
|
||||||
|
if (period === 'quarter') setAnchor(addMonths(anchor, 3 * step));
|
||||||
|
if (period === 'year') setAnchor(`${Number(anchor.slice(0, 4)) + step}-01-01`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportParams = { from: range.from, to: range.to, customer_id: customerId || undefined, team: team || undefined, include_fixed: includeFixed ? 1 : undefined };
|
||||||
|
const groupName = { clients: 'Kunden', projects: 'Projekte', tasks: 'Tasks', users: 'Mitarbeiter' }[tab];
|
||||||
|
const exportOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: `PDF – ${groupName}`, hint: 'Mit Logo, Kopf und Fuß', path: `/exports/report${qs({ ...exportParams, group: tab, format: 'pdf' })}`, filename: 'Zeitreport.pdf' },
|
||||||
|
{ label: `Excel – ${groupName}`, hint: 'Mit Summenformeln', path: `/exports/report${qs({ ...exportParams, group: tab, format: 'xlsx' })}`, filename: 'Zeitreport.xlsx' },
|
||||||
|
{ label: `CSV – ${groupName}`, path: `/exports/report${qs({ ...exportParams, group: tab, format: 'csv' })}`, filename: 'Zeitreport.csv' },
|
||||||
|
{ label: 'Excel – alle Buchungen', hint: 'Detailliste für die Rechnung', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'xlsx' })}`, filename: 'Zeitreport-Detail.xlsx' },
|
||||||
|
{ label: 'PDF – alle Buchungen', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'pdf' })}`, filename: 'Zeitreport-Detail.pdf' },
|
||||||
|
{ label: 'CSV – alle Buchungen', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'csv' })}`, filename: 'Zeitreport-Detail.csv' },
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[tab, range.from, range.to, customerId, team, includeFixed]
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = report.data;
|
||||||
|
const s = data?.summary;
|
||||||
|
const share = s && s.minutes ? (s.billable_minutes / s.minutes) * 100 : 0;
|
||||||
|
const capacity = data?.users.reduce((sum, u) => sum + (u.capacity_minutes ?? 0), 0) ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{atLeast(user, 'management') && leads.length > 0 && (
|
||||||
|
<select className={`${inputClass} !w-auto max-w-[220px]`} value={team} onChange={(e) => setTeam(e.target.value ? Number(e.target.value) : '')}>
|
||||||
|
<option value="">Alle Teams</option>
|
||||||
|
{leads.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>
|
||||||
|
Team {l.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<select className={`${inputClass} !w-auto max-w-[220px]`} value={customerId} onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : '')}>
|
||||||
|
<option value="">Alle Kunden</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select className={`${inputClass} !w-auto`} value={period} onChange={(e) => setPeriod(e.target.value as Period)}>
|
||||||
|
<option value="week">Woche</option>
|
||||||
|
<option value="month">Monat</option>
|
||||||
|
<option value="quarter">Quartal</option>
|
||||||
|
<option value="year">Jahr</option>
|
||||||
|
<option value="custom">Zeitraum</option>
|
||||||
|
</select>
|
||||||
|
{period === 'custom' ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="date" className={`${inputClass} !w-auto`} value={custom.from} onChange={(e) => setCustom({ ...custom, from: e.target.value })} />
|
||||||
|
<span className="text-ink-400">–</span>
|
||||||
|
<input type="date" className={`${inputClass} !w-auto`} value={custom.to} onChange={(e) => setCustom({ ...custom, to: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center rounded-xl border border-ink-200 bg-surface">
|
||||||
|
<button onClick={() => shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Zurück">
|
||||||
|
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
<span className="tabular flex items-center gap-2 border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-700">
|
||||||
|
<Icon name="calendar" className="h-4 w-4 text-ink-400" />
|
||||||
|
{range.label}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => shift(1)} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Weiter">
|
||||||
|
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report.error && <ErrorBox message={report.error} onRetry={report.reload} />}
|
||||||
|
{!data ? (
|
||||||
|
<Spinner label="Lade Report…" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card className={`animate-rise grid gap-6 p-5 transition-opacity sm:p-6 lg:grid-cols-[1fr_1.2fr_1.3fr_1fr] ${report.loading ? 'opacity-60' : ''}`}>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-ink-500">Gesamtstunden</p>
|
||||||
|
<CountUp value={s!.minutes / 60} format={(v) => v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} className="text-4xl font-extrabold text-ink-900" />
|
||||||
|
<p className="mt-1 text-xs text-ink-400">{s!.entries} Buchungen</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Donut percent={share} size={96} stroke={13} />
|
||||||
|
<div className="flex-1 space-y-1.5 text-sm">
|
||||||
|
<p className="flex items-center justify-between gap-3">
|
||||||
|
<span className="flex items-center gap-2 font-semibold text-ink-600">
|
||||||
|
<span className="h-3 w-3 rounded-sm" style={{ background: BILLABLE }} /> Billable
|
||||||
|
</span>
|
||||||
|
<b className="tabular text-ink-900">{formatHours(s!.billable_minutes)}</b>
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center justify-between gap-3">
|
||||||
|
<span className="flex items-center gap-2 font-semibold text-ink-600">
|
||||||
|
<span className="h-3 w-3 rounded-sm" style={{ background: NON_BILLABLE }} /> Non-billable
|
||||||
|
</span>
|
||||||
|
<b className="tabular text-ink-900">{formatHours(s!.non_billable_minutes)}</b>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-ink-500">Billable Betrag</p>
|
||||||
|
<CountUp value={s!.amount} format={(v) => formatEuro(v, 2)} className="text-4xl font-extrabold text-ink-900" />
|
||||||
|
<label className="mt-2 flex cursor-pointer items-center gap-2 text-sm font-semibold text-ink-700">
|
||||||
|
<input type="checkbox" checked={includeFixed} onChange={(e) => setIncludeFixed(e.target.checked)} className="h-4 w-4 accent-[var(--color-brand-500)]" />
|
||||||
|
Festpreisprojekte einbeziehen
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-[11px] leading-snug text-ink-400">
|
||||||
|
Festpreis-Beträge werden über den virtuellen Stundensatz (Budget ÷ gebuchte Stunden) verteilt – nur für den internen Gebrauch.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-ink-500">Auslastung</p>
|
||||||
|
<CountUp value={capacity ? (s!.minutes / capacity) * 100 : 0} format={(v) => `${Math.round(v)} %`} className="text-4xl font-extrabold text-ink-900" />
|
||||||
|
<p className="mt-1 text-xs text-ink-400">Kapazität {formatHours(capacity, 0)} h bis heute</p>
|
||||||
|
{!includeFixed && s!.fixed_amount > 0 && <p className="mt-2 text-[11px] text-ink-400">+ {formatEuro(s!.fixed_amount)} aus Festpreisprojekten</p>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="animate-rise overflow-hidden" style={{ '--i': 1 } as React.CSSProperties}>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-ink-100 px-4 pt-2">
|
||||||
|
<div className="no-scrollbar flex gap-1 overflow-x-auto overflow-y-hidden">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['clients', 'Kunden'],
|
||||||
|
['projects', 'Projekte'],
|
||||||
|
['tasks', 'Tasks'],
|
||||||
|
['users', 'Mitarbeiter'],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<button key={key} onClick={() => setTab(key)} className={`relative whitespace-nowrap px-3 py-3 text-sm font-bold ${tab === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}>
|
||||||
|
{label}
|
||||||
|
<span className="ml-1 text-[11px] text-ink-400">{data[key].length}</span>
|
||||||
|
{tab === key && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-[var(--color-brand-accent)]" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pb-2">
|
||||||
|
<ExportMenu options={exportOptions} />
|
||||||
|
<IconButton icon="printer" label="Drucken" onClick={() => window.print()} className="hidden border border-ink-200 sm:block" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ReportTable
|
||||||
|
key={tab}
|
||||||
|
rows={data[tab]}
|
||||||
|
kind={tab}
|
||||||
|
label={{ clients: 'Kunde', projects: 'Projekt', tasks: 'Task', users: 'Name' }[tab]}
|
||||||
|
onRowClick={
|
||||||
|
tab === 'clients'
|
||||||
|
? (row) => navigate(`reports/kunde/${row.customer_id}`)
|
||||||
|
: tab === 'projects'
|
||||||
|
? (row) => navigate(`reports/projekt/${row.project_id}`)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { periodRange };
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { Avatar, Button, Field, inputClass } from '../ui/controls';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { authApi } from '../../lib/api';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { ROLE_LABELS } from '../../types';
|
||||||
|
|
||||||
|
const COLORS = ['#6ba04a', '#0ea5e9', '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#eab308', '#6366f1', '#10b981', '#ec4899'];
|
||||||
|
|
||||||
|
/** Mein Konto: Farbe, Passwort, Abmelden - fuer jede Rolle. */
|
||||||
|
export const AccountDialog: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||||
|
const { user, updateUser, logout, settings } = useAuth();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [current, setCurrent] = useState('');
|
||||||
|
const [next, setNext] = useState('');
|
||||||
|
const [repeat, setRepeat] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const changeColor = async (color: string) => {
|
||||||
|
try {
|
||||||
|
const { user: saved } = await authApi.updateMe({ color });
|
||||||
|
updateUser(saved);
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePassword = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (next !== repeat) return addToast('Die neuen Passwörter stimmen nicht überein.', 'error');
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await authApi.changePassword(current, next);
|
||||||
|
addToast('Passwort geändert.', 'success');
|
||||||
|
setCurrent('');
|
||||||
|
setNext('');
|
||||||
|
setRepeat('');
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Ändern fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Mein Konto" subtitle={`${ROLE_LABELS[user.role]} · ${user.username}`} onClose={onClose}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar name={user.name} color={user.color} id={user.id} size={56} external={user.is_external} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-lg font-extrabold text-ink-900">{user.name}</p>
|
||||||
|
<p className="truncate text-sm text-ink-500">{user.email || '–'}</p>
|
||||||
|
<p className="text-xs text-ink-400">
|
||||||
|
{user.is_external ? `Extern${user.company ? ` · ${user.company}` : ''}` : settings.company_name} · Soll {user.weekly_hours ?? settings.weekly_hours} h/Woche
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-[11px] font-bold uppercase tracking-wider text-ink-500">Meine Farbe</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
onClick={() => changeColor(color)}
|
||||||
|
aria-label={`Farbe ${color}`}
|
||||||
|
className={`h-8 w-8 rounded-full transition hover:scale-110 ${user.color === color ? 'ring-2 ring-ink-800 ring-offset-2 ring-offset-surface' : ''}`}
|
||||||
|
style={{ background: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.source === 'local' ? (
|
||||||
|
<form onSubmit={changePassword} className="mt-8 space-y-4 rounded-2xl border border-ink-200 p-4">
|
||||||
|
<p className="text-sm font-extrabold text-ink-900">Passwort ändern</p>
|
||||||
|
<Field label="Aktuelles Passwort">
|
||||||
|
<input type="password" autoComplete="current-password" required className={inputClass} value={current} onChange={(e) => setCurrent(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Neues Passwort" hint="Mindestens 8 Zeichen">
|
||||||
|
<input type="password" autoComplete="new-password" minLength={8} required className={inputClass} value={next} onChange={(e) => setNext(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Wiederholen">
|
||||||
|
<input type="password" autoComplete="new-password" minLength={8} required className={inputClass} value={repeat} onChange={(e) => setRepeat(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" busy={busy} icon="key">
|
||||||
|
Passwort speichern
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p className="mt-8 rounded-xl bg-ink-50 p-4 text-sm text-ink-500">Dein Konto meldet sich über Microsoft (SSO) an – das Passwort verwaltest du dort.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<Button variant="secondary" icon="logout" onClick={logout}>
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Button, Card, ErrorBox, Field, Spinner, Toggle, inputClass } from '../ui/controls';
|
||||||
|
import { Icon, type IconName } from '../ui/Icon';
|
||||||
|
import { Team } from '../team/Team';
|
||||||
|
import { SsoDirectory } from './SsoDirectory';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { download, settingsApi, type AdminSettings } from '../../lib/api';
|
||||||
|
import { useLoader } from '../../lib/hooks';
|
||||||
|
import { useRouter } from '../../lib/router';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
|
||||||
|
type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'benutzer';
|
||||||
|
const TABS: { key: Tab; label: string; icon: IconName; adminOnly: boolean }[] = [
|
||||||
|
{ key: 'allgemein', label: 'Allgemein', icon: 'settings', adminOnly: true },
|
||||||
|
{ key: 'export', label: 'Export: Kopf & Fuß', icon: 'document', adminOnly: true },
|
||||||
|
{ key: 'sso', label: 'SSO / Entra ID', icon: 'key', adminOnly: true },
|
||||||
|
// Management sieht in den Einstellungen nur diesen Reiter.
|
||||||
|
{ key: 'sso-verwaltung', label: 'SSO-Verwaltung', icon: 'globe', adminOnly: false },
|
||||||
|
{ key: 'benutzer', label: 'Benutzerverwaltung', icon: 'team', adminOnly: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Settings: React.FC = () => {
|
||||||
|
const { route, navigate } = useRouter();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = user?.role === 'admin';
|
||||||
|
const tabs = TABS.filter((t) => isAdmin || !t.adminOnly);
|
||||||
|
const tab = (tabs.find((t) => t.key === route.params[0])?.key ?? tabs[0].key) as Tab;
|
||||||
|
const { data, error, reload, setData } = useLoader(() => (isAdmin ? settingsApi.all() : Promise.resolve(null)), [isAdmin]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid max-w-[1400px] gap-4 lg:grid-cols-[240px_1fr]">
|
||||||
|
<nav className="no-scrollbar flex gap-1 overflow-x-auto lg:flex-col">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => navigate(`einstellungen/${t.key}`)}
|
||||||
|
className={`flex shrink-0 items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-bold transition ${
|
||||||
|
tab === t.key ? 'bg-surface text-brand-700 shadow-card' : 'text-ink-500 hover:bg-surface hover:text-ink-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name={t.icon} className="h-4 w-4" />
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{tab === 'sso-verwaltung' ? (
|
||||||
|
<SsoDirectory />
|
||||||
|
) : tab === 'benutzer' ? (
|
||||||
|
<Team embedded />
|
||||||
|
) : error && !data ? (
|
||||||
|
<ErrorBox message={error} onRetry={reload} />
|
||||||
|
) : !data ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<div key={tab} className="animate-fade">
|
||||||
|
{tab === 'allgemein' && <General settings={data} onSaved={(s) => setData(s)} />}
|
||||||
|
{tab === 'export' && <ExportSettings settings={data} onSaved={(s) => setData(s)} />}
|
||||||
|
{tab === 'sso' && <SsoSettings settings={data} onSaved={reload} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function useSaver() {
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const { reloadSettings } = useAuth();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const save = async (changes: Parameters<typeof settingsApi.save>[0], after?: () => void) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await settingsApi.save(changes);
|
||||||
|
addToast('Einstellungen gespeichert.', 'success');
|
||||||
|
reloadSettings();
|
||||||
|
after?.();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { busy, save };
|
||||||
|
}
|
||||||
|
|
||||||
|
const Section: React.FC<{ title: string; hint?: string; children: React.ReactNode; footer?: React.ReactNode }> = ({ title, hint, children, footer }) => (
|
||||||
|
<Card className="animate-rise overflow-hidden">
|
||||||
|
<div className="border-b border-ink-100 px-5 py-4">
|
||||||
|
<h2 className="text-base text-ink-900">{title}</h2>
|
||||||
|
{hint && <p className="mt-0.5 text-xs text-ink-400">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 p-5">{children}</div>
|
||||||
|
{footer && <div className="flex justify-end gap-2 border-t border-ink-100 bg-ink-50/60 px-5 py-3">{footer}</div>}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const General: React.FC<{ settings: AdminSettings; onSaved: (s: AdminSettings) => void }> = ({ settings, onSaved }) => {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
weekly_hours: String(settings.weekly_hours),
|
||||||
|
hours_per_day: String(settings.hours_per_day),
|
||||||
|
budget_warn_percent: String(settings.budget_warn_percent),
|
||||||
|
budget_warn_days: String(settings.budget_warn_days),
|
||||||
|
company_name: settings.company_name,
|
||||||
|
project_code_pattern: settings.project_code_pattern,
|
||||||
|
project_name_pattern: settings.project_name_pattern,
|
||||||
|
});
|
||||||
|
const { busy, save } = useSaver();
|
||||||
|
const set = (patch: Partial<typeof form>) => setForm({ ...form, ...patch });
|
||||||
|
const n = (v: string) => Number(v.replace(',', '.'));
|
||||||
|
const example = form.project_code_pattern.replaceAll('{KUERZEL}', 'KUKA').replaceAll('{JAHR}', '2026').replaceAll('{JJ}', '26').replaceAll('{NR}', '003');
|
||||||
|
const exampleName = form.project_name_pattern.replaceAll('{KUNDE}', 'KUKA').replaceAll('{KUNDENNAME}', 'KUKA AG').replaceAll('{PROJEKT}', 'PMO').replaceAll('{CODE}', example);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const changes = {
|
||||||
|
weekly_hours: n(form.weekly_hours),
|
||||||
|
hours_per_day: n(form.hours_per_day),
|
||||||
|
budget_warn_percent: n(form.budget_warn_percent),
|
||||||
|
budget_warn_days: n(form.budget_warn_days),
|
||||||
|
company_name: form.company_name,
|
||||||
|
project_code_pattern: form.project_code_pattern,
|
||||||
|
project_name_pattern: form.project_name_pattern,
|
||||||
|
};
|
||||||
|
save(changes, () => onSaved({ ...settings, ...changes }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Section title="Arbeitszeit" hint="Grundlage für Soll-Stunden, Auslastung und die Umrechnung Stunden ↔ Tage">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Field label="Arbeitsstunden pro Woche" hint="Standard für alle, pro Benutzer überschreibbar">
|
||||||
|
<input inputMode="decimal" className={inputClass} value={form.weekly_hours} onChange={(e) => set({ weekly_hours: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Stunden pro Arbeitstag" hint="Für Tagessätze und Budget in Tagen">
|
||||||
|
<input inputMode="decimal" className={inputClass} value={form.hours_per_day} onChange={(e) => set({ hours_per_day: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Firmenname">
|
||||||
|
<input className={inputClass} value={form.company_name} onChange={(e) => set({ company_name: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
<Section title="Budgetwarnungen" hint="Ab wann Pulse beim Buchen und in der Projektübersicht warnt. Gesperrt wird immer erst bei 100 % oder nach Ablauf.">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Warnen ab Verbrauch (%)">
|
||||||
|
<input inputMode="numeric" className={inputClass} value={form.budget_warn_percent} onChange={(e) => set({ budget_warn_percent: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Warnen ab Tagen vor Ablauf">
|
||||||
|
<input inputMode="numeric" className={inputClass} value={form.budget_warn_days} onChange={(e) => set({ budget_warn_days: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
<Section
|
||||||
|
title="Projektbezeichnungen"
|
||||||
|
hint="Muster für den automatisch vergebenen Projektcode und den Anzeigenamen"
|
||||||
|
footer={
|
||||||
|
<Button icon="check" busy={busy} onClick={submit}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Projektcode" hint={<>Platzhalter: {'{KUERZEL} {JAHR} {JJ} {NR}'} · Beispiel: <b className="text-ink-700">{example}</b></>}>
|
||||||
|
<input className={`${inputClass} tabular`} value={form.project_code_pattern} onChange={(e) => set({ project_code_pattern: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Anzeigename" hint={<>Platzhalter: {'{KUNDE} {KUNDENNAME} {PROJEKT} {CODE}'} · Beispiel: <b className="text-ink-700">{exampleName}</b></>}>
|
||||||
|
<input className={inputClass} value={form.project_name_pattern} onChange={(e) => set({ project_name_pattern: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExportSettings: React.FC<{ settings: AdminSettings; onSaved: (s: AdminSettings) => void }> = ({ settings, onSaved }) => {
|
||||||
|
const [ex, setEx] = useState(settings.export);
|
||||||
|
const { busy, save } = useSaver();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const set = (patch: Partial<typeof ex>) => setEx({ ...ex, ...patch });
|
||||||
|
|
||||||
|
const onLogo = (file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
if (!/image\/(png|jpe?g)/.test(file.type)) return addToast('Bitte PNG oder JPG wählen.', 'error');
|
||||||
|
if (file.size > 1_800_000) return addToast('Das Logo ist zu groß (max. 1,8 MB).', 'error');
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => set({ logo: String(reader.result) });
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = async (format: 'pdf' | 'xlsx') => {
|
||||||
|
try {
|
||||||
|
await download(`/settings/export-preview?format=${format}`, `Vorschau.${format}`, format === 'pdf');
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Vorschau fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = (key: keyof typeof ex, label: string, hint?: string) => (
|
||||||
|
<Field label={label} hint={hint}>
|
||||||
|
<input className={inputClass} value={String(ex[key] ?? '')} onChange={(e) => set({ [key]: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 2xl:grid-cols-[1fr_520px]">
|
||||||
|
<Section
|
||||||
|
title="Kopf und Fuß der Exporte"
|
||||||
|
hint="Gilt für Stundennachweise und Reports in PDF und Excel"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" icon="document" onClick={() => preview('pdf')}>
|
||||||
|
PDF-Vorschau
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" icon="grid" onClick={() => preview('xlsx')}>
|
||||||
|
Excel-Vorschau
|
||||||
|
</Button>
|
||||||
|
<Button icon="check" busy={busy} onClick={() => save({ export: ex }, () => onSaved({ ...settings, export: ex }))}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-ink-400">Die Vorschau zeigt den zuletzt gespeicherten Stand.</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<span className="field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500">Logo</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-16 w-40 items-center justify-center rounded-xl border border-ink-200 bg-white p-2">
|
||||||
|
<img src={ex.logo || '/logo.png'} alt="Logo" className="max-h-full max-w-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block cursor-pointer text-xs font-bold text-brand-700 underline">
|
||||||
|
Hochladen
|
||||||
|
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={(e) => onLogo(e.target.files?.[0])} />
|
||||||
|
</label>
|
||||||
|
{ex.logo && (
|
||||||
|
<button className="block text-xs font-bold text-ink-400 underline" onClick={() => set({ logo: null })}>
|
||||||
|
perinco Logo verwenden
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Field label="Akzentfarbe">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="color" className="h-10 w-14 cursor-pointer rounded-lg border border-ink-200 bg-surface" value={ex.accent} onChange={(e) => set({ accent: e.target.value })} />
|
||||||
|
<input className={`${inputClass} tabular`} value={ex.accent} onChange={(e) => set({ accent: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
{text('title', 'Titel', 'z. B. Stundennachweis, Leistungsnachweis')}
|
||||||
|
{text('subtitle', 'Kopfzeile rechts (1. Zeile)')}
|
||||||
|
{text('header_right', 'Kopfzeile rechts (2. Zeile)')}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Field label="Fuß links">
|
||||||
|
<textarea className={`${inputClass} h-20`} value={ex.footer_left} onChange={(e) => set({ footer_left: e.target.value })} placeholder={'perinco GmbH\nStraße 1 · 12345 Ort'} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Fuß Mitte">
|
||||||
|
<textarea className={`${inputClass} h-20`} value={ex.footer_center} onChange={(e) => set({ footer_center: e.target.value })} placeholder={'Geschäftsführer …\nHRB …'} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Fuß rechts">
|
||||||
|
<textarea className={`${inputClass} h-20`} value={ex.footer_right} onChange={(e) => set({ footer_right: e.target.value })} placeholder={'www.perinco.de\nBank · IBAN'} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Toggle checked={ex.show_signature} onChange={(v) => set({ show_signature: v })} label="Unterschriftenfelder" hint="Am Ende des Stundennachweises – zur Abzeichnung durch den Kunden." />
|
||||||
|
{ex.show_signature && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{text('signature_left', 'Unterschrift links')}
|
||||||
|
{text('signature_right', 'Unterschrift rechts')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Card className="animate-rise h-fit p-4">
|
||||||
|
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Live-Skizze (A4)</p>
|
||||||
|
<div className="mx-auto aspect-[210/297] w-full max-w-[420px] rounded-md bg-white p-[6%] text-[#1f1f1f] shadow-lift" style={{ fontFamily: 'Helvetica, Arial, sans-serif' }}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<img src={ex.logo || '/logo.png'} alt="" className="h-8 w-auto max-w-[35%] object-contain" />
|
||||||
|
<div className="text-right text-[7px] leading-tight text-[#6e6e6e]">
|
||||||
|
<div>{ex.subtitle}</div>
|
||||||
|
<div>{ex.header_right}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-[1.5px]" style={{ background: ex.accent }} />
|
||||||
|
<p className="mt-3 text-[13px] font-bold">{ex.title || 'Stundennachweis'}</p>
|
||||||
|
<p className="text-[8px]" style={{ color: ex.accent }}>
|
||||||
|
September 2026
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 grid grid-cols-4 gap-1">
|
||||||
|
{['72,50', '72:30', '11', '34,00'].map((v) => (
|
||||||
|
<div key={v} className="rounded-sm border-l-2 bg-[#f4f7f1] px-1 py-1 text-[7px] font-bold" style={{ borderColor: ex.accent }}>
|
||||||
|
{v}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-2.5 rounded-[1px]" style={{ background: ex.accent }} />
|
||||||
|
{Array.from({ length: 9 }).map((_, i) => (
|
||||||
|
<div key={i} className={`h-2.5 border-b border-[#eee] ${i % 2 ? 'bg-[#f4f7f1]' : ''}`} />
|
||||||
|
))}
|
||||||
|
{ex.show_signature && (
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-4 text-[6px] text-[#6e6e6e]">
|
||||||
|
<div className="border-t border-[#9a9a9a] pt-0.5">{ex.signature_left}</div>
|
||||||
|
<div className="border-t border-[#9a9a9a] pt-0.5">{ex.signature_right}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-auto" />
|
||||||
|
<div className="mt-8 grid grid-cols-3 border-t border-[#ddd] pt-1 text-[6px] leading-tight text-[#6e6e6e]">
|
||||||
|
<div className="whitespace-pre-line">{ex.footer_left}</div>
|
||||||
|
<div className="whitespace-pre-line text-center">{ex.footer_center}</div>
|
||||||
|
<div className="whitespace-pre-line text-right">
|
||||||
|
{ex.footer_right}
|
||||||
|
{'\n'}Seite 1 von 1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SsoSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> = ({ settings, onSaved }) => {
|
||||||
|
const [sso, setSso] = useState({ ...settings.sso, client_secret: '' });
|
||||||
|
const { busy, save } = useSaver();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const { navigate } = useRouter();
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const set = (patch: Partial<typeof sso>) => setSso({ ...sso, ...patch });
|
||||||
|
|
||||||
|
useEffect(() => setSso({ ...settings.sso, client_secret: '' }), [settings.sso]);
|
||||||
|
|
||||||
|
const test = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
try {
|
||||||
|
const r = await settingsApi.ssoTest();
|
||||||
|
addToast(`Verbindung ok – ${r.eligible} von ${r.checked} geprüften Konten zulässig. Beispiel: ${r.sample.join(', ') || '–'}`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Test fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = (key: 'tenant_id' | 'client_id' | 'redirect_uri' | 'button_label', label: string, hint?: React.ReactNode, placeholder?: string) => (
|
||||||
|
<Field label={label} hint={hint}>
|
||||||
|
<input
|
||||||
|
name={`pulse-sso-${key}`}
|
||||||
|
autoComplete="off"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
className={`${inputClass} font-mono text-xs`}
|
||||||
|
value={sso[key]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => set({ [key]: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Section
|
||||||
|
title="Microsoft Entra ID (M365)"
|
||||||
|
hint="Anmeldung per OpenID Connect, Mitarbeiterliste über Microsoft Graph"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" icon="refresh" busy={testing} onClick={test} disabled={!settings.sso.configured}>
|
||||||
|
Verbindung testen
|
||||||
|
</Button>
|
||||||
|
<Button icon="check" busy={busy} onClick={() => save({ sso: { ...sso, client_secret: sso.client_secret || undefined } }, onSaved)}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold ${settings.sso.enabled ? 'bg-brand-50 text-brand-800' : 'bg-ink-50 text-ink-600'}`}>
|
||||||
|
<Icon name={settings.sso.enabled ? 'check' : 'info'} className="h-5 w-5" />
|
||||||
|
{settings.sso.enabled
|
||||||
|
? 'SSO ist aktiv – der Anmeldebildschirm zeigt „Mit Microsoft anmelden“.'
|
||||||
|
: settings.sso.configured
|
||||||
|
? 'Zugangsdaten hinterlegt, SSO ist noch nicht eingeschaltet.'
|
||||||
|
: 'Noch nicht eingerichtet – bis dahin melden sich alle lokal an.'}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{text('tenant_id', 'Tenant-ID (Verzeichnis-ID)', undefined, '00000000-0000-0000-0000-000000000000')}
|
||||||
|
{text('client_id', 'Client-ID (Anwendungs-ID)', undefined, '00000000-0000-0000-0000-000000000000')}
|
||||||
|
<Field label="Client Secret (Wert)" hint={settings.sso.client_secret_set ? 'Hinterlegt (verschlüsselt). Leer lassen = unverändert.' : 'Der „Wert“, nicht die Secret-ID. Wird verschlüsselt gespeichert und nie wieder angezeigt.'}>
|
||||||
|
{/* Kein type="password" - Passwortmanager (Brave) sollen hier nicht anspringen. */}
|
||||||
|
<input
|
||||||
|
name="pulse-sso-client-credential"
|
||||||
|
autoComplete="off"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
className={`${inputClass} font-mono text-xs`}
|
||||||
|
style={{ WebkitTextSecurity: 'disc' } as React.CSSProperties}
|
||||||
|
value={sso.client_secret}
|
||||||
|
onChange={(e) => set({ client_secret: e.target.value })}
|
||||||
|
placeholder={settings.sso.client_secret_set ? '••••••••••••' : ''}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{text('redirect_uri', 'Redirect-URI', 'In der App-Registrierung als Web-Plattform eintragen')}
|
||||||
|
{text('button_label', 'Beschriftung des Anmeldeknopfs')}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 rounded-2xl border border-ink-200 p-4">
|
||||||
|
<Toggle checked={sso.enabled} onChange={(v) => set({ enabled: v })} label="SSO aktivieren" hint="Zeigt den Microsoft-Anmeldeknopf. Anmelden können sich nur Konten, die in der SSO-Verwaltung übernommen wurden." />
|
||||||
|
<Toggle
|
||||||
|
checked={sso.local_login}
|
||||||
|
onChange={(v) => set({ local_login: v })}
|
||||||
|
label="Lokale Anmeldung direkt anzeigen"
|
||||||
|
hint="Aus: das Formular für externe Benutzer und lokale Konten liegt hinter einem Link. Externe melden sich immer lokal an."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Mitarbeiter übernehmen">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<p className="min-w-0 flex-1 text-sm text-ink-600">
|
||||||
|
Angezeigt werden nur Konten im Format <code className="rounded bg-ink-100 px-1 text-xs">vorname.nachname@perinco.de</code> bzw.{' '}
|
||||||
|
<code className="rounded bg-ink-100 px-1 text-xs">@icon-experts.de</code> – ohne Admin-Konten, freigegebene Postfächer und Gäste. Übernehmen und Rollen vergeben per Drag & Drop, auch für Manager.
|
||||||
|
</p>
|
||||||
|
<Button icon="globe" onClick={() => navigate('einstellungen/sso-verwaltung')}>
|
||||||
|
Zur SSO-Verwaltung
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Einrichtung in Entra ID">
|
||||||
|
<ol className="list-decimal space-y-1.5 pl-5 text-sm text-ink-600">
|
||||||
|
<li>entra.microsoft.com → App-Registrierungen → Neue Registrierung, Name „Pulse“, Kontotyp „Nur Konten in diesem Organisationsverzeichnis“.</li>
|
||||||
|
<li>
|
||||||
|
Umleitungs-URI: Plattform „Web“, <code className="rounded bg-ink-100 px-1 text-xs">{sso.redirect_uri}</code>
|
||||||
|
</li>
|
||||||
|
<li>Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel → den <b>Wert</b> hier eintragen (Ablaufdatum notieren, max. 24 Monate).</li>
|
||||||
|
<li>API-Berechtigungen → Microsoft Graph: delegiert <code className="rounded bg-ink-100 px-1 text-xs">openid profile email</code>, Anwendung <code className="rounded bg-ink-100 px-1 text-xs">User.Read.All</code> → „Administratorzustimmung erteilen“.</li>
|
||||||
|
<li>Tenant- und Client-ID aus der Übersicht kopieren, speichern, „Verbindung testen“, Mitarbeiter in der SSO-Verwaltung übernehmen, dann SSO aktivieren.</li>
|
||||||
|
</ol>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Avatar, Badge, Button, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
|
||||||
|
import { Icon, type IconName } from '../ui/Icon';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { directoryApi, usersApi, type AdUser } from '../../lib/api';
|
||||||
|
import { useLoader } from '../../lib/hooks';
|
||||||
|
import { relativeTime } from '../../lib/format';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { ROLE_LABELS, type DirectoryUser, type Role } from '../../types';
|
||||||
|
|
||||||
|
type ImportRole = Exclude<Role, 'admin'>;
|
||||||
|
|
||||||
|
const ZONES: { role: ImportRole; hint: string; icon: IconName; tone: string }[] = [
|
||||||
|
{ role: 'user', hint: 'Bucht Zeiten auf zugewiesene Projekte', icon: 'user', tone: 'border-ink-300 bg-ink-50 text-ink-700' },
|
||||||
|
{ role: 'lead', hint: 'Sieht und verwaltet sein Team', icon: 'team', tone: 'border-sky-200 bg-sky-50 text-sky-700' },
|
||||||
|
{ role: 'management', hint: 'Sieht alles, verwaltet SSO', icon: 'shield', tone: 'border-violet-200 bg-violet-50 text-violet-700' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DRAG_TYPE = 'application/x-pulse-ad-users';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSO-Verwaltung: links alle zulaessigen AD-Konten, rechts die Rollen. Ziehen
|
||||||
|
* auf eine Rolle uebernimmt das Konto nach Pulse bzw. aendert seine Rolle.
|
||||||
|
* Fuer Touch und Tastatur gibt es dasselbe ueber Auswahl + Knopf.
|
||||||
|
*/
|
||||||
|
export const SsoDirectory: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [refresh, setRefresh] = useState(0);
|
||||||
|
const { data, error, loading, reload } = useLoader(() => directoryApi.list(refresh > 0), [refresh]);
|
||||||
|
const [managers, setManagers] = useState<DirectoryUser[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filter, setFilter] = useState<'open' | 'imported' | 'all'>('open');
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [managerId, setManagerId] = useState<number | ''>('');
|
||||||
|
const [dragOver, setDragOver] = useState<ImportRole | null>(null);
|
||||||
|
const [busy, setBusy] = useState<ImportRole | 'sync' | null>(null);
|
||||||
|
|
||||||
|
const loadManagers = () =>
|
||||||
|
usersApi
|
||||||
|
.directory()
|
||||||
|
.then((list) => setManagers(list.filter((u) => u.is_active && (u.role === 'lead' || u.role === 'management'))))
|
||||||
|
.catch(() => undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
loadManagers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const users = data?.users ?? [];
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return users.filter((u) => {
|
||||||
|
if (filter === 'open' && u.pulse_user_id) return false;
|
||||||
|
if (filter === 'imported' && !u.pulse_user_id) return false;
|
||||||
|
return !q || `${u.name} ${u.email} ${u.job_title ?? ''} ${u.department ?? ''}`.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}, [users, search, filter]);
|
||||||
|
|
||||||
|
const byRole = (role: ImportRole) => users.filter((u) => u.pulse_user_id && u.pulse_role === role);
|
||||||
|
const importedCount = users.filter((u) => u.pulse_user_id).length;
|
||||||
|
const selectable = visible.filter((u) => u.pulse_role !== 'admin');
|
||||||
|
const allSelected = selectable.length > 0 && selectable.every((u) => selected.has(u.id));
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setSelected((cur) => {
|
||||||
|
const next = new Set(cur);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const assign = async (ids: string[], role: ImportRole) => {
|
||||||
|
const list = ids.filter((id) => users.find((u) => u.id === id)?.pulse_role !== 'admin');
|
||||||
|
if (!list.length) return;
|
||||||
|
setBusy(role);
|
||||||
|
try {
|
||||||
|
const r = await directoryApi.import(list, role, managerId || null);
|
||||||
|
const parts = [];
|
||||||
|
if (r.created.length) parts.push(`${r.created.length} übernommen`);
|
||||||
|
if (r.linked.length) parts.push(`${r.linked.length} aktualisiert`);
|
||||||
|
if (r.skipped.length) parts.push(`${r.skipped.length} nicht mehr im AD`);
|
||||||
|
addToast(`${ROLE_LABELS[role]}: ${parts.join(', ') || 'keine Änderung'}.`, 'success');
|
||||||
|
setSelected(new Set());
|
||||||
|
await reload();
|
||||||
|
if (role !== 'user') loadManagers();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
setBusy('sync');
|
||||||
|
try {
|
||||||
|
const r = await directoryApi.sync();
|
||||||
|
addToast(`Abgleich: ${r.updated} aktualisiert, ${r.deactivated} deaktiviert (${r.eligible} zulässige von ${r.total} AD-Konten).`, 'success');
|
||||||
|
setRefresh((n) => n + 1);
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Abgleich fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragStart = (event: React.DragEvent, u: AdUser) => {
|
||||||
|
// Gezogene Zeile gehoert zur Auswahl? Dann die ganze Auswahl mitnehmen.
|
||||||
|
const ids = selected.has(u.id) ? [...selected] : [u.id];
|
||||||
|
event.dataTransfer.setData(DRAG_TYPE, JSON.stringify(ids));
|
||||||
|
event.dataTransfer.setData('text/plain', ids.length > 1 ? `${ids.length} Benutzer` : u.name);
|
||||||
|
event.dataTransfer.effectAllowed = 'copyMove';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (event: React.DragEvent, role: ImportRole) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragOver(null);
|
||||||
|
try {
|
||||||
|
const ids = JSON.parse(event.dataTransfer.getData(DRAG_TYPE) || '[]');
|
||||||
|
if (Array.isArray(ids) && ids.length) assign(ids, role);
|
||||||
|
} catch {
|
||||||
|
/* fremdes Drag-Objekt */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error && !data) return <ErrorBox message={error} onRetry={reload} />;
|
||||||
|
if (!data) return <Spinner label="Lade Active Directory…" />;
|
||||||
|
if (!data.configured) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="key"
|
||||||
|
title="SSO ist noch nicht eingerichtet"
|
||||||
|
text={
|
||||||
|
user?.role === 'admin'
|
||||||
|
? 'Trage zuerst Tenant-ID, Client-ID und Client Secret unter „SSO / Entra ID“ ein.'
|
||||||
|
: 'Ein Administrator muss zuerst die Zugangsdaten zu Microsoft Entra ID hinterlegen.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="animate-rise flex flex-wrap items-center gap-3 p-4">
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
|
||||||
|
<Icon name="key" className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-extrabold text-ink-900">
|
||||||
|
{users.length} Mitarbeiterkonten im AD · {importedCount} in Pulse
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink-400">
|
||||||
|
Nur vorname.nachname@{data.domains.join(' / @')} – ohne Admin-Konten, freigegebene Postfächer und Gäste
|
||||||
|
{data.fetched_at && ` · Stand ${relativeTime(data.fetched_at)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="secondary" icon="refresh" busy={loading && refresh > 0} onClick={() => setRefresh((n) => n + 1)}>
|
||||||
|
Neu laden
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" icon="refresh" busy={busy === 'sync'} onClick={sync} title="Namen/Mail aktualisieren, im AD deaktivierte Konten sperren">
|
||||||
|
Abgleich
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
|
{/* Active Directory */}
|
||||||
|
<Card className="animate-rise flex min-h-[480px] flex-col overflow-hidden" style={{ '--i': 1 } as React.CSSProperties}>
|
||||||
|
<div className="space-y-3 border-b border-ink-100 p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 className="flex items-center gap-2 whitespace-nowrap text-base text-ink-900">
|
||||||
|
<Icon name="globe" className="h-4 w-4 text-brand-600" />
|
||||||
|
Active Directory
|
||||||
|
</h3>
|
||||||
|
<Segmented
|
||||||
|
size="sm"
|
||||||
|
value={filter}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFilter(v);
|
||||||
|
setSelected(new Set());
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'open', label: 'Nicht übernommen' },
|
||||||
|
{ value: 'imported', label: 'In Pulse' },
|
||||||
|
{ value: 'all', label: 'Alle' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Icon name="search" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-400" />
|
||||||
|
<input className={`${inputClass} pl-9`} placeholder="Name, E-Mail, Abteilung…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs font-bold text-ink-500">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={() => setSelected(allSelected ? new Set() : new Set(selectable.map((u) => u.id)))}
|
||||||
|
className="h-4 w-4 accent-[var(--color-brand-500)]"
|
||||||
|
/>
|
||||||
|
Alle {visible.length} auswählen
|
||||||
|
<span className="ml-auto hidden font-semibold text-ink-400 sm:inline">Ziehen auf eine Rolle →</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="scrollbar-slim max-h-[620px] flex-1 divide-y divide-ink-100 overflow-y-auto">
|
||||||
|
{visible.length === 0 && <li className="p-8 text-center text-sm text-ink-400">Keine Konten für diesen Filter.</li>}
|
||||||
|
{visible.map((u, i) => {
|
||||||
|
const isAdmin = u.pulse_role === 'admin';
|
||||||
|
const checked = selected.has(u.id);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={u.id}
|
||||||
|
draggable={!isAdmin}
|
||||||
|
onDragStart={(e) => onDragStart(e, u)}
|
||||||
|
className={`animate-rise flex items-center gap-3 px-4 py-2.5 transition ${isAdmin ? 'opacity-60' : 'cursor-grab active:cursor-grabbing'} ${
|
||||||
|
checked ? 'bg-brand-50' : 'hover:bg-ink-50'
|
||||||
|
}`}
|
||||||
|
style={{ '--i': Math.min(i, 12) } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<input type="checkbox" disabled={isAdmin} checked={checked} onChange={() => toggle(u.id)} className="h-4 w-4 shrink-0 accent-[var(--color-brand-500)]" aria-label={`${u.name} auswählen`} />
|
||||||
|
<Avatar name={u.name} size={34} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-bold text-ink-900">{u.name}</p>
|
||||||
|
<p className="truncate text-xs text-ink-400">
|
||||||
|
{u.email}
|
||||||
|
{(u.job_title || u.department) && ` · ${[u.job_title, u.department].filter(Boolean).join(', ')}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
{u.pulse_role && <Badge tone={u.pulse_role === 'user' ? 'neutral' : u.pulse_role === 'lead' ? 'sky' : u.pulse_role === 'management' ? 'violet' : 'brand'}>{ROLE_LABELS[u.pulse_role]}</Badge>}
|
||||||
|
{u.pulse_active === false && <Badge tone="red">inaktiv</Badge>}
|
||||||
|
{!u.pulse_user_id && u.local_match_id && (
|
||||||
|
<Badge tone="amber" title="Ein lokales Konto mit dieser Mailadresse wird beim Übernehmen verknüpft">
|
||||||
|
lokal vorhanden
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isAdmin && <Icon name="more" className="hidden h-4 w-4 shrink-0 rotate-90 text-ink-300 sm:block" />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="animate-sheet safe-bottom flex flex-wrap items-center gap-2 border-t border-ink-200 bg-surface p-3">
|
||||||
|
<span className="text-xs font-extrabold text-ink-700">{selected.size} ausgewählt – übernehmen als</span>
|
||||||
|
{ZONES.map((z) => (
|
||||||
|
<Button key={z.role} size="sm" variant="secondary" icon={z.icon} busy={busy === z.role} onClick={() => assign([...selected], z.role)}>
|
||||||
|
{ROLE_LABELS[z.role]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Rollen */}
|
||||||
|
<div className="animate-rise space-y-3" style={{ '--i': 2 } as React.CSSProperties}>
|
||||||
|
<Card className="flex flex-wrap items-center gap-3 p-4">
|
||||||
|
<Icon name="team" className="h-5 w-5 text-ink-400" />
|
||||||
|
<label className="min-w-0 flex-1 text-xs font-bold text-ink-500" htmlFor="sso-manager">
|
||||||
|
Vorgesetzter für übernommene Benutzer
|
||||||
|
</label>
|
||||||
|
<select id="sso-manager" className={`${inputClass} !w-auto max-w-[240px]`} value={managerId} onChange={(e) => setManagerId(e.target.value ? Number(e.target.value) : '')}>
|
||||||
|
<option value="">– nicht ändern –</option>
|
||||||
|
{managers.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name} ({ROLE_LABELS[m.role]})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{ZONES.map((zone) => {
|
||||||
|
const members = byRole(zone.role);
|
||||||
|
const over = dragOver === zone.role;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={zone.role}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!e.dataTransfer.types.includes(DRAG_TYPE)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
if (dragOver !== zone.role) setDragOver(zone.role);
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOver(null);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => onDrop(e, zone.role)}
|
||||||
|
className={`rounded-2xl border-2 border-dashed p-4 transition-all duration-200 ${
|
||||||
|
over ? 'scale-[1.01] border-brand-400 bg-brand-50 shadow-lift' : 'border-ink-200 bg-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`flex h-10 w-10 items-center justify-center rounded-xl border ${zone.tone}`}>
|
||||||
|
<Icon name={zone.icon} className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-extrabold text-ink-900">
|
||||||
|
{ROLE_LABELS[zone.role]} <span className="text-ink-400">({members.length})</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink-400">{over ? 'Loslassen zum Übernehmen' : zone.hint}</p>
|
||||||
|
</div>
|
||||||
|
{busy === zone.role && <span className="h-5 w-5 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />}
|
||||||
|
</div>
|
||||||
|
{members.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{members.map((m) => (
|
||||||
|
<span
|
||||||
|
key={m.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, m)}
|
||||||
|
title="Auf eine andere Rolle ziehen, um sie zu ändern"
|
||||||
|
className={`flex cursor-grab items-center gap-1.5 rounded-full border border-ink-200 bg-surface py-0.5 pl-0.5 pr-2.5 text-xs font-bold text-ink-700 active:cursor-grabbing ${
|
||||||
|
m.pulse_active === false ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Avatar name={m.name} size={22} />
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<p className="px-1 text-xs text-ink-400">
|
||||||
|
Anmelden per Microsoft können sich nur übernommene Konten. Rollen werden ausschließlich hier bzw. unter Team vergeben – AD-Gruppen spielen keine Rolle.
|
||||||
|
Hierarchie, Wochenstunden und Deaktivieren unter <b>Team</b>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Avatar, Badge, Button, Card, ErrorBox, IconButton, Segmented, Spinner, inputClass } from '../ui/controls';
|
||||||
|
import { ConfirmDialog } from '../ui/Modal';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { UserForm } from './UserForm';
|
||||||
|
import { buildTree, type TreeNode } from './hierarchy';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { usersApi } from '../../lib/api';
|
||||||
|
import { useLoader, usePersistentState } from '../../lib/hooks';
|
||||||
|
import { relativeTime } from '../../lib/format';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { ROLE_LABELS, type ManagedUser, type Role } from '../../types';
|
||||||
|
|
||||||
|
export const ROLE_TONE: Record<Role, 'neutral' | 'sky' | 'violet' | 'brand'> = { user: 'neutral', lead: 'sky', management: 'violet', admin: 'brand' };
|
||||||
|
|
||||||
|
/** Team: Organigramm und Liste. `embedded` fuer die Benutzerverwaltung in den Einstellungen. */
|
||||||
|
export const Team: React.FC<{ embedded?: boolean }> = ({ embedded }) => {
|
||||||
|
const { user: me } = useAuth();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const { data, error, reload } = useLoader(() => usersApi.list(), []);
|
||||||
|
const [mode, setMode] = usePersistentState<'tree' | 'list'>('pulse_team_mode', embedded ? 'list' : 'tree');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filter, setFilter] = useState<'all' | 'internal' | 'external' | 'inactive'>('all');
|
||||||
|
const [form, setForm] = useState<{ user: ManagedUser | null; manager?: number | null } | null>(null);
|
||||||
|
const [remove, setRemove] = useState<ManagedUser | null>(null);
|
||||||
|
|
||||||
|
const users = data?.users ?? [];
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return users.filter((u) => {
|
||||||
|
if (filter === 'internal' && u.is_external) return false;
|
||||||
|
if (filter === 'external' && !u.is_external) return false;
|
||||||
|
if (filter === 'inactive' ? u.is_active : !u.is_active && filter !== 'all') return false;
|
||||||
|
return !q || `${u.name} ${u.username} ${u.email ?? ''} ${u.job_title ?? ''} ${u.company ?? ''}`.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}, [users, search, filter]);
|
||||||
|
|
||||||
|
if (error && !data) return <ErrorBox message={error} onRetry={reload} />;
|
||||||
|
if (!data) return <Spinner label="Lade Team…" />;
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
total: users.filter((u) => u.is_active && u.role !== 'admin').length,
|
||||||
|
leads: users.filter((u) => u.is_active && (u.role === 'lead' || u.role === 'management')).length,
|
||||||
|
external: users.filter((u) => u.is_active && u.is_external).length,
|
||||||
|
sso: users.filter((u) => u.source === 'sso').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const doRemove = async () => {
|
||||||
|
if (!remove) return;
|
||||||
|
const target = remove;
|
||||||
|
setRemove(null);
|
||||||
|
try {
|
||||||
|
await usersApi.remove(target.id);
|
||||||
|
addToast(`${target.name} gelöscht.`, 'success');
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={embedded ? '' : 'mx-auto max-w-[1500px]'}>
|
||||||
|
{!embedded && (
|
||||||
|
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
{[
|
||||||
|
['Aktive Benutzer', counts.total, 'team'],
|
||||||
|
['Führungskräfte', counts.leads, 'shield'],
|
||||||
|
['Externe', counts.external, 'globe'],
|
||||||
|
['Aus dem AD (SSO)', counts.sso, 'key'],
|
||||||
|
].map(([label, value, icon], i) => (
|
||||||
|
<Card key={label as string} className="animate-rise flex items-center gap-3 p-4" style={{ '--i': i } as React.CSSProperties}>
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
|
||||||
|
<Icon name={icon as 'team'} className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="tabular block text-2xl font-extrabold text-ink-900">{value as number}</span>
|
||||||
|
<span className="block text-[11px] font-bold uppercase tracking-wider text-ink-400">{label as string}</span>
|
||||||
|
</span>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative min-w-[200px] flex-1 sm:max-w-xs">
|
||||||
|
<Icon name="search" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-400" />
|
||||||
|
<input className={`${inputClass} pl-9`} placeholder="Name, E-Mail, Firma…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Segmented
|
||||||
|
size="sm"
|
||||||
|
value={filter}
|
||||||
|
onChange={setFilter}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: 'Alle' },
|
||||||
|
{ value: 'internal', label: 'Intern' },
|
||||||
|
{ value: 'external', label: 'Extern' },
|
||||||
|
{ value: 'inactive', label: 'Inaktiv' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{!embedded && (
|
||||||
|
<Segmented
|
||||||
|
size="sm"
|
||||||
|
value={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
options={[
|
||||||
|
{ value: 'tree', label: 'Hierarchie' },
|
||||||
|
{ value: 'list', label: 'Liste' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button icon="plus" className="ml-auto" onClick={() => setForm({ user: null })}>
|
||||||
|
Neuer Benutzer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'tree' && !embedded ? (
|
||||||
|
<OrgChart users={users} visible={new Set(filtered.map((u) => u.id))} onEdit={(u) => setForm({ user: u })} onAdd={(managerId) => setForm({ user: null, manager: managerId })} meId={me!.id} />
|
||||||
|
) : (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="scrollbar-slim overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[760px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-ink-100 text-left text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||||
|
<th className="px-4 py-3">Name</th>
|
||||||
|
<th className="px-4 py-3">Rolle</th>
|
||||||
|
<th className="px-4 py-3">Vorgesetzter</th>
|
||||||
|
<th className="px-4 py-3">Konto</th>
|
||||||
|
<th className="px-4 py-3 text-right">Projekte</th>
|
||||||
|
<th className="px-4 py-3">Letzte Anmeldung</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((u, i) => (
|
||||||
|
<tr key={u.id} className={`animate-rise border-b border-ink-100 last:border-0 hover:bg-ink-50 ${u.is_active ? '' : 'opacity-50'}`} style={{ '--i': Math.min(i, 12) } as React.CSSProperties}>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar id={u.id} name={u.name} color={u.color} size={34} external={u.is_external} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-bold text-ink-900">{u.name}</p>
|
||||||
|
<p className="truncate text-xs text-ink-400">{u.email || u.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Badge tone={ROLE_TONE[u.role]}>{ROLE_LABELS[u.role]}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-ink-600">{users.find((m) => m.id === u.manager_id)?.name ?? '–'}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<Badge tone={u.source === 'sso' ? 'sky' : 'neutral'}>{u.source === 'sso' ? 'SSO' : 'lokal'}</Badge>
|
||||||
|
{u.is_external && <Badge tone="amber">extern{u.company ? ` · ${u.company}` : ''}</Badge>}
|
||||||
|
{!u.is_active && <Badge tone="red">inaktiv</Badge>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="tabular px-4 py-2.5 text-right font-bold text-ink-700">{u.project_count}</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-ink-400">{u.last_login_at ? relativeTime(u.last_login_at) : 'nie'}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
{u.can_manage && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<IconButton icon="edit" label="Bearbeiten" onClick={() => setForm({ user: u })} />
|
||||||
|
{u.source === 'local' && u.id !== me!.id && <IconButton icon="trash" tone="danger" label="Löschen" onClick={() => setRemove(u)} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form && (
|
||||||
|
<UserForm user={form.user} users={users} roles={data.assignable_roles} defaultManager={form.manager} onClose={() => setForm(null)} onSaved={reload} />
|
||||||
|
)}
|
||||||
|
{remove && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`${remove.name} löschen?`}
|
||||||
|
message={remove.entry_count ? `Achtung: ${remove.entry_count} Buchungen – das Löschen wird abgelehnt. Bitte deaktivieren.` : 'Das Konto wird endgültig entfernt. Unterstellte rücken eine Ebene nach oben.'}
|
||||||
|
onConfirm={doRemove}
|
||||||
|
onCancel={() => setRemove(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Organigramm: Karten mit Verbindungslinien, Ebenen aufklappbar. */
|
||||||
|
const OrgChart: React.FC<{
|
||||||
|
users: ManagedUser[];
|
||||||
|
visible: Set<number>;
|
||||||
|
onEdit: (u: ManagedUser) => void;
|
||||||
|
onAdd: (managerId: number) => void;
|
||||||
|
meId: number;
|
||||||
|
}> = ({ users, visible, onEdit, onAdd, meId }) => {
|
||||||
|
// Administratoren gehoeren nicht in die Mitarbeiterhierarchie (in der Liste bleiben sie).
|
||||||
|
const tree = useMemo(() => buildTree(users.filter((u) => u.role !== 'admin')), [users]);
|
||||||
|
// Ein Knoten bleibt sichtbar, wenn er selbst oder jemand darunter zum Filter passt.
|
||||||
|
const matches = (node: TreeNode<ManagedUser>): boolean => visible.has(node.user.id) || node.children.some(matches);
|
||||||
|
return (
|
||||||
|
<Card className="scrollbar-slim overflow-x-auto p-4 sm:p-6">
|
||||||
|
<div className="min-w-fit space-y-3">
|
||||||
|
{tree.filter(matches).map((node, i) => (
|
||||||
|
<OrgNode key={node.user.id} node={node} depth={0} index={i} matches={matches} onEdit={onEdit} onAdd={onAdd} meId={meId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrgNode: React.FC<{
|
||||||
|
node: TreeNode<ManagedUser>;
|
||||||
|
depth: number;
|
||||||
|
index: number;
|
||||||
|
matches: (n: TreeNode<ManagedUser>) => boolean;
|
||||||
|
onEdit: (u: ManagedUser) => void;
|
||||||
|
onAdd: (managerId: number) => void;
|
||||||
|
meId: number;
|
||||||
|
}> = ({ node, depth, index, matches, onEdit, onAdd, meId }) => {
|
||||||
|
const [open, setOpen] = useState(depth < 2);
|
||||||
|
const u = node.user;
|
||||||
|
const children = node.children.filter(matches);
|
||||||
|
return (
|
||||||
|
<div className="animate-rise" style={{ '--i': index } as React.CSSProperties}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{children.length > 0 ? (
|
||||||
|
<button onClick={() => setOpen(!open)} className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-ink-400 hover:bg-ink-100" aria-label={open ? 'Zuklappen' : 'Aufklappen'}>
|
||||||
|
<Icon name="chevronRight" className={`h-4 w-4 transition-transform ${open ? 'rotate-90' : ''}`} strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-6 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`group flex min-w-[260px] items-center gap-3 rounded-2xl border bg-surface p-2.5 pr-3 shadow-card transition hover:shadow-lift ${
|
||||||
|
u.id === meId ? 'border-brand-300' : 'border-ink-200'
|
||||||
|
} ${u.is_active ? '' : 'opacity-50'}`}
|
||||||
|
>
|
||||||
|
<Avatar id={u.id} name={u.name} color={u.color} size={40} external={u.is_external} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-extrabold text-ink-900">
|
||||||
|
{u.name}
|
||||||
|
{u.id === meId && <span className="ml-1 text-[10px] font-bold text-brand-600">(du)</span>}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-ink-400">{u.is_external ? `Extern · ${u.company ?? ''}` : u.job_title || '–'}</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone={ROLE_TONE[u.role]}>{ROLE_LABELS[u.role]}</Badge>
|
||||||
|
{children.length > 0 && <span className="tabular rounded-full bg-ink-100 px-2 py-0.5 text-[10px] font-extrabold text-ink-500">{children.length}</span>}
|
||||||
|
<div className="flex opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
|
||||||
|
{u.role !== 'user' && <IconButton icon="plus" label={`Mitarbeiter unter ${u.name} anlegen`} onClick={() => onAdd(u.id)} />}
|
||||||
|
{u.can_manage && <IconButton icon="edit" label="Bearbeiten" onClick={() => onEdit(u)} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{open && children.length > 0 && (
|
||||||
|
<div className="relative ml-[11px] mt-3 space-y-3 border-l-2 border-dashed border-ink-200 pl-6">
|
||||||
|
{children.map((child, i) => (
|
||||||
|
<div key={child.user.id} className="relative before:absolute before:-left-6 before:top-7 before:h-0.5 before:w-5 before:bg-ink-200">
|
||||||
|
<OrgNode node={child} depth={depth + 1} index={i} matches={matches} onEdit={onEdit} onAdd={onAdd} meId={meId} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { Button, Field, Toggle, inputClass } from '../ui/controls';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { usersApi } from '../../lib/api';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { ROLE_LABELS, type ManagedUser, type Role } from '../../types';
|
||||||
|
import { subordinates } from './hierarchy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Felder dieses Formulars gehoeren zu einem fremden Konto - Browser-Autofill
|
||||||
|
* und Passwortmanager (auch 1Password, LastPass, Bitwarden) sollen sie nicht
|
||||||
|
* als Anmelde- oder Registrierungsformular erkennen.
|
||||||
|
*/
|
||||||
|
function noAutofill(name: string) {
|
||||||
|
return {
|
||||||
|
name: `pulse-user-${name}`,
|
||||||
|
type: 'text',
|
||||||
|
autoComplete: 'off',
|
||||||
|
spellCheck: false,
|
||||||
|
'data-lpignore': 'true',
|
||||||
|
'data-1p-ignore': 'true',
|
||||||
|
'data-bwignore': 'true',
|
||||||
|
'data-form-type': 'other',
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS =['#6ba04a', '#0ea5e9', '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#eab308', '#6366f1', '#10b981', '#ec4899'];
|
||||||
|
|
||||||
|
export const UserForm: React.FC<{
|
||||||
|
user?: ManagedUser | null;
|
||||||
|
users: ManagedUser[];
|
||||||
|
roles: Role[];
|
||||||
|
defaultManager?: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}> = ({ user, users, roles, defaultManager, onClose, onSaved }) => {
|
||||||
|
const { user: me, settings } = useAuth();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const editing = Boolean(user);
|
||||||
|
const sso = user?.source === 'sso';
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: user?.name ?? '',
|
||||||
|
username: user?.username ?? '',
|
||||||
|
email: user?.email ?? '',
|
||||||
|
password: '',
|
||||||
|
role: (user?.role ?? 'user') as Role,
|
||||||
|
manager_id: user?.manager_id ?? defaultManager ?? (me?.role === 'lead' ? me.id : null),
|
||||||
|
job_title: user?.job_title ?? '',
|
||||||
|
is_external: user?.is_external ?? false,
|
||||||
|
company: user?.company ?? '',
|
||||||
|
weekly_hours: user?.weekly_hours == null ? '' : String(user.weekly_hours),
|
||||||
|
color: user?.color ?? COLORS[Math.floor(Math.random() * COLORS.length)],
|
||||||
|
is_active: user?.is_active ?? true,
|
||||||
|
});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const set = (patch: Partial<typeof form>) => setForm({ ...form, ...patch });
|
||||||
|
|
||||||
|
// Als Vorgesetzte kommen nur Fuehrungsrollen in Frage - und nie man selbst
|
||||||
|
// oder jemand aus dem eigenen Unterbaum (sonst entsteht ein Kreis).
|
||||||
|
const blocked = new Set(user ? [user.id, ...subordinates(users, user.id).map((u) => u.id)] : []);
|
||||||
|
const managers = users.filter((u) => (u.role === 'lead' || u.role === 'management') && u.is_active && !blocked.has(u.id));
|
||||||
|
const roleOptions = user && !roles.includes(user.role) ? [user.role, ...roles] : roles;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
name: form.name,
|
||||||
|
role: form.role,
|
||||||
|
manager_id: form.manager_id,
|
||||||
|
job_title: form.job_title,
|
||||||
|
is_external: form.is_external,
|
||||||
|
company: form.is_external ? form.company : null,
|
||||||
|
weekly_hours: form.weekly_hours === '' ? null : Number(form.weekly_hours.replace(',', '.')),
|
||||||
|
color: form.color,
|
||||||
|
is_active: form.is_active,
|
||||||
|
};
|
||||||
|
if (!sso) {
|
||||||
|
payload.username = form.username;
|
||||||
|
payload.email = form.email || null;
|
||||||
|
}
|
||||||
|
if (form.password) payload.password = form.password;
|
||||||
|
if (editing) await usersApi.update(user!.id, payload);
|
||||||
|
else await usersApi.create(payload);
|
||||||
|
addToast(editing ? 'Benutzer gespeichert.' : `${form.name} angelegt.`, 'success');
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={editing ? 'Benutzer bearbeiten' : 'Neuer Benutzer'}
|
||||||
|
subtitle={editing ? `${user!.username} · ${sso ? 'aus dem AD (SSO)' : 'lokales Konto'}` : 'Lokales Konto – auch für externe Mitarbeiter'}
|
||||||
|
onClose={onClose}
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button icon="check" busy={busy} onClick={save}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Name" required>
|
||||||
|
<input {...noAutofill('name')} className={inputClass} value={form.name} autoFocus onChange={(e) => set({ name: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Jobtitel">
|
||||||
|
<input {...noAutofill('title')} className={inputClass} value={form.job_title} onChange={(e) => set({ job_title: e.target.value })} placeholder="z. B. Senior Consultant" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Benutzername" required hint={sso ? 'Kommt aus dem AD' : undefined}>
|
||||||
|
<input {...noAutofill('login')} className={inputClass} value={form.username} disabled={sso} autoCapitalize="none" onChange={(e) => set({ username: e.target.value.trim() })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="E-Mail">
|
||||||
|
<input {...noAutofill('mail')} inputMode="email" className={inputClass} value={form.email} disabled={sso} onChange={(e) => set({ email: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
{!sso && (
|
||||||
|
<Field label={editing ? 'Neues Passwort' : 'Passwort'} required={!editing} hint={editing ? 'Leer lassen = unverändert' : 'Mindestens 8 Zeichen'}>
|
||||||
|
<div className="relative">
|
||||||
|
{/*
|
||||||
|
Bewusst kein type="password": hier setzt ein Admin das Passwort
|
||||||
|
eines ANDEREN Benutzers. Ein echtes Passwortfeld laesst den
|
||||||
|
Passwortmanager des Browsers anspringen - Brave stuerzt dabei
|
||||||
|
(Autofill beim Tippen) komplett ab. Maskiert wird per CSS.
|
||||||
|
*/}
|
||||||
|
<input
|
||||||
|
{...noAutofill('initial-secret')}
|
||||||
|
className={`${inputClass} pr-10`}
|
||||||
|
style={showPassword ? undefined : ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)}
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => set({ password: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md px-1.5 py-1 text-[11px] font-bold text-ink-400 hover:text-ink-700"
|
||||||
|
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||||
|
>
|
||||||
|
{showPassword ? 'verbergen' : 'zeigen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="Rolle" required>
|
||||||
|
<select className={inputClass} value={form.role} disabled={user?.id === me?.id} onChange={(e) => set({ role: e.target.value as Role })}>
|
||||||
|
{roleOptions.map((r) => (
|
||||||
|
<option key={r} value={r} disabled={!roles.includes(r)}>
|
||||||
|
{ROLE_LABELS[r]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Vorgesetzter">
|
||||||
|
<select className={inputClass} value={form.manager_id ?? ''} onChange={(e) => set({ manager_id: e.target.value ? Number(e.target.value) : null })}>
|
||||||
|
{me?.role !== 'lead' && <option value="">– keiner (oberste Ebene) –</option>}
|
||||||
|
{managers.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name} ({ROLE_LABELS[m.role]})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Wochenstunden" hint={`Leer = Standard (${settings.weekly_hours} h)`}>
|
||||||
|
<input inputMode="decimal" className={inputClass} value={form.weekly_hours} placeholder={String(settings.weekly_hours)} onChange={(e) => set({ weekly_hours: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-ink-200 p-4">
|
||||||
|
<Toggle
|
||||||
|
checked={form.is_external}
|
||||||
|
onChange={(v) => set({ is_external: v })}
|
||||||
|
label="Externer Mitarbeiter"
|
||||||
|
hint="Freelancer oder Partner außerhalb der Organisation – meldet sich lokal an, sieht nur zugewiesene Projekte."
|
||||||
|
/>
|
||||||
|
{form.is_external && (
|
||||||
|
<Field label="Firma" className="mt-3">
|
||||||
|
<input className={inputClass} value={form.company} onChange={(e) => set({ company: e.target.value })} placeholder="z. B. Freelancer, Partner GmbH" />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set({ color: c })}
|
||||||
|
aria-label={`Farbe ${c}`}
|
||||||
|
className={`h-7 w-7 rounded-full transition hover:scale-110 ${form.color === c ? 'ring-2 ring-ink-800 ring-offset-2 ring-offset-surface' : ''}`}
|
||||||
|
style={{ background: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{editing && user!.id !== me?.id && <Toggle checked={form.is_active} onChange={(v) => set({ is_active: v })} label="Aktiv" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { atLeast, type AppUser, type DirectoryUser } from '../../types';
|
||||||
|
|
||||||
|
/** Alle Unterstellten (rekursiv) eines Benutzers. */
|
||||||
|
export function subordinates<T extends { id: number; manager_id: number | null }>(users: T[], leadId: number): T[] {
|
||||||
|
const result: T[] = [];
|
||||||
|
const seen = new Set<number>([leadId]);
|
||||||
|
const queue = [leadId];
|
||||||
|
while (queue.length) {
|
||||||
|
const current = queue.shift()!;
|
||||||
|
for (const u of users) {
|
||||||
|
if (u.manager_id === current && !seen.has(u.id)) {
|
||||||
|
seen.add(u.id);
|
||||||
|
result.push(u);
|
||||||
|
queue.push(u.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wen darf der Benutzer einsehen - sich selbst zuerst. */
|
||||||
|
export function visibleTeam(users: DirectoryUser[], me: AppUser): DirectoryUser[] {
|
||||||
|
const self = users.find((u) => u.id === me.id);
|
||||||
|
const others = atLeast(me, 'management')
|
||||||
|
? users.filter((u) => u.id !== me.id)
|
||||||
|
: me.role === 'lead'
|
||||||
|
? subordinates(users, me.id)
|
||||||
|
: [];
|
||||||
|
// Administratoren sind keine Mitarbeiter - weder in Team-Auswahlen noch in Exporten.
|
||||||
|
const staff = others.filter((u) => u.is_active && u.role !== 'admin').sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||||
|
return [...(self && self.role !== 'admin' ? [self] : []), ...staff];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeNode<T> {
|
||||||
|
user: T;
|
||||||
|
children: TreeNode<T>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baum aus der manager_id-Kette. Verwaiste Knoten landen oben. */
|
||||||
|
export function buildTree<T extends { id: number; manager_id: number | null; name: string }>(users: T[]): TreeNode<T>[] {
|
||||||
|
const byId = new Map(users.map((u) => [u.id, { user: u, children: [] as TreeNode<T>[] }]));
|
||||||
|
const roots: TreeNode<T>[] = [];
|
||||||
|
for (const node of byId.values()) {
|
||||||
|
const parent = node.user.manager_id != null ? byId.get(node.user.manager_id) : undefined;
|
||||||
|
if (parent && parent !== node) parent.children.push(node);
|
||||||
|
else roots.push(node);
|
||||||
|
}
|
||||||
|
const sort = (nodes: TreeNode<T>[]) => {
|
||||||
|
nodes.sort((a, b) => b.children.length - a.children.length || a.user.name.localeCompare(b.user.name, 'de'));
|
||||||
|
nodes.forEach((n) => sort(n.children));
|
||||||
|
};
|
||||||
|
sort(roots);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/* Umriss-Icons (Heroicons v1, wie im perinco CRM) an einer Stelle. */
|
||||||
|
const PATHS = {
|
||||||
|
dashboard:
|
||||||
|
'M4 5a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 16a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-3zm10-4a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z',
|
||||||
|
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
|
folder: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
|
||||||
|
team: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||||
|
building:
|
||||||
|
'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
|
||||||
|
chart: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
|
||||||
|
settings:
|
||||||
|
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065zM15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||||||
|
plus: 'M12 4v16m8-8H4',
|
||||||
|
close: 'M6 18L18 6M6 6l12 12',
|
||||||
|
edit: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z',
|
||||||
|
trash:
|
||||||
|
'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16',
|
||||||
|
chevronLeft: 'M15 19l-7-7 7-7',
|
||||||
|
chevronRight: 'M9 5l7 7-7 7',
|
||||||
|
chevronDown: 'M19 9l-7 7-7-7',
|
||||||
|
chevronUp: 'M5 15l7-7 7 7',
|
||||||
|
download: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4',
|
||||||
|
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
|
||||||
|
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
|
check: 'M5 13l4 4L19 7',
|
||||||
|
calendar: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
|
||||||
|
logout: 'M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1',
|
||||||
|
menu: 'M4 6h16M4 12h16M4 18h16',
|
||||||
|
sun: 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z',
|
||||||
|
moon: 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z',
|
||||||
|
user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
||||||
|
key: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
|
||||||
|
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
||||||
|
tag: 'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z',
|
||||||
|
euro: 'M14.121 15.536c-1.171 1.952-3.07 1.952-4.242 0-1.172-1.953-1.172-5.119 0-7.072 1.171-1.952 3.07-1.952 4.242 0M8 10.5h4m-4 3h4m9-1.5a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
|
pot: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
|
||||||
|
list: 'M4 6h16M4 10h16M4 14h16M4 18h16',
|
||||||
|
grid: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z',
|
||||||
|
arrowUp: 'M5 10l7-7m0 0l7 7m-7-7v18',
|
||||||
|
arrowDown: 'M19 14l-7 7m0 0l-7-7m7 7V3',
|
||||||
|
sort: 'M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4',
|
||||||
|
external: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14',
|
||||||
|
printer: 'M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z',
|
||||||
|
archive:
|
||||||
|
'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4',
|
||||||
|
shield: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||||
|
document: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
||||||
|
refresh: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15',
|
||||||
|
globe: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||||
|
more: 'M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z',
|
||||||
|
copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type IconName = keyof typeof PATHS;
|
||||||
|
|
||||||
|
export const Icon: React.FC<{ name: IconName; className?: string; strokeWidth?: number; title?: string }> = ({
|
||||||
|
name,
|
||||||
|
className = 'h-5 w-5',
|
||||||
|
strokeWidth = 1.8,
|
||||||
|
title,
|
||||||
|
}) => (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden={title ? undefined : true}>
|
||||||
|
{title && <title>{title}</title>}
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d={PATHS[name]} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: React.ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZES = { sm: 'sm:max-w-sm', md: 'sm:max-w-lg', lg: 'sm:max-w-2xl', xl: 'sm:max-w-4xl' };
|
||||||
|
|
||||||
|
/** Offene Dialoge in Oeffnungsreihenfolge - Escape schliesst nur den obersten. */
|
||||||
|
const openModals: symbol[] = [];
|
||||||
|
|
||||||
|
function useDismiss(onClose: () => void) {
|
||||||
|
const closeRef = useRef(onClose);
|
||||||
|
closeRef.current = onClose;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = Symbol('modal');
|
||||||
|
openModals.push(token);
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && openModals[openModals.length - 1] === token) closeRef.current();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
// Hintergrund nicht mitscrollen (wichtig auf dem Handy).
|
||||||
|
const previous = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
|
openModals.splice(openModals.indexOf(token), 1);
|
||||||
|
document.body.style.overflow = previous;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Nur ein Klick, der im Hintergrund beginnt und endet, schliesst.
|
||||||
|
const pressed = useRef(false);
|
||||||
|
return {
|
||||||
|
onMouseDown: (e: React.MouseEvent) => {
|
||||||
|
pressed.current = e.target === e.currentTarget;
|
||||||
|
},
|
||||||
|
onClick: (e: React.MouseEvent) => {
|
||||||
|
if (pressed.current && e.target === e.currentTarget) closeRef.current();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog. Auf dem Handy als Sheet von unten ueber die volle Breite, auf dem
|
||||||
|
* Desktop zentriert.
|
||||||
|
*/
|
||||||
|
export const Modal: React.FC<ModalProps> = ({ title, subtitle, onClose, children, footer, size = 'md' }) => {
|
||||||
|
const dismiss = useDismiss(onClose);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...dismiss}
|
||||||
|
className="animate-fade fixed inset-0 z-[100] flex items-end justify-center bg-ink-950/50 backdrop-blur-sm sm:items-center sm:p-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
className={`animate-sheet sm:animate-pop flex max-h-[94dvh] w-full flex-col overflow-hidden rounded-t-3xl border border-ink-200 bg-surface shadow-lift sm:rounded-2xl ${SIZES[size]}`}
|
||||||
|
>
|
||||||
|
<div className="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-ink-200 sm:hidden" />
|
||||||
|
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-ink-100 px-5 py-4 sm:px-6 sm:py-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-lg text-ink-900 sm:text-xl">{title}</h2>
|
||||||
|
{subtitle && <div className="mt-0.5 text-xs font-semibold text-ink-400">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-ink-400 transition hover:bg-ink-100 hover:text-ink-800"
|
||||||
|
aria-label="Dialog schließen"
|
||||||
|
>
|
||||||
|
<Icon name="close" className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="scrollbar-slim flex-1 overflow-y-auto px-5 py-5 sm:px-6">{children}</div>
|
||||||
|
{footer && <div className="safe-bottom shrink-0 border-t border-ink-100 bg-ink-50/60 px-5 py-4 sm:px-6">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ConfirmProps {
|
||||||
|
title: string;
|
||||||
|
message: React.ReactNode;
|
||||||
|
confirmLabel?: string;
|
||||||
|
tone?: 'danger' | 'neutral';
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfirmDialog: React.FC<ConfirmProps> = ({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Löschen',
|
||||||
|
tone = 'danger',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const dismiss = useDismiss(onCancel);
|
||||||
|
return (
|
||||||
|
<div {...dismiss} className="animate-fade fixed inset-0 z-[130] flex items-center justify-center bg-ink-950/50 p-4 backdrop-blur-sm">
|
||||||
|
<div className="animate-pop w-full max-w-sm rounded-2xl border border-ink-200 bg-surface p-7 text-center shadow-lift">
|
||||||
|
<div
|
||||||
|
className={`mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full ${
|
||||||
|
tone === 'danger' ? 'bg-red-50 text-red-600' : 'bg-brand-50 text-brand-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name={tone === 'danger' ? 'warning' : 'info'} className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg text-ink-900">{title}</h3>
|
||||||
|
<div className="mt-2 text-sm text-ink-500">{message}</div>
|
||||||
|
<div className="mt-7 grid grid-cols-2 gap-3">
|
||||||
|
<button onClick={onCancel} className="rounded-xl border border-ink-200 py-3 text-sm font-bold text-ink-600 transition hover:bg-ink-100">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`rounded-xl py-3 text-sm font-bold text-white transition ${
|
||||||
|
tone === 'danger' ? 'bg-red-600 hover:bg-red-700' : 'bg-brand-500 hover:bg-brand-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import React, { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useCountUp, useGrow } from '../../lib/hooks';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Diagramme von Hand (SVG/CSS) - Farben kommen aus den Theme-Variablen, damit
|
||||||
|
* alle vier Designs in hell und dunkel ohne Sonderfall funktionieren.
|
||||||
|
*
|
||||||
|
* Billable ist durchgaengig die Markenfarbe, Non-billable ein helles Pendant
|
||||||
|
* derselben Farbe - so liest man "Anteil" statt "zwei Kategorien".
|
||||||
|
*/
|
||||||
|
export const BILLABLE = 'var(--color-brand-500)';
|
||||||
|
export const NON_BILLABLE = 'var(--color-brand-200)';
|
||||||
|
|
||||||
|
function niceMax(value: number, steps = 4) {
|
||||||
|
if (value <= 0) return steps;
|
||||||
|
const raw = value / steps;
|
||||||
|
const magnitude = 10 ** Math.floor(Math.log10(raw));
|
||||||
|
const factor = [1, 2, 2.5, 5, 10].find((f) => f * magnitude >= raw) ?? 10;
|
||||||
|
return factor * magnitude * steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StackPoint {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
a: number; // billable
|
||||||
|
b: number; // non-billable
|
||||||
|
highlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gestapelte Saeulen. Beim Wechsel wachsen die Saeulen neu aus der Grundlinie,
|
||||||
|
* Hover zeigt eine Karte mit beiden Werten und Anteil.
|
||||||
|
*/
|
||||||
|
export const StackedBars: React.FC<{
|
||||||
|
points: StackPoint[];
|
||||||
|
format: (value: number) => string;
|
||||||
|
axisFormat?: (value: number) => string;
|
||||||
|
height?: number;
|
||||||
|
labels?: [string, string];
|
||||||
|
/** Werte kommen in Minuten, die Achse soll in runden Stunden teilen: scale = 60. */
|
||||||
|
scale?: number;
|
||||||
|
}> = ({ points, format, axisFormat = format, height = 240, labels = ['Billable', 'Non-billable'], scale = 1 }) => {
|
||||||
|
const grown = useGrow(points.map((p) => `${p.key}${p.a}${p.b}`).join());
|
||||||
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
|
const max = niceMax(Math.max(0, ...points.map((p) => p.a + p.b)) / scale) * scale;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative select-none">
|
||||||
|
<div className="flex" style={{ height }}>
|
||||||
|
<div className="relative w-10 shrink-0">
|
||||||
|
{[0, 1, 2, 3, 4].map((line) => (
|
||||||
|
<span
|
||||||
|
key={line}
|
||||||
|
className="tabular absolute right-2 -translate-y-1/2 text-[10px] font-bold text-ink-400"
|
||||||
|
style={{ top: `${(line / 4) * 100}%` }}
|
||||||
|
>
|
||||||
|
{axisFormat((max * (4 - line)) / 4)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
{[0, 1, 2, 3, 4].map((line) => (
|
||||||
|
<div
|
||||||
|
key={line}
|
||||||
|
className={`absolute inset-x-0 border-t ${line === 4 ? 'border-ink-300' : 'border-dashed border-ink-100'}`}
|
||||||
|
style={{ top: `${(line / 4) * 100}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="absolute inset-0 flex items-end gap-[6%] px-[2%] sm:gap-[3%]">
|
||||||
|
{points.map((p, i) => {
|
||||||
|
const total = p.a + p.b;
|
||||||
|
const h = grown ? (total / max) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.key}
|
||||||
|
className="relative flex h-full min-w-0 flex-1 cursor-default items-end justify-center"
|
||||||
|
onMouseEnter={() => setHover(i)}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
onTouchStart={() => setHover(i)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`relative flex w-full max-w-[44px] flex-col-reverse overflow-hidden rounded-t-md transition-all duration-700 ease-out ${
|
||||||
|
hover === i ? 'brightness-110' : ''
|
||||||
|
} ${p.highlight ? 'ring-2 ring-brand-300 ring-offset-2 ring-offset-surface' : ''}`}
|
||||||
|
style={{ height: `${h}%`, transitionDelay: `${i * 35}ms`, minHeight: total > 0 ? 3 : 0 }}
|
||||||
|
>
|
||||||
|
<div style={{ height: total ? `${(p.a / total) * 100}%` : 0, background: BILLABLE }} />
|
||||||
|
<div style={{ height: total ? `${(p.b / total) * 100}%` : 0, background: NON_BILLABLE }} />
|
||||||
|
</div>
|
||||||
|
{hover === i && (
|
||||||
|
<div className="animate-pop pointer-events-none absolute bottom-full z-20 mb-2 w-44 rounded-xl border border-ink-200 bg-surface p-3 text-xs shadow-lift">
|
||||||
|
<p className="mb-1.5 font-extrabold text-ink-900">{p.label}</p>
|
||||||
|
<Row color={BILLABLE} label={labels[0]} value={format(p.a)} />
|
||||||
|
<Row color={NON_BILLABLE} label={labels[1]} value={format(p.b)} />
|
||||||
|
<div className="mt-1.5 flex justify-between border-t border-ink-100 pt-1.5 font-bold text-ink-800">
|
||||||
|
<span>Gesamt</span>
|
||||||
|
<span className="tabular">{format(total)}</span>
|
||||||
|
</div>
|
||||||
|
{total > 0 && <p className="mt-1 text-[10px] text-ink-400">{Math.round((p.a / total) * 100)} % billable</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-10 mt-2 flex gap-[6%] px-[2%] sm:gap-[3%]">
|
||||||
|
{points.map((p) => (
|
||||||
|
<p key={p.key} className={`min-w-0 flex-1 truncate text-center text-[10px] font-bold ${p.highlight ? 'text-brand-700' : 'text-ink-400'}`}>
|
||||||
|
{p.label}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Row: React.FC<{ color: string; label: string; value: string }> = ({ color, label, value }) => (
|
||||||
|
<div className="flex items-center justify-between gap-3 py-0.5">
|
||||||
|
<span className="flex items-center gap-1.5 text-ink-500">
|
||||||
|
<span className="h-2 w-2 rounded-sm" style={{ background: color }} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="tabular font-bold text-ink-800">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Legend: React.FC<{ items: { color: string; label: string; value?: string }[]; className?: string }> = ({ items, className = '' }) => (
|
||||||
|
<div className={`flex flex-wrap items-center gap-x-4 gap-y-1 ${className}`}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<span key={item.label} className="flex items-center gap-2 text-xs font-bold text-ink-600">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-sm" style={{ background: item.color }} />
|
||||||
|
{item.label}
|
||||||
|
{item.value && <span className="tabular text-ink-900">{item.value}</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Ring mit hochzaehlendem Prozentwert in der Mitte. */
|
||||||
|
export const Donut: React.FC<{ percent: number; size?: number; stroke?: number; color?: string; track?: string; label?: string }> = ({
|
||||||
|
percent,
|
||||||
|
size = 96,
|
||||||
|
stroke = 12,
|
||||||
|
color = BILLABLE,
|
||||||
|
track = NON_BILLABLE,
|
||||||
|
label,
|
||||||
|
}) => {
|
||||||
|
const grown = useGrow(percent);
|
||||||
|
const shown = useCountUp(percent);
|
||||||
|
const r = (size - stroke) / 2;
|
||||||
|
const c = 2 * Math.PI * r;
|
||||||
|
const p = Math.max(0, Math.min(100, percent));
|
||||||
|
return (
|
||||||
|
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size} className="-rotate-90">
|
||||||
|
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={track} strokeWidth={stroke} />
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={c}
|
||||||
|
strokeDashoffset={grown ? c * (1 - p / 100) : c}
|
||||||
|
style={{ transition: 'stroke-dashoffset 1.1s cubic-bezier(0.22, 1, 0.36, 1)' }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="tabular font-extrabold text-ink-900" style={{ fontSize: size * 0.22 }}>
|
||||||
|
{Math.round(shown)}
|
||||||
|
<span style={{ fontSize: size * 0.12 }}>%</span>
|
||||||
|
</span>
|
||||||
|
{label && <span className="text-[9px] font-bold uppercase tracking-wider text-ink-400">{label}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mehrsegment-Ring (Verteilung auf Projekte o. ae.). */
|
||||||
|
export const SegmentRing: React.FC<{ segments: { label: string; value: number; color: string }[]; size?: number; stroke?: number; center?: React.ReactNode }> = ({
|
||||||
|
segments,
|
||||||
|
size = 160,
|
||||||
|
stroke = 18,
|
||||||
|
center,
|
||||||
|
}) => {
|
||||||
|
const grown = useGrow(segments.map((s) => s.value).join());
|
||||||
|
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
|
||||||
|
const r = (size - stroke) / 2;
|
||||||
|
const c = 2 * Math.PI * r;
|
||||||
|
let offset = 0;
|
||||||
|
return (
|
||||||
|
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size} className="-rotate-90">
|
||||||
|
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-ink-100)" strokeWidth={stroke} />
|
||||||
|
{segments.map((segment, i) => {
|
||||||
|
const length = (segment.value / total) * c;
|
||||||
|
const dash = grown ? Math.max(0, length - 2) : 0;
|
||||||
|
const el = (
|
||||||
|
<circle
|
||||||
|
key={segment.label + i}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={segment.color}
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeDasharray={`${dash} ${c}`}
|
||||||
|
strokeDashoffset={-offset}
|
||||||
|
style={{ transition: `stroke-dasharray 0.9s cubic-bezier(0.22,1,0.36,1) ${i * 80}ms` }}
|
||||||
|
>
|
||||||
|
<title>{segment.label}</title>
|
||||||
|
</circle>
|
||||||
|
);
|
||||||
|
offset += length;
|
||||||
|
return el;
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
{center && <div className="absolute inset-0 flex flex-col items-center justify-center text-center">{center}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sparkline mit weichem Verlauf darunter. */
|
||||||
|
export const Sparkline: React.FC<{ values: number[]; width?: number; height?: number; color?: string }> = ({
|
||||||
|
values,
|
||||||
|
width = 120,
|
||||||
|
height = 36,
|
||||||
|
color = 'var(--color-brand-500)',
|
||||||
|
}) => {
|
||||||
|
const id = useId().replace(/:/g, '');
|
||||||
|
const max = Math.max(1, ...values);
|
||||||
|
const step = values.length > 1 ? width / (values.length - 1) : width;
|
||||||
|
const pts = values.map((v, i) => [i * step, height - 3 - (v / max) * (height - 6)] as const);
|
||||||
|
const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
|
||||||
|
const area = `${line} L${width},${height} L0,${height} Z`;
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`spark-${id}`} x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={color} stopOpacity="0.35" />
|
||||||
|
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={area} fill={`url(#spark-${id})`} className="animate-fade" />
|
||||||
|
<path d={line} fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="draw-line" style={{ '--len': width * 2 } as React.CSSProperties} />
|
||||||
|
{pts.length > 0 && <circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r={2.8} fill={color} />}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projektverlauf wie in Harvest: kumulierte Stunden als Linie, Budget als
|
||||||
|
* waagerechte Linie, aktuelle Woche hinterlegt. Alternativ Stunden je Woche als Saeulen.
|
||||||
|
*/
|
||||||
|
export const ProgressChart: React.FC<{
|
||||||
|
points: { label: string; value: number | null; bar: number; barB?: number; current?: boolean }[];
|
||||||
|
budget: number | null;
|
||||||
|
mode: 'progress' | 'weekly';
|
||||||
|
format: (v: number) => string;
|
||||||
|
height?: number;
|
||||||
|
}> = ({ points, budget, mode, format, height = 260 }) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [width, setWidth] = useState(640);
|
||||||
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new ResizeObserver(([entry]) => setWidth(Math.max(280, entry.contentRect.width)));
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
const grown = useGrow(`${mode}${points.length}`);
|
||||||
|
|
||||||
|
const padL = 44;
|
||||||
|
const padR = 12;
|
||||||
|
const padT = 18;
|
||||||
|
const padB = 28;
|
||||||
|
const innerW = width - padL - padR;
|
||||||
|
const innerH = height - padT - padB;
|
||||||
|
const values = mode === 'progress' ? points.map((p) => p.value ?? 0) : points.map((p) => p.bar + (p.barB ?? 0));
|
||||||
|
const max = niceMax(Math.max(mode === 'progress' ? budget ?? 0 : 0, ...values) * 1.08);
|
||||||
|
const x = (i: number) => padL + (points.length <= 1 ? innerW / 2 : (i / (points.length - 1)) * innerW);
|
||||||
|
const y = (v: number) => padT + innerH - (v / max) * innerH;
|
||||||
|
const slot = innerW / Math.max(1, points.length);
|
||||||
|
const labelEvery = Math.ceil(points.length / Math.max(2, Math.floor(innerW / 70)));
|
||||||
|
|
||||||
|
const line = useMemo(
|
||||||
|
() =>
|
||||||
|
points
|
||||||
|
.map((p, i) => (p.value == null ? null : `${x(i).toFixed(1)},${y(p.value).toFixed(1)}`))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((pt, i) => `${i ? 'L' : 'M'}${pt}`)
|
||||||
|
.join(' '),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[points, width, max]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative w-full">
|
||||||
|
<svg width={width} height={height} className="block overflow-visible">
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => {
|
||||||
|
const v = (max * i) / 4;
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
<line x1={padL} x2={width - padR} y1={y(v)} y2={y(v)} stroke="var(--color-ink-100)" strokeDasharray={i ? '3 4' : undefined} />
|
||||||
|
<text x={padL - 8} y={y(v) + 3} textAnchor="end" className="tabular" fontSize="10" fontWeight="700" fill="var(--color-ink-400)">
|
||||||
|
{format(v)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{points.map((p, i) =>
|
||||||
|
p.current ? (
|
||||||
|
<g key={`cur-${i}`}>
|
||||||
|
<rect x={x(i) - slot / 2} y={padT} width={slot} height={innerH} fill="var(--color-sky-50)" opacity={0.9} />
|
||||||
|
<text x={x(i)} y={padT - 5} textAnchor="middle" fontSize="9" fontWeight="700" fill="var(--color-sky-700)">
|
||||||
|
Diese Woche
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
{points.map((p, i) =>
|
||||||
|
i % labelEvery === 0 ? (
|
||||||
|
<text key={`l-${i}`} x={x(i)} y={height - 8} textAnchor="middle" fontSize="10" fontWeight="700" fill="var(--color-ink-400)">
|
||||||
|
{p.label}
|
||||||
|
</text>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'weekly' &&
|
||||||
|
points.map((p, i) => {
|
||||||
|
const bw = Math.max(3, Math.min(28, slot * 0.6));
|
||||||
|
const total = p.bar + (p.barB ?? 0);
|
||||||
|
const h = grown ? (total / max) * innerH : 0;
|
||||||
|
const ha = total ? (p.bar / total) * h : 0;
|
||||||
|
return (
|
||||||
|
<g key={`b-${i}`} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
|
||||||
|
<rect x={x(i) - slot / 2} y={padT} width={slot} height={innerH} fill="transparent" />
|
||||||
|
<rect x={x(i) - bw / 2} y={padT + innerH - h} width={bw} height={h - ha} rx={2} fill={NON_BILLABLE} style={{ transition: `all 0.7s ease-out ${i * 12}ms` }} />
|
||||||
|
<rect x={x(i) - bw / 2} y={padT + innerH - ha} width={bw} height={ha} fill={BILLABLE} style={{ transition: `all 0.7s ease-out ${i * 12}ms` }} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{mode === 'progress' && budget != null && budget > 0 && (
|
||||||
|
<g>
|
||||||
|
<line x1={padL} x2={width - padR} y1={y(budget)} y2={y(budget)} stroke="var(--color-ink-700)" strokeWidth={1.5} />
|
||||||
|
<rect x={padL + 4} y={y(budget) - 18} width={Math.max(92, format(budget).length * 7 + 56)} height={15} rx={4} fill="var(--color-ink-700)" />
|
||||||
|
<text x={padL + 10} y={y(budget) - 7.5} fontSize="9.5" fontWeight="700" fill="var(--color-surface)">
|
||||||
|
Budget: {format(budget)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
{mode === 'progress' && line && (
|
||||||
|
<>
|
||||||
|
<path d={line} fill="none" stroke="var(--color-brand-500)" strokeWidth={2.4} strokeLinejoin="round" strokeLinecap="round" className="draw-line" style={{ '--len': width * 3 } as React.CSSProperties} key={`${width}-${points.length}`} />
|
||||||
|
{points.map((p, i) =>
|
||||||
|
p.value == null ? null : (
|
||||||
|
<g key={`d-${i}`} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
|
||||||
|
<rect x={x(i) - slot / 2} y={padT} width={slot} height={innerH} fill="transparent" />
|
||||||
|
<circle cx={x(i)} cy={y(p.value)} r={hover === i ? 5 : points.length > 40 ? 0 : 3} fill="var(--color-surface)" stroke="var(--color-brand-600)" strokeWidth={2} />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{hover != null && points[hover] && (
|
||||||
|
<div
|
||||||
|
className="animate-pop pointer-events-none absolute z-10 rounded-xl border border-ink-200 bg-surface px-3 py-2 text-xs shadow-lift"
|
||||||
|
style={{ left: Math.min(width - 150, Math.max(0, x(hover) - 70)), top: 0 }}
|
||||||
|
>
|
||||||
|
<p className="font-extrabold text-ink-900">{points[hover].label}</p>
|
||||||
|
{mode === 'progress' ? (
|
||||||
|
<p className="tabular text-ink-600">kumuliert {format(points[hover].value ?? 0)}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="tabular text-ink-600">billable {format(points[hover].bar)}</p>
|
||||||
|
<p className="tabular text-ink-600">non-billable {format(points[hover].barB ?? 0)}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Waagerechter Doppelbalken (billable + non-billable) fuer Ranglisten und Tabellen. */
|
||||||
|
export const SplitBar: React.FC<{ a: number; b: number; max: number; height?: string; delay?: number; className?: string }> = ({
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
max,
|
||||||
|
height = 'h-2.5',
|
||||||
|
delay = 0,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const grown = useGrow(`${a}-${b}-${max}`);
|
||||||
|
const total = a + b;
|
||||||
|
return (
|
||||||
|
<div className={`flex ${height} overflow-hidden rounded-full bg-ink-100 ${className}`}>
|
||||||
|
<div
|
||||||
|
className="flex h-full overflow-hidden rounded-full transition-[width] duration-1000 ease-out"
|
||||||
|
style={{ width: grown && max ? `${(total / max) * 100}%` : '0%', transitionDelay: `${delay}ms` }}
|
||||||
|
>
|
||||||
|
<div style={{ width: total ? `${(a / total) * 100}%` : 0, background: BILLABLE }} />
|
||||||
|
<div style={{ width: total ? `${(b / total) * 100}%` : 0, background: NON_BILLABLE }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hochzaehlender Text fuer grosse Zahlen. */
|
||||||
|
export const CountUp: React.FC<{ value: number; format: (v: number) => string; className?: string }> = ({ value, format, className }) => {
|
||||||
|
const shown = useCountUp(value);
|
||||||
|
return <span className={`tabular ${className ?? ''}`}>{format(shown)}</span>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Icon, type IconName } from './Icon';
|
||||||
|
import { colorFor, initials } from '../../lib/format';
|
||||||
|
import { useCountUp, useGrow } from '../../lib/hooks';
|
||||||
|
|
||||||
|
export const inputClass =
|
||||||
|
'w-full rounded-xl border border-ink-200 bg-surface px-3.5 py-2.5 text-sm text-ink-900 outline-none transition placeholder:text-ink-300 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 disabled:bg-ink-50 disabled:text-ink-400';
|
||||||
|
|
||||||
|
export const labelClass = 'field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500';
|
||||||
|
|
||||||
|
export const Field: React.FC<{ label: string; hint?: React.ReactNode; children: React.ReactNode; className?: string; required?: boolean }> = ({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
required,
|
||||||
|
}) => (
|
||||||
|
<label className={`block ${className}`}>
|
||||||
|
<span className={labelClass}>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-brand-600"> *</span>}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
{hint && <span className="mt-1 block text-xs text-ink-400">{hint}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||||
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
|
primary: 'bg-brand-500 text-white shadow-card hover:bg-brand-600',
|
||||||
|
secondary: 'border border-ink-200 bg-surface text-ink-700 hover:border-brand-300 hover:text-brand-700',
|
||||||
|
ghost: 'text-ink-600 hover:bg-ink-100',
|
||||||
|
danger: 'bg-red-600 text-white hover:bg-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Button: React.FC<
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant; icon?: IconName; busy?: boolean; size?: 'sm' | 'md' }
|
||||||
|
> = ({ variant = 'primary', icon, busy, size = 'md', className = '', children, disabled, ...props }) => (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
className={`inline-flex items-center justify-center gap-2 rounded-xl font-bold transition disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||||
|
size === 'sm' ? 'px-3 py-1.5 text-xs' : 'px-4 py-2.5 text-sm'
|
||||||
|
} ${VARIANTS[variant]} ${className}`}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent opacity-70" />
|
||||||
|
) : (
|
||||||
|
icon && <Icon name={icon} className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconButton: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement> & { icon: IconName; label: string; tone?: 'default' | 'danger' }> = ({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
tone = 'default',
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
className={`rounded-lg p-2 text-ink-400 transition ${
|
||||||
|
tone === 'danger' ? 'hover:bg-red-50 hover:text-red-600' : 'hover:bg-ink-100 hover:text-ink-800'
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
<Icon name={icon} className="h-4 w-4" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Segmentierter Umschalter (Woche/Monat/Jahr ...). Mit gleitendem Marker. */
|
||||||
|
export function Segmented<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
size = 'md',
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
value: T;
|
||||||
|
options: { value: T; label: string }[];
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const index = Math.max(0, options.findIndex((o) => o.value === value));
|
||||||
|
return (
|
||||||
|
<div className={`relative inline-grid rounded-xl border border-ink-200 bg-ink-50 p-1 ${className}`} style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-y-1 left-1 rounded-lg bg-surface shadow-card transition-transform duration-300 ease-out"
|
||||||
|
style={{ width: `calc((100% - 0.5rem) / ${options.length})`, transform: `translateX(${index * 100}%)` }}
|
||||||
|
/>
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
aria-pressed={option.value === value}
|
||||||
|
className={`relative z-10 whitespace-nowrap rounded-lg font-bold transition ${size === 'sm' ? 'px-2.5 py-1 text-[11px]' : 'px-3.5 py-1.5 text-xs'} ${
|
||||||
|
option.value === value ? 'text-brand-700' : 'text-ink-500 hover:text-ink-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Badge: React.FC<{ tone?: 'brand' | 'neutral' | 'amber' | 'red' | 'sky' | 'violet'; children: React.ReactNode; className?: string; title?: string }> = ({
|
||||||
|
tone = 'neutral',
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
title,
|
||||||
|
}) => {
|
||||||
|
const tones = {
|
||||||
|
brand: 'border-brand-200 bg-brand-50 text-brand-700',
|
||||||
|
neutral: 'border-ink-200 bg-ink-50 text-ink-600',
|
||||||
|
amber: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||||
|
red: 'border-red-200 bg-red-50 text-red-700',
|
||||||
|
sky: 'border-sky-200 bg-sky-50 text-sky-700',
|
||||||
|
violet: 'border-violet-200 bg-violet-50 text-violet-700',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span title={title} className={`inline-flex items-center gap-1 whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-bold ${tones[tone]} ${className}`}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Card: React.FC<{ children: React.ReactNode; className?: string; style?: React.CSSProperties }> = ({ children, className = '', style }) => (
|
||||||
|
<section style={style} className={`min-w-0 rounded-2xl border border-ink-200 bg-surface shadow-card ${className}`}>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Panel: React.FC<{
|
||||||
|
title: React.ReactNode;
|
||||||
|
hint?: React.ReactNode;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
index?: number;
|
||||||
|
}> = ({ title, hint, action, children, className = '', index = 0 }) => (
|
||||||
|
<Card className={`animate-rise p-4 sm:p-5 ${className}`} style={{ '--i': index } as React.CSSProperties}>
|
||||||
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-base text-ink-900">{title}</h3>
|
||||||
|
{hint && <p className="mt-0.5 text-xs text-ink-400">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const EmptyState: React.FC<{ icon?: IconName; title: string; text?: React.ReactNode; action?: React.ReactNode }> = ({
|
||||||
|
icon = 'info',
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
action,
|
||||||
|
}) => (
|
||||||
|
<div className="animate-fade flex flex-col items-center justify-center rounded-2xl border border-dashed border-ink-200 px-6 py-12 text-center">
|
||||||
|
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-brand-50 text-brand-600">
|
||||||
|
<Icon name={icon} className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-ink-800">{title}</p>
|
||||||
|
{text && <p className="mt-1 max-w-sm text-xs text-ink-400">{text}</p>}
|
||||||
|
{action && <div className="mt-4">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Spinner: React.FC<{ label?: string }> = ({ label = 'Lade…' }) => (
|
||||||
|
<div className="flex items-center justify-center gap-3 py-16 text-xs font-bold uppercase tracking-[0.2em] text-ink-400">
|
||||||
|
<span className="h-5 w-5 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ErrorBox: React.FC<{ message: string; onRetry?: () => void }> = ({ message, onRetry }) => (
|
||||||
|
<div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
|
||||||
|
<Icon name="warning" className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span className="flex-1">{message}</span>
|
||||||
|
{onRetry && (
|
||||||
|
<button onClick={onRetry} className="text-xs font-bold underline">
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Avatar: React.FC<{ name: string; color?: string | null; size?: number; id?: number; external?: boolean; className?: string }> = ({
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
size = 32,
|
||||||
|
id,
|
||||||
|
external,
|
||||||
|
className = '',
|
||||||
|
}) => (
|
||||||
|
<span
|
||||||
|
title={external ? `${name} (extern)` : name}
|
||||||
|
className={`relative inline-flex shrink-0 items-center justify-center rounded-full font-extrabold text-white ring-2 ring-surface ${className}`}
|
||||||
|
style={{ width: size, height: size, fontSize: Math.max(9, size * 0.36), backgroundColor: colorFor(id ?? name, color) }}
|
||||||
|
>
|
||||||
|
{initials(name)}
|
||||||
|
{external && (
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-amber-400 text-[7px] text-white ring-2 ring-surface">
|
||||||
|
E
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AvatarStack: React.FC<{ people: { id: number; name: string; color: string | null; is_external?: boolean }[]; max?: number; size?: number }> = ({
|
||||||
|
people,
|
||||||
|
max = 5,
|
||||||
|
size = 26,
|
||||||
|
}) => (
|
||||||
|
<div className="flex items-center -space-x-1">
|
||||||
|
{people.slice(0, max).map((p) => (
|
||||||
|
<Avatar key={p.id} id={p.id} name={p.name} color={p.color} size={size} external={p.is_external} />
|
||||||
|
))}
|
||||||
|
{people.length > max && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-ink-100 text-[10px] font-extrabold text-ink-600 ring-2 ring-surface"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
+{people.length - max}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Budgetbalken: verbrauchter Anteil waechst beim Einblenden, Farbe kippt ab
|
||||||
|
* Warnschwelle auf Bernstein und bei Ueberbuchung auf Rot.
|
||||||
|
*/
|
||||||
|
export const BudgetBar: React.FC<{ percent: number | null; warnAt?: number; height?: string; className?: string; muted?: boolean; goal?: boolean }> = ({
|
||||||
|
percent,
|
||||||
|
warnAt = 80,
|
||||||
|
height = 'h-2',
|
||||||
|
className = '',
|
||||||
|
muted,
|
||||||
|
goal,
|
||||||
|
}) => {
|
||||||
|
const grown = useGrow(percent);
|
||||||
|
if (percent == null) {
|
||||||
|
return <div className={`${height} rounded-full bg-[repeating-linear-gradient(45deg,var(--color-ink-100),var(--color-ink-100)_6px,var(--color-ink-50)_6px,var(--color-ink-50)_12px)] ${className}`} title="Unbegrenzt" />;
|
||||||
|
}
|
||||||
|
const color = muted ? 'bg-ink-300' : goal ? (percent >= 100 ? 'bg-brand-500' : 'bg-brand-300') : percent >= 100 ? 'bg-red-500' : percent >= warnAt ? 'bg-amber-500' : 'bg-brand-500';
|
||||||
|
return (
|
||||||
|
<div className={`${height} overflow-hidden rounded-full bg-ink-100 ${className}`}>
|
||||||
|
<div className={`h-full rounded-full ${color} transition-[width] duration-1000 ease-out`} style={{ width: grown ? `${Math.min(100, percent)}%` : '0%' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Grosse Kennzahl mit hochzaehlender Zahl. */
|
||||||
|
export const Stat: React.FC<{
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
format: (value: number) => string;
|
||||||
|
hint?: React.ReactNode;
|
||||||
|
icon?: IconName;
|
||||||
|
accent?: string;
|
||||||
|
index?: number;
|
||||||
|
delta?: number | null;
|
||||||
|
}> = ({ label, value, format, hint, icon, accent = 'var(--color-brand-500)', index = 0, delta }) => {
|
||||||
|
const shown = useCountUp(value);
|
||||||
|
return (
|
||||||
|
<Card className="animate-rise relative overflow-hidden p-4 sm:p-5" style={{ '--i': index } as React.CSSProperties}>
|
||||||
|
<span aria-hidden className="absolute inset-y-0 left-0 w-1" style={{ background: accent }} />
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-[11px] font-extrabold uppercase tracking-[0.14em] text-ink-400">{label}</p>
|
||||||
|
{icon && <Icon name={icon} className="h-5 w-5 shrink-0 text-ink-300" />}
|
||||||
|
</div>
|
||||||
|
<p className="tabular mt-2 text-2xl font-extrabold text-ink-900 sm:text-3xl">{format(shown)}</p>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-ink-400">
|
||||||
|
{delta != null && Number.isFinite(delta) && (
|
||||||
|
<span className={`inline-flex items-center gap-0.5 font-bold ${delta >= 0 ? 'text-brand-600' : 'text-red-600'}`}>
|
||||||
|
<Icon name={delta >= 0 ? 'arrowUp' : 'arrowDown'} className="h-3 w-3" strokeWidth={2.5} />
|
||||||
|
{Math.abs(Math.round(delta))} %
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hint}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Toggle: React.FC<{ checked: boolean; onChange: (value: boolean) => void; label: React.ReactNode; hint?: React.ReactNode; disabled?: boolean }> = ({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
disabled,
|
||||||
|
}) => (
|
||||||
|
<label className={`flex items-start gap-3 ${disabled ? 'opacity-60' : 'cursor-pointer'}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative mt-0.5 h-6 w-11 shrink-0 rounded-full transition ${checked ? 'bg-brand-500' : 'bg-ink-200'}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-all ${checked ? 'left-[22px]' : 'left-0.5'}`} />
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-bold text-ink-800">{label}</span>
|
||||||
|
{hint && <span className="mt-0.5 block text-xs text-ink-400">{hint}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PageHeader: React.FC<{ title?: React.ReactNode; children?: React.ReactNode; className?: string }> = ({ title, children, className = '' }) => (
|
||||||
|
<div className={`mb-4 flex flex-wrap items-center justify-between gap-3 ${className}`}>
|
||||||
|
{title && <div className="min-w-0">{title}</div>}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import type {
|
||||||
|
AppUser,
|
||||||
|
BudgetPot,
|
||||||
|
Customer,
|
||||||
|
DirectoryUser,
|
||||||
|
ManagedUser,
|
||||||
|
Project,
|
||||||
|
PublicSettings,
|
||||||
|
Role,
|
||||||
|
TimeEntry,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'pulse_token';
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function setToken(token: string) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
} catch {
|
||||||
|
/* ohne localStorage bleibt die Sitzung nur bis zum Neuladen */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function clearToken() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
/* egal */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let onUnauthorized: (() => void) | null = null;
|
||||||
|
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||||
|
onUnauthorized = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
||||||
|
const token = getToken();
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`/api${path}`, { ...options, headers });
|
||||||
|
} catch {
|
||||||
|
throw new ApiError('Server nicht erreichbar. Bitte Verbindung prüfen.', 0);
|
||||||
|
}
|
||||||
|
if (response.status === 401 && !path.startsWith('/auth/login')) {
|
||||||
|
clearToken();
|
||||||
|
onUnauthorized?.();
|
||||||
|
}
|
||||||
|
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||||
|
const payload = isJson ? await response.json().catch(() => null) : null;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(payload?.error || `Anfrage fehlgeschlagen (HTTP ${response.status}).`, response.status);
|
||||||
|
}
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = <T>(path: string) => request<T>(path);
|
||||||
|
const send = <T>(method: string, path: string, body?: unknown) =>
|
||||||
|
request<T>(path, { method, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||||
|
|
||||||
|
function qs(params: Record<string, string | number | boolean | null | undefined>) {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v === undefined || v === null || v === '') continue;
|
||||||
|
search.set(k, String(v));
|
||||||
|
}
|
||||||
|
const s = search.toString();
|
||||||
|
return s ? `?${s}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt eine Datei (Export) mit Token und stoesst den Download an. */
|
||||||
|
export async function download(path: string, fallbackName: string, open = false) {
|
||||||
|
const token = getToken();
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`/api${path}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||||
|
} catch {
|
||||||
|
throw new ApiError('Server nicht erreichbar.', 0);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null);
|
||||||
|
throw new ApiError(payload?.error || `Export fehlgeschlagen (HTTP ${response.status}).`, response.status);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const disposition = response.headers.get('content-disposition') || '';
|
||||||
|
const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
|
||||||
|
const name = match ? decodeURIComponent(match[1]) : fallbackName;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
if (open && blob.type === 'application/pdf') {
|
||||||
|
window.open(url, '_blank', 'noopener');
|
||||||
|
} else {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { qs };
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Endpunkte -- */
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
config: () => get<{ sso_enabled: boolean; sso_label: string; local_login: boolean }>('/auth/config'),
|
||||||
|
login: (identifier: string, password: string) =>
|
||||||
|
send<{ token: string; user: AppUser }>('POST', '/auth/login', { identifier, password }),
|
||||||
|
me: () => get<{ user: AppUser }>('/auth/me'),
|
||||||
|
updateMe: (changes: Partial<Pick<AppUser, 'theme' | 'theme_mode' | 'color'>>) =>
|
||||||
|
send<{ user: AppUser }>('PATCH', '/auth/me', changes),
|
||||||
|
changePassword: (current_password: string, new_password: string) =>
|
||||||
|
send<{ ok: true }>('POST', '/auth/change-password', { current_password, new_password }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const settingsApi = {
|
||||||
|
public: () => get<PublicSettings>('/settings/public'),
|
||||||
|
all: () => get<AdminSettings>('/settings'),
|
||||||
|
save: (changes: Partial<AdminSettingsInput>) => send<{ ok: true }>('PUT', '/settings', changes),
|
||||||
|
ssoTest: () => send<{ ok: true; sample: string[]; checked: number; eligible: number }>('POST', '/settings/sso/test'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ExportSettings {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
accent: string;
|
||||||
|
logo: string | null;
|
||||||
|
header_right: string;
|
||||||
|
footer_left: string;
|
||||||
|
footer_center: string;
|
||||||
|
footer_right: string;
|
||||||
|
show_signature: boolean;
|
||||||
|
signature_left: string;
|
||||||
|
signature_right: string;
|
||||||
|
}
|
||||||
|
export interface SsoSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
button_label: string;
|
||||||
|
tenant_id: string;
|
||||||
|
client_id: string;
|
||||||
|
client_secret: string;
|
||||||
|
client_secret_set?: boolean;
|
||||||
|
configured?: boolean;
|
||||||
|
redirect_uri: string;
|
||||||
|
local_login: boolean;
|
||||||
|
}
|
||||||
|
export interface AdminSettings extends PublicSettings {
|
||||||
|
export: ExportSettings;
|
||||||
|
sso: SsoSettings;
|
||||||
|
}
|
||||||
|
export type AdminSettingsInput = Omit<AdminSettings, 'export' | 'sso'> & {
|
||||||
|
export: Partial<ExportSettings>;
|
||||||
|
sso: Partial<SsoSettings> & { clear_secret?: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AdUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
given_name: string | null;
|
||||||
|
surname: string | null;
|
||||||
|
email: string;
|
||||||
|
job_title: string | null;
|
||||||
|
department: string | null;
|
||||||
|
pulse_user_id: number | null;
|
||||||
|
pulse_role: Role | null;
|
||||||
|
pulse_active: boolean | null;
|
||||||
|
local_match_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const directoryApi = {
|
||||||
|
list: (refresh = false) =>
|
||||||
|
get<{ configured: boolean; domains: string[]; fetched_at: string | null; users: AdUser[] }>(`/directory/ad-users${refresh ? '?refresh=1' : ''}`),
|
||||||
|
import: (ad_ids: string[], role: Role, manager_id: number | null) =>
|
||||||
|
send<{ created: number[]; linked: number[]; skipped: string[] }>('POST', '/directory/import', { ad_ids, role, manager_id }),
|
||||||
|
sync: () => send<{ total: number; eligible: number; linked: number; updated: number; deactivated: number }>('POST', '/directory/sync'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usersApi = {
|
||||||
|
directory: () => get<DirectoryUser[]>('/users/directory'),
|
||||||
|
list: () => get<{ users: ManagedUser[]; assignable_roles: Role[] }>('/users'),
|
||||||
|
create: (data: Record<string, unknown>) => send<AppUser>('POST', '/users', data),
|
||||||
|
update: (id: number, data: Record<string, unknown>) => send<AppUser>('PUT', `/users/${id}`, data),
|
||||||
|
remove: (id: number) => send<{ ok: true }>('DELETE', `/users/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CustomerOverview extends Customer {
|
||||||
|
projects: Array<
|
||||||
|
Pick<Project, 'id' | 'code' | 'name' | 'display_name' | 'type' | 'is_active' | 'used_minutes' | 'budget_hours' | 'earned' | 'open' | 'budget_value' | 'members' | 'end_date'>
|
||||||
|
>;
|
||||||
|
members: Project['members'];
|
||||||
|
used_minutes: number;
|
||||||
|
earned: number;
|
||||||
|
open: number;
|
||||||
|
monthly: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customersApi = {
|
||||||
|
list: () => get<Customer[]>('/customers'),
|
||||||
|
overview: () => get<{ months: string[]; customers: CustomerOverview[] }>('/customers/overview'),
|
||||||
|
create: (data: Partial<Customer>) => send<Customer>('POST', '/customers', data),
|
||||||
|
update: (id: number, data: Partial<Customer>) => send<Customer>('PUT', `/customers/${id}`, data),
|
||||||
|
remove: (id: number) => send<{ ok: true }>('DELETE', `/customers/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PotInput = Partial<Pick<BudgetPot, 'label' | 'valid_from' | 'valid_to' | 'budget_amount' | 'rate' | 'budget_hours' | 'notes'>>;
|
||||||
|
|
||||||
|
export const projectsApi = {
|
||||||
|
list: (scope?: 'mine') => get<Project[]>(`/projects${qs({ scope })}`),
|
||||||
|
get: (id: number) => get<Project>(`/projects/${id}`),
|
||||||
|
create: (data: Record<string, unknown>) => send<Project>('POST', '/projects', data),
|
||||||
|
update: (id: number, data: Record<string, unknown>) => send<Project>('PUT', `/projects/${id}`, data),
|
||||||
|
remove: (id: number) => send<{ ok: true }>('DELETE', `/projects/${id}`),
|
||||||
|
setMembers: (id: number, user_ids: number[]) => send<Project>('PUT', `/projects/${id}/members`, { user_ids }),
|
||||||
|
addPot: (id: number, pot: PotInput) => send<Project>('POST', `/projects/${id}/pots`, pot),
|
||||||
|
updatePot: (id: number, potId: number, pot: PotInput) => send<Project>('PUT', `/projects/${id}/pots/${potId}`, pot),
|
||||||
|
removePot: (id: number, potId: number) => send<Project>('DELETE', `/projects/${id}/pots/${potId}`),
|
||||||
|
addTask: (id: number, task: { name: string; billable?: boolean }) => send<Project>('POST', `/projects/${id}/tasks`, task),
|
||||||
|
updateTask: (id: number, taskId: number, task: Partial<{ name: string; billable: boolean; is_active: boolean }>) =>
|
||||||
|
send<Project>('PUT', `/projects/${id}/tasks/${taskId}`, task),
|
||||||
|
removeTask: (id: number, taskId: number) => send<Project>('DELETE', `/projects/${id}/tasks/${taskId}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const entriesApi = {
|
||||||
|
list: (from: string, to: string, userId?: number) => get<TimeEntry[]>(`/entries${qs({ from, to, user_id: userId })}`),
|
||||||
|
create: (data: { project_id: number; task_id: number | null; entry_date: string; minutes: number; note: string }) =>
|
||||||
|
send<{ entry: TimeEntry; warning: string | null }>('POST', '/entries', data),
|
||||||
|
update: (id: number, data: Partial<{ project_id: number; task_id: number | null; entry_date: string; minutes: number; note: string }>) =>
|
||||||
|
send<{ entry: TimeEntry; warning: string | null }>('PUT', `/entries/${id}`, data),
|
||||||
|
remove: (id: number) => send<{ ok: true }>('DELETE', `/entries/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||||
|
export type RankPeriod = 'days' | 'week' | 'month' | 'quarter' | 'year';
|
||||||
|
|
||||||
|
export interface SeriesBucket {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
label: string;
|
||||||
|
minutes: number;
|
||||||
|
billable_minutes: number;
|
||||||
|
non_billable_minutes: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardData {
|
||||||
|
granularity: Granularity;
|
||||||
|
rank: RankPeriod;
|
||||||
|
rank_range: { from: string; to: string };
|
||||||
|
scope: 'all' | 'team';
|
||||||
|
team_size: number;
|
||||||
|
series: SeriesBucket[];
|
||||||
|
kpis: {
|
||||||
|
current: SeriesBucket & { capacity_minutes: number };
|
||||||
|
previous: SeriesBucket | null;
|
||||||
|
period_label: string;
|
||||||
|
previous_label: string | null;
|
||||||
|
};
|
||||||
|
ranking: Array<{
|
||||||
|
user_id: number;
|
||||||
|
name: string;
|
||||||
|
color: string | null;
|
||||||
|
job_title: string | null;
|
||||||
|
is_external: boolean;
|
||||||
|
minutes: number;
|
||||||
|
billable_minutes: number;
|
||||||
|
non_billable_minutes: number;
|
||||||
|
amount: number;
|
||||||
|
entries: number;
|
||||||
|
capacity_minutes: number;
|
||||||
|
}>;
|
||||||
|
projects: Array<{ project_id: number; name: string; customer_color: string | null; minutes: number; billable_minutes: number }>;
|
||||||
|
recent: Array<{
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
user_name: string;
|
||||||
|
user_color: string | null;
|
||||||
|
project_display: string;
|
||||||
|
customer_color: string | null;
|
||||||
|
task_name: string | null;
|
||||||
|
note: string | null;
|
||||||
|
entry_date: string;
|
||||||
|
minutes: number;
|
||||||
|
billable: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardApi = {
|
||||||
|
get: (granularity: Granularity, rank: RankPeriod) => get<DashboardData>(`/dashboard${qs({ granularity, rank })}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GroupRow {
|
||||||
|
key: string | number;
|
||||||
|
name: string;
|
||||||
|
color?: string | null;
|
||||||
|
minutes: number;
|
||||||
|
billable_minutes: number;
|
||||||
|
non_billable_minutes: number;
|
||||||
|
amount: number;
|
||||||
|
user_id?: number;
|
||||||
|
customer_id?: number;
|
||||||
|
project_id?: number;
|
||||||
|
customer_name?: string;
|
||||||
|
code?: string;
|
||||||
|
type?: string;
|
||||||
|
is_external?: boolean;
|
||||||
|
capacity_minutes?: number;
|
||||||
|
utilization?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeReport {
|
||||||
|
range: { from: string; to: string };
|
||||||
|
include_fixed: boolean;
|
||||||
|
summary: {
|
||||||
|
minutes: number;
|
||||||
|
billable_minutes: number;
|
||||||
|
non_billable_minutes: number;
|
||||||
|
amount: number;
|
||||||
|
amount_without_fixed: number;
|
||||||
|
fixed_amount: number;
|
||||||
|
entries: number;
|
||||||
|
};
|
||||||
|
clients: GroupRow[];
|
||||||
|
projects: GroupRow[];
|
||||||
|
tasks: GroupRow[];
|
||||||
|
users: GroupRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDetail {
|
||||||
|
project: Project;
|
||||||
|
totals: { minutes: number; billable_minutes: number; non_billable_minutes: number; amount: number };
|
||||||
|
weekly: Array<{ week_start: string; label: string; minutes: number; billable_minutes: number; cumulative_minutes: number | null }>;
|
||||||
|
tasks: GroupRow[];
|
||||||
|
team: GroupRow[];
|
||||||
|
pots: Array<BudgetPot & { tasks: GroupRow[]; team: GroupRow[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerReport {
|
||||||
|
customer: Customer;
|
||||||
|
totals: {
|
||||||
|
minutes: number;
|
||||||
|
billable_minutes: number;
|
||||||
|
non_billable_minutes: number;
|
||||||
|
amount: number;
|
||||||
|
earned: number;
|
||||||
|
open: number;
|
||||||
|
budget_value: number;
|
||||||
|
};
|
||||||
|
series: Array<{ month: string; minutes: number; billable_minutes: number; amount: number }>;
|
||||||
|
projects: Array<Omit<ProjectDetail, 'weekly'>>;
|
||||||
|
team: GroupRow[];
|
||||||
|
tasks: GroupRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reportsApi = {
|
||||||
|
time: (params: { from: string; to: string; customer_id?: number | null; team?: number | null; include_fixed?: boolean }) =>
|
||||||
|
get<TimeReport>(`/reports/time${qs({ ...params, include_fixed: params.include_fixed ? 1 : undefined })}`),
|
||||||
|
project: (id: number) => get<ProjectDetail>(`/reports/project/${id}`),
|
||||||
|
customer: (id: number) => get<CustomerReport>(`/reports/customer/${id}`),
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { AppUser, PublicSettings } from '../types';
|
||||||
|
import { authApi, clearToken, getToken, setToken, setUnauthorizedHandler, settingsApi } from './api';
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: AppUser | null;
|
||||||
|
settings: PublicSettings;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (identifier: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
updateUser: (user: AppUser) => void;
|
||||||
|
reloadSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: PublicSettings = {
|
||||||
|
weekly_hours: 40,
|
||||||
|
hours_per_day: 8,
|
||||||
|
budget_warn_percent: 80,
|
||||||
|
budget_warn_days: 14,
|
||||||
|
company_name: 'perinco GmbH',
|
||||||
|
project_code_pattern: '{KUERZEL}-{JAHR}-{NR}',
|
||||||
|
project_name_pattern: '{KUNDE} - {PROJEKT}',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSO: der Callback des Backends leitet auf /#sso_token=... zurueck. Das
|
||||||
|
* Fragment wird sofort aus der Adresszeile entfernt, damit das Token weder im
|
||||||
|
* Verlauf noch in einem Lesezeichen landet.
|
||||||
|
*/
|
||||||
|
function takeSsoToken() {
|
||||||
|
const match = window.location.hash.match(/sso_token=([^&]+)/);
|
||||||
|
if (!match) return;
|
||||||
|
setToken(decodeURIComponent(match[1]));
|
||||||
|
resetRoute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach einer Anmeldung startet jeder auf der Startseite seiner Rolle - auch
|
||||||
|
* wenn noch eine Seite vom letzten Mal in der Adresse steht. Ein Neuladen mit
|
||||||
|
* gueltiger Sitzung bleibt dagegen, wo es ist.
|
||||||
|
*/
|
||||||
|
function resetRoute() {
|
||||||
|
window.history.replaceState(null, '', window.location.pathname);
|
||||||
|
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<AppUser | null>(null);
|
||||||
|
const [settings, setSettings] = useState<PublicSettings>(DEFAULT_SETTINGS);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
clearToken();
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUnauthorizedHandler(() => setUser(null));
|
||||||
|
return () => setUnauthorizedHandler(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reloadSettings = useCallback(() => {
|
||||||
|
settingsApi
|
||||||
|
.public()
|
||||||
|
.then(setSettings)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
takeSsoToken();
|
||||||
|
(async () => {
|
||||||
|
if (!getToken()) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { user: current } = await authApi.me();
|
||||||
|
if (!cancelled) setUser(current);
|
||||||
|
} catch {
|
||||||
|
clearToken();
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setIsLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) reloadSettings();
|
||||||
|
}, [user?.id, reloadSettings]);
|
||||||
|
|
||||||
|
const login = useCallback(async (identifier: string, password: string) => {
|
||||||
|
const { token, user: loggedIn } = await authApi.login(identifier, password);
|
||||||
|
setToken(token);
|
||||||
|
resetRoute();
|
||||||
|
setUser(loggedIn);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({ user, settings, isLoading, login, logout, updateUser: setUser, reloadSettings }),
|
||||||
|
[user, settings, isLoading, login, logout, reloadSettings]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth muss innerhalb von <AuthProvider> verwendet werden.');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
* Zeit- und Datumsformate. Datumswerte laufen als 'YYYY-MM-DD' durch die App
|
||||||
|
* und werden nur zur Anzeige umgewandelt - so verrutscht nichts durch Zeitzonen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest eine Zeiteingabe in Minuten.
|
||||||
|
* "1:23" -> 83 Stunden:Minuten
|
||||||
|
* "1,5" -> 90 Dezimalstunden mit Komma
|
||||||
|
* "1.5" -> 90 ... oder Punkt
|
||||||
|
* "2" -> 120 ganze Stunden
|
||||||
|
* ":45" -> 45
|
||||||
|
* "1h 30m", "90m", "45 min" -> werden ebenfalls verstanden
|
||||||
|
* Liefert null bei ungueltiger Eingabe.
|
||||||
|
*/
|
||||||
|
export function parseDuration(input: string): number | null {
|
||||||
|
const raw = input.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
let m = raw.match(/^(\d{0,2}):(\d{1,2})$/);
|
||||||
|
if (m) {
|
||||||
|
const hours = Number(m[1] || 0);
|
||||||
|
const minutes = Number(m[2]);
|
||||||
|
if (minutes >= 60) return null;
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
m = raw.match(/^(\d{1,2})(?:[.,](\d{1,2}))?\s*h?$/);
|
||||||
|
if (m) {
|
||||||
|
const value = Number(`${m[1]}.${m[2] || 0}`);
|
||||||
|
return Math.round(value * 60);
|
||||||
|
}
|
||||||
|
m = raw.match(/^[.,](\d{1,2})\s*h?$/);
|
||||||
|
if (m) return Math.round(Number(`0.${m[1]}`) * 60);
|
||||||
|
m = raw.match(/^(?:(\d{1,2})\s*h)?\s*(?:(\d{1,3})\s*(?:m|min))?$/);
|
||||||
|
if (m && (m[1] || m[2])) return Number(m[1] || 0) * 60 + Number(m[2] || 0);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 83 -> "1:23" */
|
||||||
|
export function formatDuration(minutes: number): string {
|
||||||
|
const total = Math.round(Math.max(0, minutes));
|
||||||
|
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 90 -> "1,50" */
|
||||||
|
export function formatHours(minutes: number, digits = 2): string {
|
||||||
|
return (minutes / 60).toLocaleString('de-DE', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(value: number, digits = 0): string {
|
||||||
|
return value.toLocaleString('de-DE', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEuro(value: number | null | undefined, digits = 0): string {
|
||||||
|
if (value == null) return '–';
|
||||||
|
return `${value.toLocaleString('de-DE', { minimumFractionDigits: digits, maximumFractionDigits: digits })} €`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compactEuro(value: number): string {
|
||||||
|
if (Math.abs(value) >= 1_000_000) return `${formatNumber(value / 1_000_000, 1)} Mio. €`;
|
||||||
|
if (Math.abs(value) >= 10_000) return `${formatNumber(value / 1000, 0)} T€`;
|
||||||
|
return formatEuro(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Datum -- */
|
||||||
|
|
||||||
|
export function toIso(date: Date): string {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromIso(value: string): Date {
|
||||||
|
const [y, m, d] = value.slice(0, 10).split('-').map(Number);
|
||||||
|
return new Date(y, m - 1, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayIso(): string {
|
||||||
|
return toIso(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(value: string, days: number): string {
|
||||||
|
const date = fromIso(value);
|
||||||
|
date.setDate(date.getDate() + days);
|
||||||
|
return toIso(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMonths(value: string, months: number): string {
|
||||||
|
const date = fromIso(value);
|
||||||
|
date.setDate(1);
|
||||||
|
date.setMonth(date.getMonth() + months);
|
||||||
|
return toIso(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mondayOf(value: string): string {
|
||||||
|
const date = fromIso(value);
|
||||||
|
const day = date.getDay() || 7;
|
||||||
|
date.setDate(date.getDate() - day + 1);
|
||||||
|
return toIso(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthStart(value: string): string {
|
||||||
|
return `${value.slice(0, 7)}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthEnd(value: string): string {
|
||||||
|
const date = fromIso(monthStart(value));
|
||||||
|
date.setMonth(date.getMonth() + 1);
|
||||||
|
date.setDate(0);
|
||||||
|
return toIso(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoWeek(value: string): number {
|
||||||
|
const date = fromIso(value);
|
||||||
|
const day = date.getDay() || 7;
|
||||||
|
date.setDate(date.getDate() + 4 - day);
|
||||||
|
const yearStart = new Date(date.getFullYear(), 0, 1);
|
||||||
|
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEEKDAYS_SHORT = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||||
|
export const WEEKDAYS_LONG = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
||||||
|
export const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||||
|
|
||||||
|
/** 0 = Montag */
|
||||||
|
export function weekdayIndex(value: string): number {
|
||||||
|
return (fromIso(value).getDay() + 6) % 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value: string | null | undefined, style: 'short' | 'long' | 'day' = 'short'): string {
|
||||||
|
if (!value) return '–';
|
||||||
|
const date = fromIso(value);
|
||||||
|
if (style === 'long') {
|
||||||
|
return `${WEEKDAYS_LONG[weekdayIndex(value)]}, ${date.getDate()}. ${MONTHS[date.getMonth()]} ${date.getFullYear()}`;
|
||||||
|
}
|
||||||
|
if (style === 'day') return `${WEEKDAYS_SHORT[weekdayIndex(value)]} ${date.getDate()}.${date.getMonth() + 1}.`;
|
||||||
|
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativeTime(value: string): string {
|
||||||
|
const date = new Date(value.replace(' ', 'T'));
|
||||||
|
const diff = (Date.now() - date.getTime()) / 1000;
|
||||||
|
if (Number.isNaN(diff)) return '';
|
||||||
|
if (diff < 60) return 'gerade eben';
|
||||||
|
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min.`;
|
||||||
|
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std.`;
|
||||||
|
const days = Math.floor(diff / 86400);
|
||||||
|
return days === 1 ? 'gestern' : `vor ${days} Tagen`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase())
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stabile Farbe aus einem Namen, wenn keine gesetzt ist. */
|
||||||
|
const PALETTE = ['#6ba04a', '#0ea5e9', '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#eab308', '#6366f1', '#10b981', '#ec4899'];
|
||||||
|
export function colorFor(key: string | number, preferred?: string | null): string {
|
||||||
|
if (preferred) return preferred;
|
||||||
|
const text = String(key);
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) | 0;
|
||||||
|
return PALETTE[Math.abs(hash) % PALETTE.length];
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile Ansicht: schmales Fenster ODER ein Touch-Geraet mit Mobil-Kennung.
|
||||||
|
* Beides zaehlt, damit ein Tablet quer die Desktopansicht bekommt, ein Handy
|
||||||
|
* quer aber nicht.
|
||||||
|
*/
|
||||||
|
function detectMobile() {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
const narrow = window.innerWidth < 768;
|
||||||
|
const ua = /Android|iPhone|iPod|Mobile|Windows Phone/i.test(navigator.userAgent);
|
||||||
|
const coarse = window.matchMedia?.('(pointer: coarse)').matches;
|
||||||
|
return narrow || (ua && coarse && window.innerWidth < 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [mobile, setMobile] = useState(detectMobile);
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => setMobile(detectMobile());
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
window.addEventListener('orientationchange', onResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
window.removeEventListener('orientationchange', onResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laedt Daten und haelt Lade- und Fehlerzustand. `deps` loest neu aus,
|
||||||
|
* `reload` laedt still (ohne Ladeanzeige) nach.
|
||||||
|
*/
|
||||||
|
export function useLoader<T>(load: () => Promise<T>, deps: unknown[]) {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const loadRef = useRef(load);
|
||||||
|
loadRef.current = load;
|
||||||
|
const counter = useRef(0);
|
||||||
|
|
||||||
|
const run = useCallback((silent = false) => {
|
||||||
|
const id = ++counter.current;
|
||||||
|
if (!silent) setLoading(true);
|
||||||
|
return loadRef
|
||||||
|
.current()
|
||||||
|
.then((result) => {
|
||||||
|
if (id !== counter.current) return;
|
||||||
|
setData(result);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (id !== counter.current) return;
|
||||||
|
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen.');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (id === counter.current) setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
run();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
return { data, error, loading, reload: () => run(true), setData };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zaehlt eine Zahl weich hoch (ease-out), respektiert reduzierte Bewegung. */
|
||||||
|
export function useCountUp(target: number, duration = 800): number {
|
||||||
|
const [value, setValue] = useState(0);
|
||||||
|
const fromRef = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
if (reduce || duration <= 0) {
|
||||||
|
fromRef.current = target;
|
||||||
|
setValue(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = fromRef.current;
|
||||||
|
const start = performance.now();
|
||||||
|
let frame = 0;
|
||||||
|
const step = (now: number) => {
|
||||||
|
const progress = Math.min(1, (now - start) / duration);
|
||||||
|
const eased = 1 - (1 - progress) ** 3;
|
||||||
|
setValue(from + (target - from) * eased);
|
||||||
|
if (progress < 1) frame = requestAnimationFrame(step);
|
||||||
|
else fromRef.current = target;
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(step);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [target, duration]);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true einen Frame nach dem Mount bzw. nach Aenderung von `key` - fuer Wachstumsanimationen. */
|
||||||
|
export function useGrow(key: unknown) {
|
||||||
|
const [grown, setGrown] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setGrown(false);
|
||||||
|
const timer = setTimeout(() => setGrown(true), 40);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [key]);
|
||||||
|
return grown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePersistentState<T>(key: string, initial: T) {
|
||||||
|
const [value, setValue] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(key);
|
||||||
|
return stored ? (JSON.parse(stored) as T) : initial;
|
||||||
|
} catch {
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
/* egal */
|
||||||
|
}
|
||||||
|
}, [key, value]);
|
||||||
|
return [value, setValue] as const;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
|
import type { ViewKey } from '../types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Kleiner Hash-Router: #/reports/projekt/12. Reicht fuer eine SPA hinter
|
||||||
|
* Apache ohne Serverkonfiguration, und der Zurueck-Knopf des Handys
|
||||||
|
* funktioniert wie erwartet.
|
||||||
|
*/
|
||||||
|
export interface Route {
|
||||||
|
view: ViewKey | null;
|
||||||
|
params: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(): Route {
|
||||||
|
const hash = window.location.hash.replace(/^#\/?/, '');
|
||||||
|
if (hash.startsWith('sso_token')) return { view: null, params: [] };
|
||||||
|
const [view, ...params] = hash.split('/').filter(Boolean).map(decodeURIComponent);
|
||||||
|
return { view: (view as ViewKey) || null, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHashRoute() {
|
||||||
|
const [route, setRoute] = useState<Route>(parse);
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setRoute(parse());
|
||||||
|
window.addEventListener('hashchange', onChange);
|
||||||
|
return () => window.removeEventListener('hashchange', onChange);
|
||||||
|
}, []);
|
||||||
|
const navigate = useCallback((path: string, replace = false) => {
|
||||||
|
const target = `#/${path.replace(/^\/+/, '')}`;
|
||||||
|
if (replace) {
|
||||||
|
window.history.replaceState(null, '', target);
|
||||||
|
setRoute(parse());
|
||||||
|
} else if (window.location.hash !== target) {
|
||||||
|
window.location.hash = target;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return { route, navigate };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RouterContext = createContext<{ route: Route; navigate: (path: string, replace?: boolean) => void } | null>(null);
|
||||||
|
|
||||||
|
export function useRouter() {
|
||||||
|
const ctx = useContext(RouterContext);
|
||||||
|
if (!ctx) throw new Error('useRouter ausserhalb des RouterContext');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { authApi } from './api';
|
||||||
|
import { useAuth } from './auth';
|
||||||
|
import { THEME_PALETTES, type ThemePalette } from '../types';
|
||||||
|
import { RainyWeather } from '../components/layout/RainyWeather';
|
||||||
|
import { SnowWeather } from '../components/layout/SnowWeather';
|
||||||
|
import { AuraSky } from '../components/layout/AuraSky';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Darstellung der Oberflaeche. Sie hat zwei unabhaengige Achsen:
|
||||||
|
*
|
||||||
|
* Farbwelt (palette) perinco | pixel | outdoor | cyberpunk | rainy | snow | aura
|
||||||
|
* -> Klasse theme-* am <html>
|
||||||
|
* Modus (mode) hell | dunkel -> Klasse dark am <html>
|
||||||
|
*
|
||||||
|
* Beide gehoeren zum **Benutzer** und nicht zum Browser: sie stehen in
|
||||||
|
* pulse_users.theme und pulse_users.theme_mode. Wer sich an einem anderen Rechner
|
||||||
|
* anmeldet, findet seine Wahl wieder, und melden sich zwei Leute an derselben
|
||||||
|
* Maschine an, sieht jeder seine eigene.
|
||||||
|
*
|
||||||
|
* Der localStorage bleibt als Zwischenspeicher daneben liegen, aber nicht als
|
||||||
|
* Quelle der Wahrheit: er wird vom Inline-Skript in index.html gelesen, um die
|
||||||
|
* Klassen vor dem ersten Frame zu setzen. Ohne das blitzt beim Laden das
|
||||||
|
* Standarddesign auf, weil das Bundle erst danach laeuft. Er hat genau eine
|
||||||
|
* sichtbare Folge: melden sich zwei Benutzer an demselben Rechner an, sieht der
|
||||||
|
* zweite fuer einen Augenblick das Design des ersten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
/** null = der Systemeinstellung folgen */
|
||||||
|
type ModePreference = ThemeMode | null;
|
||||||
|
|
||||||
|
const MODE_KEY = 'pulse_theme';
|
||||||
|
const PALETTE_KEY = 'pulse_palette';
|
||||||
|
|
||||||
|
const DEFAULT_PALETTE: ThemePalette = 'perinco';
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
/** Tatsaechlich aktive Darstellung. */
|
||||||
|
theme: ThemeMode;
|
||||||
|
/** Gespeicherte Wahl des Modus; null bedeutet "folgt dem System". */
|
||||||
|
preference: ModePreference;
|
||||||
|
/** Aktive Farbwelt. */
|
||||||
|
palette: ThemePalette;
|
||||||
|
/** Schaltet explizit auf den jeweils anderen Modus. */
|
||||||
|
toggle: () => void;
|
||||||
|
/** Zurueck zur Systemeinstellung. */
|
||||||
|
useSystem: () => void;
|
||||||
|
/** Farbwelt wechseln. */
|
||||||
|
setPalette: (palette: ThemePalette) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
function readCachedMode(): ModePreference {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(MODE_KEY);
|
||||||
|
return stored === 'light' || stored === 'dark' ? stored : null;
|
||||||
|
} catch {
|
||||||
|
// Privater Modus ohne localStorage - dann eben ohne Zwischenspeicher.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCachedPalette(): ThemePalette {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(PALETTE_KEY);
|
||||||
|
return THEME_PALETTES.includes(stored as ThemePalette)
|
||||||
|
? (stored as ThemePalette)
|
||||||
|
: DEFAULT_PALETTE;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_PALETTE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt in den Zwischenspeicher fuers naechste Laden; Fehler sind egal. */
|
||||||
|
function cache(key: string, value: string | null) {
|
||||||
|
try {
|
||||||
|
if (value === null) localStorage.removeItem(key);
|
||||||
|
else localStorage.setItem(key, value);
|
||||||
|
} catch {
|
||||||
|
// Nicht speicherbar - dann blitzt beim naechsten Laden eben kurz das
|
||||||
|
// Standarddesign auf. Die Wahl selbst liegt in der Datenbank.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemTheme(): ThemeMode {
|
||||||
|
return typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Klassen, an denen die Farbwerte in index.css haengen. Das
|
||||||
|
* Inline-Skript in index.html macht dasselbe schon vor dem ersten Frame.
|
||||||
|
*
|
||||||
|
* Die Klassen aller anderen Farbwelten werden entfernt, nicht nur die eigene
|
||||||
|
* gesetzt: sonst bliebe beim Wechsel die vorige stehen und zwei Themes
|
||||||
|
* ueberlagerten sich.
|
||||||
|
*/
|
||||||
|
function applyTheme(mode: ThemeMode, palette: ThemePalette) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.toggle('dark', mode === 'dark');
|
||||||
|
root.style.colorScheme = mode;
|
||||||
|
|
||||||
|
for (const candidate of THEME_PALETTES) {
|
||||||
|
root.classList.toggle(`theme-${candidate}`, candidate === palette);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { user, updateUser } = useAuth();
|
||||||
|
|
||||||
|
const [preference, setPreference] = useState<ModePreference>(() => readCachedMode());
|
||||||
|
const [palette, setPaletteState] = useState<ThemePalette>(() => readCachedPalette());
|
||||||
|
const [resolvedSystem, setResolvedSystem] = useState<ThemeMode>(() => systemTheme());
|
||||||
|
|
||||||
|
const theme = preference ?? resolvedSystem;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme, palette);
|
||||||
|
}, [theme, palette]);
|
||||||
|
|
||||||
|
// Ohne eigene Wahl folgt die App einer Aenderung im Betriebssystem sofort.
|
||||||
|
useEffect(() => {
|
||||||
|
const query = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||||
|
if (!query) return;
|
||||||
|
const onChange = (event: MediaQueryListEvent) =>
|
||||||
|
setResolvedSystem(event.matches ? 'dark' : 'light');
|
||||||
|
query.addEventListener('change', onChange);
|
||||||
|
return () => query.removeEventListener('change', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Wahl des angemeldeten Benutzers uebernehmen - einmal je Anmeldung.
|
||||||
|
*
|
||||||
|
* Der Merker ist wichtig: `user` aendert sich auch, wenn jemand sein Foto
|
||||||
|
* tauscht oder gerade das Design umstellt (updateUser). Ohne ihn wuerde
|
||||||
|
* dieser Effekt bei jeder solchen Aenderung erneut laufen und koennte eine
|
||||||
|
* gerade getroffene Wahl mit dem alten Stand ueberschreiben.
|
||||||
|
*/
|
||||||
|
const syncedForUser = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
// Abmelden: die Anzeige bleibt, wie sie ist. Der naechste Benutzer
|
||||||
|
// bekommt beim Anmelden seine eigene Wahl.
|
||||||
|
syncedForUser.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (syncedForUser.current === user.id) return;
|
||||||
|
syncedForUser.current = user.id;
|
||||||
|
|
||||||
|
const nextPalette = THEME_PALETTES.includes(user.theme as ThemePalette)
|
||||||
|
? (user.theme as ThemePalette)
|
||||||
|
: DEFAULT_PALETTE;
|
||||||
|
const nextMode: ModePreference =
|
||||||
|
user.theme_mode === 'light' || user.theme_mode === 'dark' ? user.theme_mode : null;
|
||||||
|
|
||||||
|
setPaletteState(nextPalette);
|
||||||
|
setPreference(nextMode);
|
||||||
|
cache(PALETTE_KEY, nextPalette === DEFAULT_PALETTE ? null : nextPalette);
|
||||||
|
cache(MODE_KEY, nextMode);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Speichern am Benutzer. Die Anzeige wechselt sofort und wartet nicht auf das
|
||||||
|
* Backend - ein Design, das nach dem Klick eine halbe Sekunde ueberlegt,
|
||||||
|
* fuehlt sich kaputt an. Schlaegt das Speichern fehl, gilt die Wahl fuer
|
||||||
|
* diese Sitzung und ist nach dem naechsten Anmelden weg; das ist harmlos und
|
||||||
|
* deshalb keine Fehlermeldung wert.
|
||||||
|
*/
|
||||||
|
const persist = useCallback(
|
||||||
|
(changes: { theme?: ThemePalette | null; theme_mode?: ModePreference }) => {
|
||||||
|
if (!user) return;
|
||||||
|
authApi
|
||||||
|
.updateMe(changes)
|
||||||
|
.then(({ user: saved }) => updateUser(saved))
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('[Design] Konnte nicht gespeichert werden:', err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[user, updateUser]
|
||||||
|
);
|
||||||
|
|
||||||
|
const storeMode = useCallback(
|
||||||
|
(next: ModePreference) => {
|
||||||
|
setPreference(next);
|
||||||
|
cache(MODE_KEY, next);
|
||||||
|
persist({ theme_mode: next });
|
||||||
|
},
|
||||||
|
[persist]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPalette = useCallback(
|
||||||
|
(next: ThemePalette) => {
|
||||||
|
setPaletteState(next);
|
||||||
|
cache(PALETTE_KEY, next === DEFAULT_PALETTE ? null : next);
|
||||||
|
// Der Standard wird als null gespeichert, damit "keine Wahl getroffen"
|
||||||
|
// und "ausdruecklich perinco" in der Datenbank dasselbe bedeuten.
|
||||||
|
persist({ theme: next === DEFAULT_PALETTE ? null : next });
|
||||||
|
},
|
||||||
|
[persist]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<ThemeContextValue>(
|
||||||
|
() => ({
|
||||||
|
theme,
|
||||||
|
preference,
|
||||||
|
palette,
|
||||||
|
toggle: () => storeMode(theme === 'dark' ? 'light' : 'dark'),
|
||||||
|
useSystem: () => storeMode(null),
|
||||||
|
setPalette,
|
||||||
|
}),
|
||||||
|
[theme, preference, palette, storeMode, setPalette]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
{/* Wetter und Himmel gehoeren zum Theme, nicht zu einer Ansicht. */}
|
||||||
|
{palette === 'rainy' && <RainyWeather />}
|
||||||
|
{palette === 'snow' && <SnowWeather />}
|
||||||
|
{palette === 'aura' && <AuraSky />}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextValue {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error('useTheme muss innerhalb von <ThemeProvider> verwendet werden.');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
import { ToastProvider } from './components/Toast';
|
||||||
|
import { AuthProvider } from './lib/auth';
|
||||||
|
import { ThemeProvider } from './lib/theme';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (!container) throw new Error('Root-Element #root nicht gefunden.');
|
||||||
|
|
||||||
|
// ThemeProvider unter AuthProvider: das Design haengt am Benutzer.
|
||||||
|
createRoot(container).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ToastProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||